Skip to content

fix(serve): MCP query_graph traverses undirected, so a seed with no outgoing edges reaches its callers - #3373

Closed
kuchtgpt-svg wants to merge 1 commit into
Graphify-Labs:v8from
kuchtgpt-svg:fix/mcp-query-graph-undirected-traversal
Closed

fix(serve): MCP query_graph traverses undirected, so a seed with no outgoing edges reaches its callers#3373
kuchtgpt-svg wants to merge 1 commit into
Graphify-Labs:v8from
kuchtgpt-svg:fix/mcp-query-graph-undirected-traversal

Conversation

@kuchtgpt-svg

Copy link
Copy Markdown

Summary

Over MCP, query_graph answers with the seed node alone whenever the seed has no outgoing edges — a leaf function that is only ever called, imported and contained. The CLI query on the same graph.json returns the callers, the test and the neighbouring modules.

Measured on a real graph (3910 nodes / 7991 edges, TypeScript + SQL), seed taktTime():

surface result
CLI graphify query "taktTime" 57 nodes, 160 edges
MCP query_graph (question: "taktTime") 1 node — the seed

Unchanged across depth 2/3, token_budget 400/2000, and with an explicit context_filter: ["call"]. The header says 1 nodes found, not truncated: the traversal never expanded.

Root cause

_load_graph forces directed: True so renderers can recover stored arc order (#2309), and _bfs / _dfs expand through G.neighbors(), which on a DiGraph yields 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 query avoids this on purpose — its loader keeps the graph undirected and stashes _src/_tgt per edge so _subgraph_to_text still renders caller→callee (#2080; the comment in cli.py names exactly this failure mode: "forcing a DiGraph would make G.neighbors() return successors only, silently dropping every caller-side result for a seed with no outgoing edges"). _query_graph_text is shared, but the MCP path feeds it the directed graph from _load_graph. Same shape as #1174, where affected had missed the fix the other read surfaces got.

The other MCP tools are unaffected: get_neighbors walks successors and predecessors explicitly, shortest_path builds its own graph from _src/_tgt.

Fix

_traversal_view(G): an undirected copy for the traversal with _src/_tgt stashed on each edge (existing markers win, as in the CLI loader), used by _query_graph_text in place of G for the BFS/DFS and the context filter. An undirected input is returned as-is, so the CLI path is byte-for-byte unchanged. _load_graph is untouched — get_neighbors and shortest_path rely on direction.

After the change, on the graph above: MCP query_graph → 57 nodes / 160 edges; the NODE and EDGE lines are identical to the CLI's on six different questions (including edge direction).

Tests

tests/test_query_mcp_direction.py seeds one calls edge 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 with 1 nodes found. Also covered: caller-side seeding unchanged; MultiDiGraph keeps 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 plain DiGraph fold into one edge exactly as the CLI's undirected load of graph.json folds them, so the two surfaces stay identical.

uv run pytest tests/test_query_mcp_direction.py tests/test_serve.py tests/test_query_cli.py tests/test_query_induced_edges.py tests/test_query_names_its_graph.py
183 passed

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 pristine v8 (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 touch serve.py; they look Windows/uv-tool-environment bound. The pristine run additionally failed test_incremental_mtime_collision::test_same_size_rewrite_in_one_tick_is_requeued once (mtime-tick timing).

ruff check clean on both files.

… 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`.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 resultsgraphify/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).

Comment thread graphify/serve.py
H.add_edge(u, v, **{**d, "_src": d.get("_src", u), "_tgt": d.get("_tgt", v)})
return H

def _query_graph_text(

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 regression_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.

@kuchtgpt-svg

Copy link
Copy Markdown
Author

[агент] (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" (_traversal_view, plain DiGraph input):

The fold is deliberate and pre-existing rather than introduced here. The CLI query loads graph.json through json_graph.node_link_graph into an nx.Graph, so a reciprocal pair u->v / v->u already becomes one undirected edge there, last link winning. Building the MCP traversal view the same way is what makes the two surfaces return identical NODE/EDGE lines — checked on a 3910-node / 7991-edge graph across six questions.

Two facts that bound the impact:

  • Keeping both arcs would change nothing visible today: _subgraph_to_text renders the first parallel edge only (next(iter(G[u][v].values()))), and _complete_induced_edges dedups per unordered pair on an undirected graph. Preserving both directions is a renderer change, not a loader change, and it would apply to the CLI equally.
  • On the real graph above there are zero reciprocal pairs (counted over links), so the case did not arise in the measurements; it is real for mutual recursion or circular imports in other corpora.

If you would rather have the view keep both arcs now (always build an nx.MultiGraph for a directed input, so a later renderer change can show them), that is a two-line change and I am happy to push it — say the word. Otherwise I would leave parity with the CLI as is and open a separate issue for rendering parallel/reciprocal edges on both surfaces.

On the coupling note for _query_graph_text: the change adds one callee (_traversal_view); the 25 callers / 10 callees are the function's existing shape.

CI shows action_required — first-time contributor run waiting for approval.

@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.56 — landed on v8 via cherry-pick with your authorship preserved. Thanks @kuchtgpt-svg — an MCP query_graph seeded on a node with only incoming edges now traverses undirected and reaches its callers. Release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.56

@safishamsi safishamsi closed this Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants