Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>. 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

Expand Down
36 changes: 34 additions & 2 deletions codegraph/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,37 @@
_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

resolved = db_path or (Path.cwd() / ".codegraph" / "graph.db")
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

Expand Down Expand Up @@ -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))]

Expand Down
69 changes: 69 additions & 0 deletions tests/test_mcp_graph_not_built.py
Original file line number Diff line number Diff line change
@@ -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
Loading