From 3cbb6664264fd83967d4eef12ffcfb8f338c2cad Mon Sep 17 00:00:00 2001 From: MaikeH Date: Tue, 28 Jul 2026 15:42:23 -0300 Subject: [PATCH 1/7] chore: rename MCP server from lexnotify-map to architecture-map The master plan (section 7) is explicit that "the service name is architecture-map, not lexnotify-map" -- the server was already generic (single --repo-root arg, no LexNotify-specific logic), just mislabeled. Renames the FastMCP instance and its primary tool names to match section 7.1 (search_capability, get_component, get_map_status), keeping the old names as deprecated aliases for backward compatibility. Adds verify_references, the one tool from section 7.1 that had no implementation at all (evidence cross-checking per section 3.5). find_similar and get_impact_graph remain unimplemented -- both need import-edge extraction the scanner doesn't produce yet, documented as a known gap in the module docstring rather than silently missing. Companion PR in LexNotify-Web (#805) updates map-guardian.md to the new tool names. --- src/aak/mcp_server.py | 179 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 145 insertions(+), 34 deletions(-) diff --git a/src/aak/mcp_server.py b/src/aak/mcp_server.py index 6deb5ca..89a3944 100644 --- a/src/aak/mcp_server.py +++ b/src/aak/mcp_server.py @@ -1,10 +1,18 @@ -"""lexnotify-map MCP server -- exposes architecture-map queries to agents -(Claude Code, Codex) so they can check for existing components before -writing new code. - -Verticalized on LexNotify by decision (2026-07-28); the code is generic -enough that a second instance could point --repo-root at another repo -without any changes here. +"""architecture-map MCP server -- exposes read-only architecture-map queries +to agents (Claude Code, Codex) so they can check for existing components +before writing new code. + +One process serves one repo (`--repo-root`), matching the master plan's +pre-VPS deployment mode ("run as an on-demand local stdio process... stop +when the CLI task ends", section 7.2). Multi-repo namespacing behind a +shared authenticated service is a post-VPS concern (section 7.2, "after the +new VPS, when remote acceleration is enabled") and is intentionally not +implemented here. + +Known gap: `find_similar` and `get_impact_graph` (section 7.1) are not +implemented. Both need import/dependency-edge extraction that the scanner +(`aak.scan.core`) does not currently produce -- adding them requires scanner +work first, not just a new MCP tool. """ from __future__ import annotations import json @@ -14,7 +22,7 @@ from .manifest import load_manifest, ManifestError, ProjectManifest -mcp = FastMCP("lexnotify-map") +mcp = FastMCP("architecture-map") _manifest: ProjectManifest | None = None _repo_root: Path | None = None @@ -47,20 +55,21 @@ def _adr_dir() -> Path: return manifest.repo_root / rel -@mcp.tool() -def find_component(query: str, limit: int = 20) -> list[dict]: - """Search architecture-map components by substring in path or exports. +def _check_repo_id(repoId: str) -> dict | None: + """Validate the caller-supplied repoId against the repo this process + actually serves. Returns an error dict if it doesn't match, else None. - Use BEFORE writing any new helper, route, or component to confirm - something similar doesn't already exist. Case-insensitive. + This process is single-repo (see module docstring); repoId is not used + for routing. It exists so a caller can't silently get answers for the + wrong repo if it mixed up which server instance it's talking to. + """ + manifest = _get_manifest() + if repoId != manifest.repo_id: + return {"error": f"repoId mismatch: this server instance serves '{manifest.repo_id}', not '{repoId}'"} + return None - Args: - query: search term (e.g. "chat", "notification", "getOrCreate") - limit: max results to return (default 20) - Returns: - List of {path, exports, layer} that match. - """ +def _search_components(query: str, limit: int) -> list[dict]: data = _read_map() q = query.lower() results = [] @@ -72,21 +81,66 @@ def find_component(query: str, limit: int = 20) -> list[dict]: return results +def _get_component(path: str) -> dict: + data = _read_map() + for c in data.get("components", []): + if c["path"] == path: + return c + return {"error": f"component not found: {path}"} + + @mcp.tool() -def component_detail(path: str) -> dict: +def search_capability(repoId: str, query: str, limit: int = 20) -> list[dict]: + """Search architecture-map components by substring in path or exports. + + Use BEFORE writing any new helper, route, or component to confirm + something similar doesn't already exist. Case-insensitive. + + Args: + repoId: repo identifier from the project manifest (e.g. "mhx/lexnotify") + query: search term (e.g. "chat", "notification", "getOrCreate") + limit: max results to return (default 20) + + Returns: + List of {path, exports, layer} that match, or {"error": "..."} on repoId mismatch. + """ + err = _check_repo_id(repoId) + if err: + return [err] + return _search_components(query, limit) + + +@mcp.tool() +def find_component(query: str, limit: int = 20) -> list[dict]: + """Deprecated alias for search_capability without repoId validation. + Kept for backward compatibility; prefer search_capability. + """ + return _search_components(query, limit) + + +@mcp.tool() +def get_component(repoId: str, componentId: str) -> dict: """Return the full architecture-map entry for a component by exact path. Args: - path: file path relative to repo root (e.g. "src/lib/case-chat.ts") + repoId: repo identifier from the project manifest (e.g. "mhx/lexnotify") + componentId: file path relative to repo root (e.g. "src/lib/case-chat.ts") Returns: - {path, exports, layer}, or {"error": "..."} if not found. + {path, exports, layer}, or {"error": "..."} if not found or repoId mismatch. """ - data = _read_map() - for c in data.get("components", []): - if c["path"] == path: - return c - return {"error": f"component not found: {path}"} + err = _check_repo_id(repoId) + if err: + return err + return _get_component(componentId) + + +@mcp.tool() +def component_detail(path: str) -> dict: + """Deprecated alias for get_component without repoId validation. + Kept for backward compatibility; prefer get_component. + """ + return _get_component(path) @mcp.tool() @@ -173,22 +227,79 @@ def get_adr(slug: str) -> str: return path.read_text(encoding="utf-8") +def _map_status() -> dict: + data = _read_map() + return { + "generatedAt": data.get("generatedAt"), + "sourceSha": data.get("sourceSha"), + "coverage": data.get("coverage"), + "componentCount": len(data.get("components", [])), + "extractionMethod": data.get("extractionMethod"), + } + + @mcp.tool() -def map_status() -> dict: +def get_map_status(repoId: str) -> dict: """Return architecture-map metadata: when it was generated, from which commit, coverage, and component count. Use to judge whether the map might be stale before trusting an answer from the other tools. + Args: + repoId: repo identifier from the project manifest (e.g. "mhx/lexnotify") + Returns: - {generatedAt, sourceSha, coverage, componentCount, extractionMethod} + {generatedAt, sourceSha, coverage, componentCount, extractionMethod}, + or {"error": "..."} on repoId mismatch. """ + err = _check_repo_id(repoId) + if err: + return err + return _map_status() + + +@mcp.tool() +def map_status() -> dict: + """Deprecated alias for get_map_status without repoId validation. + Kept for backward compatibility; prefer get_map_status. + """ + return _map_status() + + +@mcp.tool() +def verify_references(repoId: str, paths: list[str], symbols: list[str]) -> dict: + """Cross-check cited file paths and exported symbols against the current + architecture map. Use to validate evidence before citing it in a Codex + audit or Gemini brief -- "not found" without recorded search coverage is + not valid evidence (master plan section 3.5). + + Args: + repoId: repo identifier from the project manifest (e.g. "mhx/lexnotify") + paths: file paths to verify (relative to repo root) + symbols: exported symbol names to verify (searched across all components) + + Returns: + {missingPaths: [...], missingSymbols: [...], verifiedPaths: [...], verifiedSymbols: [...]} + or {"error": "..."} on repoId mismatch. + """ + err = _check_repo_id(repoId) + if err: + return err + data = _read_map() + components = data.get("components", []) + known_paths = {c["path"] for c in components} + known_symbols = {e for c in components for e in c.get("exports", [])} + + verified_paths = [p for p in paths if p in known_paths] + missing_paths = [p for p in paths if p not in known_paths] + verified_symbols = [s for s in symbols if s in known_symbols] + missing_symbols = [s for s in symbols if s not in known_symbols] + return { - "generatedAt": data.get("generatedAt"), - "sourceSha": data.get("sourceSha"), - "coverage": data.get("coverage"), - "componentCount": len(data.get("components", [])), - "extractionMethod": data.get("extractionMethod"), + "verifiedPaths": verified_paths, + "missingPaths": missing_paths, + "verifiedSymbols": verified_symbols, + "missingSymbols": missing_symbols, } From e64ce999513e3aba4abdafb5b78142ef39c96256 Mon Sep 17 00:00:00 2001 From: MaikeH Date: Tue, 28 Jul 2026 15:56:18 -0300 Subject: [PATCH 2/7] chore: remove deprecated find_component/component_detail/map_status aliases Review feedback on this PR: the only confirmed consumer org-wide (map-guardian.md in LexNotify-Web, the sole hit for `lexnotify-map` across a full gh api search/code sweep) is already migrated to the new names in the companion PR (LexNotify/LexNotify-Web#805). Keeping unvalidated duplicate tools around for a consumer that doesn't exist just doubles the tool surface an agent has to parse, with no repoId validation on the old path -- not backward compatibility, just dead weight from day one. Follow-up filed to add test coverage for mcp_server.py (had none before or after this PR). --- src/aak/mcp_server.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/aak/mcp_server.py b/src/aak/mcp_server.py index 89a3944..f2eca63 100644 --- a/src/aak/mcp_server.py +++ b/src/aak/mcp_server.py @@ -110,14 +110,6 @@ def search_capability(repoId: str, query: str, limit: int = 20) -> list[dict]: return _search_components(query, limit) -@mcp.tool() -def find_component(query: str, limit: int = 20) -> list[dict]: - """Deprecated alias for search_capability without repoId validation. - Kept for backward compatibility; prefer search_capability. - """ - return _search_components(query, limit) - - @mcp.tool() def get_component(repoId: str, componentId: str) -> dict: """Return the full architecture-map entry for a component by exact path. @@ -135,14 +127,6 @@ def get_component(repoId: str, componentId: str) -> dict: return _get_component(componentId) -@mcp.tool() -def component_detail(path: str) -> dict: - """Deprecated alias for get_component without repoId validation. - Kept for backward compatibility; prefer get_component. - """ - return _get_component(path) - - @mcp.tool() def check_duplication(description: str) -> dict: """Check whether a described capability likely already exists. @@ -257,14 +241,6 @@ def get_map_status(repoId: str) -> dict: return _map_status() -@mcp.tool() -def map_status() -> dict: - """Deprecated alias for get_map_status without repoId validation. - Kept for backward compatibility; prefer get_map_status. - """ - return _map_status() - - @mcp.tool() def verify_references(repoId: str, paths: list[str], symbols: list[str]) -> dict: """Cross-check cited file paths and exported symbols against the current From 8ac5682a2d81582fdf94bdee5ea098633f91c8b1 Mon Sep 17 00:00:00 2001 From: Maike Neo <144666677+neomaike@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:20 -0400 Subject: [PATCH 3/7] fix(mcp): enforce commit-bound freshness for #791 --- src/aak/mcp_server.py | 139 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/src/aak/mcp_server.py b/src/aak/mcp_server.py index f2eca63..6381c79 100644 --- a/src/aak/mcp_server.py +++ b/src/aak/mcp_server.py @@ -16,6 +16,9 @@ """ from __future__ import annotations import json +import re +import subprocess +from datetime import datetime, timezone from pathlib import Path from mcp.server.fastmcp import FastMCP @@ -26,6 +29,8 @@ _manifest: ProjectManifest | None = None _repo_root: Path | None = None +SUPPORTED_INDEX_SCHEMA_VERSIONS = {"1.0"} +SHA_RE = re.compile(r"^[0-9a-f]{40}$") def _get_manifest() -> ProjectManifest: @@ -41,6 +46,95 @@ def _read_map() -> dict: return json.load(f) +def _git_value(*args: str) -> str | None: + if _repo_root is None: + return None + try: + result = subprocess.run( + ["git", "-C", str(_repo_root), *args], + capture_output=True, text=True, timeout=5, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + value = result.stdout.strip() + return value if result.returncode == 0 and value else None + + +def _index_status(expected_commit: str | None = None) -> dict: + """Compute a fail-closed freshness contract without exposing repo content.""" + manifest = _get_manifest() + observed = _git_value("rev-parse", "HEAD") + expected = expected_commit or observed + branch = _git_value("branch", "--show-current") or "detached" + base = { + "repository": manifest.repo_id, + "branch": branch, + "expectedCommit": expected, + "observedCommit": observed, + "indexedCommit": None, + "sourceSha": None, + "indexedAt": None, + "schemaVersion": None, + "indexedFileCount": None, + "componentCount": None, + "includedScope": list(manifest.source_roots), + "excludedScope": [".git", "node_modules", ".next", "coverage", "dist", "build", "__pycache__", ".venv", "venv"], + "freshness": "unknown", + "reason": "index metadata could not be verified", + "checkedAt": datetime.now(timezone.utc).isoformat(), + } + marker = manifest.map_path.with_suffix(manifest.map_path.suffix + ".indexing") + if marker.exists(): + return {**base, "freshness": "indexing", "reason": "an isolated index build is in progress"} + try: + data = _read_map() + except (OSError, json.JSONDecodeError) as exc: + return {**base, "freshness": "failed", "reason": f"index backend unavailable or invalid: {type(exc).__name__}"} + source_sha = data.get("sourceSha") + schema = data.get("schemaVersion") + components = data.get("components") + status = { + **base, + "indexedCommit": source_sha, + "sourceSha": source_sha, + "indexedAt": data.get("generatedAt"), + "schemaVersion": schema, + "indexedFileCount": len(components) if isinstance(components, list) else None, + "componentCount": len(components) if isinstance(components, list) else None, + "includedScope": data.get("sourceRoots", base["includedScope"]), + "coverage": data.get("coverage"), + "extractionMethod": data.get("extractionMethod"), + } + if schema not in SUPPORTED_INDEX_SCHEMA_VERSIONS: + return {**status, "freshness": "incompatible", "reason": f"unsupported index schema version: {schema!r}"} + if data.get("repoId") != manifest.repo_id: + return {**status, "freshness": "incompatible", "reason": "index repository does not match the configured project"} + if not isinstance(components, list): + return {**status, "freshness": "incompatible", "reason": "index components field is not an array"} + if not isinstance(source_sha, str) or not SHA_RE.fullmatch(source_sha): + return {**status, "freshness": "unknown", "reason": "index sourceSha is absent or invalid"} + if not isinstance(expected, str) or not SHA_RE.fullmatch(expected): + return {**status, "freshness": "unknown", "reason": "expected commit is absent or invalid"} + if source_sha != expected: + return {**status, "freshness": "stale", "reason": "indexed commit does not match the expected commit"} + return {**status, "freshness": "fresh", "reason": "indexed commit matches the expected commit"} + + +def _freshness_error(expected_commit: str | None = None) -> dict | None: + status = _index_status(expected_commit) + if status["freshness"] == "fresh": + return None + return { + "error": { + "code": "INDEX_NOT_FRESH", + "message": "architectural conclusion blocked because the index is not fresh", + "retryable": status["freshness"] in {"indexing", "failed", "stale"}, + }, + "index": status, + "historicalOnly": status["freshness"] == "stale", + } + + def _read_incidents() -> list: manifest = _get_manifest() if not manifest.incidents_path.is_file(): @@ -90,7 +184,7 @@ def _get_component(path: str) -> dict: @mcp.tool() -def search_capability(repoId: str, query: str, limit: int = 20) -> list[dict]: +def search_capability(repoId: str, query: str, limit: int = 20, expectedCommit: str | None = None) -> list[dict]: """Search architecture-map components by substring in path or exports. Use BEFORE writing any new helper, route, or component to confirm @@ -107,11 +201,14 @@ def search_capability(repoId: str, query: str, limit: int = 20) -> list[dict]: err = _check_repo_id(repoId) if err: return [err] + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return [freshness_err] return _search_components(query, limit) @mcp.tool() -def get_component(repoId: str, componentId: str) -> dict: +def get_component(repoId: str, componentId: str, expectedCommit: str | None = None) -> dict: """Return the full architecture-map entry for a component by exact path. Args: @@ -124,11 +221,14 @@ def get_component(repoId: str, componentId: str) -> dict: err = _check_repo_id(repoId) if err: return err + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return freshness_err return _get_component(componentId) @mcp.tool() -def check_duplication(description: str) -> dict: +def check_duplication(description: str, expectedCommit: str | None = None) -> dict: """Check whether a described capability likely already exists. Cross-references known duplication incidents (business terms / accepted @@ -144,6 +244,9 @@ def check_duplication(description: str) -> dict: matchedIncident, when present, points at the canonical target (path + symbol) that should be reused instead of rebuilt. """ + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return freshness_err incidents = _read_incidents() desc_lower = description.lower() @@ -169,12 +272,15 @@ def check_duplication(description: str) -> dict: @mcp.tool() -def list_adrs() -> list[dict]: +def list_adrs(expectedCommit: str | None = None) -> list[dict]: """List the repo's Architecture Decision Records. Returns: List of {slug, title, path}. """ + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return [freshness_err] adr_dir = _adr_dir() if not adr_dir.is_dir(): return [] @@ -196,7 +302,7 @@ def list_adrs() -> list[dict]: @mcp.tool() -def get_adr(slug: str) -> str: +def get_adr(slug: str, expectedCommit: str | None = None) -> str | dict: """Return the full markdown content of an ADR by slug. Args: @@ -205,25 +311,17 @@ def get_adr(slug: str) -> str: Returns: Markdown content, or an "error: ..." string if not found. """ + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return freshness_err path = _adr_dir() / f"{slug}.md" if not path.is_file(): return f"error: ADR not found: {slug}" return path.read_text(encoding="utf-8") -def _map_status() -> dict: - data = _read_map() - return { - "generatedAt": data.get("generatedAt"), - "sourceSha": data.get("sourceSha"), - "coverage": data.get("coverage"), - "componentCount": len(data.get("components", [])), - "extractionMethod": data.get("extractionMethod"), - } - - @mcp.tool() -def get_map_status(repoId: str) -> dict: +def get_map_status(repoId: str, expectedCommit: str | None = None) -> dict: """Return architecture-map metadata: when it was generated, from which commit, coverage, and component count. Use to judge whether the map might be stale before trusting an answer from the other tools. @@ -238,11 +336,11 @@ def get_map_status(repoId: str) -> dict: err = _check_repo_id(repoId) if err: return err - return _map_status() + return _index_status(expectedCommit) @mcp.tool() -def verify_references(repoId: str, paths: list[str], symbols: list[str]) -> dict: +def verify_references(repoId: str, paths: list[str], symbols: list[str], expectedCommit: str | None = None) -> dict: """Cross-check cited file paths and exported symbols against the current architecture map. Use to validate evidence before citing it in a Codex audit or Gemini brief -- "not found" without recorded search coverage is @@ -260,6 +358,9 @@ def verify_references(repoId: str, paths: list[str], symbols: list[str]) -> dict err = _check_repo_id(repoId) if err: return err + freshness_err = _freshness_error(expectedCommit) + if freshness_err: + return freshness_err data = _read_map() components = data.get("components", []) From c3d2a1d91d02da7138cf9ab3f3834145922c3c9a Mon Sep 17 00:00:00 2001 From: Maike Neo <144666677+neomaike@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:51 -0400 Subject: [PATCH 4/7] test(mcp): cover fail-closed freshness states for #791 --- tests/test_mcp_freshness.py | 120 ++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_mcp_freshness.py diff --git a/tests/test_mcp_freshness.py b/tests/test_mcp_freshness.py new file mode 100644 index 0000000..021f2c9 --- /dev/null +++ b/tests/test_mcp_freshness.py @@ -0,0 +1,120 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from aak import mcp_server +from aak.manifest import ProjectManifest + + +def _manifest(repo: Path) -> ProjectManifest: + return ProjectManifest( + schema_version="1.0", repo_id="lexnotify/web", tier=1, + default_branch="main", kit_version="1.0", + source_roots=["app", "src"], + architecture={"map": "docs/architecture/ARCHITECTURE_MAP.json"}, + repo_root=repo, + ) + + +@pytest.fixture +def repo(tmp_path, monkeypatch): + subprocess.run(["git", "init", "-b", "main", str(tmp_path)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.invalid"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "Test"], check=True) + (tmp_path / "tracked.txt").write_text("fixture", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "tracked.txt"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "-m", "fixture"], check=True, capture_output=True) + map_path = tmp_path / "docs/architecture/ARCHITECTURE_MAP.json" + map_path.parent.mkdir(parents=True) + monkeypatch.setattr(mcp_server, "_repo_root", tmp_path) + monkeypatch.setattr(mcp_server, "_manifest", _manifest(tmp_path)) + return tmp_path, map_path + + +def _head(repo: Path) -> str: + return subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, + capture_output=True, text=True, + ).stdout.strip() + + +def _write_map(path: Path, source_sha: str | None, **overrides): + doc = { + "schemaVersion": "1.0", "repoId": "lexnotify/web", + "generatedAt": "2026-07-28T00:00:00+00:00", "sourceSha": source_sha, + "extractionMethod": "fixture", "coverage": "fixture", + "components": [{"path": "src/a.ts", "exports": ["a"], "layer": "lib"}], + } + doc.update(overrides) + path.write_text(json.dumps(doc), encoding="utf-8") + + +def test_fresh_index_allows_existing_tool_contracts(repo): + root, map_path = repo + sha = _head(root) + _write_map(map_path, sha) + status = mcp_server.get_map_status("lexnotify/web", sha) + assert status["freshness"] == "fresh" + assert status["sourceSha"] == sha + assert status["expectedCommit"] == sha + assert status["componentCount"] == 1 + assert mcp_server.search_capability("lexnotify/web", "a", expectedCommit=sha)[0]["path"] == "src/a.ts" + assert mcp_server.get_component("lexnotify/web", "src/a.ts", sha)["path"] == "src/a.ts" + + +def test_stale_index_blocks_conclusive_tools(repo): + root, map_path = repo + _write_map(map_path, "0" * 40) + result = mcp_server.search_capability("lexnotify/web", "a", expectedCommit=_head(root)) + assert result[0]["error"]["code"] == "INDEX_NOT_FRESH" + assert result[0]["index"]["freshness"] == "stale" + assert result[0]["historicalOnly"] is True + + +@pytest.mark.parametrize("source_sha", [None, "not-a-sha"]) +def test_missing_or_invalid_source_sha_is_unknown(repo, source_sha): + _, map_path = repo + _write_map(map_path, source_sha) + assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "unknown" + + +def test_invalid_expected_sha_is_unknown_and_blocked(repo): + root, map_path = repo + _write_map(map_path, _head(root)) + assert mcp_server.get_map_status("lexnotify/web", "bad")["freshness"] == "unknown" + assert mcp_server.get_component("lexnotify/web", "src/a.ts", "bad")["error"]["code"] == "INDEX_NOT_FRESH" + + +def test_incompatible_schema_is_blocked(repo): + root, map_path = repo + _write_map(map_path, _head(root), schemaVersion="2.0") + assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "incompatible" + assert mcp_server.verify_references("lexnotify/web", [], [])["error"]["code"] == "INDEX_NOT_FRESH" + + +def test_indexing_marker_never_replaces_last_valid_index(repo): + root, map_path = repo + _write_map(map_path, _head(root)) + map_path.with_suffix(map_path.suffix + ".indexing").write_text("building", encoding="utf-8") + assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "indexing" + assert mcp_server.check_duplication("chat")["error"]["code"] == "INDEX_NOT_FRESH" + + +def test_backend_unavailable_is_failed(repo): + _, map_path = repo + assert not map_path.exists() + status = mcp_server.get_map_status("lexnotify/web") + assert status["freshness"] == "failed" + assert "FileNotFoundError" in status["reason"] + + +def test_repository_mismatch_fails_before_reading_index(repo): + assert "repoId mismatch" in mcp_server.get_map_status("other/repo")["error"] + + +def test_repo_metadata_mismatch_is_incompatible(repo): + root, map_path = repo + _write_map(map_path, _head(root), repoId="other/repo") + assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "incompatible" From 096209f91b0cdf46a1966251b8877a535198bd4a Mon Sep 17 00:00:00 2001 From: Maike Neo <144666677+neomaike@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:26:31 -0400 Subject: [PATCH 5/7] fix(mcp): close expected commit freshness bypass --- src/aak/mcp_server.py | 55 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/aak/mcp_server.py b/src/aak/mcp_server.py index 6381c79..51b6dbb 100644 --- a/src/aak/mcp_server.py +++ b/src/aak/mcp_server.py @@ -60,11 +60,37 @@ def _git_value(*args: str) -> str | None: return value if result.returncode == 0 and value else None +def _git_success(*args: str) -> bool: + if _repo_root is None: + return False + try: + result = subprocess.run( + ["git", "-C", str(_repo_root), *args], + capture_output=True, text=True, timeout=5, check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _commit_problem(commit: object, observed: str | None, label: str) -> str | None: + """Validate an exact commit SHA and constrain it to the served checkout.""" + if not isinstance(commit, str) or not SHA_RE.fullmatch(commit): + return f"{label} commit is absent or invalid; a full 40-character SHA is required" + if not _git_success("cat-file", "-e", f"{commit}^{{commit}}"): + return f"{label} commit does not exist in the served repository" + if observed is None or not SHA_RE.fullmatch(observed): + return "observed repository commit could not be verified" + if not _git_success("merge-base", "--is-ancestor", commit, observed): + return f"{label} commit is outside the served branch history" + return None + + def _index_status(expected_commit: str | None = None) -> dict: """Compute a fail-closed freshness contract without exposing repo content.""" manifest = _get_manifest() observed = _git_value("rev-parse", "HEAD") - expected = expected_commit or observed + expected = expected_commit branch = _git_value("branch", "--show-current") or "detached" base = { "repository": manifest.repo_id, @@ -85,6 +111,14 @@ def _index_status(expected_commit: str | None = None) -> dict: } marker = manifest.map_path.with_suffix(manifest.map_path.suffix + ".indexing") if marker.exists(): + try: + marker_data = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {**base, "freshness": "failed", "reason": "indexing marker is invalid"} + if not isinstance(marker_data, dict) or marker_data.get("status") not in { + "requested", "indexing", "validating", "ready", "publishing", + }: + return {**base, "freshness": "failed", "reason": "indexing marker is invalid"} return {**base, "freshness": "indexing", "reason": "an isolated index build is in progress"} try: data = _read_map() @@ -111,10 +145,12 @@ def _index_status(expected_commit: str | None = None) -> dict: return {**status, "freshness": "incompatible", "reason": "index repository does not match the configured project"} if not isinstance(components, list): return {**status, "freshness": "incompatible", "reason": "index components field is not an array"} - if not isinstance(source_sha, str) or not SHA_RE.fullmatch(source_sha): - return {**status, "freshness": "unknown", "reason": "index sourceSha is absent or invalid"} - if not isinstance(expected, str) or not SHA_RE.fullmatch(expected): - return {**status, "freshness": "unknown", "reason": "expected commit is absent or invalid"} + source_problem = _commit_problem(source_sha, observed, "indexed") + if source_problem: + return {**status, "freshness": "unknown", "reason": source_problem} + expected_problem = _commit_problem(expected, observed, "expected") + if expected_problem: + return {**status, "freshness": "unknown", "reason": expected_problem} if source_sha != expected: return {**status, "freshness": "stale", "reason": "indexed commit does not match the expected commit"} return {**status, "freshness": "fresh", "reason": "indexed commit matches the expected commit"} @@ -124,10 +160,15 @@ def _freshness_error(expected_commit: str | None = None) -> dict | None: status = _index_status(expected_commit) if status["freshness"] == "fresh": return None + failed = status["freshness"] == "failed" return { "error": { - "code": "INDEX_NOT_FRESH", - "message": "architectural conclusion blocked because the index is not fresh", + "code": "INDEX_BACKEND_UNAVAILABLE" if failed else "INDEX_NOT_FRESH", + "message": ( + "architectural conclusion blocked because the index backend is unavailable" + if failed else + "architectural conclusion blocked because the index is not fresh" + ), "retryable": status["freshness"] in {"indexing", "failed", "stale"}, }, "index": status, From a660745cc7e755d43144be65bcf22d6afef97123 Mon Sep 17 00:00:00 2001 From: Maike Neo <144666677+neomaike@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:26:56 -0400 Subject: [PATCH 6/7] test(mcp): cover freshness bypass and commit scope --- tests/test_mcp_freshness.py | 60 +++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/tests/test_mcp_freshness.py b/tests/test_mcp_freshness.py index 021f2c9..bdaabc9 100644 --- a/tests/test_mcp_freshness.py +++ b/tests/test_mcp_freshness.py @@ -40,6 +40,13 @@ def _head(repo: Path) -> str: ).stdout.strip() +def _commit(repo: Path, name: str) -> str: + (repo / name).write_text(name, encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", name], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-m", name], check=True, capture_output=True) + return _head(repo) + + def _write_map(path: Path, source_sha: str | None, **overrides): doc = { "schemaVersion": "1.0", "repoId": "lexnotify/web", @@ -66,8 +73,10 @@ def test_fresh_index_allows_existing_tool_contracts(repo): def test_stale_index_blocks_conclusive_tools(repo): root, map_path = repo - _write_map(map_path, "0" * 40) - result = mcp_server.search_capability("lexnotify/web", "a", expectedCommit=_head(root)) + indexed = _head(root) + expected = _commit(root, "new.txt") + _write_map(map_path, indexed) + result = mcp_server.search_capability("lexnotify/web", "a", expectedCommit=expected) assert result[0]["error"]["code"] == "INDEX_NOT_FRESH" assert result[0]["index"]["freshness"] == "stale" assert result[0]["historicalOnly"] is True @@ -87,6 +96,38 @@ def test_invalid_expected_sha_is_unknown_and_blocked(repo): assert mcp_server.get_component("lexnotify/web", "src/a.ts", "bad")["error"]["code"] == "INDEX_NOT_FRESH" +def test_missing_expected_sha_is_unknown_and_blocks_all_conclusive_tools(repo): + root, map_path = repo + _write_map(map_path, _head(root)) + assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "unknown" + assert mcp_server.search_capability("lexnotify/web", "a")[0]["error"]["code"] == "INDEX_NOT_FRESH" + assert mcp_server.get_component("lexnotify/web", "src/a.ts")["error"]["code"] == "INDEX_NOT_FRESH" + assert mcp_server.check_duplication("chat")["error"]["code"] == "INDEX_NOT_FRESH" + assert mcp_server.list_adrs()[0]["error"]["code"] == "INDEX_NOT_FRESH" + assert mcp_server.get_adr("decision")["error"]["code"] == "INDEX_NOT_FRESH" + assert mcp_server.verify_references("lexnotify/web", [], [])["error"]["code"] == "INDEX_NOT_FRESH" + + +@pytest.mark.parametrize("expected", ["a" * 7, "g" * 40, "A" * 40, "0" * 40]) +def test_short_malformed_or_nonexistent_expected_sha_is_unknown(repo, expected): + root, map_path = repo + _write_map(map_path, _head(root)) + assert mcp_server.get_map_status("lexnotify/web", expected)["freshness"] == "unknown" + + +def test_commit_from_another_branch_cannot_be_declared_fresh(repo): + root, map_path = repo + main = _head(root) + subprocess.run(["git", "-C", str(root), "switch", "-c", "other"], check=True, capture_output=True) + other = _commit(root, "other.txt") + subprocess.run(["git", "-C", str(root), "switch", "main"], check=True, capture_output=True) + assert _head(root) == main + _write_map(map_path, other) + status = mcp_server.get_map_status("lexnotify/web", other) + assert status["freshness"] == "unknown" + assert "outside the served branch history" in status["reason"] + + def test_incompatible_schema_is_blocked(repo): root, map_path = repo _write_map(map_path, _head(root), schemaVersion="2.0") @@ -97,7 +138,9 @@ def test_incompatible_schema_is_blocked(repo): def test_indexing_marker_never_replaces_last_valid_index(repo): root, map_path = repo _write_map(map_path, _head(root)) - map_path.with_suffix(map_path.suffix + ".indexing").write_text("building", encoding="utf-8") + map_path.with_suffix(map_path.suffix + ".indexing").write_text( + json.dumps({"status": "indexing"}), encoding="utf-8", + ) assert mcp_server.get_map_status("lexnotify/web")["freshness"] == "indexing" assert mcp_server.check_duplication("chat")["error"]["code"] == "INDEX_NOT_FRESH" @@ -108,6 +151,17 @@ def test_backend_unavailable_is_failed(repo): status = mcp_server.get_map_status("lexnotify/web") assert status["freshness"] == "failed" assert "FileNotFoundError" in status["reason"] + result = mcp_server.get_component("lexnotify/web", "src/a.ts", _head(repo[0])) + assert result["error"]["code"] == "INDEX_BACKEND_UNAVAILABLE" + + +def test_corrupt_indexing_marker_is_failed(repo): + root, map_path = repo + _write_map(map_path, _head(root)) + map_path.with_suffix(map_path.suffix + ".indexing").write_text("building", encoding="utf-8") + status = mcp_server.get_map_status("lexnotify/web", _head(root)) + assert status["freshness"] == "failed" + assert "invalid" in status["reason"] def test_repository_mismatch_fails_before_reading_index(repo): From 2e5a9f992c78b20420ea120d92072feeb7d1931e Mon Sep 17 00:00:00 2001 From: Maike Neo <144666677+neomaike@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:39:57 -0400 Subject: [PATCH 7/7] test(mcp): bind golden corpus to explicit commit precondition --- tests/test_duplication_golden_corpus.py | 27 ++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/test_duplication_golden_corpus.py b/tests/test_duplication_golden_corpus.py index 4878833..8a148ff 100644 --- a/tests/test_duplication_golden_corpus.py +++ b/tests/test_duplication_golden_corpus.py @@ -78,14 +78,23 @@ def golden_repo(tmp_path, monkeypatch): monkeypatch.setattr(mcp_server_mod, "_manifest", None) monkeypatch.setattr(mcp_server_mod, "_repo_root", tmp_path) - yield tmp_path + expected_commit = "a" * 40 + + def require_expected_commit(value): + assert value == expected_commit + return None + + monkeypatch.setattr(mcp_server_mod, "_freshness_error", require_expected_commit) + yield tmp_path, expected_commit monkeypatch.setattr(mcp_server_mod, "_manifest", None) monkeypatch.setattr(mcp_server_mod, "_repo_root", None) def test_replays_case_chat_incident(golden_repo): + _, expected_commit = golden_repo result = mcp_server_mod.check_duplication( - "preciso implementar chat verticalizado por processo pra nova tela de audiencias" + "preciso implementar chat verticalizado por processo pra nova tela de audiencias", + expectedCommit=expected_commit, ) assert result["matchedIncident"] is not None assert result["matchedIncident"]["canonicalTarget"] == { @@ -94,8 +103,10 @@ def test_replays_case_chat_incident(golden_repo): def test_replays_notification_pipeline_incident(golden_repo): + _, expected_commit = golden_repo result = mcp_server_mod.check_duplication( - "vou mexer no pipeline de notificacoes pra adicionar um canal novo de SMS" + "vou mexer no pipeline de notificacoes pra adicionar um canal novo de SMS", + expectedCommit=expected_commit, ) assert result["matchedIncident"] is not None assert result["matchedIncident"]["canonicalTarget"] == { @@ -104,8 +115,10 @@ def test_replays_notification_pipeline_incident(golden_repo): def test_unrelated_request_matches_no_incident(golden_repo): + _, expected_commit = golden_repo result = mcp_server_mod.check_duplication( - "quero adicionar um botao de exportar CSV na tela de relatorios" + "quero adicionar um botao de exportar CSV na tela de relatorios", + expectedCommit=expected_commit, ) assert result["matchedIncident"] is None @@ -114,7 +127,11 @@ def test_case_chat_candidate_components_surface_both_canonical_symbols(golden_re """The fix for the case-chat incident introduced two canonical symbols (getOrCreateCaseChat for provisioning, openProcessChat for navigation) -- a search for "chat" should surface both, not just one.""" - result = mcp_server_mod.check_duplication("preciso implementar chat verticalizado por processo") + _, expected_commit = golden_repo + result = mcp_server_mod.check_duplication( + "preciso implementar chat verticalizado por processo", + expectedCommit=expected_commit, + ) candidate_paths = {c["path"] for c in result["candidateComponents"]} assert "src/domain/cases/case-chat.ts" in candidate_paths assert "src/lib/process-chat.ts" in candidate_paths