Procházet zdrojové kódy

fix(mcp): don't block initialize handshake on heavy init (#172)

The MCP `initialize` handler was awaiting `tryInitializeDefault` —
which opens the SQLite DB and runs `await initGrammars()` (tree-sitter
WASM bootstrap) — before sending the JSON-RPC response. On slow
filesystems (Docker Desktop VirtioFS on macOS, WSL2) this could exceed
Claude Code's ~30s handshake timeout, leaving the codegraph child
process alive and unresponsive with no tools visible in the client.

Send the response first; defer the open to a tracked background
promise. The lazy retry path used by `tools/list` and `tools/call`
now awaits that promise instead of racing it with `openSync`, so we
never double-open the SQLite file.

Adds a subprocess-based regression test that asserts the JSON-RPC
response arrives on stdout before `startWatching()` logs to stderr.
This ordering check catches the regression on any filesystem, not
just slow ones where the timing matters in practice.

Reported by @sashanclrp; isolated by @sgrimm's wire capture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry před 1 měsícem
rodič
revize
449282ad86
3 změnil soubory, kde provedl 200 přidání a 10 odebrání
  1. 18 0
      CHANGELOG.md
  2. 149 0
      __tests__/mcp-initialize.test.ts
  3. 33 10
      src/mcp/index.ts

+ 18 - 0
CHANGELOG.md

@@ -7,6 +7,24 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
 This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
 and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.7.10] - 2026-05-19
+
+### Fixed
+- **MCP**: tools no longer silently fail to appear in clients on slow
+  filesystems (Docker Desktop VirtioFS on macOS, WSL2). The `initialize`
+  handshake was blocking on opening the SQLite database and bootstrapping
+  the tree-sitter WASM runtime, which on slow I/O could exceed Claude
+  Code's ~30s handshake timeout — leaving the codegraph process alive but
+  unresponsive and no tools visible. The handshake now returns immediately
+  and defers project open to the background; tool calls wait on the
+  in-flight init rather than racing it with a second open. Closes
+  [#172](https://github.com/colbymchenry/codegraph/issues/172). Thanks to
+  [@sashanclrp](https://github.com/sashanclrp) for the original report and
+  detailed reproduction, and [@sgrimm](https://github.com/sgrimm) for the
+  decisive wire capture that isolated the actual root cause.
+
+[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10
+
 ## [0.7.8] - 2026-05-17
 
 ### Fixed

+ 149 - 0
__tests__/mcp-initialize.test.ts

@@ -0,0 +1,149 @@
+/**
+ * MCP `initialize` handshake regression tests.
+ *
+ * Issue #172: on slow filesystems (Docker Desktop VirtioFS on macOS, WSL2),
+ * the MCP server was blocking the initialize response on CodeGraph.open() and
+ * Parser.init() (web-tree-sitter WASM bootstrap), which could take longer than
+ * Claude Code's ~30s handshake timeout. The child process stayed alive and
+ * had received the request, but never sent a response, so tools never
+ * appeared in the client. The fix sends the initialize response before
+ * kicking off the heavy init in the background. These tests guard the
+ * contract that initialize is fast regardless of how much work init does.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
+  return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
+    cwd,
+    stdio: ['pipe', 'pipe', 'pipe'],
+  }) as ChildProcessWithoutNullStreams;
+}
+
+function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) {
+  const msg = JSON.stringify({
+    jsonrpc: '2.0',
+    id: 0,
+    method: 'initialize',
+    params: {
+      protocolVersion: '2025-11-25',
+      capabilities: {},
+      clientInfo: { name: 'test', version: '0.0.0' },
+      rootUri: `file://${projectPath}`,
+    },
+  });
+  child.stdin.write(msg + '\n');
+}
+
+/**
+ * Collect stdout lines and stderr text from the child, tagging each piece
+ * with a monotonic sequence number. Lets us assert ordering between the
+ * JSON-RPC response (stdout) and side-effect logs (stderr).
+ */
+function tagStreams(child: ChildProcessWithoutNullStreams) {
+  const events: Array<{ seq: number; stream: 'stdout' | 'stderr'; text: string }> = [];
+  let seq = 0;
+  let stdoutBuf = '';
+  let stderrBuf = '';
+  child.stdout.on('data', (chunk) => {
+    stdoutBuf += chunk.toString('utf8');
+    let idx;
+    while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
+      const line = stdoutBuf.slice(0, idx);
+      stdoutBuf = stdoutBuf.slice(idx + 1);
+      events.push({ seq: seq++, stream: 'stdout', text: line });
+    }
+  });
+  child.stderr.on('data', (chunk) => {
+    stderrBuf += chunk.toString('utf8');
+    let idx;
+    while ((idx = stderrBuf.indexOf('\n')) !== -1) {
+      const line = stderrBuf.slice(0, idx);
+      stderrBuf = stderrBuf.slice(idx + 1);
+      events.push({ seq: seq++, stream: 'stderr', text: line });
+    }
+  });
+  return events;
+}
+
+function waitFor<T>(
+  events: ReadonlyArray<{ seq: number; stream: string; text: string }>,
+  predicate: (e: { seq: number; stream: string; text: string }) => boolean,
+  timeoutMs: number,
+): Promise<{ seq: number; stream: string; text: string }> {
+  return new Promise((resolve, reject) => {
+    const started = Date.now();
+    const tick = () => {
+      const hit = events.find(predicate);
+      if (hit) return resolve(hit);
+      if (Date.now() - started > timeoutMs) {
+        return reject(new Error(`Timed out waiting for predicate. Events: ${JSON.stringify(events)}`));
+      }
+      setTimeout(tick, 20);
+    };
+    tick();
+  });
+}
+
+describe('MCP initialize handshake (issue #172)', () => {
+  let tempDir: string;
+  let child: ChildProcessWithoutNullStreams | null = null;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-'));
+  });
+
+  afterEach(() => {
+    if (child && !child.killed) {
+      child.kill('SIGKILL');
+      child = null;
+    }
+    fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('responds to initialize quickly when no .codegraph exists in cwd', async () => {
+    child = spawnServer(tempDir);
+    const events = tagStreams(child);
+    sendInitialize(child, tempDir);
+    const response = await waitFor(events, (e) => e.stream === 'stdout', 5000);
+    const json = JSON.parse(response.text);
+    expect(json.jsonrpc).toBe('2.0');
+    expect(json.id).toBe(0);
+    expect(json.result.protocolVersion).toBeDefined();
+    expect(json.result.capabilities.tools).toBeDefined();
+  }, 10000);
+
+  it('sends initialize response BEFORE tryInitializeDefault finishes', async () => {
+    // Seed a real .codegraph so the server's tryInitializeDefault path runs
+    // its full body: CodeGraph.open() (which awaits initGrammars()) and then
+    // startWatching() (which logs "File watcher active" to stderr). On any
+    // platform, that stderr log is observable evidence that tryInitializeDefault
+    // has completed. The contract we're protecting: the JSON-RPC response on
+    // stdout must arrive BEFORE that stderr log. If a future change re-awaits
+    // tryInitializeDefault before sendResult, this ordering inverts and the
+    // test fails — regardless of how fast the local filesystem is.
+    const cg = await CodeGraph.init(tempDir);
+    cg.close();
+
+    child = spawnServer(tempDir);
+    const events = tagStreams(child);
+    sendInitialize(child, tempDir);
+
+    const response = await waitFor(events, (e) => e.stream === 'stdout', 10000);
+    const watcherLog = await waitFor(
+      events,
+      (e) => e.stream === 'stderr' && e.text.includes('File watcher active'),
+      10000,
+    );
+    expect(response.seq).toBeLessThan(watcherLog.seq);
+    const json = JSON.parse(response.text);
+    expect(json.id).toBe(0);
+    expect(json.result.serverInfo.name).toBe('codegraph');
+  }, 20000);
+});

+ 33 - 10
src/mcp/index.ts

@@ -64,6 +64,9 @@ export class MCPServer {
   private cg: CodeGraph | null = null;
   private toolHandler: ToolHandler;
   private projectPath: string | null;
+  // In-flight background init kicked off from handleInitialize. Tracked so the
+  // sync retry path doesn't race against it (double-opening the SQLite file).
+  private initPromise: Promise<void> | null = null;
 
   constructor(projectPath?: string) {
     this.projectPath = projectPath || null;
@@ -130,8 +133,16 @@ export class MCPServer {
    * Called lazily on tool calls that need the default project.
    * Re-walks parent directories each time so it picks up projects
    * initialized after the MCP server started.
+   *
+   * Awaits any in-flight background init (kicked off by handleInitialize) so
+   * we never open the SQLite file twice concurrently.
    */
-  private retryInitIfNeeded(): void {
+  private async retryInitIfNeeded(): Promise<void> {
+    // Wait for the background init started during handleInitialize, if any.
+    if (this.initPromise) {
+      try { await this.initPromise; } catch { /* errored init falls through to retry */ }
+    }
+
     // Already initialized successfully
     if (this.toolHandler.hasDefaultCodeGraph()) return;
     // No project path to retry with
@@ -266,13 +277,17 @@ export class MCPServer {
       projectPath = process.cwd();
     }
 
-    // Try to initialize the default project (non-fatal if it fails)
-    await this.tryInitializeDefault(projectPath);
-
-    // We accept the client's protocol version but respond with our supported version.
-    // The `instructions` field is surfaced by MCP clients in the agent's system
-    // prompt automatically — it's the right place for the universal tool-selection
-    // playbook, ahead of individual tool descriptions.
+    // Respond to the handshake BEFORE doing any heavy initialization. Loading
+    // the SQLite DB and the tree-sitter WASM runtime can take many seconds on
+    // slow filesystems (Docker Desktop VirtioFS on macOS, WSL2). Clients like
+    // Claude Code time out the handshake at ~30s, which manifested as
+    // "MCP tools never appear" — the child was alive and had received the
+    // initialize but was still awaiting initGrammars(). See issue #172.
+    //
+    // We accept the client's protocol version but respond with our supported
+    // version. The `instructions` field is surfaced by MCP clients in the
+    // agent's system prompt automatically — it's the right place for the
+    // universal tool-selection playbook, ahead of individual tool descriptions.
     this.transport.sendResult(request.id, {
       protocolVersion: PROTOCOL_VERSION,
       capabilities: {
@@ -281,13 +296,21 @@ export class MCPServer {
       serverInfo: SERVER_INFO,
       instructions: SERVER_INSTRUCTIONS,
     });
+
+    // Kick off the default-project init in the background. Tool calls that
+    // arrive before it finishes will see the "not initialized yet" path and
+    // fall through to `retryInitIfNeeded`, which now waits for this promise
+    // rather than racing against it with a second open.
+    this.initPromise = this.tryInitializeDefault(projectPath).finally(() => {
+      this.initPromise = null;
+    });
   }
 
   /**
    * Handle tools/list request
    */
   private async handleToolsList(request: JsonRpcRequest): Promise<void> {
-    this.retryInitIfNeeded();
+    await this.retryInitIfNeeded();
     this.transport.sendResult(request.id, {
       tools: this.toolHandler.getTools(),
     });
@@ -327,7 +350,7 @@ export class MCPServer {
 
     // If the default project isn't initialized yet, retry in case it was
     // initialized after the MCP server started (e.g. user ran codegraph init)
-    this.retryInitIfNeeded();
+    await this.retryInitIfNeeded();
 
     const result = await this.toolHandler.execute(toolName, toolArgs);