From 4c3c76b35b47a1966a7c64fe4c0b3bea56dfb1ac Mon Sep 17 00:00:00 2001 From: Azeem Date: Tue, 25 Aug 2026 01:13:54 +0000 Subject: [PATCH] fix(serve): MCP orientation refreshes the strict hook's freshness stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strict read guard admits an agent only while the 'recently oriented' stamp (graphify-out/cache/last_query_stamp) is fresh. The CLI's query / path / explain each touch it (cli._touch_query_stamp), but their MCP twins never did — an agent that oriented through the server via query_graph was still treated as blind and had its first raw read denied. call_tool now touches the stamp after every successful call to one of the four orientation tools: query_graph (= query), shortest_path (= path), get_node + get_neighbors (the MCP split of explain). The stamp lands next to the graph that answered, so a project_path call stamps THAT project's graphify-out/cache — the graph whose reads the guard gates. Browsing tools (graph_stats, god_nodes, get_community) and the PR tools don't stamp on the CLI and still don't over MCP; a failed call stamps nothing. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++ graphify/serve.py | 23 ++++++++- tests/test_serve_http.py | 103 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82baccf9e..73bad69006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Fix: orienting through the MCP server now refreshes the strict read hook's "recently oriented" stamp, exactly like the CLI. `graphify query` / `path` / `explain` each touch `graphify-out/cache/last_query_stamp` so the strict hook lets the next raw file read through — but their MCP twins never did, so an agent that consulted the graph via `query_graph` was still treated as blind and had its first read denied. The stamp is now touched after every successful `query_graph`, `shortest_path`, `get_node`, or `get_neighbors` call (get_node + get_neighbors are the MCP split of `explain`), next to the graph that actually answered — a `project_path` call stamps that project's `graphify-out/cache`, which is the graph whose reads the guard gates. Browsing tools (`graph_stats`, `god_nodes`, `get_community`) and the PR tools don't stamp on the CLI and still don't over MCP; a failed call stamps nothing. + ## 0.9.50 (2026-08-25) - Fix: Ruby methods whose names end in `!`, `?`, or `=` now keep distinct node ids, so `save` and `save!` (or `foo` and `foo=`) no longer collide into one node; the label keeps the raw spelling and member-call resolution still matches (#3077, thanks @hopstreax). diff --git a/graphify/serve.py b/graphify/serve.py index 58c0925b4e..6610a6951d 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -1968,6 +1968,19 @@ def _tool_triage_prs(arguments: dict) -> str: "triage_prs": _tool_triage_prs, } + # The MCP twins of the CLI commands whose runs refresh the "recently + # oriented" stamp that the strict read hook honours (`graphify query` / + # `path` / `explain` each call cli._touch_query_stamp). query_graph and + # shortest_path map 1:1 onto `query` and `path`; get_node + get_neighbors + # are the MCP split of `explain`. Graph-level browsing (god_nodes, + # graph_stats, get_community) and PR triage don't stamp on the CLI either, + # so they stay out. Without this set, an agent that orients through the + # MCP server is still treated as blind by the strict guard and gets its + # first raw read denied — after it already consulted the graph. + _ORIENTATION_TOOLS = frozenset( + {"query_graph", "get_node", "get_neighbors", "shortest_path"} + ) + def _load_community_labels() -> dict[int, str]: labels_path = Path(active_graph_path).parent / ".graphify_labels.json" if labels_path.exists(): @@ -2048,9 +2061,17 @@ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: return [types.TextContent(type="text", text=f"Unknown tool: {name}")] try: _select_graph(project_path) # bind G/communities to the target graph - return [types.TextContent(type="text", text=handler(arguments))] + text = handler(arguments) except Exception as exc: return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")] + if name in _ORIENTATION_TOOLS: + # Same stamp, same location as the CLI: next to the graph that + # actually answered (so a project_path call stamps THAT project). + # _touch_query_stamp is fail-silent; a stamp failure never breaks + # the tool result. + from graphify.cli import _touch_query_stamp + _touch_query_stamp(Path(active_graph_path)) + return [types.TextContent(type="text", text=text)] if hasattr(Server, "list_tools"): # mcp 1.x: decorator-based registration. The SDK wraps the raw returns diff --git a/tests/test_serve_http.py b/tests/test_serve_http.py index 7893a0f4e3..ae15c73ccd 100644 --- a/tests/test_serve_http.py +++ b/tests/test_serve_http.py @@ -361,3 +361,106 @@ def test_cli_api_key_from_env(monkeypatch): monkeypatch.setattr(serve_mod, "serve_http", lambda gp, **k: captured.update(**k)) serve_mod._main(["g.json", "--transport", "http"]) assert captured["api_key"] == "from-env" + + +# --- orientation stamp: MCP graph reads refresh the strict-hook freshness stamp + + +def _stamp_path(graph_file: str) -> Path: + """The 'recently oriented' stamp the strict read hook checks, next to the + graph that answered (same location cli._touch_query_stamp writes).""" + return Path(graph_file).parent / "cache" / "last_query_stamp" + + +def _age_stamp(stamp: Path) -> None: + stamp.parent.mkdir(parents=True, exist_ok=True) + stamp.write_text("0") + import os + + os.utime(stamp, (1000, 1000)) + + +def test_mcp_query_graph_touches_orientation_stamp(tmp_path): + """query_graph is orientation: an agent that consulted the graph through + the MCP server must be as 'recently oriented' as one that ran the CLI + `graphify query` — otherwise the strict read hook denies its next read.""" + graph_file = _graph_file(tmp_path) + app = serve_mod._build_http_app(graph_file, json_response=True) + with _client(app) as client: + headers = _init_session(client) + assert not _stamp_path(graph_file).exists() + assert "Alpha" in _call_tool(client, headers, "query_graph", {"question": "Alpha"}, rid=2) + assert _stamp_path(graph_file).exists() + + +@pytest.mark.parametrize( + ("tool", "arguments"), + [ + ("query_graph", {"question": "Alpha"}), + ("get_node", {"label": "Alpha"}), + ("get_neighbors", {"label": "Alpha"}), + ("shortest_path", {"source": "Alpha", "target": "Beta"}), + ], +) +def test_mcp_orientation_tools_refresh_aged_stamp(tmp_path, tool, arguments): + """Each MCP twin of the stamping CLI commands (query/path/explain) must + refresh an existing-but-old stamp on a successful call.""" + graph_file = _graph_file(tmp_path) + stamp = _stamp_path(graph_file) + _age_stamp(stamp) + app = serve_mod._build_http_app(graph_file, json_response=True) + with _client(app) as client: + headers = _init_session(client) + _call_tool(client, headers, tool, arguments, rid=2) + assert stamp.stat().st_mtime > 1000, f"{tool} did not refresh the stamp" + + +@pytest.mark.parametrize( + ("tool", "arguments"), + [ + ("graph_stats", {}), + ("god_nodes", {}), + ("get_community", {"community_id": 0}), + ], +) +def test_mcp_non_orientation_tools_leave_stamp_alone(tmp_path, tool, arguments): + """Graph-level browsing does not stamp on the CLI (`god-nodes` never + touches it) and must not stamp over MCP either.""" + graph_file = _graph_file(tmp_path) + stamp = _stamp_path(graph_file) + _age_stamp(stamp) + app = serve_mod._build_http_app(graph_file, json_response=True) + with _client(app) as client: + headers = _init_session(client) + _call_tool(client, headers, tool, arguments, rid=2) + assert stamp.stat().st_mtime == 1000, f"{tool} unexpectedly refreshed the stamp" + + +def test_mcp_project_path_query_stamps_that_project(tmp_path): + """A project_path call stamps next to the graph that answered — the + project's graphify-out/cache — not next to the server's default graph.""" + proj = _project_with_graph(tmp_path, node_count=3) + default_graph = _graph_file(tmp_path) + app = serve_mod._build_http_app(default_graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + _call_tool(client, headers, "query_graph", {"question": "N1", "project_path": proj}, rid=2) + assert (Path(proj) / "graphify-out" / "cache" / "last_query_stamp").exists() + assert not _stamp_path(default_graph).exists() + + +def test_mcp_failed_call_does_not_stamp(tmp_path): + """A tool error is not orientation: a query against a missing project graph + must leave no freshness stamp anywhere.""" + default_graph = _graph_file(tmp_path) + missing = tmp_path / "no-such-project" + app = serve_mod._build_http_app(default_graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool( + client, headers, "query_graph", + {"question": "Alpha", "project_path": str(missing)}, rid=2, + ) + assert "not found" in out.lower() + assert not _stamp_path(default_graph).exists() + assert not (missing / "graphify-out" / "cache" / "last_query_stamp").exists()