codegraph serve --mcp runs a newline-delimited JSON-RPC MCP server over
stdin/stdout. It does not use LSP Content-Length framing.
Protocol handshake: initialize returns serverInfo.name: "codegraph".
serverInfo.version reports the running binary's crate version (from
CARGO_PKG_VERSION), so it tracks releases automatically rather than being
hardcoded.
protocolVersion is negotiated, not fixed. The server (built on rmcp 3.0.1)
echoes back whatever revision the client asks for, as long as it is one it knows:
2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, or
2026-07-28. An unrecognized request falls back to 2024-11-05. What the
negotiated revision changes:
| client requests | negotiated | resultType in results |
streamable-HTTP session |
|---|---|---|---|
| 2024-11-05 | 2024-11-05 | absent | no Mcp-Session-Id (our config) |
| 2025-03-26 | 2025-03-26 | absent | no Mcp-Session-Id (our config) |
| 2025-06-18 | 2025-06-18 | absent | no Mcp-Session-Id (our config) |
| 2025-11-25 | 2025-11-25 | absent | no Mcp-Session-Id (our config) |
| 2026-07-28 | 2026-07-28 | "complete" |
no Mcp-Session-Id (per spec) |
Results carry the SEP-2322 discriminator resultType: "complete" only for a
2026-07-28 peer; older peers get the key stripped, and per spec a missing
resultType means "complete". At 2026-07-28 the streamable-HTTP transport is
stateless (SEP-2567): no Mcp-Session-Id, no standalone GET/DELETE stream, no
Last-Event-ID resumption. At the four pre-2026 revisions the spec would allow a
session id, but this server is configured with legacy session mode off, so it
sends none there either: don't write session-resumption handling against it. Only
the cause differs — legacy statelessness is our configuration and could be
reversed, while 2026-07-28 statelessness is mandated and cannot. That transport also
validates the SEP-2243 standard headers — a request whose MCP-Protocol-Version
is missing, or whose Mcp-Method
or Mcp-Name disagrees with the body, is rejected with HTTP 400 and JSON-RPC
error code -32020.
The server advertises only the tools capability, and every tool call returns
a complete result; it never constructs the task or input-required response forms,
so Tasks (SEP-2663) and MRTR / elicitation-in-tool are not implemented.
Subscriptions are also unimplemented: the handler accepts no subscription filter,
and the legacy subscribe methods return method-not-found. Beyond the mandatory
initialize handshake — itself an inherited default, which is why version
negotiation happens automatically on top of our get_info() and why the
2024-11-05 there is only a fallback — these inherited defaults also answer
successfully despite that narrow capability advertisement: discovery returns rmcp's known protocol versions plus this server's get_info();
completion returns an empty default result; prompts/list, resources/list, and
resources/templates/list each return an empty list rather than an error, so
probing for prompts or resources gets a successful response with nothing in it;
and ping succeeds on the legacy revisions (it is method-not-found at
2026-07-28). None of these are our implementations — they are inherited SDK
defaults we do not override, and they add no advertised capability. Logging
setLevel, prompts/get, and resources/read return method-not-found.
Add to your agent's MCP config file, or run codegraph install --yes to write
it automatically:
Default (no -p): tools/list always returns the full tool surface, even
before a project is resolved. When the server resolves a default project — the
working directory is at or inside an indexed project (find-up), or the client
sends rootUri/workspaceFolders/roots — all tools work with projectPath
optional. When it cannot resolve one (a roots-less client launched from a fixed
directory that is not inside any project, e.g. a shared global config using the
home directory as cwd), tools are still listed but projectPath is marked
required in each tool's schema; the agent must then pass it per call. See
Project resolution for the full three-case breakdown.
Optional -p <path> / --path <path>: pin the server to one fixed project
regardless of cwd (e.g.
"args": ["serve", "--mcp", "-p", "/abs/path/to/project"]).
Supported agents: Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, Trae, Qoder, Zed.
Zed uses a different MCP config shape than other agents. Instead of mcpServers,
it uses a context_servers key, and the entry has no type field:
{
"context_servers": {
"codegraph": {
"command": "codegraph",
"args": ["serve", "--mcp"],
"env": {},
},
},
}Global vs. project-level path constraint. Zed's global user settings
(~/.config/zed/settings.json on Linux and macOS; %APPDATA%\Zed\settings.json
on Windows) cannot inject a per-project --path — there is no ${workspaceFolder}
expansion in Zed's context_servers args. A global entry therefore runs
read-only off whatever index the working directory resolves to.
To pin a project's server to an absolute path and get live per-project indexing,
run codegraph init --target=zed inside the project. This writes a
project-level .zed/settings.json with the absolute --path baked in:
{
"context_servers": {
"codegraph": {
"command": "codegraph",
"args": ["serve", "--mcp", "--path", "/absolute/path/to/project"],
"env": {},
},
},
}This is the only way to give Zed a per-project path. Without it the server falls back to home safe mode if Zed's CWD cannot find an indexed project via find-up.
Run codegraph install --target=zed to write the bare global entry, or
codegraph init --target=zed inside a project to write the project-level entry.
Symptom. You open a remote SSH project in Zed (Zed UI on your local machine,
code and .codegraph-v2/ index on a remote Linux host). All codegraph MCP tools
return empty — "No relevant code found" — even after the index built successfully.
Root cause. Zed currently executes context_servers entries on the local
machine, not on the remote host. This is true even when a remote SSH project is
open: the command: "codegraph" runs locally, cannot reach the remote
.codegraph-v2/ index, and returns nothing. Native remote MCP execution — running
the server on the remote host — is not yet implemented in Zed (tracked in Zed's
GitHub issues; status as of mid-2026).
Recommended fix — streamable-HTTP over a forwarded port. Run the MCP server
on the remote host with the HTTP transport, forward its port through SSH, and give
Zed a url instead of a command. Zed then talks to a local port that SSH pipes
to the remote server, so the process reading the index runs where the index lives.
On the remote host:
codegraph serve --http --detach --path /abs/path/to/project--http binds 127.0.0.1:8111 by default (override with
--http-addr <host>:<port>); --detach runs it in the background and prints its
pid and log path. codegraph http list shows running servers and
codegraph http stop 127.0.0.1:8111 terminates one. With --path, the project
must already be indexed — run codegraph init /abs/path/to/project first.
Forward the port from your local machine:
ssh -N -L 8111:127.0.0.1:8111 <your-ssh-host-alias>Then in .zed/settings.json on the local machine:
{
"context_servers": {
"codegraph": {
"url": "http://localhost:8111/mcp",
},
},
}Why forward rather than expose the port. The default bind is loopback-only,
so nothing is reachable from the network — the SSH tunnel is what makes the remote
server visible, and it makes the endpoint genuinely local from Zed's point of
view. That matters because MCP hosts commonly permit plain http only for
localhost and require https for anything remote (Kiro enforces exactly this).
Forwarding keeps you on the localhost side of that rule without terminating TLS.
codegraph install --target=zed writes this HTTP entry into your settings.json
as a //-commented alternative next to the active stdio entry, marked RECOMMENDED
for remote — uncomment it rather than typing it out.
Fallback — SSH stdio bridge. If you cannot forward a port, make Zed's local
command be ssh into the remote host and run codegraph there. SSH proxies
stdin/stdout transparently, so the MCP JSON-RPC stream flows through the tunnel
without any change to the codegraph binary itself.
In your project's .zed/settings.json on the local machine:
{
"context_servers": {
"codegraph": {
"command": "ssh",
"args": [
"-T",
"<your-ssh-host-alias>",
"cd /abs/path/to/project && /abs/path/to/codegraph serve --mcp --path /abs/path/to/project",
],
"env": {},
},
},
}Why each part matters:
command: "ssh"— runs on the local machine (satisfying Zed's "MCP runs locally" constraint) while the actual codegraph process runs on the remote host and reads the remote index.-T— disables PTY allocation. Without it, SSH allocates a pseudo-terminal whose control sequences corrupt the MCP JSON-RPC byte stream.<your-ssh-host-alias>— use the host alias from your local~/.ssh/config(e.g.code-server). An alias lets you keep key/port/ProxyJump options out of this config and reuse an existing entry.- Absolute path to codegraph — a non-login SSH shell may not source
~/.cargo/env, so~/.cargo/binis often absent fromPATH. Use the full path to the binary (e.g./config/.cargo/bin/codegraphor wherever you installed it on the remote host). --path /abs/path/to/project— pins the server to the right project explicitly, so resolution never depends on the remote cwd or the MCP roots handshake over the tunnel.
Bridge caveats. Each Zed window opens a fresh SSH session (the shared daemon is not reused across them), so startup is slightly slower than a local connection. The remote codegraph daemon does still run for the duration of that session and serves queries normally. The HTTP transport avoids this — one detached server handles every window — which is why it is the recommended path. Both are stopgaps until Zed ships native remote MCP support.
tools/list surfaces only the 4 default tools by default
(explore, node, search, callers — the DEFAULT_MCP_TOOLS set). All 10
tools remain callable via tools/call. To expose additional tools in tools/list,
set the CODEGRAPH_MCP_TOOLS environment variable to a comma-separated list of
short names, e.g.:
CODEGRAPH_MCP_TOOLS=explore,node,search,callers,impact,check codegraph serve --mcp| Tool | Purpose |
|---|---|
codegraph_explore |
PRIMARY tool: blast radius + relationship map + dynamic-dispatch boundaries + source blocks (output is size-adaptive to project scale). |
codegraph_search |
FTS5 + multi-signal scored symbol search. |
codegraph_node |
Node detail (symbol view) or file view (line-numbered source). A smarter Read. |
codegraph_callers |
Callers of a symbol (along calls/references/imports edges). |
codegraph_callees |
Targets a symbol calls. |
codegraph_impact |
Blast radius of changing a symbol (transitive incoming deps). |
codegraph_status |
Index status summary (files/nodes/edges/DB size/stale files). |
codegraph_files |
List/tree indexed files under a path. |
codegraph_check |
Circular-dependency detection. Returns each cycle as a.ts -> b.ts -> a.ts. |
codegraph_export |
Whole-graph NetworkX node-link JSON export with optional PageRank centrality. |
Every tool is query-only, so each carries MCP tool annotations in
tools/list — readOnlyHint: true, destructiveHint: false,
idempotentHint: true, openWorldHint: false. Hosts that respect these hints
can call codegraph tools freely without write-confirmation prompts.
codegraph_explore is the primary entry point for agent queries. One call
returns the symbols relevant to a query, their verbatim source grouped by file,
plus the call/impact graph around them. Prefer it over individual callers/
callees chains when surveying an unfamiliar area.
The blast-radius block that explore prints reports measured test coverage.
When no test file calls a root directly, codegraph walks UP the caller graph
before saying anything — up to 3 hops (direct callers are hop 1), bounded by 64
caller lookups per root. The three outcomes, verbatim:
| Outcome | Suffix |
|---|---|
| A test was reached through callers | ; tested via callers: `f1`, `f2` +N |
| Nothing found, budget survived | ; no tests found within 3 caller hops |
| Nothing found, lookup budget exhausted | ; no test calls this directly |
At most two test file names are shown; the +N tail counts the rest. The
traversal frontier itself is uncapped, so the display cap never hides a hop.
Neither not-found form carries a warning glyph any more: the note states what was
searched instead of implying the symbol is untested, which for anything reached
through a helper it usually was not.
codegraph_node accepts either a symbol ID (from a search result) or a
file path. When given a file path it returns the file's source with line numbers,
which is a more accurate alternative to a plain Read tool call.
codegraph_impact returns the transitive incoming dependency set — every
symbol that would break if the queried symbol changed. Use it before a refactor
to understand the blast radius instead of walking callers manually.
codegraph_check returns cycles as ordered lists of file paths. It's
additive: most projects have zero cycles; run it after a large dependency
restructuring to confirm no new cycles were introduced.
codegraph_export dumps the complete graph as NetworkX node-link JSON.
Useful for external visualization tools, custom analysis scripts, or feeding an
LLM a high-level structural summary of the entire codebase.
Two distinct error channels:
- Unknown tool name — JSON-RPC error
-32602(invalid params). - Missing or invalid required argument — tool result with
{content: ..., isError: true}andError: <message>body.
When the project is indexed (.codegraph-v2/ exists), serve --mcp does not run
inline. Instead it spawns — or proxies to — a single shared detached daemon
process per project. Multiple agent clients (e.g. Claude Code + Cursor open
simultaneously) all attach to the same daemon, so the index is loaded and
maintained once.
The daemon runs a file watcher (codegraph-watch) that live-reindexes changed
files. Events are debounced (default ~2 s; tunable via
CODEGRAPH_WATCH_DEBOUNCE_MS) so a burst of saves triggers one incremental
rebuild rather than many. The watcher is auto-disabled on WSL2 /mnt/ drives
where recursive watch is too slow; set CODEGRAPH_FORCE_WATCH=1 to override.
When the resolved root is exactly $HOME or the filesystem root (/), the
server first disables the daemon, the file watcher, AND catch-up sync — not just
the watcher. This happens when an IDE or agent (e.g. Kiro) launches
codegraph serve --mcp with no --path and its CWD is the home directory;
without the guard, the server would spawn a daemon that indexes the entire home
tree and peg a CPU at 99%. In this initial safe mode the server still answers the
handshake, but it will not start background services against $HOME. If the
client advertises MCP roots support, the server sends roots/list, adopts the
first indexed root from the client's response, starts or attaches to that root's
shared project daemon, then proxies the current stdio session to that daemon.
That lets a single global config recover the real project even when the launch
CWD was home, without hardcoding --path. CODEGRAPH_FORCE_WATCH does not
override this guard (it only overrides the WSL2 /mnt/ disable). A real project
nested under $HOME (e.g. ~/projects/myapp) is unaffected and gets the full
daemon, watcher, and catch-up. To guarantee per-project services for clients that
do not support roots, pin the root via --path <project> in the client's MCP
config args (e.g. a workspace-level .kiro/settings/mcp.json), or open the
project folder as the working directory.
tools/list always returns the full default tool surface (4 tools by
default, or the CODEGRAPH_MCP_TOOLS allowlist). What changes depending on
whether a default project was resolved is which tool parameters are required:
projectPathoptional — the server resolved a default project. Tools just work with no per-call path argument.projectPathrequired — no default project was resolved. Tools are still listed, but the schema marksprojectPathrequired on every tool so the agent knows to supply it per call. You can also pin a single project with-p/--pathinstead.
The server resolves a default project by walking three sources in order:
--pathflag — explicit pin; always wins.- find-up from cwd — ascends from the working directory to the nearest
.codegraph-v2/index root. A cwd at or inside an indexed project resolves it here, andprojectPathis optional. - MCP
initializehandshake — if find-up yields nothing, the server reads theinitializemessage sent by the client and adopts the workspace it advertises (rootUri,rootPath, orworkspaceFolders[0].uri) — provided that path is already indexed. If the client does not include those fields but advertisescapabilities.roots, the server sends aroots/listrequest and adopts the first indexed root from the response.
If all three sources yield nothing, the server serves the full tool list with
projectPath marked required. This is the case for roots-less clients that use
a fixed launch directory not inside any project — for example, 通义灵码/Lingma
configured with a single global MCP entry whose working directory is the home
directory. In that scenario the tools are listed and the agent can still call
them by passing an explicit projectPath; for single-project setups, pinning
-p /path/to/project in the MCP config args is the simpler alternative.
Note: the home-directory / filesystem-root guard (see [Daemon & live watch] above) also skips the normal watcher and catch-up sync for those paths. A real project nested under
$HOME(e.g.~/projects/myapp) is unaffected — it is resolved via find-up and gets the full daemon and watcher.
The daemon exits automatically after all clients disconnect and an idle timeout
elapses. Logs are appended to .codegraph-v2/daemon.log. A stale lock (e.g. after
a crash) can be cleared with codegraph unlock.
On Unix, the detached daemon calls setsid to become a session leader, so when
the short-lived proxy that spawned it exits the daemon is reparented to init
and reaped automatically — no <defunct> zombie appears in the process table.
The daemon exits when its real host (the IDE or agent running serve --mcp)
dies, detected via host_pid liveness; raw parent-pid divergence is not used
for this check because a deliberately daemonized process legitimately reparents
to init.
To disable the daemon entirely and run the MCP server in the foreground, set
CODEGRAPH_NO_DAEMON=1. For the full set of env-var knobs — timeouts, sweep
intervals, watch settings — see docs/cli.md.
codegraph install --prompt-hook writes a Claude Code UserPromptSubmit hook
that pipes each user prompt into codegraph prompt-hook on stdin. Claude sends a
JSON payload — {prompt, cwd} (the "prompt"/"cwd" object) — so the hook reads
.prompt as the query and resolves the project from .cwd (falling back to
--path, then the process cwd). A raw-string argument or raw-string stdin still
works for direct invocation (codegraph prompt-hook "how does X work").
The hook is a three-tier confidence gate — it decides not just whether to inject context but how much:
- Structural question or named symbol → full context. A structural /
flow / impact / "where-how" question in any of ~29 covered languages (across
Latin, Cyrillic, Greek, CJK, Hangul, Arabic, Hebrew, Thai, and Devanagari
scripts), OR a code-shaped token (
getUserId,get_user,Counter(),user.login) that is verified as a real symbol in the index, runscodegraph_exploreand injects its full output (capped at 16000 bytes). - Plain words matching indexed symbols → short hint. When the prompt has no
structural keyword or verified token but its prose words match indexed
symbol-name segments (e.g.
checkout state machine→CheckoutStateMachine), the hook injects a short pointer naming the matching symbols and letting the agent write the explore query itself — it never runs explore, so a fuzzy match can't flood the prompt with the wrong feature's source. - Everything else → silent. Ordinary prose (
please fix this typo) is a zero-cost silent no-op — nothing is printed.
The gate is a pure, deterministic function of the prompt plus the current
indexed node-name set: the plain-words tier is derived at query time from the
existing symbol names (no extra table, no schema change), and there is no
telemetry or tracking of any kind. Set CODEGRAPH_NO_PROMPT_HOOK=1 (or
CODEGRAPH_PROMPT_HOOK=0) to disable the hook without editing the config. Every
failure path — kill-switch, non-matching prompt, no index, engine error — exits 0
with no output; the hook is degradable by contract and never breaks the prompt.
The index typically lags file writes by ~1 second when the daemon is running. In that window a file's stored line ranges can point at the wrong bytes, so every tool that emits source first checks the file it is about to read.
A referenced file is compared against its stored record before its text is used. Freshness is fail-closed: proven fresh is an earned state, not the default:
- The file record loads and size + millisecond mtime match → proven fresh,
no hashing. This is the same fast path
codegraph syncuses. - Stat mismatch → the file's sha256 content hash is computed and compared. A matching indexed hash also proves freshness; a hash mismatch is possibly drifted.
Every outcome that cannot prove either condition is possibly drifted: the file record is absent or unreadable, the source read fails, or an oversized file has a stat mismatch. Oversized files are never read or hashed, so only their size + millisecond mtime fast path can prove freshness.
So a touch, or a rewrite that produces identical bytes, is not drift — the
hash check absorbs it. The probe is memoized per tool call, so one explore never
stats the same file twice.
Stored line numbers are unsafe once the bytes moved, so codegraph will not slice a drifted file at them. Instead:
codegraph_nodeon a symbol in a drifted file emits the file's full current source when it fits under the 2000-line whole-file cap (FILE_MODE_MAX_LINES, the same ceiling normal file mode uses). Over the cap, the body is omitted with a notice; the location and signature are still shown, flagged as possibly shifted.codegraph_exploreflags a drifted file and disables adaptive, skeleton, and cluster slicing for it. Whole-file rendering is correct by construction, so a small file still comes back in full; a large one is omitted rather than sliced.
Byte size stays gated separately by indexing.max_file_size (1 MiB default) —
a file over that limit was never extracted, so its text is not served either way.
A response is prefixed with:
⚠️ Some files referenced below were edited since the last index sync — their codegraph entries may be stale:
- path/to/file.rs
For accurate content of those specific files, Read them directly. The rest of this response is fresh.
only when that response actually serves or cites current bytes from a
possibly-drifted file. An unreadable or oversized file whose content is omitted
is still not treated as fresh, but it does not fabricate a banner entry for
bytes the response never exposed. A response with no cited possibly-drifted bytes
gets no banner, which is what makes "trust everything not listed" a real
guarantee rather than an assumption — the agent instructions had described this
banner before anything produced it. Re-run codegraph index, or wait for the
watcher, if you see it on a hot codebase.
{ "mcpServers": { "codegraph": { "command": "codegraph", "args": ["serve", "--mcp"], }, }, }