Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
23 changes: 22 additions & 1 deletion graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/test_serve_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_mcp_query_graph_touches_orientation_stamp()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_mcp_orientation_tools_refresh_aged_stamp()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_mcp_non_orientation_tools_leave_stamp_alone()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_mcp_project_path_query_stamps_that_project()

fans out to 8 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_mcp_failed_call_does_not_stamp()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""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()
Loading