| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- /**
- * 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';
- /**
- * 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.
- 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 = params.arguments || {};
- 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);
- }
- }
|