fix(serve): MCP query_graph traverses undirected, so a seed with no outgoing edges reaches its callers - #3373
Conversation
… outgoing edges reaches its callers `_load_graph` forces `directed: True` so renderers can recover stored arc order (Graphify-Labs#2309), and `_bfs`/`_dfs` expand through `G.neighbors()`, which on a DiGraph yields successors only. Seeded on a leaf that is only ever called, imported and contained, the MCP `query_graph` therefore answered with the seed alone, while the CLI `query` — which loads the same graph.json undirected on purpose (Graphify-Labs#2080) — returned the callers, the test and the neighbouring modules. Same shape as Graphify-Labs#1174: one read surface missed a fix the others got. Add `_traversal_view`: an undirected copy for the traversal with `_src`/ `_tgt` stashed per edge, so `_subgraph_to_text` keeps rendering caller->callee regardless of the side the traversal reached the edge from. An undirected input is returned as-is, so the CLI path is unchanged; `_load_graph` is untouched because `get_neighbors` and `shortest_path` rely on direction explicitly. Regression tests seed the same `calls` edge from both endpoints through `_load_graph`, in both traversal modes, with and without a context filter; the callee-seeded ones fail on the prior code with `1 nodes found`.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.
Formal verification. 1 change(s) tested, no difference found (not proven).
Graphify review — findings
Fixes MCP query_graph returning only the seed node when seeded on a leaf that has no outgoing edges (e.g. a function that is only called/imported), where the CLI query returned the callers and neighbours. _query_graph_text now traverses through _traversal_view, an undirected copy of the directed graph _load_graph builds, stashing _src/_tgt on each edge so _subgraph_to_text still renders caller→callee regardless of the side reached; undirected inputs pass through untouched and existing edge markers win. Mutual arcs fold into a single undirected edge on a plain DiGraph (matching the CLI's undirected on-disk load), while a MultiDiGraph becomes a MultiGraph with networkx-assigned keys so parallel and mutual edges survive.
Worth a look
- Directed reciprocal edges are collapsed from query_graph results —
graphify/serve.py:1270· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 506 functions depend on the 135 functions this change touches.
Health — this change adds coupling hotspots:
- new:
_query_graph_text()— 25 callers, 10 callees - new:
dispatch_command()— 2 callers, 124 callees - new:
_score_query()— 15 callers, 6 callees - new:
_query_terms()— 20 callers, 3 callees - new:
run_benchmark()— 16 callers, 3 callees - new:
_load_graph()— 14 callers, 3 callees - new:
_build_server()— 2 callers, 16 callees - new:
_query_subgraph_tokens()— 7 callers, 3 callees - …and 9 more — each is listed as a finding
Verification — 506 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 331 function(s) in the blast radius were not formally verified this run
Formal verification
No difference found (not proven): No behavior difference found in \_query\_graph\_text (not a proof).
The verifier ran both versions of \_query\_graph\_text on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
· 1 grounded finding(s) anchored inline below; 16 more finding(s) on lines outside this diff (see the check run).
| H.add_edge(u, v, **{**d, "_src": d.get("_src", u), "_tgt": d.get("_tgt", v)}) | ||
| return H | ||
|
|
||
| def _query_graph_text( |
There was a problem hiding this comment.
_query_graph_text()
fans out to 10 callees (efferent coupling); 25 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
[агент] (this account is operated by an agent on the maintainer's behalf; the marker is a house rule) On the advisory "Directed reciprocal edges are collapsed from query_graph results" ( The fold is deliberate and pre-existing rather than introduced here. The CLI Two facts that bound the impact:
If you would rather have the view keep both arcs now (always build an On the coupling note for CI shows |
|
Shipped in v0.9.56 — landed on |
Summary
Over MCP,
query_graphanswers with the seed node alone whenever the seed has no outgoing edges — a leaf function that is only ever called, imported and contained. The CLIqueryon the samegraph.jsonreturns the callers, the test and the neighbouring modules.Measured on a real graph (3910 nodes / 7991 edges, TypeScript + SQL), seed
taktTime():graphify query "taktTime"query_graph(question: "taktTime")Unchanged across
depth2/3,token_budget400/2000, and with an explicitcontext_filter: ["call"]. The header says1 nodes found, not truncated: the traversal never expanded.Root cause
_load_graphforcesdirected: Trueso renderers can recover stored arc order (#2309), and_bfs/_dfsexpand throughG.neighbors(), which on aDiGraphyields successors only.taktTime()has six incoming edges (calls×3,imports×2,contains) and zero outgoing, so the frontier is empty after the seed.The CLI
queryavoids this on purpose — its loader keeps the graph undirected and stashes_src/_tgtper edge so_subgraph_to_textstill renders caller→callee (#2080; the comment incli.pynames exactly this failure mode: "forcing a DiGraph would makeG.neighbors()return successors only, silently dropping every caller-side result for a seed with no outgoing edges")._query_graph_textis shared, but the MCP path feeds it the directed graph from_load_graph. Same shape as #1174, whereaffectedhad missed the fix the other read surfaces got.The other MCP tools are unaffected:
get_neighborswalks successors and predecessors explicitly,shortest_pathbuilds its own graph from_src/_tgt.Fix
_traversal_view(G): an undirected copy for the traversal with_src/_tgtstashed on each edge (existing markers win, as in the CLI loader), used by_query_graph_textin place ofGfor the BFS/DFS and the context filter. An undirected input is returned as-is, so the CLI path is byte-for-byte unchanged._load_graphis untouched —get_neighborsandshortest_pathrely on direction.After the change, on the graph above: MCP
query_graph→ 57 nodes / 160 edges; theNODEandEDGElines are identical to the CLI's on six different questions (including edge direction).Tests
tests/test_query_mcp_direction.pyseeds onecallsedge from the callee side through_load_graph(the MCP path), in both traversal modes and with an explicit context filter; on the previous code these fail with1 nodes found. Also covered: caller-side seeding unchanged;MultiDiGraphkeeps parallel and mutual edges (fails if the stored keys are carried over — on an undirected multigraph a key is unique per unordered pair); undirected input is returned as-is; mutual arcs on a plainDiGraphfold into one edge exactly as the CLI's undirected load ofgraph.jsonfolds them, so the two surfaces stay identical.Full suite on this box (Windows 11, Python 3.14,
uv sync --extra mcp --extra sql): 31 failed / 5356 passed / 62 skipped on the branch, and the same 31 fail on pristinev8(c9f9901):test_hooks,test_install*,test_skillgen,test_watch,test_ollama_retry_cap,test_non_regular_files,test_uninstall_scope,test_languages,test_ignore_file_encoding,test_merge_chunks_validation— none touchserve.py; they look Windows/uv-tool-environment bound. The pristine run additionally failedtest_incremental_mtime_collision::test_same_size_rewrite_in_one_tick_is_requeuedonce (mtime-tick timing).ruff checkclean on both files.