diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c50d325..76a0450 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: virtualenvs-in-project: true - name: Install dependencies - run: poetry install --with dev + run: poetry install --extras dev - name: Verify venv exists run: | @@ -31,10 +31,10 @@ jobs: 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 diff --git a/README.md b/README.md index 205e9d0..5be9624 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 30732b5..bb190fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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", @@ -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] diff --git a/sumd/cqrs/events.py b/sumd/cqrs/events.py index 15b06ff..975582b 100644 --- a/sumd/cqrs/events.py +++ b/sumd/cqrs/events.py @@ -3,6 +3,7 @@ from __future__ import annotations import dataclasses +import hashlib import json import uuid from datetime import datetime, timezone @@ -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") @@ -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 diff --git a/sumd/mcp_server.py b/sumd/mcp_server.py index b6d1294..536358c 100644 --- a/sumd/mcp_server.py +++ b/sumd/mcp_server.py @@ -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 { @@ -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}")] @@ -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}")] @@ -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, @@ -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") diff --git a/tests/test_cqrs_es.py b/tests/test_cqrs_es.py index 1ca066d..7dc5954 100644 --- a/tests/test_cqrs_es.py +++ b/tests/test_cqrs_es.py @@ -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.""" diff --git a/tests/test_mcp_cqrs_dsl.py b/tests/test_mcp_cqrs_dsl.py index cb76e3e..49478b3 100644 --- a/tests/test_mcp_cqrs_dsl.py +++ b/tests/test_mcp_cqrs_dsl.py @@ -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.""" diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 77571af..9d26e15 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -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 # --------------------------------------------------------------------------- @@ -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 # ---------------------------------------------------------------------------