feat(serve): add multi-graph support to MCP server (#581) - #2099
feat(serve): add multi-graph support to MCP server (#581)#2099YanisGuerault wants to merge 2 commits into
Conversation
12f436c to
6d020bd
Compare
Enable serving multiple knowledge graphs from a single MCP endpoint. Always registry-based: single graph = 1-entry registry, multi-graph = directory scan via --graphs-dir flag or GRAPHS_DIR env var. - Add GraphContext dataclass + GraphRegistry (from_path, from_directory) - Refactor _build_server to accept GraphRegistry with per-call resolution - Tool visibility keyed on registry size: list_graphs/use_graph when >1, PR tools when ==1. New graph param on all tool schemas. - Add --graphs-dir CLI flag forcing HTTP transport with auto-rescan - Remove multi_serve.py and graphify-multi-mcp entry point (consolidated) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6d020bd to
2e39ab0
Compare
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 5 advisory finding(s) below merit a look before merge.
Formal verification. No changes could be formally verified in this run.
Graphify review — findings
Adds a multi-graph MCP server mode alongside the existing single-graph one: a new GraphRegistry loads either a single graph.json or every <name>/graph.json under a directory, rescans on an interval to pick up file changes (dropping graphs whose files vanish or fail to load), and _resolve_graph selects a target by explicit graph param, session default, or the sole loaded graph. Reworks _build_server to route all tool handlers through the registry and expose list_graphs/use_graph, and returns its handler dict for testing. Ships a multi Docker target driven by GRAPHS_DIR/SCAN_INTERVAL/PORT env vars plus a docker-compose.multi.yml and README docs, leaving the default single-graph image and entrypoint unchanged.
Worth a look
- Default Docker build now produces multi-graph image instead of existing single-graph image —
Dockerfile:28· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Multi-graph compose publishes unauthenticated MCP service by default —
docker-compose.multi.yml:11· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- except (OSError, SystemExit, Exception) swallows SystemExit and all errors, deleting graph —
graphify/serve.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- GraphRegistry.get/names read shared _graphs without lock —
graphify/serve.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Non-atomic single-graph resolution can return None during concurrent rescan —
graphify/serve.py:146· 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 — 1850 functions depend on the 1647 functions this change touches.
Health — this change adds coupling hotspots:
- new:
dispatch_command()— 2 callers, 122 callees - new:
_build_server()— 12 callers, 17 callees - new:
_query_graph_text()— 20 callers, 10 callees - new:
_score_query()— 15 callers, 6 callees - new:
_build_http_app()— 19 callers, 4 callees - new:
_query_terms()— 20 callers, 3 callees - new:
_subgraph_to_text()— 20 callers, 3 callees - new:
_main()— 8 callers, 7 callees - …and 14 more — each is listed as a finding
Verification — 1850 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: 1689 function(s) in the blast radius were not formally verified this run
Formal verification
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Could not verify: Could not verify \_build\_http\_app.
The verifier did not have enough to check \_build\_http\_app, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 24 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_build\_server.
The verifier did not have enough to check \_build\_server, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 54 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Verification did not run: Verification did not run for \_main.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=1): error: Graph path must be a .json file, got: '/workspace/job-a1e2a6c2baa84ce596d72365c42bb445/head/a' error: Graph path
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Verification did not run: Verification did not run for serve.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=1): error: Graph path must be a .json file, got: '/workspace/job-a1e2a6c2baa84ce596d72365c42bb445/head/a longer string' erro
Verification did not run: Verification did not run for serve\_http.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=124): timeout after 30s
· 9 grounded finding(s) anchored inline below; 13 more finding(s) on lines outside this diff (see the check run).
| ) | ||
|
|
||
|
|
||
| def _load_graph(graph_path: str) -> nx.Graph: |
There was a problem hiding this comment.
_load_graph()
12 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
|
||
|
|
||
| def _build_server(graph_path: str): | ||
| def _build_server(registry: GraphRegistry, *, session_state: dict | None = None): |
There was a problem hiding this comment.
_build_server()
fans out to 17 callees (efferent coupling); 12 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| path = _resolve_graph_path(project_path) | ||
| G, communities = _load_ctx(path) | ||
| active_graph_path = str(Path(path).resolve()) | ||
| def _get_ctx(arguments: dict) -> GraphContext: |
There was a problem hiding this comment.
_get_ctx()
9 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -1781,6 +1919,8 @@ def _tool_get_node(arguments: dict) -> str: | |||
| ]) | |||
|
|
|||
| def _tool_get_neighbors(arguments: dict) -> str: | |||
There was a problem hiding this comment.
_tool_get_neighbors()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -1884,6 +2035,8 @@ def _tool_list_prs(arguments: dict) -> str: | |||
| return format_prs_text(prs, base) | |||
|
|
|||
| def _tool_get_pr_impact(arguments: dict) -> str: | |||
There was a problem hiding this comment.
_tool_get_pr_impact()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -1990,17 +2174,33 @@ async def list_resources() -> list[types.Resource]: | |||
| ] | |||
|
|
|||
| async def read_resource(uri: AnyUrl) -> str: | |||
There was a problem hiding this comment.
read_resource()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return server, _handlers | ||
|
|
||
|
|
||
| def serve(graph_path: str | None = None) -> None: |
There was a problem hiding this comment.
serve()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -2175,8 +2374,9 @@ async def __call__(self, scope, receive, send) -> None: | |||
|
|
|||
|
|
|||
| def _build_http_app( | |||
There was a problem hiding this comment.
_build_http_app()
19 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=3) | ||
|
|
||
|
|
||
| def test_corrupt_project_graph_is_a_tool_error_without_killing_server(tmp_path): |
There was a problem hiding this comment.
test_corrupt_project_graph_is_a_tool_error_without_killing_server()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Replace directory discovery with explicit repository paths and remove the legacy multi-MCP deployment workflow.
3e02e0a to
70194e1
Compare
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 5 advisory finding(s) below merit a look before merge.
Formal verification. 2 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds a public graphify <repo>... --mcp CLI path that serves one or more existing repository graphs over MCP, parsed by _run_mcp_cli and dispatched to stdio via serve or HTTP via serve_http, rejecting duplicate repo basenames, missing graphify-out/graph.json, and malformed transport/host/port/api-key options. Introduces GraphRegistry and GraphContext to hold multiple named graphs, with from_paths skipping the learning overlay and project-path access, and exposes list_graphs/use_graph tools plus an optional per-tool graph parameter so callers can target a specific graph or set a session default. Retools the Dockerfile and adds docker-compose.multi.yml to run the public CLI against a mounted repo tree instead of baking a graph into the image.
Worth a look
- HTTP transport binds to 0.0.0.0 without requiring API key —
graphify/__main__.py:519· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- CLI allows unauthenticated non-loopback HTTP MCP binding —
graphify/__main__.py:584· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- GraphRegistry.get/names read shared dict without holding the lock while rescan mutates it —
graphify/serve.py· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Per-call project_path graph selection removed from tool dispatch —
graphify/serve.py· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- MCP tool calls no longer honor project_path routing —
graphify/serve.py:2080· Escalate · high- 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 — 839 functions depend on the 570 functions this change touches.
Health — this change adds coupling hotspots:
- new:
dispatch_command()— 2 callers, 122 callees - new:
_build_server()— 14 callers, 17 callees - new:
_query_graph_text()— 20 callers, 10 callees - new:
_score_query()— 15 callers, 6 callees - new:
_build_http_app()— 20 callers, 4 callees - new:
render()— 13 callers, 5 callees - new:
_get_ctx()— 9 callers, 7 callees - new:
_query_terms()— 20 callers, 3 callees - …and 21 more — each is listed as a finding
Verification — 839 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: 827 function(s) in the blast radius were not formally verified this run
Formal verification
No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).
The verifier ran both versions of \_run\_cli 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.
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Could not verify: Could not verify \_build\_http\_app.
The verifier did not have enough to check \_build\_http\_app, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 24 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_build\_server.
The verifier did not have enough to check \_build\_server, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 54 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Could not verify: Could not verify \_load\_graph.
The verifier did not have enough to check \_load\_graph, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Verification did not run: Verification did not run for \_main.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=1): error: Graph path must be a .json file, got: '/workspace/job-7af6d2a6766b4d54bd60987fc9155ca7/head/a' error: Graph path
Could not verify: Could not verify \_\_init\_\_.
The verifier did not have enough to check \_\_init\_\_, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: signature changed: no shared positional arity
Verification did not run: Verification did not run for serve.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=1): error: Graph path must be a .json file, got: '/workspace/job-7af6d2a6766b4d54bd60987fc9155ca7/head/a longer string' erro
Verification did not run: Verification did not run for serve\_http.
The verification could not execute (an environment/toolchain issue, not a statement about the code).
Guarantee: No guarantee, the check itself did not complete.
Note: Detail: harness produced no verdict (rc=124): timeout after 30s
No difference found (not proven): No behavior difference found in monolith\_roundtrip (not a proof).
The verifier ran both versions of monolith\_roundtrip 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.
· 11 grounded finding(s) anchored inline below; 18 more finding(s) on lines outside this diff (see the check run).
| serve(registry=registry) | ||
|
|
||
|
|
||
| def _serve_mcp_repositories( |
There was a problem hiding this comment.
_serve_mcp_repositories()
6 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return True | ||
|
|
||
|
|
||
| def _run_cli() -> None: |
There was a problem hiding this comment.
_run_cli()
high coupling complexity (Ca·Ce = 15).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| ) | ||
|
|
||
|
|
||
| def _load_graph(graph_path: str, *, load_learning_overlay: bool = True) -> nx.Graph: |
There was a problem hiding this comment.
_load_graph()
12 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
|
||
|
|
||
| def _build_server(graph_path: str): | ||
| def _build_server(registry: GraphRegistry, *, session_state: dict | None = None): |
There was a problem hiding this comment.
_build_server()
fans out to 17 callees (efferent coupling); 14 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return fallback_session_state | ||
| return session_states.setdefault(session, {}) | ||
|
|
||
| def _get_ctx(arguments: dict) -> GraphContext: |
There was a problem hiding this comment.
_get_ctx()
fans out to 7 callees (efferent coupling); 9 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -1884,6 +2023,8 @@ def _tool_list_prs(arguments: dict) -> str: | |||
| return format_prs_text(prs, base) | |||
|
|
|||
| def _tool_get_pr_impact(arguments: dict) -> str: | |||
There was a problem hiding this comment.
_tool_get_pr_impact()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -1990,17 +2164,34 @@ async def list_resources() -> list[types.Resource]: | |||
| ] | |||
|
|
|||
| async def read_resource(uri: AnyUrl) -> str: | |||
There was a problem hiding this comment.
read_resource()
fans out to 8 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
|
|
||
|
|
||
| def serve(graph_path: str | None = None) -> None: | ||
| def serve(graph_path: str | None = None, *, registry: GraphRegistry | None = None) -> None: |
There was a problem hiding this comment.
serve()
fans out to 6 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| @@ -2175,8 +2366,9 @@ async def __call__(self, scope, receive, send) -> None: | |||
|
|
|||
|
|
|||
| def _build_http_app( | |||
There was a problem hiding this comment.
_build_http_app()
20 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=3) | ||
|
|
||
|
|
||
| def test_corrupt_project_graph_is_a_tool_error_without_killing_server(tmp_path): |
There was a problem hiding this comment.
test_corrupt_project_graph_is_a_tool_error_without_killing_server()
fans out to 7 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Summary
--graphs-dirflag orGRAPHS_DIRenv varChanges
GraphContextdataclass +GraphRegistry(from_path,from_directory, hot-reload viarescan)_build_serverto acceptGraphRegistrywith per-call context resolution (_get_ctx)list_graphs/use_graphwhen >1, PR tools when ==1graphparam on all tool schemas (replacesproject_path)--graphs-dirCLI flag forces HTTP transport with daemon rescan thread🤖 Generated with Claude Code