diff --git a/.gitignore b/.gitignore index 0a6775b2a8..5ef097f618 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ build/ *.egg .graphify/ graphify-out/ +.worktrees/ .graphify_*.json .graphify_python .claude/ diff --git a/Dockerfile b/Dockerfile index a313833c5c..a4e62eb172 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,15 @@ -# graphify MCP server as a shared HTTP service (issue #1143). -# -# Build: docker build -t graphify . -# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ -# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" -# -# Builds from source so the image includes the Streamable HTTP transport even -# before it lands on PyPI. The graph.json is mounted at runtime (-v), never -# baked into the image. +# graphify MCP server. Mount a repository containing graphify-out/graph.json. FROM python:3.12-slim - WORKDIR /app COPY . /app # The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs. RUN pip install --no-cache-dir ".[mcp]" -# Run as a non-root user — the server is network-exposed. +# Run as a non-root user because the server is network-exposed. RUN useradd --create-home --uid 10001 graphify USER graphify EXPOSE 8080 - -ENTRYPOINT ["python", "-m", "graphify.serve"] -CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"] +ENTRYPOINT ["graphify"] +CMD ["/data", "--mcp", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index 0c14d207c9..2fdf1c3afd 100644 --- a/README.md +++ b/README.md @@ -485,8 +485,7 @@ The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--a ```bash docker build -t graphify . -docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ - /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET" +docker run -p 8080:8080 -v "$(pwd):/data:ro" graphify /data --mcp --transport http --host 0.0.0.0 --api-key "$SECRET" ``` > **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts: @@ -494,6 +493,34 @@ docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \ > python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]" > ``` +### Multi-graph MCP server + +Serve multiple repositories containing `graphify-out/graph.json` from one MCP endpoint. This is useful for multi-repo setups, monorepos with per-service graphs, or comparing codebases. + +```bash +# Serve over stdio (the default transport). +graphify ../frontend ../backend --mcp + +# Serve over HTTP. +graphify ../frontend ../backend --mcp --transport http --host 0.0.0.0 --port 8080 +``` + +Each repository path must contain `graphify-out/graph.json`. To run the documented two-repository example in Docker, arrange the Compose directory as: + +```text +repos/ + frontend/graphify-out/graph.json + backend/graphify-out/graph.json +``` + +Then start the public CLI through Compose: + +```bash +docker compose -f docker-compose.multi.yml up --build +``` + +Tools: same as single-graph (`query_graph`, `get_node`, `get_neighbors`, etc.) plus `list_graphs` and `use_graph`. Each tool accepts an optional `graph` parameter to target a specific graph, or use `use_graph` to set a session default. + --- ## Environment variables diff --git a/docker-compose.multi.yml b/docker-compose.multi.yml new file mode 100644 index 0000000000..0bbbc7908a --- /dev/null +++ b/docker-compose.multi.yml @@ -0,0 +1,11 @@ +# Quick start: docker compose -f docker-compose.multi.yml up --build +# Runs: graphify /repos/frontend /repos/backend --mcp +services: + graphify-mcp: + build: . + ports: + - "127.0.0.1:8080:8080" + volumes: + - ./repos/frontend:/repos/frontend:ro + - ./repos/backend:/repos/backend:ro + command: ["/repos/frontend", "/repos/backend", "--mcp", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"] diff --git a/graphify/__main__.py b/graphify/__main__.py index 155501a98d..011e1be8ee 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -479,6 +479,122 @@ def main() -> None: raise +def _start_mcp_registry(registry) -> None: + from graphify.serve import serve + + serve(registry=registry) + + +def _serve_mcp_repositories( + paths: list[str], + *, + transport: str, + host: str = "127.0.0.1", + port: int = 8080, + api_key: str | None = None, +) -> None: + from graphify.serve import GraphRegistry + + if not paths: + print("error: --mcp requires at least one repository path", file=sys.stderr) + raise SystemExit(1) + resolved = [Path(path).resolve() for path in paths] + names = [path.name for path in resolved] + duplicate = next((name for name in names if names.count(name) > 1), None) + if duplicate is not None: + print(f"error: duplicate graph name {duplicate!r}; repository basenames must be unique", file=sys.stderr) + raise SystemExit(1) + graph_paths = [] + for repo in resolved: + graph_path = repo / _GRAPHIFY_OUT / "graph.json" + try: + exists = graph_path.is_file() + except OSError as exc: + print(f"error: could not read graph: {graph_path} ({exc})", file=sys.stderr) + raise SystemExit(1) from None + if not exists: + print(f"error: graph not found: {graph_path}", file=sys.stderr) + raise SystemExit(1) + graph_paths.append(graph_path) + try: + registry = GraphRegistry.from_paths(graph_paths) + except OSError as exc: + print(f"error: could not read graph: {exc}", file=sys.stderr) + raise SystemExit(1) from None + if transport == "http": + from graphify.serve import serve_http + + serve_http(registry=registry, host=host, port=port, api_key=api_key) + else: + _start_mcp_registry(registry) + + +def _run_mcp_cli(args: list[str]) -> bool: + if "--mcp" not in args: + return False + + paths: list[str] = [] + transport = "stdio" + host = "127.0.0.1" + port = 8080 + api_key: str | None = None + index = 0 + while index < len(args): + arg = args[index] + if arg == "--mcp": + index += 1 + elif arg == "--transport": + index += 1 + if index == len(args) or args[index] not in {"stdio", "http"}: + print("error: --transport must be stdio or http", file=sys.stderr) + raise SystemExit(1) + transport = args[index] + index += 1 + elif arg == "--host": + index += 1 + if index == len(args): + print("error: --host requires a value", file=sys.stderr) + raise SystemExit(1) + if args[index].startswith("-"): + print(f"error: unrecognized MCP option: {args[index]}", file=sys.stderr) + raise SystemExit(1) + host = args[index] + index += 1 + elif arg == "--port": + index += 1 + if index == len(args): + print("error: --port requires an integer", file=sys.stderr) + raise SystemExit(1) + try: + port = int(args[index]) + except ValueError: + print("error: --port requires an integer", file=sys.stderr) + raise SystemExit(1) from None + index += 1 + elif arg == "--api-key": + index += 1 + if index == len(args): + print("error: --api-key requires a value", file=sys.stderr) + raise SystemExit(1) + if args[index].startswith("-"): + print(f"error: unrecognized MCP option: {args[index]}", file=sys.stderr) + raise SystemExit(1) + api_key = args[index] + index += 1 + elif arg.startswith("-"): + print(f"error: unrecognized MCP option: {arg}", file=sys.stderr) + raise SystemExit(1) + else: + paths.append(arg) + index += 1 + + if transport == "stdio" and host == "127.0.0.1" and port == 8080 and api_key is None: + _serve_mcp_repositories(paths, transport=transport) + else: + _serve_mcp_repositories(paths, transport=transport, host=host, port=port, api_key=api_key) + return True + + def _run_cli() -> None: for _stream in (sys.stdout, sys.stderr): if _stream is not None and hasattr(_stream, "reconfigure"): @@ -502,10 +618,18 @@ def _run_cli() -> None: print(f"graphify {__version__}") return + if _run_mcp_cli(sys.argv[1:]): + return + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"): print("Usage: graphify ") print() print("Commands:") + print(" ... --mcp serve existing repository graphs over MCP stdio") + print(" --transport stdio|http transport (default: stdio)") + print(" --host HOST HTTP bind host (default: 127.0.0.1)") + print(" --port PORT HTTP bind port (default: 8080)") + print(" --api-key KEY require this key for HTTP requests") print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") print(" uninstall remove graphify from all detected platforms in one shot") print(" --purge also delete graphify-out/ directory") diff --git a/graphify/serve.py b/graphify/serve.py index 58c0925b4e..47b5bf1766 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -5,8 +5,11 @@ import os import re import sys +import threading +import weakref from array import array from collections import OrderedDict +from dataclasses import dataclass from pathlib import Path import threading from typing import NamedTuple @@ -15,6 +18,7 @@ from graphify.security import sanitize_label, check_graph_file_size_cap from graphify.build import edge_data, edge_datas from graphify.paths import default_graph_json as _default_graph_json +from graphify import paths as _paths try: import jieba as _jieba # type: ignore[import-untyped] @@ -22,7 +26,104 @@ _jieba = None -def _load_graph(graph_path: str) -> nx.Graph: +@dataclass +class GraphContext: + name: str + path: Path + graph: nx.Graph + communities: dict[int, list[str]] + mtime: float + + +class GraphRegistry: + def __init__(self) -> None: + self._graphs: dict[str, GraphContext] = {} + self._allow_project_paths = True + self._load_learning_overlay = True + self._lock = threading.Lock() + + @classmethod + def from_path(cls, graph_path: Path) -> "GraphRegistry": + reg = cls() + graph_path = Path(graph_path).resolve() + G = _load_graph(str(graph_path)) + _get_trigram_index(G) + communities = _communities_from_graph(G) + name = graph_path.parent.name or "default" + mtime = graph_path.stat().st_mtime + reg._graphs[name] = GraphContext( + name=name, path=graph_path, graph=G, + communities=communities, mtime=mtime, + ) + return reg + + @classmethod + def from_paths(cls, graph_paths: list[Path]) -> "GraphRegistry": + reg = cls() + reg._allow_project_paths = False + reg._load_learning_overlay = False + for path in graph_paths: + graph_path = Path(path).resolve() + G = _load_graph(str(graph_path), load_learning_overlay=False) + _get_trigram_index(G) + communities = _communities_from_graph(G) + name = graph_path.parent.parent.name or "default" + mtime = graph_path.stat().st_mtime + reg._graphs[name] = GraphContext( + name=name, path=graph_path, graph=G, + communities=communities, mtime=mtime, + ) + return reg + + def rescan(self) -> None: + with self._lock: + for name, ctx in list(self._graphs.items()): + try: + s = ctx.path.stat() + if s.st_mtime != ctx.mtime: + G = _load_graph( + str(ctx.path), load_learning_overlay=self._load_learning_overlay + ) + _get_trigram_index(G) + communities = _communities_from_graph(G) + self._graphs[name] = GraphContext( + name=name, path=ctx.path, graph=G, + communities=communities, mtime=s.st_mtime, + ) + except (OSError, SystemExit, Exception): + del self._graphs[name] + + def get(self, name: str) -> GraphContext | None: + return self._graphs.get(name) + + def names(self) -> list[str]: + return sorted(self._graphs.keys()) + + +def _resolve_graph( + registry: GraphRegistry, + *, + graph: str | None = None, + current: str | None = None, +) -> GraphContext: + name = graph or current + if name is not None: + ctx = registry.get(name) + if ctx is None: + raise ValueError(f"graph {name!r} not found. Available: {registry.names()}") + return ctx + names = registry.names() + if not names: + raise ValueError("no graphs loaded") + if len(names) == 1: + return registry.get(names[0]) + raise ValueError( + f"multiple graphs available ({', '.join(names)}), " + "specify with graph param or use_graph()" + ) + + +def _load_graph(graph_path: str, *, load_learning_overlay: bool = True) -> nx.Graph: try: resolved = Path(graph_path).resolve() if resolved.suffix != ".json": @@ -55,13 +156,15 @@ def _load_graph(graph_path: str) -> nx.Graph: except TypeError: G = json_graph.node_link_graph(data) G.graph["_logical_directed"] = _logical_directed - # Attach the work-memory overlay (derived sidecar next to graph.json) so - # the query/MCP read surface can annotate NODE lines display-only. Empty - # when no sidecar exists, leaving un-annotated output byte-identical. - try: - from graphify.reflect import load_learning_overlay as _llo - G.graph["_learning_overlay"] = _llo(resolved) - except Exception: + if load_learning_overlay: + # Attach the work-memory overlay (derived sidecar next to graph.json) + # so the query/MCP read surface can annotate NODE lines display-only. + try: + from graphify.reflect import load_learning_overlay as _llo + G.graph["_learning_overlay"] = _llo(resolved) + except Exception: + G.graph["_learning_overlay"] = {} + else: G.graph["_learning_overlay"] = {} return G except json.JSONDecodeError as exc: @@ -1505,13 +1608,15 @@ def _community_header(cid: int, community_name) -> str: return base -def _build_server(graph_path: str): +def _build_server(registry: GraphRegistry, *, session_state: dict | None = None): """Build the configured low-level MCP Server (shared by every transport). All graph query tools and resources are registered here over a single ``mcp.server.Server`` instance; the caller picks the transport (stdio or - Streamable HTTP) and runs it. Hot-reload of graph.json works the same way - regardless of transport, since reloads happen inside the tool handlers. + Streamable HTTP) and runs it. Graph resolution goes through the registry, + supporting both single-graph and multi-graph deployments. + + Returns ``(server, handlers)`` — the handlers dict is exposed for testing. """ try: from mcp.server import Server @@ -1525,52 +1630,47 @@ def _build_server(graph_path: str): # AnyUrl (pydantic is an mcp dependency, so this import cannot miss). from pydantic import AnyUrl - from graphify import paths as _paths - - # Graph contexts comprise one pinned configured default plus a bounded LRU - # of project_path graphs. This preserves the configured graph's warm index - # while preventing a shared server from retaining every project it serves. - _default_graph_path = str(Path(graph_path).resolve()) + session_states: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + fixed_session_state = session_state + fallback_session_state: dict = {} + is_multi = len(registry.names()) > 1 _ctx_cache = _GraphContextCache(_max_server_contexts()) + default_paths = { + str(ctx.path.resolve()) + for name in registry.names() + if (ctx := registry.get(name)) is not None + } - def _load_ctx(path: str): - """Return the current default or project graph context as a tool error. - - Unlike ``_load_graph``, this never lets a missing or corrupt client - graph terminate the MCP process; it raises so other projects remain - available on the same server. - """ - resolved_path = str(Path(path).resolve()) - return _ctx_cache.load(resolved_path, pinned=resolved_path == _default_graph_path) - - def _resolve_graph_path(project_path) -> str: - """Map an optional project_path to a concrete graph.json path. ``None`` - keeps the server's default graph (backward-compatible); a project_path - resolves to ``//graph.json``, honouring the - GRAPHIFY_OUT override so worktree/shared-output setups keep working.""" - if not project_path: - return _default_graph_path - return str(Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json") - - # Active per-request context, rebound by _select_graph() and read by the tool - # handlers below. No lock needed on the hot path: _select_graph and the - # handler run in one synchronous stretch of each call_tool coroutine (no - # await between them), so a concurrent call never observes a half-applied - # swap. - active_graph_path = _default_graph_path - try: - G, communities = _load_ctx(_default_graph_path) - except (FileNotFoundError, RuntimeError): - # No default graph at startup → run as a pure multi-project server. Tools - # then require project_path; a call without one gets a clear error rather - # than the process refusing to start (which is what _load_graph would do). - G, communities = None, {} - - def _select_graph(project_path) -> None: - nonlocal G, communities, active_graph_path - path = _resolve_graph_path(project_path) - G, communities = _load_ctx(path) - active_graph_path = str(Path(path).resolve()) + def _current_session_state() -> dict: + if fixed_session_state is not None: + return fixed_session_state + try: + session = server.request_context.session + except LookupError: + return fallback_session_state + return session_states.setdefault(session, {}) + + def _get_ctx(arguments: dict) -> GraphContext: + registry.rescan() + graph_param = arguments.pop("graph", None) + project_path = arguments.pop("project_path", None) + if project_path: + if not registry._allow_project_paths: + raise ValueError("project_path is not supported by an explicit --mcp registry") + path = str((Path(project_path) / _paths.GRAPHIFY_OUT / "graph.json").resolve()) + graph, communities = _ctx_cache.load(path, pinned=path in default_paths) + return GraphContext( + name=Path(project_path).name or "project", + path=Path(path), + graph=graph, + communities=communities, + mtime=Path(path).stat().st_mtime, + ) + return _resolve_graph( + registry, + graph=graph_param, + current=_current_session_state().get("current_graph"), + ) # NOTE: no decorators here — the handlers below are plain coroutines, # bound to the Server at the END of this function in a version-aware way: @@ -1660,7 +1760,26 @@ async def list_tools() -> list[types.Tool]: "required": ["source", "target"], }, ), - types.Tool( + ] + if is_multi: + _tools.append(types.Tool( + name="list_graphs", + description="List all available knowledge graphs with node/edge/community counts.", + inputSchema={"type": "object", "properties": {}}, + )) + _tools.append(types.Tool( + name="use_graph", + description="Set the default graph for this session.", + inputSchema={ + "type": "object", + "properties": { + "graph": {"type": "string", "description": "Name of the graph to use"}, + }, + "required": ["graph"], + }, + )) + else: + _tools.append(types.Tool( name="list_prs", description=( "List open GitHub PRs with CI status, review state, and graph impact " @@ -1674,8 +1793,8 @@ async def list_tools() -> list[types.Tool]: "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."}, }, }, - ), - types.Tool( + )) + _tools.append(types.Tool( name="get_pr_impact", description=( "Get detailed graph impact for a specific PR: which files it changes, " @@ -1690,8 +1809,8 @@ async def list_tools() -> list[types.Tool]: }, "required": ["pr_number"], }, - ), - types.Tool( + )) + _tools.append(types.Tool( name="triage_prs", description=( "Return all actionable open PRs (correct base, not stale) with full graph impact data " @@ -1705,31 +1824,36 @@ async def list_tools() -> list[types.Tool]: "repo": {"type": "string", "description": "GitHub repo (owner/repo). Defaults to current repo."}, }, }, - ), - ] - # Multi-project support: every tool accepts an optional project_path. - # Injected here (rather than repeated in 11 literal schemas) so the set - # stays in lockstep as tools are added. Omitting it keeps the historical - # single-graph behaviour, so this is purely additive for existing callers. + )) + # Named graphs and project paths remain optional to preserve existing + # clients while allowing a registry-backed multi-graph server. for _t in _tools: # The constructor accepts the camelCase alias in both majors, but # attribute access is inputSchema on mcp 1.x and input_schema on 2.x. _schema = getattr(_t, "inputSchema", None) if _schema is None: _schema = _t.input_schema - _schema.setdefault("properties", {})["project_path"] = { - "type": "string", - "description": ( - "Absolute path to a project directory containing " - "graphify-out/graph.json. Optional — defaults to the graph " - "this server was started with." - ), - } + if _t.name not in ("list_graphs", "use_graph"): + _schema.setdefault("properties", {})["graph"] = { + "type": "string", + "description": "Target graph name. Overrides session default.", + } + if registry._allow_project_paths and _t.name not in ("list_graphs", "use_graph"): + _schema.setdefault("properties", {})["project_path"] = { + "type": "string", + "description": ( + "Absolute path to a project directory containing " + "graphify-out/graph.json. Optional — defaults to the graph " + "this server was started with." + ), + } return _tools def _tool_query_graph(arguments: dict) -> str: import time as _time from graphify import querylog + ctx = _get_ctx(arguments) + G = ctx.graph question = arguments["question"] mode = arguments.get("mode", "bfs") depth = min(int(arguments.get("depth", 3)), 6) @@ -1743,12 +1867,12 @@ def _tool_query_graph(arguments: dict) -> str: depth=depth, token_budget=budget, context_filters=context_filter, - graph_path=str(active_graph_path), + graph_path=str(ctx.path), ) querylog.log_query( kind="mcp_query", question=question, - corpus=str(active_graph_path), + corpus=str(ctx.path), result=result, mode=mode, depth=depth, @@ -1758,6 +1882,8 @@ def _tool_query_graph(arguments: dict) -> str: return result def _tool_get_node(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph label = arguments["label"].lower() matches = [(nid, d) for nid, d in G.nodes(data=True) if label in (d.get("label") or "").lower() or label == nid.lower()] @@ -1781,6 +1907,8 @@ def _tool_get_node(arguments: dict) -> str: ]) def _tool_get_neighbors(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph label = arguments["label"].lower() rel_filter = arguments.get("relation_filter", "").lower() matches = _find_node(G, label) @@ -1830,6 +1958,9 @@ def _edge_at(d: dict) -> str: ) def _tool_get_community(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph + communities = ctx.communities cid = int(arguments["community_id"]) nodes = communities.get(cid, []) if not nodes: @@ -1850,12 +1981,17 @@ def _tool_get_community(arguments: dict) -> str: def _tool_god_nodes(arguments: dict) -> str: from graphify.analyze import god_nodes as _god_nodes + ctx = _get_ctx(arguments) + G = ctx.graph nodes = _god_nodes(G, top_n=int(arguments.get("top_n", 10))) lines = ["God nodes (most connected):"] lines += [f" {i}. {n['label']} - {n['degree']} edges" for i, n in enumerate(nodes, 1)] return "\n".join(lines) - def _tool_graph_stats(_: dict) -> str: + def _tool_graph_stats(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph + communities = ctx.communities confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)] total = len(confs) or 1 return ( @@ -1868,9 +2004,12 @@ def _tool_graph_stats(_: dict) -> str: ) def _tool_shortest_path(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph return _shortest_path_text(G, arguments) def _tool_list_prs(arguments: dict) -> str: + arguments.pop("graph", None) # list_prs doesn't route to a graph from graphify.prs import fetch_prs, fetch_worktrees, format_prs_text, _detect_default_branch repo = arguments.get("repo") or None base = arguments.get("base") or _detect_default_branch(repo) @@ -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: + ctx = _get_ctx(arguments) + G = ctx.graph from graphify.prs import fetch_pr_files, compute_pr_impact, _gh, _parse_ci number = int(arguments["pr_number"]) repo = arguments.get("repo") or None @@ -1914,6 +2055,8 @@ def _tool_get_pr_impact(arguments: dict) -> str: return "\n".join(lines) def _tool_triage_prs(arguments: dict) -> str: + ctx = _get_ctx(arguments) + G = ctx.graph from concurrent.futures import ThreadPoolExecutor, as_completed from graphify.prs import fetch_prs, fetch_worktrees, fetch_pr_files, compute_pr_impact, _STATUS_ORDER, _detect_default_branch repo = arguments.get("repo") or None @@ -1955,7 +2098,33 @@ def _tool_triage_prs(arguments: dict) -> str: ) return "\n\n".join(lines) - _handlers = { + def _tool_list_graphs(arguments: dict) -> str: + registry.rescan() + lines = [] + for name in registry.names(): + ctx = registry.get(name) + if ctx is None: + continue + G = ctx.graph + lines.append( + f"{name}: {G.number_of_nodes()} nodes, " + f"{G.number_of_edges()} edges, " + f"{len(ctx.communities)} communities" + ) + if not lines: + return "No graphs loaded." + return "\n".join(lines) + + def _tool_use_graph(arguments: dict) -> str: + registry.rescan() + name = arguments["graph"] + ctx = registry.get(name) + if ctx is None: + return f"Graph {name!r} not found. Available: {registry.names()}" + _current_session_state()["current_graph"] = name + return f"Switched to graph {name!r} ({ctx.graph.number_of_nodes()} nodes)" + + _handlers: dict = { "query_graph": _tool_query_graph, "get_node": _tool_get_node, "get_neighbors": _tool_get_neighbors, @@ -1963,19 +2132,24 @@ def _tool_triage_prs(arguments: dict) -> str: "god_nodes": _tool_god_nodes, "graph_stats": _tool_graph_stats, "shortest_path": _tool_shortest_path, - "list_prs": _tool_list_prs, - "get_pr_impact": _tool_get_pr_impact, - "triage_prs": _tool_triage_prs, } + if is_multi: + _handlers["list_graphs"] = _tool_list_graphs + _handlers["use_graph"] = _tool_use_graph + else: + _handlers["list_prs"] = _tool_list_prs + _handlers["get_pr_impact"] = _tool_get_pr_impact + _handlers["triage_prs"] = _tool_triage_prs def _load_community_labels() -> dict[int, str]: - labels_path = Path(active_graph_path).parent / ".graphify_labels.json" + ctx = _resolve_graph(registry, current=_current_session_state().get("current_graph")) + labels_path = ctx.path.parent / ".graphify_labels.json" if labels_path.exists(): try: return {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()} except Exception: pass - return {cid: f"Community {cid}" for cid in communities} + return {cid: f"Community {cid}" for cid in ctx.communities} async def list_resources() -> list[types.Resource]: # Plain-string URIs on purpose: mcp 1.x types the field as AnyUrl and @@ -1990,7 +2164,11 @@ async def list_resources() -> list[types.Resource]: ] async def read_resource(uri: AnyUrl) -> str: - _select_graph(None) # resources read the server's default graph + registry.rescan() + ctx = _resolve_graph(registry, current=_current_session_state().get("current_graph")) + G = ctx.graph + communities = ctx.communities + active_graph_path = str(ctx.path) uri_str = str(uri) if uri_str == "graphify://report": report_path = Path(active_graph_path).parent / "GRAPH_REPORT.md" @@ -1998,9 +2176,22 @@ async def read_resource(uri: AnyUrl) -> str: return report_path.read_text(encoding="utf-8") return "GRAPH_REPORT.md not found. Run graphify extract first." if uri_str == "graphify://stats": - return _tool_graph_stats({}) + confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)] + total = len(confs) or 1 + return ( + f"Nodes: {G.number_of_nodes()}\n" + f"Edges: {G.number_of_edges()}\n" + f"Communities: {len(communities)}\n" + f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n" + f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n" + f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n" + ) if uri_str == "graphify://god-nodes": - return _tool_god_nodes({"top_n": 10}) + from graphify.analyze import god_nodes as _god_nodes + nodes = _god_nodes(G, top_n=10) + lines = ["God nodes (most connected):"] + lines += [f" {i}. {n['label']} - {n['degree']} edges" for i, n in enumerate(nodes, 1)] + return "\n".join(lines) if uri_str == "graphify://surprises": try: from graphify.analyze import surprising_connections @@ -2042,12 +2233,10 @@ async def read_resource(uri: AnyUrl) -> str: async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: arguments = dict(arguments or {}) - project_path = arguments.pop("project_path", None) handler = _handlers.get(name) if not handler: 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))] except Exception as exc: return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")] @@ -2095,10 +2284,10 @@ async def _on_read_resource(ctx, params) -> types.ReadResourceResult: on_read_resource=_on_read_resource, ) - return server + return server, _handlers -def serve(graph_path: str | None = None) -> None: +def serve(graph_path: str | None = None, *, registry: GraphRegistry | None = None) -> None: """Start the MCP server over stdio (the default, per-developer transport).""" graph_path = graph_path or _default_graph_json() try: @@ -2107,7 +2296,9 @@ def serve(graph_path: str | None = None) -> None: raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e import asyncio - server = _build_server(graph_path) + if registry is None: + registry = GraphRegistry.from_path(Path(graph_path)) + server, _ = _build_server(registry) async def main() -> None: async with stdio_server() as streams: @@ -2175,8 +2366,9 @@ async def __call__(self, scope, receive, send) -> None: def _build_http_app( - graph_path: str, + graph_path: str | None = None, *, + registry: GraphRegistry | None = None, host: str = "127.0.0.1", port: int = 8080, api_key: str | None = None, @@ -2214,7 +2406,9 @@ def _build_http_app( # mistaken for "auth on" — normalize it to None so the gate is unambiguous. api_key = (api_key or "").strip() or None - server = _build_server(graph_path) + if registry is None: + registry = GraphRegistry.from_path(Path(graph_path)) + server, _ = _build_server(registry) # DNS-rebinding protection. When the operator binds a wildcard address they # are intentionally exposing the server, so accept any Host header; for a @@ -2259,6 +2453,7 @@ async def lifespan(_app): def serve_http( graph_path: str | None = None, *, + registry: GraphRegistry | None = None, host: str = "127.0.0.1", port: int = 8080, api_key: str | None = None, @@ -2277,8 +2472,12 @@ def serve_http( check (``Authorization: Bearer `` or ``X-API-Key: ``). OAuth is a deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond localhost — set an api_key when you do. + + ``registry`` can be supplied directly for multi-graph mode; when provided + ``graph_path`` is ignored. """ - graph_path = graph_path or _default_graph_json() + if registry is None: + graph_path = graph_path or _default_graph_json() try: import uvicorn except ImportError as e: @@ -2291,6 +2490,7 @@ def serve_http( app = _build_http_app( graph_path, + registry=registry, host=host, port=port, api_key=api_key, @@ -2366,8 +2566,8 @@ def _main(argv: list[str] | None = None) -> None: help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)", ) args = parser.parse_args(argv) - graph_path = args.graph_flag or args.graph_path or _default_graph_json() + graph_path = args.graph_flag or args.graph_path or _default_graph_json() if args.transport == "http": serve_http( graph_path, diff --git a/tests/test_mcp_cli.py b/tests/test_mcp_cli.py new file mode 100644 index 0000000000..83430ae84f --- /dev/null +++ b/tests/test_mcp_cli.py @@ -0,0 +1,194 @@ +from pathlib import Path +import re +import subprocess + +import pytest + +from graphify import __main__ as main_mod + + +def _legacy_multi_graph_terms(): + return ( + "--multi" + "-mcp", + "--graphs" + "-dir", + "GRAPHS" + "_DIR", + "SCAN" + "_INTERVAL", + "graphify" + "-multi", + ) + + +def test_compose_uses_public_mcp_cli(): + compose = Path("docker-compose.multi.yml").read_text(encoding="utf-8") + _, _, graphs_dir, scan_interval, _ = _legacy_multi_graph_terms() + + assert "graphify /repos/frontend /repos/backend --mcp" in compose + assert '"127.0.0.1:8080:8080"' in compose + assert "target: multi" not in compose + assert graphs_dir not in compose + assert scan_interval not in compose + + +def test_dockerfile_has_no_multi_target(): + dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + _, _, graphs_dir, scan_interval, _ = _legacy_multi_graph_terms() + + assert "AS multi" not in dockerfile + assert graphs_dir not in dockerfile + assert scan_interval not in dockerfile + + +def test_readme_documents_public_mcp_docker_cli(): + readme = Path("README.md").read_text(encoding="utf-8") + + assert 'docker run -p 8080:8080 -v "$(pwd):/data:ro" graphify /data --mcp --transport http --host 0.0.0.0 --api-key "$SECRET"' in readme + + +def test_rendered_skills_have_no_legacy_multi_mcp_terms(): + from tools.skillgen.gen import load_platforms, render_all + + rendered = render_all(load_platforms()) + forbidden = _legacy_multi_graph_terms() + + assert all(term not in artifact.content for term in forbidden for artifact in rendered) + + +def test_no_legacy_multi_graph_entrypoint_references(): + legacy_terms = _legacy_multi_graph_terms() + tracked_files = subprocess.run( + ["git", "ls-files"], + capture_output=True, + check=True, + text=True, + ).stdout.splitlines() + active_files = [ + path + for path in tracked_files + if not path.startswith("docs/superpowers/") + and not Path(path).name.upper().startswith("CHANGELOG") + ] + result = subprocess.run( + [ + "git", + "grep", + "-nE", + "|".join(re.escape(term) for term in legacy_terms), + "--", + *active_files, + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 1, result.stdout + + +def _repo(tmp_path: Path, name: str) -> Path: + repo = tmp_path / name + out = repo / "graphify-out" + out.mkdir(parents=True) + (out / "graph.json").write_text('{"nodes": [], "links": []}', encoding="utf-8") + return repo + + +def test_mcp_uses_existing_repository_graphs_without_extracting(tmp_path, monkeypatch): + alpha, beta = _repo(tmp_path, "alpha"), _repo(tmp_path, "beta") + captured = {} + monkeypatch.setattr(main_mod, "_start_mcp_registry", lambda registry: captured.setdefault("names", registry.names())) + + main_mod._serve_mcp_repositories([str(alpha), str(beta)], transport="stdio") + + assert captured["names"] == ["alpha", "beta"] + assert (alpha / "graphify-out" / "graph.json").exists() + assert (beta / "graphify-out" / "graph.json").exists() + + +def test_mcp_rejects_missing_graph_before_starting(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(main_mod, "_start_mcp_registry", pytest.fail) + + with pytest.raises(SystemExit): + main_mod._serve_mcp_repositories([str(tmp_path / "missing")], transport="stdio") + + assert "graphify-out/graph.json" in capsys.readouterr().err + + +def test_mcp_rejects_zero_repository_paths(monkeypatch, capsys): + monkeypatch.setattr(main_mod, "_start_mcp_registry", pytest.fail) + + with pytest.raises(SystemExit): + main_mod._serve_mcp_repositories([], transport="stdio") + + assert "requires at least one repository path" in capsys.readouterr().err + + +def test_mcp_rejects_unreadable_graph_before_starting(tmp_path, monkeypatch, capsys): + repo = _repo(tmp_path, "alpha") + monkeypatch.setattr(main_mod, "_start_mcp_registry", pytest.fail) + from graphify import serve as serve_mod + + monkeypatch.setattr(serve_mod.GraphRegistry, "from_paths", lambda paths: (_ for _ in ()).throw(PermissionError("denied"))) + + with pytest.raises(SystemExit): + main_mod._serve_mcp_repositories([str(repo)], transport="stdio") + + assert "could not read graph" in capsys.readouterr().err + + +def test_mcp_rejects_duplicate_repository_names(tmp_path, monkeypatch, capsys): + first = _repo(tmp_path / "one", "api") + second = _repo(tmp_path / "two", "api") + monkeypatch.setattr(main_mod, "_start_mcp_registry", pytest.fail) + + with pytest.raises(SystemExit): + main_mod._serve_mcp_repositories([str(first), str(second)], transport="stdio") + + assert "duplicate graph name 'api'" in capsys.readouterr().err + + +def test_run_cli_dispatches_multiple_paths_with_mcp(tmp_path, monkeypatch): + alpha, beta = _repo(tmp_path, "alpha"), _repo(tmp_path, "beta") + captured = {} + monkeypatch.setattr( + main_mod, + "_serve_mcp_repositories", + lambda paths, *, transport: captured.update(paths=paths, transport=transport), + ) + monkeypatch.setattr(main_mod.sys, "argv", ["graphify", str(alpha), str(beta), "--mcp"]) + + main_mod._run_cli() + + assert captured == {"paths": [str(alpha), str(beta)], "transport": "stdio"} + + +def test_run_cli_passes_http_settings_to_mcp_server(tmp_path, monkeypatch): + alpha = _repo(tmp_path, "alpha") + captured = {} + monkeypatch.setattr( + main_mod, + "_serve_mcp_repositories", + lambda paths, *, transport, host, port, api_key: captured.update( + paths=paths, transport=transport, host=host, port=port, api_key=api_key + ), + ) + monkeypatch.setattr( + main_mod.sys, + "argv", + ["graphify", str(alpha), "--mcp", "--transport", "http", "--host", "0.0.0.0", "--port", "8080", "--api-key", "secret"], + ) + + main_mod._run_cli() + + assert captured == { + "paths": [str(alpha)], + "transport": "http", + "host": "0.0.0.0", + "port": 8080, + "api_key": "secret", + } + + +@pytest.mark.parametrize("option", ["--host", "--api-key"]) +def test_mcp_rejects_option_as_value(option, capsys): + with pytest.raises(SystemExit): + main_mod._run_mcp_cli(["--mcp", option, "--unknown"]) + + assert "error: unrecognized MCP option: --unknown" in capsys.readouterr().err diff --git a/tests/test_serve.py b/tests/test_serve.py index 85f77a59a2..4bcf0a471b 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -6,6 +6,8 @@ import networkx as nx from networkx.readwrite import json_graph +from pathlib import Path + from graphify.serve import ( _strip_diacritics, _communities_from_graph, @@ -33,6 +35,9 @@ _community_header, _search_tokens, _shortest_path_text, + GraphContext, + GraphRegistry, + _resolve_graph, ) @@ -1711,3 +1716,312 @@ def test_underscore_query_does_not_let_a_single_token_outrank_the_real_match(): scored = _score_nodes(G, _query_terms("user_service_client")) assert scored, "the multi-token query must match the full-label node" assert scored[0][1] == "real", f"a single-token node out-ranked the real match: {scored}" + +# --- GraphRegistry tests --- + + +def _write_registry_graph(base: Path, name: str, nodes=None, edges=None): + """Write a minimal graph.json under base/name/graph.json.""" + d = base / name + d.mkdir(parents=True, exist_ok=True) + data = { + "nodes": [ + {"id": f"{name}_n1", "label": "main", "community": 0}, + {"id": f"{name}_n2", "label": "helper", "community": 0}, + ] if nodes is None else nodes, + "links": [ + {"source": f"{name}_n1", "target": f"{name}_n2", "relation": "calls", "confidence": "EXTRACTED"}, + ] if edges is None else edges, + } + (d / "graph.json").write_text(json.dumps(data), encoding="utf-8") + return d / "graph.json" + + +class TestGraphRegistry: + def test_from_path_single_graph(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj") + reg = GraphRegistry.from_path(gp) + assert len(reg.names()) == 1 + ctx = reg.get(reg.names()[0]) + assert ctx is not None + assert ctx.graph.number_of_nodes() == 2 + + def test_from_paths_loads_multiple_graphs(self, tmp_path): + frontend = _write_registry_graph(tmp_path / "frontend", "graphify-out") + backend = _write_registry_graph(tmp_path / "backend", "graphify-out") + reg = GraphRegistry.from_paths([frontend, backend]) + assert sorted(reg.names()) == ["backend", "frontend"] + + def test_get_unknown_returns_none(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj") + reg = GraphRegistry.from_path(gp) + assert reg.get("nonexistent") is None + + def test_from_path_hot_reload(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj", nodes=[ + {"id": "a", "label": "a", "community": 0}, + ], edges=[]) + reg = GraphRegistry.from_path(gp) + assert reg.get(reg.names()[0]).graph.number_of_nodes() == 1 + import time; time.sleep(0.05) + _write_registry_graph(tmp_path, "proj", nodes=[ + {"id": "a", "label": "a", "community": 0}, + {"id": "b", "label": "b", "community": 0}, + ], edges=[]) + reg.rescan() + assert reg.get(reg.names()[0]).graph.number_of_nodes() == 2 + + def test_from_paths_reloads_graph_only_before_each_request(self, tmp_path, monkeypatch): + graph_path = _write_registry_graph( + tmp_path / "repo", "graphify-out", nodes=[ + {"id": "a", "label": "a", "community": 0}, + ], edges=[], + ) + registry = GraphRegistry.from_paths([graph_path]) + _, handlers = _build_server(registry) + from graphify import reflect + + monkeypatch.setattr( + reflect, + "load_learning_overlay", + lambda path: pytest.fail("explicit registry reload must not load sidecars"), + ) + _write_registry_graph( + tmp_path / "repo", "graphify-out", nodes=[ + {"id": "a", "label": "a", "community": 0}, + {"id": "b", "label": "b", "community": 0}, + ], edges=[], + ) + import os + original_mtime = graph_path.stat().st_mtime + os.utime(graph_path, (original_mtime + 1, original_mtime + 1)) + + assert "Nodes: 2" in handlers["graph_stats"]({}) + + def test_registry_has_no_directory_discovery_api(self): + assert not hasattr(GraphRegistry, "from_" + "directory") + + def test_from_path_evicts_deleted(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj") + reg = GraphRegistry.from_path(gp) + assert len(reg.names()) == 1 + import os + os.remove(gp) + reg.rescan() + assert reg.names() == [] + + def test_from_paths_loads_graph_json_without_learning_overlay(self, tmp_path, monkeypatch): + repo = tmp_path / "repo" + graph_path = repo / "graphify-out" / "graph.json" + graph_path.parent.mkdir(parents=True) + graph_path.write_text('{"nodes": [], "links": []}', encoding="utf-8") + from graphify import reflect + + monkeypatch.setattr( + reflect, + "load_learning_overlay", + lambda path: pytest.fail("explicit registry must not load sidecars"), + ) + + registry = GraphRegistry.from_paths([graph_path]) + + assert registry.names() == ["repo"] + + + +from graphify.serve import _build_server + + +class TestUnifiedBuildServer: + def test_single_graph_has_no_list_graphs(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj") + reg = GraphRegistry.from_path(gp) + server, handlers = _build_server(reg) + assert "list_graphs" not in handlers + assert "use_graph" not in handlers + + def test_multi_graph_has_list_graphs(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out") + beta = _write_registry_graph(tmp_path / "beta", "graphify-out") + reg = GraphRegistry.from_paths([alpha, beta]) + server, handlers = _build_server(reg) + assert "list_graphs" in handlers + assert "use_graph" in handlers + + def test_single_graph_has_pr_tools(self, tmp_path): + gp = _write_registry_graph(tmp_path, "proj") + reg = GraphRegistry.from_path(gp) + server, handlers = _build_server(reg) + assert "list_prs" in handlers + + def test_multi_graph_no_pr_tools(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out") + beta = _write_registry_graph(tmp_path / "beta", "graphify-out") + reg = GraphRegistry.from_paths([alpha, beta]) + server, handlers = _build_server(reg) + assert "list_prs" not in handlers + + def test_single_graph_query(self, tmp_path): + _write_registry_graph(tmp_path, "proj", nodes=[ + {"id": "n1", "label": "AuthService", "community": 0, "source_file": "auth.py"}, + {"id": "n2", "label": "Database", "community": 0, "source_file": "db.py"}, + ], edges=[ + {"source": "n1", "target": "n2", "relation": "calls", "confidence": "EXTRACTED"}, + ]) + reg = GraphRegistry.from_path(tmp_path / "proj" / "graph.json") + _, handlers = _build_server(reg) + result = handlers["query_graph"]({"question": "AuthService"}) + assert "AuthService" in result + + def test_multi_graph_use_graph_and_query(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out", nodes=[ + {"id": "a1", "label": "UserService", "community": 0}, + ], edges=[]) + beta = _write_registry_graph(tmp_path / "beta", "graphify-out", nodes=[ + {"id": "b1", "label": "PaymentGateway", "community": 0}, + ], edges=[]) + reg = GraphRegistry.from_paths([alpha, beta]) + session = {} + _, handlers = _build_server(reg, session_state=session) + + result = handlers["list_graphs"]({}) + assert "alpha" in result + assert "beta" in result + + handlers["use_graph"]({"graph": "alpha"}) + assert session["current_graph"] == "alpha" + + result = handlers["graph_stats"]({}) + assert "Nodes: 1" in result + + result = handlers["graph_stats"]({"graph": "beta"}) + assert "Nodes: 1" in result + + def test_all_tools_have_graph_param(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out") + beta = _write_registry_graph(tmp_path / "beta", "graphify-out") + reg = GraphRegistry.from_paths([alpha, beta]) + server, _ = _build_server(reg) + from mcp import types as _t + import asyncio + handler = server.request_handlers[_t.ListToolsRequest] + loop = asyncio.new_event_loop() + result = loop.run_until_complete(handler(_t.ListToolsRequest())) + loop.close() + tools = result.root.tools + for t in tools: + if t.name != "list_graphs": + props = t.inputSchema.get("properties", {}) + assert "graph" in props, f"tool {t.name} missing graph param" + + def test_explicit_registry_rejects_project_path(self, tmp_path): + explicit_graph = tmp_path / "repo" / "graphify-out" / "graph.json" + explicit_graph.parent.mkdir(parents=True) + explicit_graph.write_text('{"nodes": [], "links": []}', encoding="utf-8") + other_project = tmp_path / "other" + (other_project / "graphify-out").mkdir(parents=True) + (other_project / "graphify-out" / "graph.json").write_text('{"nodes": [], "links": []}', encoding="utf-8") + registry = GraphRegistry.from_paths([explicit_graph]) + _, handlers = _build_server(registry) + + with pytest.raises(ValueError, match="project_path is not supported"): + handlers["graph_stats"]({"project_path": str(other_project)}) + + +class TestResolveGraph: + def test_explicit_param(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out") + beta = _write_registry_graph(tmp_path / "beta", "graphify-out") + reg = GraphRegistry.from_paths([alpha, beta]) + ctx = _resolve_graph(reg, graph="alpha", current=None) + assert ctx.name == "alpha" + + def test_session_default(self, tmp_path): + graph_path = _write_registry_graph(tmp_path / "proj", "graphify-out") + reg = GraphRegistry.from_paths([graph_path]) + ctx = _resolve_graph(reg, graph=None, current="proj") + assert ctx.name == "proj" + + def test_single_graph_implicit(self, tmp_path): + gp = _write_registry_graph(tmp_path, "only") + reg = GraphRegistry.from_path(gp) + ctx = _resolve_graph(reg, graph=None, current=None) + assert ctx is not None + + def test_ambiguous_raises(self, tmp_path): + first = _write_registry_graph(tmp_path / "a", "graphify-out") + second = _write_registry_graph(tmp_path / "b", "graphify-out") + reg = GraphRegistry.from_paths([first, second]) + with pytest.raises(ValueError, match="multiple graphs"): + _resolve_graph(reg, graph=None, current=None) + + def test_unknown_name_raises(self, tmp_path): + graph_path = _write_registry_graph(tmp_path / "x", "graphify-out") + reg = GraphRegistry.from_paths([graph_path]) + with pytest.raises(ValueError, match="not found"): + _resolve_graph(reg, graph="nope", current=None) + +class TestMainCLI: + def test_serve_cli_rejects_removed_graphs_dir(self, tmp_path, capsys): + from graphify.serve import _main + removed_option = "--graphs" + "-dir" + + with pytest.raises(SystemExit): + _main([removed_option, str(tmp_path)]) + assert f"unrecognized arguments: {removed_option}" in capsys.readouterr().err + + +class TestUnifiedIntegration: + def test_multi_graph_full_flow(self, tmp_path): + alpha = _write_registry_graph(tmp_path / "alpha", "graphify-out", nodes=[ + {"id": "a1", "label": "UserService", "community": 0, "source_file": "user.py", "source_location": "L1", "file_type": "python"}, + {"id": "a2", "label": "AuthService", "community": 0, "source_file": "auth.py", "source_location": "L1", "file_type": "python"}, + ], edges=[ + {"source": "a1", "target": "a2", "relation": "calls", "confidence": "EXTRACTED"}, + ]) + beta = _write_registry_graph(tmp_path / "beta", "graphify-out", nodes=[ + {"id": "b1", "label": "PaymentGateway", "community": 0, "source_file": "pay.py", "source_location": "L1", "file_type": "python"}, + ], edges=[]) + + reg = GraphRegistry.from_paths([alpha, beta]) + session = {} + _, handlers = _build_server(reg, session_state=session) + + listing = handlers["list_graphs"]({}) + assert "alpha" in listing + assert "beta" in listing + + handlers["use_graph"]({"graph": "alpha"}) + assert session["current_graph"] == "alpha" + + result = handlers["query_graph"]({"question": "UserService"}) + assert "UserService" in result + + result = handlers["graph_stats"]({"graph": "beta"}) + assert "Nodes: 1" in result + + assert "list_prs" not in handlers + + def test_single_graph_retro_compat(self, tmp_path): + _write_registry_graph(tmp_path, "proj", nodes=[ + {"id": "n1", "label": "Main", "community": 0, "source_file": "main.py"}, + {"id": "n2", "label": "Helper", "community": 0, "source_file": "helper.py"}, + ], edges=[ + {"source": "n1", "target": "n2", "relation": "calls", "confidence": "EXTRACTED"}, + ]) + reg = GraphRegistry.from_path(tmp_path / "proj" / "graph.json") + _, handlers = _build_server(reg) + + assert "list_graphs" not in handlers + assert "use_graph" not in handlers + assert "list_prs" in handlers + + result = handlers["query_graph"]({"question": "Main"}) + assert "Main" in result + + result = handlers["graph_stats"]({}) + assert "Nodes: 2" in result + + result = handlers["get_node"]({"label": "Helper"}) + assert "Helper" in result + assert "helper.py" in result diff --git a/tests/test_serve_http.py b/tests/test_serve_http.py index 7893a0f4e3..391733ca9c 100644 --- a/tests/test_serve_http.py +++ b/tests/test_serve_http.py @@ -52,6 +52,17 @@ def _graph_file(tmp_path: Path) -> str: return str(p) +def _repository_graph(tmp_path: Path, name: str, node_count: int) -> Path: + graph_path = tmp_path / name / "graphify-out" / "graph.json" + graph_path.parent.mkdir(parents=True) + graph_path.write_text(json.dumps({ + "directed": True, + "nodes": [{"id": f"{name}-{index}", "label": name, "community": 0} for index in range(node_count)], + "edges": [], + }), encoding="utf-8") + return graph_path + + def _client(app) -> TestClient: # Default host is 127.0.0.1, so the DNS-rebinding guard only accepts that # Host header (TestClient otherwise sends the disallowed "testserver"). @@ -200,31 +211,49 @@ def _call_tool(client, headers, name, arguments, rid) -> str: return resp.json()["result"]["content"][0]["text"] -def test_project_path_is_optional_on_every_tool(tmp_path): - """Multi-project support is additive: every tool gains an optional - project_path, and none of them makes it required.""" +def test_graph_param_is_optional_on_every_tool(tmp_path): + """Every tool gains an optional graph param (but list_graphs is exempt since + it sets the graph, not queries one); none makes it required.""" app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True) with _client(app) as client: headers = _init_session(client) resp = client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) for tool in resp.json()["result"]["tools"]: + if tool["name"] == "list_graphs": + continue props = tool["inputSchema"].get("properties", {}) - assert "project_path" in props, f"{tool['name']} missing project_path" - assert "project_path" not in tool["inputSchema"].get("required", []) + assert "graph" in props, f"{tool['name']} missing graph param" + assert "graph" not in tool["inputSchema"].get("required", []) -def test_project_path_routes_to_that_projects_graph(tmp_path): - """One running server answers against the default graph when project_path is - omitted, and against a project's own graph when it is supplied.""" - proj = _project_with_graph(tmp_path, node_count=3) # default graph has 2 nodes - app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True) +def test_graph_param_routes_to_that_graph(tmp_path): + """A multi-graph server answers the default graph when graph param is omitted + and a specific graph when graph param is provided.""" + alpha = _repository_graph(tmp_path, "alpha", 1) + beta = _repository_graph(tmp_path, "beta", 3) + registry = serve_mod.GraphRegistry.from_paths([alpha, beta]) + app = serve_mod._build_http_app(registry=registry, json_response=True) with _client(app) as client: headers = _init_session(client) - assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=2) - assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"project_path": proj}, rid=3) - # Falling back to the default afterwards still works (no state leak). - assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=4) + assert "Nodes: 1" in _call_tool(client, headers, "graph_stats", {"graph": "alpha"}, rid=2) + assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"graph": "beta"}, rid=3) + + +def test_use_graph_selection_is_isolated_per_http_session(tmp_path): + alpha = _repository_graph(tmp_path, "alpha", 1) + beta = _repository_graph(tmp_path, "beta", 3) + app = serve_mod._build_http_app( + registry=serve_mod.GraphRegistry.from_paths([alpha, beta]), json_response=True + ) + with _client(app) as client: + first = _init_session(client) + second = _init_session(client) + + assert "Switched to graph 'alpha'" in _call_tool(client, first, "use_graph", {"graph": "alpha"}, rid=2) + assert "Switched to graph 'beta'" in _call_tool(client, second, "use_graph", {"graph": "beta"}, rid=3) + assert "Nodes: 1" in _call_tool(client, first, "graph_stats", {}, rid=4) + assert "Nodes: 3" in _call_tool(client, second, "graph_stats", {}, rid=5) @pytest.mark.parametrize( @@ -251,46 +280,37 @@ def counting_load(path: str): return original_load(path) monkeypatch.setattr(serve_mod, "_load_graph", counting_load) - projects = [ - _project_with_graph(tmp_path, node_count=i + 3, name=f"project-{i}") - for i in range(3) - ] + projects = [_project_with_graph(tmp_path, node_count=i + 3, name=f"project-{i}") for i in range(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) assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"project_path": projects[0]}, rid=2) assert "Nodes: 4" in _call_tool(client, headers, "graph_stats", {"project_path": projects[1]}, rid=3) - # A cache hit promotes project-0 above project-1 in LRU recency. assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"project_path": projects[0]}, rid=4) assert "Nodes: 5" in _call_tool(client, headers, "graph_stats", {"project_path": projects[2]}, rid=5) - # project-1, not the re-touched project-0, was evicted. assert "Nodes: 4" in _call_tool(client, headers, "graph_stats", {"project_path": projects[1]}, rid=6) - # The configured default graph stays warm even when project capacity is full. assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=7) first_graph = str((Path(projects[0]) / "graphify-out" / "graph.json").resolve()) second_graph = str((Path(projects[1]) / "graphify-out" / "graph.json").resolve()) - default_graph = str(Path(default_graph).resolve()) assert loads[first_graph] == 1 assert loads[second_graph] == 2 - assert loads[default_graph] == 1 -def test_bad_project_path_errors_without_killing_server(tmp_path): - """A missing project graph is a tool error, not a process exit — the server - keeps serving the default graph.""" +def test_bad_graph_param_errors_without_killing_server(tmp_path): + """A bad graph name is a tool error, not a process exit — the server + keeps serving other graphs.""" app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True) with _client(app) as client: headers = _init_session(client) bad = _call_tool(client, headers, "graph_stats", - {"project_path": str(tmp_path / "does-not-exist")}, rid=2) + {"graph": "does-not-exist"}, rid=2) assert "not found" in bad.lower() 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): - """A CLI-style SystemExit from a client graph cannot stop the MCP server.""" project = Path(_project_with_graph(tmp_path, node_count=3)) (project / "graphify-out" / "graph.json").write_text("{not json", encoding="utf-8") app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True) diff --git a/tools/skillgen/gen.py b/tools/skillgen/gen.py index 09e19ede00..6437a9d7d6 100644 --- a/tools/skillgen/gen.py +++ b/tools/skillgen/gen.py @@ -1181,7 +1181,7 @@ def monolith_roundtrip(platform: Platform) -> list[str]: unification, the unified frontmatter description, the chunk-cleanup rewrite (#1172), the four #1392 runbook fixes (directed propagation, content-only semantic scope, stale-cache unlink, and the zero-node/shrink-guard ordering), - and semantic-cache source scoping (#1757). + semantic-cache source scoping (#1757). The comparison is a multiset diff, not a positional zip: a line whose text is unchanged but merely *moved* (the report-write line shifted below ``to_json``