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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **`codegraph init` now writes a robust `.mcp.json`** — uses the absolute
path to the `codegraph` binary (resolved from the running interpreter's
bin dir, then `shutil.which`, then bare fallback), passes an explicit
`--db .codegraph/graph.db` flag, and sets `cwd` to the repo root. The
0.1.1 fix wrote the file but used a bare `codegraph` command with no
`--db` and no `cwd`, which forced users to hand-edit the file when
their MCP client's `$PATH` or working directory didn't line up. Old
pre-0.1.2 default entries are migrated forward in place; user-customised
entries are left alone. (Surfaced when a user added polycodegraph to a
FastAPI project — Claude reported needing to fix all three.)

## [0.1.1] — 2026-05-24

### Fixed
Expand Down
60 changes: 57 additions & 3 deletions codegraph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,57 @@ def _update_gitignore(repo_root: Path) -> None:
gi_path.write_text(f"{entry}\n")


def _resolve_codegraph_binary() -> str:
"""Best-effort absolute path to the `codegraph` executable.

Order: sibling of the running interpreter (venv/pipx install), then
`shutil.which`, then bare "codegraph" as a last-resort fallback.
"""
import shutil
import sys

candidate = Path(sys.executable).parent / "codegraph"
if candidate.exists():
return str(candidate)
which = shutil.which("codegraph")
if which:
return which
return "codegraph"


def _build_mcp_entry(repo_root: Path) -> dict[str, object]:
"""The canonical codegraph entry for project-level `.mcp.json`."""
db_path = repo_root / ".codegraph" / "graph.db"
return {
"command": _resolve_codegraph_binary(),
"args": ["mcp", "serve", "--db", str(db_path)],
"cwd": str(repo_root),
}


def _is_default_codegraph_entry(entry: object) -> bool:
"""True if the entry looks like a pre-0.1.2 default (no --db, no cwd).

Used to migrate users forward without clobbering a customised entry.
"""
if not isinstance(entry, dict):
return False
if entry.get("command") != "codegraph":
return False
args = entry.get("args")
if args != ["mcp", "serve"]:
return False
return "cwd" not in entry


def _write_project_mcp_json(repo_root: Path) -> str:
"""Register the codegraph MCP server in the repo-local .mcp.json.

Returns "created" if the file was newly written, "merged" if an existing
.mcp.json was extended with the `codegraph` entry, or "already-present" if
a `codegraph` entry was already there (left untouched in that case).
.mcp.json was extended with the `codegraph` entry, "migrated" if a
pre-0.1.2 default entry was rewritten with the robust shape, or
"already-present" if a customised `codegraph` entry was already there
(left untouched in that case).

Project-level .mcp.json is auto-loaded by Claude Code and Cursor as soon
as you open the project — no global config edits needed. Other clients
Expand All @@ -271,7 +316,7 @@ def _write_project_mcp_json(repo_root: Path) -> str:
import json

mcp_path = repo_root / ".mcp.json"
entry = {"command": "codegraph", "args": ["mcp", "serve"]}
entry = _build_mcp_entry(repo_root)

if mcp_path.exists():
try:
Expand All @@ -286,6 +331,10 @@ def _write_project_mcp_json(repo_root: Path) -> str:
servers = {}
data["mcpServers"] = servers
if "codegraph" in servers:
if _is_default_codegraph_entry(servers["codegraph"]):
servers["codegraph"] = entry
mcp_path.write_text(json.dumps(data, indent=2) + "\n")
return "migrated"
return "already-present"
servers["codegraph"] = entry
mcp_path.write_text(json.dumps(data, indent=2) + "\n")
Expand Down Expand Up @@ -391,6 +440,11 @@ def init(
console.print(
"[green]✓[/green] Added `codegraph` entry to existing .mcp.json"
)
elif mcp_state == "migrated":
console.print(
"[green]✓[/green] Migrated .mcp.json `codegraph` entry to the "
"0.1.2 shape (absolute binary path + --db + cwd)"
)
elif mcp_state == "already-present":
console.print(
"[dim]·[/dim] .mcp.json already has a `codegraph` entry — left as-is"
Expand Down
116 changes: 116 additions & 0 deletions tests/test_cli_mcp_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tests for `_write_project_mcp_json` (item #1 of v0.1.2 backlog).

The 0.1.1 fix wrote `.mcp.json` but used a bare `codegraph` command with
no `--db` and no `cwd`. 0.1.2 fixes that to use an absolute binary path,
an explicit `--db` pointing at `.codegraph/graph.db`, and `cwd` set to
the repo root. Pre-0.1.2 default entries are migrated forward in place;
customised entries are left alone.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

from codegraph.cli import (
_build_mcp_entry,
_is_default_codegraph_entry,
_resolve_codegraph_binary,
_write_project_mcp_json,
)


def test_build_mcp_entry_has_absolute_path_db_and_cwd(tmp_path: Path) -> None:
entry = _build_mcp_entry(tmp_path)
assert isinstance(entry["command"], str)
# Either an absolute resolved path or the bare fallback. The path-aware
# branch is exercised by test_resolve_codegraph_binary_prefers_sibling.
args = entry["args"]
assert isinstance(args, list)
assert args[0] == "mcp"
assert args[1] == "serve"
assert "--db" in args
db_idx = args.index("--db") + 1
assert args[db_idx] == str(tmp_path / ".codegraph" / "graph.db")
assert entry["cwd"] == str(tmp_path)


def test_resolve_codegraph_binary_prefers_sibling(
tmp_path: Path, monkeypatch
) -> None:
fake_venv = tmp_path / "venv" / "bin"
fake_venv.mkdir(parents=True)
fake_py = fake_venv / "python"
fake_py.write_text("")
fake_cg = fake_venv / "codegraph"
fake_cg.write_text("")
monkeypatch.setattr(sys, "executable", str(fake_py))
assert _resolve_codegraph_binary() == str(fake_cg)


def test_is_default_codegraph_entry_detects_old_shape() -> None:
assert _is_default_codegraph_entry(
{"command": "codegraph", "args": ["mcp", "serve"]}
)
# Customised: different binary
assert not _is_default_codegraph_entry(
{"command": "/opt/cg/bin/codegraph", "args": ["mcp", "serve"]}
)
# Customised: extra cwd
assert not _is_default_codegraph_entry(
{"command": "codegraph", "args": ["mcp", "serve"], "cwd": "/repo"}
)
# Customised: different args
assert not _is_default_codegraph_entry(
{"command": "codegraph", "args": ["mcp", "serve", "--db", "x"]}
)
assert not _is_default_codegraph_entry("not-a-dict")


def test_write_mcp_json_fresh(tmp_path: Path) -> None:
state = _write_project_mcp_json(tmp_path)
assert state == "created"
data = json.loads((tmp_path / ".mcp.json").read_text())
entry = data["mcpServers"]["codegraph"]
assert entry["cwd"] == str(tmp_path)
assert "--db" in entry["args"]


def test_write_mcp_json_migrates_pre_012_default(tmp_path: Path) -> None:
(tmp_path / ".mcp.json").write_text(
json.dumps(
{"mcpServers": {"codegraph": {"command": "codegraph", "args": ["mcp", "serve"]}}}
)
)
state = _write_project_mcp_json(tmp_path)
assert state == "migrated"
entry = json.loads((tmp_path / ".mcp.json").read_text())["mcpServers"]["codegraph"]
assert entry["cwd"] == str(tmp_path)
assert "--db" in entry["args"]


def test_write_mcp_json_preserves_customised_entry(tmp_path: Path) -> None:
custom = {
"command": "/opt/cg/bin/codegraph",
"args": ["mcp", "serve", "--verbose"],
"env": {"FOO": "bar"},
}
(tmp_path / ".mcp.json").write_text(
json.dumps({"mcpServers": {"codegraph": custom, "other": {"command": "x"}}})
)
state = _write_project_mcp_json(tmp_path)
assert state == "already-present"
data = json.loads((tmp_path / ".mcp.json").read_text())
assert data["mcpServers"]["codegraph"] == custom
assert data["mcpServers"]["other"] == {"command": "x"}


def test_write_mcp_json_merges_when_codegraph_missing(tmp_path: Path) -> None:
(tmp_path / ".mcp.json").write_text(
json.dumps({"mcpServers": {"other": {"command": "x"}}})
)
state = _write_project_mcp_json(tmp_path)
assert state == "merged"
data = json.loads((tmp_path / ".mcp.json").read_text())
assert "codegraph" in data["mcpServers"]
assert data["mcpServers"]["other"] == {"command": "x"}
Loading