mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 18:23:25 +08:00
A tools/call request passes its arguments as an object. MCPSession took
params.arguments || {} without checking that, so a string or number went
straight to the tool handler, where the session-view check
('_cgExploreSession' in args) threw. The call then came back as a tool
result flagged isError: "This is an internal codegraph error, retry the
call once; if it persists, continue without codegraph". That reports a
client's malformed request as codegraph's own malfunction, with advice to
stop using it. An array went through as if it were the arguments. The
stdio proxy, serving in process while the daemon is unavailable, had the
same unchecked read.
Both now read the arguments through one helper, toolCallArguments(), and
answer anything but an object with a JSON-RPC invalid-params error
(-32602, "Tool arguments must be an object"), as a call with no tool
name already is; no tool runs, and the proxy starts no engine for it.
Missing, null or another empty value still means no arguments.
Validation: new mcp-tool-call-arguments.test.ts covers the session (the
direct stdio server and every daemon client) and the real proxy entry
point in a child process with the daemon unavailable: red on main (2 of
3 failed; the well-formed control passed), green with the fix.
explore-session-state and proxy-shutdown pass. Driving serve --mcp over
stdio: on main, codegraph_explore, codegraph_node and codegraph_search
with a string argument returned isError "internal codegraph error" and
codegraph_status accepted [1], 42 and true; with the fix those six get
-32602, while "", null, {} and absent arguments run as before.
Reimplements PR #2986 by @kjgggggg.
Fixes #2981
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
/**
|
|
* MCP per-connection session — speaks the JSON-RPC protocol (initialize,
|
|
* tools/list, tools/call) over a single {@link JsonRpcTransport}. It owns
|
|
* per-client state only (which protocol version the client asked for, whether
|
|
* it advertised `roots`, the one-shot roots/list latch); the heavyweight
|
|
* resources (CodeGraph, watcher, ToolHandler) live in the shared
|
|
* {@link MCPEngine} so daemon mode can collapse N inotify sets / DB handles
|
|
* to one.
|
|
*
|
|
* The state-machine itself mirrors what `MCPServer` used to do inline before
|
|
* issue #411 split it out — the same regression tests in
|
|
* `__tests__/mcp-initialize.test.ts` still drive this code path.
|
|
*/
|
|
|
|
import * as path from 'path';
|
|
import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } from './transport';
|
|
import { MCPEngine } from './engine';
|
|
import { tools } from './tools';
|
|
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server-instructions';
|
|
import { CodeGraphPackageVersion } from './version';
|
|
import { resolveServerRoot } from '../directory';
|
|
import { getTelemetry, ClientInfo } from '../telemetry';
|
|
import { getUpdateNotice } from '../upgrade/update-check';
|
|
import { ExploreSessionState } from './explore-session-state';
|
|
|
|
/**
|
|
* MCP Server Info — kept on the session because some clients log it. The
|
|
* version tracks the real package version (was a hard-coded '0.1.0').
|
|
*/
|
|
// Exported so the proxy can answer `initialize` locally with the IDENTICAL
|
|
// payload the daemon would send — no drift between the two handshake paths.
|
|
export const SERVER_INFO = {
|
|
name: 'codegraph',
|
|
version: CodeGraphPackageVersion,
|
|
};
|
|
|
|
/**
|
|
* Instructions for the `initialize` response, with the update-availability
|
|
* notice appended when one is known (#1243). Exported so the proxy's local
|
|
* handshake sends the IDENTICAL payload — same convention as SERVER_INFO.
|
|
* `getUpdateNotice` is a memoized synchronous cache read, so the #172
|
|
* respond-fast contract holds; when no notice exists the instructions are
|
|
* byte-identical to the bare constants.
|
|
*
|
|
* Test-authoring note: on a machine whose real `~/.codegraph` cache knows a
|
|
* newer release, spawned servers append the notice — a test asserting exact
|
|
* instructions equality must set `CODEGRAPH_NO_UPDATE_CHECK=1` in the spawn
|
|
* env or it will fail only in the weeks after a release ships.
|
|
*/
|
|
export function initializeInstructions(base: string, notice: string | null = getUpdateNotice()): string {
|
|
if (!notice) return base;
|
|
return (
|
|
`${base}\n\n---\n${notice} This server keeps running the old version until ` +
|
|
`the user upgrades — mention it when convenient; do not run the upgrade yourself.`
|
|
);
|
|
}
|
|
|
|
/** MCP Protocol Version (latest the server claims). */
|
|
export const PROTOCOL_VERSION = '2024-11-05';
|
|
|
|
/** The invalid-params message for {@link toolCallArguments}' null. */
|
|
export const TOOL_ARGUMENTS_NOT_OBJECT = 'Tool arguments must be an object';
|
|
|
|
/**
|
|
* The arguments a `tools/call` passes, or null when they are not an object.
|
|
* A string, number or array there is a malformed request, the client's to
|
|
* fix, so it is answered with a JSON-RPC invalid-params error, as a call that
|
|
* names no tool is, and never run: run, a string made the tool handler throw,
|
|
* which came back as an internal codegraph error to retry (#2981). Exported so
|
|
* the proxy, serving in process while the daemon is unavailable, answers it
|
|
* the same way. No arguments (missing, null or another empty value) are none.
|
|
*/
|
|
export function toolCallArguments(params: { arguments?: unknown }): Record<string, unknown> | null {
|
|
const args = params.arguments || {};
|
|
return typeof args === 'object' && !Array.isArray(args) ? (args as Record<string, unknown>) : null;
|
|
}
|
|
|
|
/**
|
|
* How long to wait for the client's `roots/list` response before giving up
|
|
* and falling back to the process cwd.
|
|
*/
|
|
const ROOTS_LIST_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* Convert a file:// URI to a filesystem path. Handles URL encoding and
|
|
* Windows drive letter paths.
|
|
*/
|
|
function fileUriToPath(uri: string): string {
|
|
try {
|
|
const url = new URL(uri);
|
|
let filePath = decodeURIComponent(url.pathname);
|
|
if (process.platform === 'win32' && /^\/[a-zA-Z]:/.test(filePath)) {
|
|
filePath = filePath.slice(1);
|
|
}
|
|
return path.resolve(filePath);
|
|
} catch {
|
|
return uri.replace(/^file:\/\/\/?/, '');
|
|
}
|
|
}
|
|
|
|
/** First usable filesystem path from a `roots/list` result, or null. */
|
|
function firstRootPath(result: unknown): string | null {
|
|
if (!result || typeof result !== 'object') return null;
|
|
const roots = (result as { roots?: unknown }).roots;
|
|
if (!Array.isArray(roots) || roots.length === 0) return null;
|
|
const first = roots[0] as { uri?: unknown };
|
|
if (typeof first?.uri !== 'string') return null;
|
|
return fileUriToPath(first.uri);
|
|
}
|
|
|
|
export interface MCPSessionOptions {
|
|
/**
|
|
* Explicit project path from the `--path` CLI flag. When set, the session
|
|
* will not bother asking the client for `roots/list` — we already know
|
|
* where the project lives.
|
|
*/
|
|
explicitProjectPath?: string | null;
|
|
}
|
|
|
|
/**
|
|
* One MCP client's view of the server. Created fresh per stdio launch
|
|
* (direct mode) or per socket connection (daemon mode).
|
|
*/
|
|
export class MCPSession {
|
|
private clientSupportsRoots = false;
|
|
/** From the initialize handshake — attributes usage rollups to the agent host. */
|
|
private clientInfo: ClientInfo | undefined;
|
|
private rootsAttempted = false;
|
|
private resolvePromise: Promise<void> | null = null;
|
|
private explicitProjectPath: string | null;
|
|
/**
|
|
* What `codegraph_explore` has already returned to THIS client, per project
|
|
* (CG-17). Owned by the session, not the engine: the daemon shares one engine
|
|
* (and one ToolHandler, and a pool of worker threads) across every connected
|
|
* client, so state kept over there would blend two agents' histories and let
|
|
* one session's calls suppress source the other has never seen. It dies with
|
|
* the session — a reconnecting client starts clean.
|
|
*/
|
|
private readonly exploreSession = new ExploreSessionState();
|
|
|
|
constructor(
|
|
private transport: JsonRpcTransport,
|
|
private engine: MCPEngine,
|
|
opts: MCPSessionOptions = {},
|
|
) {
|
|
this.explicitProjectPath = opts.explicitProjectPath ?? null;
|
|
}
|
|
|
|
/**
|
|
* Start handling messages from the transport. Returns immediately — the
|
|
* session lives for as long as the transport is open.
|
|
*/
|
|
start(): void {
|
|
this.transport.start(this.handleMessage.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Tear down the session. Does NOT touch the engine (the engine may serve
|
|
* other sessions) or call `process.exit` (the daemon decides when to exit).
|
|
*/
|
|
stop(): void {
|
|
this.transport.stop();
|
|
}
|
|
|
|
/** Underlying transport — exposed for daemon-side close hooks. */
|
|
getTransport(): JsonRpcTransport {
|
|
return this.transport;
|
|
}
|
|
|
|
/**
|
|
* This session's explore call history (CG-17). Exposed so tests can assert
|
|
* that two sessions on one daemon keep separate state; nothing in the server
|
|
* reaches for another session's copy.
|
|
*/
|
|
getExploreSessionState(): ExploreSessionState {
|
|
return this.exploreSession;
|
|
}
|
|
|
|
private async handleMessage(message: JsonRpcRequest | JsonRpcNotification): Promise<void> {
|
|
const isRequest = 'id' in message;
|
|
switch (message.method) {
|
|
case 'initialize':
|
|
if (isRequest) await this.handleInitialize(message as JsonRpcRequest);
|
|
break;
|
|
case 'initialized':
|
|
// Notification that client has finished initialization — no action needed.
|
|
break;
|
|
case 'tools/list':
|
|
if (isRequest) await this.handleToolsList(message as JsonRpcRequest);
|
|
break;
|
|
case 'tools/call':
|
|
if (isRequest) await this.handleToolsCall(message as JsonRpcRequest);
|
|
break;
|
|
case 'ping':
|
|
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, {});
|
|
break;
|
|
case 'resources/list':
|
|
// We expose no MCP resources, but some clients (opencode, Codex) probe
|
|
// for them on connect; reply with an empty list instead of a
|
|
// MethodNotFound error that surfaces as a scary `-32601` log line. (#621)
|
|
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resources: [] });
|
|
break;
|
|
case 'resources/templates/list':
|
|
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { resourceTemplates: [] });
|
|
break;
|
|
case 'prompts/list':
|
|
// Likewise — no prompts exposed, but answer the probe cleanly. (#621)
|
|
if (isRequest) this.transport.sendResult((message as JsonRpcRequest).id, { prompts: [] });
|
|
break;
|
|
default:
|
|
if (isRequest) {
|
|
this.transport.sendError(
|
|
(message as JsonRpcRequest).id,
|
|
ErrorCodes.MethodNotFound,
|
|
`Method not found: ${message.method}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleInitialize(request: JsonRpcRequest): Promise<void> {
|
|
const params = request.params as {
|
|
rootUri?: string;
|
|
workspaceFolders?: Array<{ uri: string; name: string }>;
|
|
capabilities?: { roots?: unknown };
|
|
clientInfo?: { name?: unknown; version?: unknown };
|
|
} | undefined;
|
|
|
|
this.clientSupportsRoots = !!params?.capabilities?.roots;
|
|
if (params?.clientInfo) {
|
|
this.clientInfo = {
|
|
name: typeof params.clientInfo.name === 'string' ? params.clientInfo.name : undefined,
|
|
version: typeof params.clientInfo.version === 'string' ? params.clientInfo.version : undefined,
|
|
};
|
|
}
|
|
|
|
// Explicit project signal, strongest first: client-provided rootUri /
|
|
// workspaceFolders (LSP-style), else the --path the server was launched
|
|
// with. cwd is NOT used here — we defer it so a roots/list answer can
|
|
// win over it. See issue #196.
|
|
let explicitPath: string | null = null;
|
|
if (params?.rootUri) {
|
|
explicitPath = fileUriToPath(params.rootUri);
|
|
} else if (params?.workspaceFolders?.[0]?.uri) {
|
|
explicitPath = fileUriToPath(params.workspaceFolders[0].uri);
|
|
} else if (this.explicitProjectPath) {
|
|
explicitPath = this.explicitProjectPath;
|
|
}
|
|
|
|
// Pick the instructions variant by the root's index state — synchronous
|
|
// and bounded (an existsSync walk-up plus, when that misses, the depth- and
|
|
// count-bounded workspace down-scan; no DB open, so the #172 respond-fast
|
|
// contract holds). This is the SAME resolution the engine's doInitialize
|
|
// runs (#1606), so the variant matches what the engine will actually adopt
|
|
// — a workspace whose single indexed sub-project becomes the default gets
|
|
// the full single-project playbook, race-free by construction (both sides
|
|
// compute it independently; no ordering between handshake and engine init
|
|
// is assumed). When the root ISN'T indexed (and nothing was adopted), send
|
|
// the per-project variant (tools are still exposed — see handleToolsList):
|
|
// it tells the agent there is no default project and to pass `projectPath`
|
|
// to any project that has a `.codegraph/`. Gating tool AVAILABILITY on
|
|
// whether `./` is indexed was the #964 bug — it broke monorepos (only
|
|
// sub-projects indexed) and never surfaced the tools after a mid-session
|
|
// `codegraph init`. When no explicit path is known yet (roots/list dance
|
|
// pending), cwd is the best predictor of where the default will resolve.
|
|
// A root in a sensitive folder resolves to null here as in the engine, so
|
|
// that session gets the no-default-project variant too.
|
|
const indexed = resolveServerRoot(explicitPath ?? process.cwd()).root !== null;
|
|
|
|
// Respond to the handshake BEFORE doing any heavy init — see issue #172.
|
|
this.transport.sendResult(request.id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: { tools: {} },
|
|
serverInfo: SERVER_INFO,
|
|
instructions: initializeInstructions(indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_NO_ROOT_INDEX),
|
|
});
|
|
|
|
if (explicitPath) {
|
|
// Kick off engine init in the background. If another session in the
|
|
// same daemon already opened the project, `ensureInitialized` is a
|
|
// ~free no-op — N concurrent clients pay exactly one open.
|
|
this.resolvePromise = this.engine.ensureInitialized(explicitPath);
|
|
}
|
|
}
|
|
|
|
private async handleToolsList(request: JsonRpcRequest): Promise<void> {
|
|
await this.retryInitIfNeeded();
|
|
// Always expose the tools — even when the server root has no index. Gating
|
|
// availability on whether `./` is indexed (the old behavior) breaks the
|
|
// monorepo case where only sub-projects carry a `.codegraph/` (the agent
|
|
// saw zero tools and couldn't even reach an indexed sub-project by
|
|
// `projectPath`), and it hides the tools from a session that started before
|
|
// the user ran `codegraph init` (most hosts request the list once, so the
|
|
// freshly-built index never surfaces). #964. The not-indexed case is still
|
|
// safe: a call against an un-indexed path returns SUCCESS-shaped guidance
|
|
// ("pass projectPath / run codegraph init"), never `isError`, so it can't
|
|
// teach the agent to abandon codegraph. `getTools()` returns the default
|
|
// surface even before a project is open.
|
|
this.transport.sendResult(request.id, {
|
|
tools: this.engine.getToolHandler().getTools(),
|
|
});
|
|
}
|
|
|
|
private async handleToolsCall(request: JsonRpcRequest): Promise<void> {
|
|
const params = request.params as {
|
|
name: string;
|
|
arguments?: Record<string, unknown>;
|
|
};
|
|
|
|
if (!params || !params.name) {
|
|
this.transport.sendError(request.id, ErrorCodes.InvalidParams, 'Missing tool name');
|
|
return;
|
|
}
|
|
|
|
const toolName = params.name;
|
|
const toolArgs = toolCallArguments(params);
|
|
if (!toolArgs) {
|
|
this.transport.sendError(request.id, ErrorCodes.InvalidParams, TOOL_ARGUMENTS_NOT_OBJECT);
|
|
return;
|
|
}
|
|
|
|
const tool = tools.find((t) => t.name === toolName);
|
|
if (!tool) {
|
|
this.transport.sendError(
|
|
request.id,
|
|
ErrorCodes.InvalidParams,
|
|
`Unknown tool: ${toolName}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} pre-init\n`);
|
|
await this.retryInitIfNeeded();
|
|
|
|
if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} dispatch\n`);
|
|
const result = await this.engine.getToolHandler().execute(toolName, toolArgs, this.exploreSession);
|
|
if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} done\n`);
|
|
this.transport.sendResult(request.id, result);
|
|
// After the reply is on the wire — telemetry must never delay a tool
|
|
// response (in-memory increment only; see src/telemetry).
|
|
getTelemetry().recordUsage('mcp_tool', toolName, !result.isError, this.clientInfo);
|
|
}
|
|
|
|
/**
|
|
* Lazy default-project resolution. Three layers:
|
|
* 1. await the in-flight init kicked off from `handleInitialize` (if any);
|
|
* 2. if still uninitialized and we never asked the client for its roots,
|
|
* do so now (one-shot); fall back to cwd if the client lacks roots;
|
|
* 3. last-resort: re-walk from the best candidate — picks up projects
|
|
* that were `codegraph init`'d *after* the server started.
|
|
*/
|
|
private async retryInitIfNeeded(): Promise<void> {
|
|
if (this.resolvePromise) {
|
|
try { await this.resolvePromise; } catch { /* fall through to retry */ }
|
|
this.resolvePromise = null;
|
|
}
|
|
|
|
if (this.engine.hasDefaultCodeGraph()) return;
|
|
|
|
const hint = this.explicitProjectPath ?? this.engine.getProjectPath();
|
|
if (!hint && !this.rootsAttempted) {
|
|
this.rootsAttempted = true;
|
|
this.resolvePromise = this.clientSupportsRoots
|
|
? this.initFromRoots()
|
|
: this.engine.ensureInitialized(process.cwd());
|
|
try { await this.resolvePromise; } catch { /* fall through */ }
|
|
this.resolvePromise = null;
|
|
if (this.engine.hasDefaultCodeGraph()) return;
|
|
}
|
|
|
|
// Last resort: walk from the best candidate (sync open). Picks up
|
|
// projects that appeared after the server started.
|
|
const candidate = hint ?? process.cwd();
|
|
this.engine.retryInitializeSync(candidate);
|
|
}
|
|
|
|
/**
|
|
* Ask the client for its workspace root via `roots/list` and open the
|
|
* first one. Falls back to `process.cwd()` on timeout or empty answer.
|
|
*/
|
|
private async initFromRoots(): Promise<void> {
|
|
let target = process.cwd();
|
|
try {
|
|
const result = await this.transport.request('roots/list', undefined, ROOTS_LIST_TIMEOUT_MS);
|
|
const rootPath = firstRootPath(result);
|
|
if (rootPath) {
|
|
target = rootPath;
|
|
} else {
|
|
process.stderr.write('[CodeGraph MCP] Client returned no workspace roots; falling back to process cwd.\n');
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`[CodeGraph MCP] roots/list request failed (${msg}); falling back to process cwd.\n`);
|
|
}
|
|
await this.engine.ensureInitialized(target);
|
|
}
|
|
}
|