diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0b58b73..92fced7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The safety watchdog no longer kills a healthy index on severely degraded storage. It used to judge liveness purely by the event loop, so one long database write on a struggling disk looked identical to a hung process and could get a valid, in-progress index terminated. During `codegraph index`/`codegraph init` the watchdog now also checks whether the index files on disk are advancing before it acts: slow-but-progressing work is left alone (bounded by a hard cap), while a genuinely hung process is still killed exactly as fast as before. (#1231) - Incremental sync now picks up cross-file relationships that only become resolvable after an edit — for example, when a file gains an export that another, unchanged file was already importing or calling. Previously the reference in the unchanged file was never revisited, so callers, impact, and flow results silently omitted the new edge (while status reported a clean index) until a full re-index. References that can't be resolved yet are now remembered and automatically retried whenever a change introduces a symbol that could satisfy them — this also covers a class gaining a new method that other files already call. Thanks @loadcosmos for the report with a minimal reproduction. (#1240) - The reverse case is fixed too: when an edit removes or moves a symbol (or deletes its file), callers in unchanged files now re-resolve during the same sync — rebinding to the symbol's new home when it moved, or waiting to reconnect automatically when it comes back — instead of silently losing their relationship until a full re-index. (#1240) +- MCP clients that don't forward the `env` block from the server config to the spawned process (e.g. OpenCode, Antigravity IDE) can now pin the exposed tool surface with a CLI flag: `codegraph serve --mcp --tools=explore,search,node` (#1192). The flag is a fallback for `CODEGRAPH_MCP_TOOLS` and works through every runtime path — direct mode, the proxy, and the shared daemon. ## [1.4.0] - 2026-07-10 diff --git a/__tests__/cli-mcp-tools-flag.test.ts b/__tests__/cli-mcp-tools-flag.test.ts new file mode 100644 index 000000000..fd5da3672 --- /dev/null +++ b/__tests__/cli-mcp-tools-flag.test.ts @@ -0,0 +1,246 @@ +/** + * Regression test for #1192: the `codegraph serve --mcp --tools=` CLI + * flag mirrors `CODEGRAPH_MCP_TOOLS` for MCP clients (OpenCode, Antigravity + * IDE, …) that don't forward the `env` block from the MCP server config to + * the spawned process. Without the flag, the user sees only the default + * `codegraph_explore` regardless of what they set in the config. + * + * The flag works by setting `process.env.CODEGRAPH_MCP_TOOLS` BEFORE + * `MCPServer` construction, so it propagates to: + * 1. the proxy's static ListTools (runLocalHandshakeProxy → getStaticTools) + * 2. the detached daemon via the spawn's `env: { ...process.env }` + * 3. the direct-mode `ToolHandler.getTools()` (this suite tests this path) + * + * We exercise the in-process direct path with `CODEGRAPH_NO_DAEMON=1` because + * the env-var reading logic is shared across all three (#1, #2, #3 above all + * read `process.env.CODEGRAPH_MCP_TOOLS` at call time). The CLI plumbing is + * exercised end-to-end: argv → commander parse → action → env var → filter. + */ +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'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnServer( + cwd: string, + extraArgs: string[] = [], + extraEnv: Record = {}, +): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [BIN, 'serve', '--mcp', ...extraArgs], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + // Pin direct mode: avoids leaking a detached daemon if the test bails out, + // and isolates the env-var read to one process (the launcher's). + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', ...extraEnv }, + }) as ChildProcessWithoutNullStreams; +} + +function sendRequest( + child: ChildProcessWithoutNullStreams, + id: number, + method: string, + params: Record = {}, +) { + const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }); + child.stdin.write(msg + '\n'); +} + +interface StreamEvent { + seq: number; + stream: 'stdout' | 'stderr'; + text: string; +} + +function tagStreams(child: ChildProcessWithoutNullStreams): StreamEvent[] { + const events: StreamEvent[] = []; + 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; +} + +async function waitForResponse( + events: StreamEvent[], + id: number, + timeoutMs: number, +): Promise<{ result?: { tools?: Array<{ name: string }> }; error?: unknown }> { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + for (const e of events) { + if (e.stream !== 'stdout') continue; + try { + const parsed = JSON.parse(e.text); + if (parsed && parsed.id === id) return parsed; + } catch { /* ignore */ } + } + await new Promise((r) => setTimeout(r, 20)); + } + throw new Error(`Timed out waiting for id=${id}. Events: ${JSON.stringify(events)}`); +} + +describe('`codegraph serve --mcp --tools=` (issue #1192)', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-tools-flag-')); + }); + + afterEach(() => { + if (child && !child.killed) { + child.kill('SIGKILL'); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('exposes ONLY the default surface (explore) when --tools is omitted', async () => { + child = spawnServer(tempDir); + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 5000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 5000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_explore']); + }, 15000); + + it('--tools= exposes the requested tools (#1192)', async () => { + child = spawnServer(tempDir, ['--tools=explore,search,node']); + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 5000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 5000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_explore', 'codegraph_node', 'codegraph_search']); + }, 15000); + + it('--tools accepts a single tool (regression of the default surface)', async () => { + child = spawnServer(tempDir, ['--tools=callers']); + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 5000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 5000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_callers']); + }, 15000); + + it('--tools= with empty value falls back to the default surface', async () => { + // An empty --tools is treated the same as "not set" — the env var is + // never assigned, so the default (explore-only) applies. Matches the + // existing whitespace-only env-var behavior in toolAllowlist(). + child = spawnServer(tempDir, ['--tools=']); + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 5000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 5000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_explore']); + }, 15000); + + it('--tools wins when both the flag and a pre-set CODEGRAPH_MCP_TOOLS are present', async () => { + // If a user accidentally set the env var in their shell AND added the + // --tools flag, the flag wins. This guards the documented behavior in + // the flag's help text ("Overrides CODEGRAPH_MCP_TOOLS"). + child = spawnServer( + tempDir, + ['--tools=explore,impact'], + { CODEGRAPH_MCP_TOOLS: 'explore,callers,callees' }, + ); + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 5000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 5000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_explore', 'codegraph_impact']); + }, 15000); + + it('the proxy path also honors --tools (env var set in the launcher process)', async () => { + // The proxy path is what an MCP host actually talks to when daemon sharing + // is on. The launcher process is the one that parses --tools and sets + // process.env.CODEGRAPH_MCP_TOOLS; runLocalHandshakeProxy → getStaticTools + // reads the same env var at call time. We do NOT use CODEGRAPH_NO_DAEMON + // here — the proxy intercepts tools/list before the daemon is involved. + // Seed a real .codegraph so the daemon machinery engages (otherwise + // resolveDaemonRoot returns null and the server falls back to direct). + const { CodeGraph } = await import('../src'); + const cg = await CodeGraph.init(tempDir); + cg.close(); + + const child2 = spawn(process.execPath, [BIN, 'serve', '--mcp', '--tools=explore,files'], { + cwd: tempDir, + stdio: ['pipe', 'pipe', 'pipe'], + // Don't disable the daemon — we want the proxy path. + env: { ...process.env }, + }) as ChildProcessWithoutNullStreams; + child = child2; + const events = tagStreams(child); + sendRequest(child, 0, 'initialize', { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${tempDir}`, + }); + await waitForResponse(events, 0, 10000); + + sendRequest(child, 1, 'tools/list', {}); + const reply = await waitForResponse(events, 1, 10000); + const names = (reply.result!.tools ?? []).map((t) => t.name).sort(); + expect(names).toEqual(['codegraph_explore', 'codegraph_files']); + }, 25000); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 15d1afb66..c8c99a578 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1684,7 +1684,14 @@ program .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') .option('--mcp', 'Run as MCP server (stdio transport)') .option('--no-watch', 'Disable the file watcher (no auto-sync; useful on slow filesystems like WSL2 /mnt drives)') - .action(async (options: { path?: string; mcp?: boolean; watch?: boolean }) => { + // #1192: some MCP clients (OpenCode, Antigravity IDE, …) don't forward the + // `env` block from the MCP server config to the spawned process, so a user + // setting `CODEGRAPH_MCP_TOOLS` in their MCP config sees only the default + // surface. The CLI flag is set BEFORE MCPServer construction, so the proxy + // (static ListTools), the daemon-spawn env spread, and the daemon's + // session handler all pick it up — the env-var logic below doesn't change. + .option('--tools ', 'Comma-separated MCP tools to expose (e.g. "explore,search,node"). Overrides CODEGRAPH_MCP_TOOLS; works even when the MCP client does not forward env vars from its config (#1192).') + .action(async (options: { path?: string; mcp?: boolean; watch?: boolean; tools?: string }) => { const projectPath = options.path ? resolveProjectPath(options.path) : undefined; // Commander sets watch=false when --no-watch is passed. Route it through @@ -1693,6 +1700,14 @@ program process.env.CODEGRAPH_NO_WATCH = '1'; } + // #1192: explicit --tools flag wins over any pre-set env var so the + // operator's intent in the MCP config is always honored. Set it here so + // the proxy (runLocalHandshakeProxy → getStaticTools), the daemon spawn + // (env: { ...process.env }), and the direct-mode ToolHandler all see it. + if (options.tools !== undefined && options.tools !== '') { + process.env.CODEGRAPH_MCP_TOOLS = options.tools; + } + try { if (options.mcp) { // `serve --mcp` is the stdio MCP server an AI agent launches for itself,