Skip to content

Commit 9e7fbcb

Browse files
joanfgarciasafishamsi
authored andcommitted
feat(serve): optional project_path for multi-project MCP serving
Add an optional project_path to every MCP tool so one server process can answer against many projects. Omitted -> the server's default graph (fully backward-compatible); an absolute project_path -> that project's <GRAPHIFY_OUT>/graph.json, routed per call. Implementation (all in _build_server): - _ctx_cache + _load_ctx(): per-graph context cache with mtime+size hot-reload, unified across the default graph and every project graph. Unlike _load_graph it raises instead of sys.exit on a bad file. - _resolve_graph_path()/_select_graph(): map project_path to a graph.json and rebind G/communities/active_graph_path per call. No hot-path lock: select+handler run in one synchronous span of the call_tool coroutine. - Tolerant startup: serve with no default graph (pure multi-project mode) instead of exiting; a bad project_path is a tool error, not a crash. - project_path injected as an optional (non-required) field on all tools. Tests: 3 new HTTP-transport tests (optional-on-every-tool, routing, bad-path-no-crash). Full serve suite green (91 passed).
1 parent 6e97088 commit 9e7fbcb

3 files changed

Lines changed: 159 additions & 48 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
44

55
## Unreleased
66

7+
- Feat: the MCP server can serve many projects from one process via an optional `project_path` on every tool (#1594, thanks @joanfgarcia). Omit it and nothing changes — the server answers against the graph it was started with. Pass an absolute `project_path` and that call is routed to `<project_path>/<GRAPHIFY_OUT>/graph.json` instead, with its own mtime+size hot-reload, so one stdio/HTTP server backs a whole workspace of repos. Graphs load lazily and cache per resolved path; a missing/corrupt project graph is a tool error, not a process exit, and the server starts even when its default graph is absent. Backward-compatible and additive.
78
- Fix: Swift singleton cached into a local var now resolves later calls (#1604, thanks @jerryliurui). `let x = NetworkManager.shared` followed by `x.fetchData()` on a subsequent line produced zero call edges — local `let`/`var` bindings inside method bodies weren't typed (only class-level properties and params were), and a static-member init (`Type.shared`, a navigation expression) wasn't recognized even where locals were typed. Method-body locals are now typed from both constructor (`Type()`) and static-member (`Type.shared`) initializers, so `x.method()` resolves to the receiver type via the existing single-definition guard. This singleton-into-local idiom is one of the most common Swift call patterns.
89
- Fix: the skill's Python-interpreter detection now accepts Homebrew `python@3.x` paths (#1586, thanks @SUDARSHANCHAUDHARI). The shebang allowlist rejected any path with a character outside `[a-zA-Z0-9/_.-]`, but Homebrew installs versioned Python under `python@3.13`, so a valid interpreter containing `@` was skipped and detection fell through to a bare `python3` that lacked graphify (every step then failed with `ModuleNotFoundError`). `@` is now allowed across all skill variants (matching the #473 hooks.py fix); injection characters are still rejected.
910
- Fix: `graphify merge-graphs` no longer crashes on inputs that disagree on graph type (#1606, thanks @AdrianRusan). Per-repo `graph.json` files don't always share the same `directed` / `multigraph` flags, and `compose` requires one uniform type, so a mixed set raised an unhandled `NetworkXError`. All inputs are now normalized to a plain undirected graph (which the cross-repo merged view already is) before composing.

‎graphify/serve.py‎

Lines changed: 89 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def _compute_idf(G: nx.Graph, terms: list[str]) -> dict[str, float]:
158158
Common terms like 'error' or 'exception' that match hundreds of nodes get
159159
low weights; rare identifiers like 'FooBarService' get high weights.
160160
Cache is stored on the graph object itself so it auto-invalidates when
161-
_maybe_reload() replaces G with a new object.
161+
a hot-reload replaces G with a new object.
162162
"""
163163
cache: dict[str, float] = G.graph.setdefault("_idf_cache", {})
164164
N = G.number_of_nodes() or 1
@@ -210,7 +210,7 @@ def _node_search_text(data: dict, nid: str) -> str:
210210
def _get_trigram_index(G: nx.Graph) -> dict:
211211
"""Lazily build and cache a trigram -> node-position postings map on the graph.
212212
213-
Cached on `G.graph` so it auto-invalidates when _maybe_reload() swaps in a
213+
Cached on `G.graph` so it auto-invalidates when a hot-reload swaps in a
214214
fresh graph object, exactly like `_idf_cache`. `set_cache` memoizes per-trigram
215215
id-sets across queries within one graph generation.
216216
"""
@@ -737,56 +737,81 @@ def _build_server(graph_path: str):
737737
except ImportError as e:
738738
raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e
739739

740-
G = _load_graph(graph_path)
741-
communities = _communities_from_graph(G)
742-
# Build the trigram query index eagerly so the first query doesn't pay the
743-
# one-time build (a few seconds on a large graph). serve exists to answer
744-
# queries, so absorbing the cost at startup beats spiking a random first
745-
# request; it's cached on G.graph, so queries just reuse it.
746-
_get_trigram_index(G)
747-
748-
# Hot-reload state: mtime+size key lets us detect graph.json changes without
749-
# polling. Initialised from the file stat at startup so the first tool call
750-
# never triggers a redundant reload.
751-
_reload_lock = threading.Lock()
752-
try:
753-
_s = Path(graph_path).stat()
754-
_reload_state: dict = {"mtime_ns": _s.st_mtime_ns, "size": _s.st_size}
755-
except FileNotFoundError:
756-
_reload_state = {"mtime_ns": 0, "size": -1}
757-
758-
def _maybe_reload() -> None:
759-
nonlocal G, communities
740+
from graphify import paths as _paths
741+
742+
# Per-graph context cache: resolved graph.json path -> {key, G, communities}.
743+
# The server's default graph is just the first entry; a tool call carrying a
744+
# project_path adds its own. Routing every graph through one cache means the
745+
# eager trigram index and the mtime+size hot-reload behave identically for
746+
# the default graph and for any project graph.
747+
_default_graph_path = graph_path
748+
_ctx_lock = threading.Lock()
749+
_ctx_cache: dict[str, dict] = {}
750+
751+
def _load_ctx(path: str):
752+
"""Return (G, communities) for a graph.json path, reusing a cached
753+
context until the file's (mtime, size) changes and then transparently
754+
rebuilding it. Unlike ``_load_graph`` it never exits the process on a
755+
missing/corrupt file — it raises, so a bad project_path surfaces as a
756+
tool error instead of killing a server that is happily serving other
757+
projects."""
760758
try:
761-
s = Path(graph_path).stat()
759+
s = Path(path).stat()
762760
key = (s.st_mtime_ns, s.st_size)
763761
except FileNotFoundError:
764-
return
765-
if key == (_reload_state["mtime_ns"], _reload_state["size"]):
766-
return
767-
with _reload_lock:
762+
raise FileNotFoundError(f"graph.json not found: {path}")
763+
ent = _ctx_cache.get(path)
764+
if ent is not None and ent["key"] == key:
765+
return ent["G"], ent["communities"]
766+
with _ctx_lock:
767+
ent = _ctx_cache.get(path)
768+
if ent is not None and ent["key"] == key:
769+
return ent["G"], ent["communities"] # another thread built it
768770
try:
769-
s = Path(graph_path).stat()
770-
key = (s.st_mtime_ns, s.st_size)
771-
except FileNotFoundError:
772-
return
773-
if key == (_reload_state["mtime_ns"], _reload_state["size"]):
774-
return # another thread already reloaded
775-
try:
776-
new_G = _load_graph(graph_path)
777-
except SystemExit:
778-
return # keep serving stale graph on transient read error
779-
_get_trigram_index(new_G) # warm before exposing, so the first
780-
# post-reload query is fast too (same rationale as startup)
781-
G = new_G
782-
communities = _communities_from_graph(new_G)
783-
_reload_state["mtime_ns"], _reload_state["size"] = key
771+
new_G = _load_graph(path)
772+
except SystemExit as e: # _load_graph exits on missing/corrupt file
773+
raise RuntimeError(f"could not load graph.json at {path}") from e
774+
# Warm the trigram index before exposing the graph so the first query
775+
# against it is fast (same rationale as the original startup warm-up).
776+
_get_trigram_index(new_G)
777+
comm = _communities_from_graph(new_G)
778+
_ctx_cache[path] = {"key": key, "G": new_G, "communities": comm}
779+
return new_G, comm
780+
781+
def _resolve_graph_path(project_path) -> str:
782+
"""Map an optional project_path to a concrete graph.json path. ``None``
783+
keeps the server's default graph (backward-compatible); a project_path
784+
resolves to ``<project_path>/<GRAPHIFY_OUT>/graph.json``, honouring the
785+
GRAPHIFY_OUT override so worktree/shared-output setups keep working."""
786+
if not project_path:
787+
return _default_graph_path
788+
return str(Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json")
789+
790+
# Active per-request context, rebound by _select_graph() and read by the tool
791+
# handlers below. No lock needed on the hot path: _select_graph and the
792+
# handler run in one synchronous stretch of each call_tool coroutine (no
793+
# await between them), so a concurrent call never observes a half-applied
794+
# swap.
795+
active_graph_path = _default_graph_path
796+
try:
797+
G, communities = _load_ctx(_default_graph_path)
798+
except (FileNotFoundError, RuntimeError):
799+
# No default graph at startup → run as a pure multi-project server. Tools
800+
# then require project_path; a call without one gets a clear error rather
801+
# than the process refusing to start (which is what _load_graph would do).
802+
G, communities = None, {}
803+
804+
def _select_graph(project_path) -> None:
805+
nonlocal G, communities, active_graph_path
806+
path = _resolve_graph_path(project_path)
807+
G, communities = _load_ctx(path)
808+
active_graph_path = path
784809

785810
server = Server("graphify")
786811

787812
@server.list_tools()
788813
async def list_tools() -> list[types.Tool]:
789-
return [
814+
_tools = [
790815
types.Tool(
791816
name="query_graph",
792817
description="Search the knowledge graph using BFS or DFS. Returns relevant nodes and edges as text context.",
@@ -907,6 +932,20 @@ async def list_tools() -> list[types.Tool]:
907932
},
908933
),
909934
]
935+
# Multi-project support: every tool accepts an optional project_path.
936+
# Injected here (rather than repeated in 11 literal schemas) so the set
937+
# stays in lockstep as tools are added. Omitting it keeps the historical
938+
# single-graph behaviour, so this is purely additive for existing callers.
939+
for _t in _tools:
940+
_t.inputSchema.setdefault("properties", {})["project_path"] = {
941+
"type": "string",
942+
"description": (
943+
"Absolute path to a project directory containing "
944+
"graphify-out/graph.json. Optional — defaults to the graph "
945+
"this server was started with."
946+
),
947+
}
948+
return _tools
910949

911950
def _tool_query_graph(arguments: dict) -> str:
912951
import time as _time
@@ -928,7 +967,7 @@ def _tool_query_graph(arguments: dict) -> str:
928967
querylog.log_query(
929968
kind="mcp_query",
930969
question=question,
931-
corpus=str(graph_path),
970+
corpus=str(active_graph_path),
932971
result=result,
933972
mode=mode,
934973
depth=depth,
@@ -1171,7 +1210,7 @@ def _tool_triage_prs(arguments: dict) -> str:
11711210
}
11721211

11731212
def _load_community_labels() -> dict[int, str]:
1174-
labels_path = Path(graph_path).parent / ".graphify_labels.json"
1213+
labels_path = Path(active_graph_path).parent / ".graphify_labels.json"
11751214
if labels_path.exists():
11761215
try:
11771216
return {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()}
@@ -1192,10 +1231,10 @@ async def list_resources() -> list[types.Resource]:
11921231

11931232
@server.read_resource()
11941233
async def read_resource(uri: AnyUrl) -> str:
1195-
_maybe_reload()
1234+
_select_graph(None) # resources read the server's default graph
11961235
uri_str = str(uri)
11971236
if uri_str == "graphify://report":
1198-
report_path = Path(graph_path).parent / "GRAPH_REPORT.md"
1237+
report_path = Path(active_graph_path).parent / "GRAPH_REPORT.md"
11991238
if report_path.exists():
12001239
return report_path.read_text(encoding="utf-8")
12011240
return "GRAPH_REPORT.md not found. Run graphify extract first."
@@ -1244,11 +1283,13 @@ async def read_resource(uri: AnyUrl) -> str:
12441283

12451284
@server.call_tool()
12461285
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
1247-
_maybe_reload()
1286+
arguments = dict(arguments or {})
1287+
project_path = arguments.pop("project_path", None)
12481288
handler = _handlers.get(name)
12491289
if not handler:
12501290
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
12511291
try:
1292+
_select_graph(project_path) # bind G/communities to the target graph
12521293
return [types.TextContent(type="text", text=handler(arguments))]
12531294
except Exception as exc:
12541295
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]

‎tests/test_serve_http.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,75 @@ def test_tools_list_over_http(tmp_path):
170170
assert {"query_graph", "get_node", "graph_stats"} <= names
171171

172172

173+
def _project_with_graph(tmp_path, node_count: int) -> str:
174+
"""Create ``<proj>/graphify-out/graph.json`` and return the project dir."""
175+
proj = tmp_path / "proj"
176+
(proj / "graphify-out").mkdir(parents=True)
177+
graph = {
178+
"directed": True,
179+
"nodes": [{"id": f"n{i}", "label": f"N{i}", "community": 0} for i in range(node_count)],
180+
"edges": [],
181+
}
182+
(proj / "graphify-out" / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
183+
return str(proj)
184+
185+
186+
def _init_session(client) -> dict:
187+
init = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
188+
assert init.status_code == 200
189+
headers = {**_MCP_HEADERS, "mcp-session-id": init.headers.get("mcp-session-id")}
190+
client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
191+
return headers
192+
193+
194+
def _call_tool(client, headers, name, arguments, rid) -> str:
195+
resp = client.post("/mcp", headers=headers, json={
196+
"jsonrpc": "2.0", "id": rid, "method": "tools/call",
197+
"params": {"name": name, "arguments": arguments},
198+
})
199+
assert resp.status_code == 200
200+
return resp.json()["result"]["content"][0]["text"]
201+
202+
203+
def test_project_path_is_optional_on_every_tool(tmp_path):
204+
"""Multi-project support is additive: every tool gains an optional
205+
project_path, and none of them makes it required."""
206+
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
207+
with _client(app) as client:
208+
headers = _init_session(client)
209+
resp = client.post("/mcp", headers=headers,
210+
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
211+
for tool in resp.json()["result"]["tools"]:
212+
props = tool["inputSchema"].get("properties", {})
213+
assert "project_path" in props, f"{tool['name']} missing project_path"
214+
assert "project_path" not in tool["inputSchema"].get("required", [])
215+
216+
217+
def test_project_path_routes_to_that_projects_graph(tmp_path):
218+
"""One running server answers against the default graph when project_path is
219+
omitted, and against a project's own graph when it is supplied."""
220+
proj = _project_with_graph(tmp_path, node_count=3) # default graph has 2 nodes
221+
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
222+
with _client(app) as client:
223+
headers = _init_session(client)
224+
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=2)
225+
assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"project_path": proj}, rid=3)
226+
# Falling back to the default afterwards still works (no state leak).
227+
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=4)
228+
229+
230+
def test_bad_project_path_errors_without_killing_server(tmp_path):
231+
"""A missing project graph is a tool error, not a process exit — the server
232+
keeps serving the default graph."""
233+
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
234+
with _client(app) as client:
235+
headers = _init_session(client)
236+
bad = _call_tool(client, headers, "graph_stats",
237+
{"project_path": str(tmp_path / "does-not-exist")}, rid=2)
238+
assert "not found" in bad.lower()
239+
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=3)
240+
241+
173242
def test_stateless_mode_initialize(tmp_path):
174243
app = serve_mod._build_http_app(_graph_file(tmp_path), stateless=True, json_response=True)
175244
with _client(app) as client:

0 commit comments

Comments
 (0)