Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ jobs:
virtualenvs-in-project: true

- name: Install dependencies
run: poetry install --with dev
run: poetry install --extras dev

- name: Verify venv exists
run: |
test -f .venv/bin/python || (echo "❌ venv not created" && exit 1)
echo "✅ venv verified at .venv/"

- name: Verify pyqual installed
run: poetry run pyqual --version
run: poetry run python -c "import pyqual"

- name: Run quality pipeline
run: poetry run task quality
- name: Run test suite
run: poetry run pytest -q

- name: Verify SUMD generation works
run: poetry run task sumd
- name: Verify SUMD CLI is available
run: poetry run python -m sumd.cli --help
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ The MCP server exposes CQRS ES tools:
- `get_events` - Retrieve event history
- `get_aggregate` - Get current aggregate state

MCP file exports, document generation, CQRS commands and DSL execution are
disabled by default. Start the server with `SUMD_MCP_ALLOW_MUTATION=1` only when
trusted MCP clients should be allowed to write files or persistent events.
Read-only parsing, validation and query tools remain available.

## DSL (Domain Specific Language)

SUMD provides a powerful DSL for interactive operations and scripting:
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
"goal>=2.1.190",
"costs>=0.1.51",
"pfix>=0.1.73",
"mcp>=1.27.1",
"mcp>=1.27.1,<2.0.0",
]
classifiers = [
"Development Status :: 3 - Alpha",
Expand All @@ -33,7 +33,7 @@ license = "Apache-2.0"

[project.optional-dependencies]
mcp = [
"mcp>=1.27.1",
"mcp>=1.27.1,<2.0.0",
]
dev = [
"pytest>=9.0.3",
Expand All @@ -46,7 +46,7 @@ dev = [
"goal>=2.1.190",
"costs>=0.1.51",
"pfix>=0.1.73",
"mcp>=1.27.1",
"mcp>=1.27.1,<2.0.0",
]

[tool.pytest.ini_options]
Expand Down
18 changes: 13 additions & 5 deletions sumd/cqrs/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import dataclasses
import hashlib
import json
import uuid
from datetime import datetime, timezone
Expand Down Expand Up @@ -88,7 +89,8 @@ def _persist_event(self, event: Event) -> None:
return

self._storage_path.mkdir(parents=True, exist_ok=True)
event_file = self._storage_path / f"{event.aggregate_id}.jsonl"
aggregate_key = hashlib.sha256(event.aggregate_id.encode("utf-8")).hexdigest()
event_file = self._storage_path / f"{aggregate_key}.jsonl"

with open(event_file, "a", encoding="utf-8") as f:
f.write(json.dumps(event.to_dict()) + "\n")
Expand All @@ -109,14 +111,20 @@ def _load_events(self) -> None:
return

for event_file in self._storage_path.glob("*.jsonl"):
aggregate_id = event_file.stem
try:
lines = event_file.read_text(encoding="utf-8").splitlines()
except Exception:
continue
self._events[aggregate_id] = [
ev for line in lines if (ev := self._parse_event_line(line)) is not None
]
for line in lines:
event = self._parse_event_line(line)
if event is None:
continue
events = self._events.setdefault(event.aggregate_id, [])
if all(existing.event_id != event.event_id for existing in events):
events.append(event)

for events in self._events.values():
events.sort(key=lambda event: (event.version, event.timestamp))


# SUMD-specific events
Expand Down
14 changes: 14 additions & 0 deletions sumd/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@
# Helper
# ---------------------------------------------------------------------------

_MUTATION_ENV = "SUMD_MCP_ALLOW_MUTATION"


def _require_mutation(action: str) -> None:
enabled = os.getenv(_MUTATION_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
if not enabled:
raise PermissionError(
f"MCP mutation '{action}' is disabled; start the server with {_MUTATION_ENV}=1"
)


def _doc_to_dict(doc) -> dict[str, Any]:
return {
Expand Down Expand Up @@ -420,6 +430,7 @@ async def _tool_export_sumd(arguments: dict) -> list[types.TextContent]:
else:
content = json.dumps(data, indent=2, ensure_ascii=False)
if output_path:
_require_mutation("export_sumd")
out = _resolve_path(output_path)
out.write_text(content, encoding="utf-8")
return [types.TextContent(type="text", text=f"Exported to {out}")]
Expand Down Expand Up @@ -495,6 +506,7 @@ async def _tool_generate_sumd(arguments: dict) -> list[types.TextContent]:
lines += [section["content"], ""]
content = "\n".join(lines)
if output_path:
_require_mutation("generate_sumd")
out = _resolve_path(output_path)
out.write_text(content, encoding="utf-8")
return [types.TextContent(type="text", text=f"Generated {out}")]
Expand All @@ -507,6 +519,7 @@ async def _tool_generate_sumd(arguments: dict) -> list[types.TextContent]:

async def _tool_execute_command(arguments: dict) -> list[types.TextContent]:
"""Execute CQRS command."""
_require_mutation("execute_command")
from sumd.cqrs.commands import (
CreateSumdDocument,
UpdateSumdDocument,
Expand Down Expand Up @@ -635,6 +648,7 @@ async def _tool_get_aggregate(arguments: dict) -> list[types.TextContent]:

async def _tool_execute_dsl(arguments: dict) -> list[types.TextContent]:
"""Execute DSL expression."""
_require_mutation("execute_dsl")
dsl_expression = arguments["dsl_expression"]
context_vars = arguments.get("context_vars", {})
working_directory = arguments.get("working_directory")
Expand Down
18 changes: 18 additions & 0 deletions tests/test_cqrs_es.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ def test_persistence(self):

assert len(events) == 1
assert events[0].data["project_name"] == "Test Project"

def test_aggregate_id_cannot_escape_storage_directory(self, tmp_path):
storage_path = tmp_path / "events"
event_store = EventStore(storage_path)
event_store.save_event(
SumdDocumentCreated(
aggregate_id="../escaped",
data={"project_name": "Test Project"},
)
)

assert not (tmp_path / "escaped.jsonl").exists()
persisted = list(storage_path.glob("*.jsonl"))
assert len(persisted) == 1
assert persisted[0].parent == storage_path

reloaded = EventStore(storage_path)
assert len(reloaded.get_events("../escaped")) == 1

def test_get_events_from_version(self):
"""Test retrieving events from specific version."""
Expand Down
5 changes: 5 additions & 0 deletions tests/test_mcp_cqrs_dsl.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
)


@pytest.fixture(autouse=True)
def allow_mcp_mutations(monkeypatch):
monkeypatch.setenv("SUMD_MCP_ALLOW_MUTATION", "1")


class TestMCPCQRSCommands:
"""Test MCP CQRS command tools."""

Expand Down
13 changes: 13 additions & 0 deletions tests/test_mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@

from sumd.mcp_server import (
_doc_to_dict,
_require_mutation,
_resolve_path,
_TOOL_HANDLERS,
list_tools,
)


@pytest.fixture(autouse=True)
def allow_mcp_mutations(monkeypatch):
monkeypatch.setenv("SUMD_MCP_ALLOW_MUTATION", "1")


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -94,6 +100,13 @@ def test_relative_resolves_from_cwd(self):
assert result.name == "SUMD.md"


class TestMutationCapability:
def test_disabled_by_default(self, monkeypatch):
monkeypatch.delenv("SUMD_MCP_ALLOW_MUTATION", raising=False)
with pytest.raises(PermissionError, match="SUMD_MCP_ALLOW_MUTATION"):
_require_mutation("generate_sumd")


# ---------------------------------------------------------------------------
# Tool listing
# ---------------------------------------------------------------------------
Expand Down
Loading