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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9b6a917d32
commit
36c8dbc404
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user