diff --git a/CHANGELOG.md b/CHANGELOG.md index 42bc3bb..8896bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dependency markers (`Body`, `Depends`, …) are blocklisted so the graph stays clean. Framework-agnostic: any function with type annotations benefits, not just route handlers. +- **MCP server returns an actionable error when the graph isn't built.** + Previously, every tool would happily return an empty result against + a missing `.codegraph/graph.db`. Claude / Cursor paraphrased that as + "workspace is empty," sending users down the wrong rabbit hole. Now + the dispatcher catches the missing-db case and returns a structured + `{error: "graph_not_built", message: "polycodegraph: no graph found + at . Run \`codegraph build\` in the repo root first.", db_path: + ...}`. New `GraphNotBuiltError` exposed from + `codegraph.mcp_server.server` for callers that want to handle it + explicitly. ## [0.1.1] — 2026-05-24 diff --git a/codegraph/mcp_server/server.py b/codegraph/mcp_server/server.py index bed6015..012f060 100644 --- a/codegraph/mcp_server/server.py +++ b/codegraph/mcp_server/server.py @@ -18,11 +18,27 @@ _CACHED_DB_PATH: Path | None = None +class GraphNotBuiltError(FileNotFoundError): + """Raised when an MCP tool is invoked but `.codegraph/graph.db` is missing. + + Carries the resolved path so the dispatcher can surface it in the + actionable message sent back to the LLM. + """ + + def __init__(self, db_path: Path) -> None: + self.db_path = db_path + super().__init__(str(db_path)) + + def _load_graph(db_path: Path | None = None) -> nx.MultiDiGraph: """Load (or return cached) the MultiDiGraph from *db_path*. If *db_path* is None, auto-resolves to ``cwd/.codegraph/graph.db``. - A different *db_path* forces a reload. + A different *db_path* forces a reload. Raises + :class:`GraphNotBuiltError` when the database file does not exist + so the dispatcher can return an actionable error instead of an + empty graph (which previous Claude/Cursor prompts paraphrased as + "workspace is empty", which was misleading). """ global _CACHED_GRAPH, _CACHED_DB_PATH @@ -30,6 +46,9 @@ def _load_graph(db_path: Path | None = None) -> nx.MultiDiGraph: if _CACHED_GRAPH is not None and resolved == _CACHED_DB_PATH: return _CACHED_GRAPH + if not resolved.exists(): + raise GraphNotBuiltError(resolved) + from codegraph.graph.store_networkx import to_digraph from codegraph.graph.store_sqlite import SQLiteGraphStore @@ -867,7 +886,20 @@ async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: if name.startswith("workspace_"): graph: nx.MultiDiGraph | None = None else: - graph = _load_graph(None) + try: + graph = _load_graph(None) + except GraphNotBuiltError as exc: + result = { + "error": "graph_not_built", + "message": ( + f"polycodegraph: no graph found at {exc.db_path}. " + f"Run `codegraph build` in the repo root first." + ), + "db_path": str(exc.db_path), + } + return [ + TextContent(type="text", text=json.dumps(result, indent=2)) + ] result = handler_fn(graph, arguments) return [TextContent(type="text", text=json.dumps(result, indent=2))] diff --git a/tests/test_mcp_graph_not_built.py b/tests/test_mcp_graph_not_built.py new file mode 100644 index 0000000..0f79809 --- /dev/null +++ b/tests/test_mcp_graph_not_built.py @@ -0,0 +1,69 @@ +"""Tests for the actionable graph-not-built MCP error (v0.1.2 #8). + +Pre-0.1.2 the MCP server would happily return an empty graph if the +db didn't exist; Claude/Cursor paraphrased this as "workspace is empty" +which sent users down the wrong rabbit hole. 0.1.2 returns a structured +``{error: graph_not_built, message: ...}`` so the LLM can quote the +exact `codegraph build` command back to the user. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from codegraph.mcp_server.server import ( + GraphNotBuiltError, + _load_graph, +) + + +def test_load_graph_raises_when_db_missing(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + # Reset module-level cache so this test isn't contaminated by prior runs. + import codegraph.mcp_server.server as srv + srv._CACHED_GRAPH = None + srv._CACHED_DB_PATH = None + with pytest.raises(GraphNotBuiltError) as exc_info: + _load_graph(None) + assert exc_info.value.db_path == tmp_path / ".codegraph" / "graph.db" + + +def test_dispatch_returns_actionable_error_when_db_missing( + tmp_path: Path, monkeypatch +) -> None: + """End-to-end-ish: invoke the dispatch and assert the JSON shape.""" + monkeypatch.chdir(tmp_path) + import codegraph.mcp_server.server as srv + srv._CACHED_GRAPH = None + srv._CACHED_DB_PATH = None + + server = srv._build_server("test") + # The decorated handler is the one we want — pull it from the registry. + # mcp.Server stores tool handlers internally; easiest path: call the + # exposed pure handler directly with a load-stub. Instead we trust + # the load_graph raise + dispatch glue tested as a unit: + import asyncio + + async def _invoke() -> str: + # Use the same mechanism the server uses: the `request_handlers` + # dict carries the call-tool function under `types.CallToolRequest`. + from mcp import types + + handler = server.request_handlers[types.CallToolRequest] + req = types.CallToolRequest( + method="tools/call", + params=types.CallToolRequestParams( + name="find_symbol", arguments={"query": "x"}, + ), + ) + resp = await handler(req) + content = resp.root.content[0] + return content.text + + text = asyncio.run(_invoke()) + payload = json.loads(text) + assert payload["error"] == "graph_not_built" + assert "codegraph build" in payload["message"] + assert "db_path" in payload