diff --git a/CHANGELOG.md b/CHANGELOG.md index 5198c04ff..e3abca36c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`codegraph_explore` now explains where a flow ends instead of going silent.** When the symbols you ask about don't connect statically — because the code dispatches through a runtime mechanism like a computed call (`handlers[action.type](...)`), Python's `getattr`, a command/mediator bus (`sender.Send(new DeleteCommand(...))`), reflection, or `new Proxy` — explore now announces the exact dispatch site (file and line) where the static path stops, and when the dispatch key is visible in the source it shortlists the likely runtime targets (for example pointing a MediatR command straight at its `Handler.Handle` method). Detection is deterministic and runs only when a flow fails to connect; fully connected flows are unchanged, and nothing about indexing or the graph itself changes. Relatedly, a custom event bus whose emit and handler connect through a single synthesized hop now shows that hop explicitly (with the registration site) — it previously rendered nothing because the connection was "too short" for the flow section. (#687) - **Anonymous usage telemetry, documented field-by-field and easy to turn off.** CodeGraph now collects a small set of anonymous usage statistics — which commands and MCP tools get used, which languages get indexed, which agents connect — so language and agent support work goes where real usage is. Never any code, file paths, file or symbol names, search queries, or IP addresses; usage aggregates locally into daily totals before anything is sent, and the ingest endpoint is public, auditable code in the repository that enforces the documented field list. The installer asks up front with a visible default-on toggle (and never re-asks); everywhere else a one-line notice prints before the first send. Disable any time with `codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, or the cross-tool `DO_NOT_TRACK=1` standard — off means off: nothing is recorded, nothing is sent, and buffered data is deleted. `TELEMETRY.md` documents every field. +- CodeGraph can now run its MCP server over Streamable HTTP with `codegraph serve --mcp --http`, giving remote-capable MCP clients a local `/mcp` endpoint while keeping stdio as the default. - **Subagents and non-MCP agents can now reach CodeGraph.** Two new CLI commands — `codegraph explore ""` and `codegraph node ` — print exactly what the matching MCP tools return (relevant symbols' source + call paths; one symbol's source + callers; file reads with line numbers), so any agent with a shell can use the graph. And `codegraph install` now writes a small marker-fenced CodeGraph section into each agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) pointing at both surfaces — that file is what Task-tool subagents actually see, where the MCP server's own guidance only reaches the main agent. Measured on a delegated code-exploration task: subagents went from almost never using CodeGraph (~1 in 9 runs) to using it in every run, including runs with zero grep/file-reading fallback. The section is small, survives your own content, upgrades cleanly from the old long block, and `codegraph uninstall` removes it. Thanks @liuyao37511. (#704) - **The MCP tool list is now a focused default of four** — `codegraph_explore`, `codegraph_node`, `codegraph_search`, and `codegraph_callers`. The other four (`codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) remain fully functional — the CLI and library API are unchanged, and `CODEGRAPH_MCP_TOOLS` re-enables any of them — but they're no longer listed to agents by default: measured agent behavior shows they're never or rarely picked, and the information they carry already arrives inline on the tools agents do use (explore's blast-radius section, node's dependents note, a symbol's own body as its callee list). A leaner list saves context tokens every session and steers agents to the right tool by presence alone. - **CodeGraph now goes quiet instead of failing loudly in unindexed projects.** When an AI agent's session starts in a workspace that has no CodeGraph index, the MCP server now announces itself as inactive with a short note and lists no tools at all — instead of presenting the full toolset and erroring on every call, which taught agents to distrust CodeGraph even where it works. Querying another project that isn't indexed likewise returns clear guidance (use your regular tools for that codebase; the user can run `codegraph init` there to enable CodeGraph) instead of an error, and genuine internal errors now tell the agent to retry once rather than give up on CodeGraph entirely. Indexing stays your decision — agents are told not to run it themselves. (#769) diff --git a/__tests__/frameworks-integration.test.ts b/__tests__/frameworks-integration.test.ts index 344a0f6c9..a6a92014c 100644 --- a/__tests__/frameworks-integration.test.ts +++ b/__tests__/frameworks-integration.test.ts @@ -609,7 +609,10 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => { describe('JVM FQN imports — end-to-end', () => { let tmpDir: string | undefined; + let cg: CodeGraph | undefined; afterEach(() => { + cg?.close(); + cg = undefined; if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; }); @@ -627,7 +630,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar'); @@ -644,7 +647,6 @@ describe('JVM FQN imports — end-to-end', () => { .find((e) => e.kind === 'imports'); expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined(); - cg.close(); }); it('resolves a Kotlin top-level function import', async () => { @@ -658,7 +660,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util'); @@ -679,7 +681,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar'); @@ -711,7 +713,7 @@ describe('JVM FQN imports — end-to-end', () => { 'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n' ); - const cg = CodeGraph.initSync(tmpDir); + cg = CodeGraph.initSync(tmpDir); await cg.indexAll(); const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar'); diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c00d528f6..6929418b0 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -38,6 +38,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; import { getDaemonSocketPath } from '../src/mcp/daemon-paths'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); @@ -49,7 +50,7 @@ interface SpawnedServer { } function spawnServer(cwd: string, env: NodeJS.ProcessEnv = {}): SpawnedServer { - const child = spawn(process.execPath, [BIN, 'serve', '--mcp'], { + const child = spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // #618: the daemon-attach log line is now off by default; opt the test @@ -161,10 +162,36 @@ function killTree(...procs: ChildProcessWithoutNullStreams[]): void { } } +async function waitForProcessTreeExit(...procs: ChildProcessWithoutNullStreams[]): Promise { + await Promise.all(procs.map((p) => { + if (!p.pid || p.exitCode !== null || p.signalCode !== null) return Promise.resolve(false); + return waitProcessExit(p.pid, 3000); + })); +} + async function waitProcessExit(pid: number, timeoutMs: number): Promise { return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false); } +async function rmDirWhenUnlocked(dir: string, timeoutMs = 5000): Promise { + const started = Date.now(); + let lastError: unknown; + while (Date.now() - started <= timeoutMs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'EPERM' && code !== 'EBUSY' && code !== 'ENOTEMPTY') { + throw err; + } + lastError = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw lastError; +} + describe('Shared MCP daemon (issue #411)', () => { let tempDir: string; // the (possibly symlinked) path processes are spawned with let realRoot: string; // its canonical form — what the daemon keys paths on @@ -179,6 +206,7 @@ describe('Shared MCP daemon (issue #411)', () => { afterEach(async () => { killTree(...servers.map((s) => s.child)); + await waitForProcessTreeExit(...servers.map((s) => s.child)); // The daemon is detached (not a tracked child) — reap it explicitly via the // pid it recorded, so a test can't leak a background daemon. Guard against // our own pid: the version-mismatch test plants `pid: process.pid` in the @@ -186,10 +214,10 @@ describe('Shared MCP daemon (issue #411)', () => { const daemonPid = readLockPid(realRoot); if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) { try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ } + await waitProcessExit(daemonPid, 3000); } - await new Promise((r) => setTimeout(r, 50)); servers.length = 0; - fs.rmSync(tempDir, { recursive: true, force: true }); + await rmDirWhenUnlocked(tempDir); }); it('two invocations share ONE detached daemon; both attach as proxies', async () => { diff --git a/__tests__/mcp-http.test.ts b/__tests__/mcp-http.test.ts new file mode 100644 index 000000000..57c617eb0 --- /dev/null +++ b/__tests__/mcp-http.test.ts @@ -0,0 +1,187 @@ +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 { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function spawnHttpServer(cwd: string): ChildProcessWithoutNullStreams { + return spawn( + process.execPath, + [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--http', '--port', '0', '--no-watch'], + { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' }, + }, + ) as ChildProcessWithoutNullStreams; +} + +function waitForHttpUrl(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let stderr = ''; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for HTTP server URL. stderr=${stderr}`)); + }, 5000); + const onData = (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + const match = stderr.match(/listening on (http:\/\/[^\s]+)/); + if (match?.[1]) { + cleanup(); + resolve(match[1]); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`server exited before listening, code=${code}, stderr=${stderr}`)); + }; + const cleanup = () => { + clearTimeout(timer); + child.stderr.off('data', onData); + child.off('exit', onExit); + }; + child.stderr.on('data', onData); + child.on('exit', onExit); + }); +} + +function initializeBody(projectPath: string) { + return { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test', version: '0.0.0' }, + rootUri: `file://${projectPath}`, + }, + }; +} + +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + const cleanup = () => { + clearTimeout(timer); + resolve(); + }; + child.once('exit', cleanup); + child.kill('SIGTERM'); + }); +} + +describe('MCP Streamable HTTP transport', () => { + let tempDir: string; + let child: ChildProcessWithoutNullStreams | null = null; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-http-')); + }); + + afterEach(async () => { + if (child) { + await stopChild(child); + child = null; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('serves initialize over POST /mcp as application/json', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify(initializeBody(tempDir)), + }); + + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toMatch(/application\/json/); + const json = await res.json() as { + jsonrpc: string; + id: number; + result: { serverInfo: { name: string }; capabilities: { tools: unknown } }; + }; + expect(json.jsonrpc).toBe('2.0'); + expect(json.id).toBe(0); + expect(json.result.serverInfo.name).toBe('codegraph'); + expect(json.result.capabilities.tools).toBeDefined(); + }, 10000); + + it('accepts notifications with 202 and no response body', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }), + }); + + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }, 10000); + + it('accepts JSON-RPC responses with 202 and no response body', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 'client-1', result: {} }), + }); + + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }, 10000); + + it('does not offer a standalone GET SSE stream yet', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'GET', + headers: { accept: 'text/event-stream' }, + }); + + expect(res.status).toBe(405); + }, 10000); + + it('rejects invalid Origin headers to prevent local DNS rebinding', async () => { + child = spawnHttpServer(tempDir); + const baseUrl = await waitForHttpUrl(child); + + const res = await fetch(baseUrl, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + origin: 'https://evil.example', + }, + body: JSON.stringify(initializeBody(tempDir)), + }); + + expect(res.status).toBe(403); + }, 10000); +}); diff --git a/__tests__/mcp-initialize.test.ts b/__tests__/mcp-initialize.test.ts index 0a320773d..555b031af 100644 --- a/__tests__/mcp-initialize.test.ts +++ b/__tests__/mcp-initialize.test.ts @@ -16,11 +16,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // Pin to direct (in-process) mode. #172 is a contract about the in-process @@ -34,6 +35,23 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + function sendInitialize(child: ChildProcessWithoutNullStreams, projectPath: string) { const msg = JSON.stringify({ jsonrpc: '2.0', @@ -107,9 +125,9 @@ describe('MCP initialize handshake (issue #172)', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + await stopChild(child); child = null; } fs.rmSync(tempDir, { recursive: true, force: true }); diff --git a/__tests__/mcp-roots.test.ts b/__tests__/mcp-roots.test.ts index 8e1d4520d..189984b7f 100644 --- a/__tests__/mcp-roots.test.ts +++ b/__tests__/mcp-roots.test.ts @@ -21,17 +21,35 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { // --no-watch keeps the test deterministic and avoids watcher startup noise. - return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--no-watch'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + /** Parse every JSON-RPC message the server writes to stdout into an array. */ function collectMessages(child: ChildProcessWithoutNullStreams): Array> { const messages: Array> = []; @@ -84,9 +102,9 @@ describe('MCP project resolution via roots/list (issue #196)', () => { projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-')); }); - afterEach(() => { - if (child && !child.killed) { - child.kill('SIGKILL'); + afterEach(async () => { + if (child) { + await stopChild(child); child = null; } fs.rmSync(cwdDir, { recursive: true, force: true }); diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts index 2b0019d6d..09d1bafdc 100644 --- a/__tests__/mcp-unindexed.test.ts +++ b/__tests__/mcp-unindexed.test.ts @@ -17,11 +17,12 @@ import * as path from 'path'; import * as os from 'os'; import { CodeGraph } from '../src'; import { ToolHandler } from '../src/mcp/tools'; +import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); function spawnServer(cwd: string): ChildProcessWithoutNullStreams { - return spawn(process.execPath, [BIN, 'serve', '--mcp'], { + return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], // Direct (in-process) mode — the unindexed path never has a daemon @@ -36,6 +37,23 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams { }) as ChildProcessWithoutNullStreams; } +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + /** Send a JSON-RPC request and resolve with the response matching its id. */ function request( child: ChildProcessWithoutNullStreams, @@ -92,15 +110,7 @@ describe('Unindexed-workspace session policy', () => { afterEach(async () => { if (child) { - // Wait for the child to actually exit before removing its cwd — on - // Windows a just-killed process briefly holds the directory/SQLite - // handles, and an immediate rmSync fails the teardown with EPERM - // (the documented file-locking class that fails the sibling - // mcp-initialize/mcp-roots suites). kill + await exit + retried - // removal keeps this suite green on Windows. - const exited = new Promise((resolve) => child!.once('exit', () => resolve())); - child.kill('SIGKILL'); - await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]); + await stopChild(child); child = null; } fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index caf41c7df..15ab2ec0e 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2005,6 +2005,8 @@ func main() { (r) => r.dstKind === 'file' && r.dstPath && r.dstPath.endsWith('vector') ); expect(stdlibFile).toBeUndefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2064,6 +2066,8 @@ func main() { (r) => r.dstKind === 'file' && r.dstPath === 'src/lib.php' ); expect(resolved, 'page.php → src/lib.php imports edge missing').toBeDefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2098,6 +2102,8 @@ func main() { rows.find((r) => r.dstKind === 'file' && r.dstPath === 'inc/db.php'), 'index.php → inc/db.php imports edge missing' ).toBeDefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } @@ -2137,6 +2143,8 @@ func main() { rows.find((r) => r.dstKind === 'file' && r.dstPath === 'lib/inc/db.php'), 'app/page.php must NOT mis-connect to unrelated lib/inc/db.php' ).toBeUndefined(); + db.close(); + cg.close(); } finally { fs.rmSync(tempProject, { recursive: true, force: true }); } diff --git a/package.json b/package.json index a802f7975..271eec4ce 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,9 @@ "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", "cli": "npm run build && node dist/bin/codegraph.js", - "test": "vitest run", - "test:watch": "vitest", - "test:eval": "vitest run __tests__/evaluation/", + "test": "node --liftoff-only ./node_modules/vitest/vitest.mjs run", + "test:watch": "node --liftoff-only ./node_modules/vitest/vitest.mjs", + "test:eval": "node --liftoff-only ./node_modules/vitest/vitest.mjs run __tests__/evaluation/", "eval": "npm run build && npx tsx __tests__/evaluation/runner.ts", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"" }, diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index b0c2f4b48..1d467f051 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1351,6 +1351,15 @@ program /** * codegraph serve */ +function parseHttpPort(raw: string | undefined): number { + if (raw === undefined || raw === '') return 3000; + const port = Number(raw); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error(`Invalid HTTP port: ${raw}`); + } + return port; +} + program // Hidden from `--help`: this is the stdio entry point an AI agent launches // for itself (the installer wires `args: ['serve','--mcp']` into every @@ -1360,9 +1369,12 @@ program .command('serve', { hidden: true }) .description('Start CodeGraph as an MCP server for AI assistants') .option('-p, --path ', 'Project path (optional for MCP mode, uses rootUri from client)') - .option('--mcp', 'Run as MCP server (stdio transport)') + .option('--mcp', 'Run as MCP server') + .option('--http', 'Run MCP server over Streamable HTTP instead of stdio') + .option('--host ', 'Host for HTTP MCP mode (default: 127.0.0.1)') + .option('--port ', 'Port for HTTP MCP mode (default: 3000, use 0 for a random free port)') .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 }) => { + .action(async (options: { path?: string; mcp?: boolean; http?: boolean; host?: string; port?: string; watch?: boolean }) => { const projectPath = options.path ? resolveProjectPath(options.path) : undefined; // Commander sets watch=false when --no-watch is passed. Route it through @@ -1373,27 +1385,41 @@ program try { if (options.mcp) { - // `serve --mcp` is the stdio MCP server an AI agent launches for itself, - // not a command to run by hand. A human in a terminal would otherwise - // see it hang waiting for JSON-RPC on stdin, which reads as broken. If - // stdin is an interactive TTY, explain instead of hanging. The agent's - // pipe and the detached daemon both have a non-TTY stdin, so this only - // ever fires for a person who typed it. - if (process.stdin.isTTY && !process.env.CODEGRAPH_DAEMON_INTERNAL) { - console.error(chalk.bold('\nCodeGraph MCP server\n')); - console.error("This is the MCP server your AI agent (Claude Code, Cursor, Codex, opencode, …)"); - console.error("starts automatically — you don't run it yourself."); - console.error(`\nIt's already wired up by ${chalk.cyan('codegraph install')}. To check on things:`); - console.error(` ${chalk.cyan('codegraph status')} ${chalk.dim('— is this project indexed and healthy?')}`); - console.error(` ${chalk.cyan('codegraph daemon')} ${chalk.dim('— list or stop background MCP servers')}`); - console.error(chalk.dim('\n(Running it directly only does something when an MCP client drives it over stdin.)')); - return; + if (options.http) { + const parsedPort = parseHttpPort(options.port); + const { MCPHttpServer } = await import('../mcp/http-server'); + const server = new MCPHttpServer({ + projectPath, + host: options.host, + port: parsedPort, + }); + const url = await server.start(); + process.stderr.write(`CodeGraph MCP HTTP server listening on ${url}\n`); + process.on('SIGINT', () => server.stop()); + process.on('SIGTERM', () => server.stop()); + } else { + // `serve --mcp` is the stdio MCP server an AI agent launches for itself, + // not a command to run by hand. A human in a terminal would otherwise + // see it hang waiting for JSON-RPC on stdin, which reads as broken. If + // stdin is an interactive TTY, explain instead of hanging. The agent's + // pipe and the detached daemon both have a non-TTY stdin, so this only + // ever fires for a person who typed it. + if (process.stdin.isTTY && !process.env.CODEGRAPH_DAEMON_INTERNAL) { + console.error(chalk.bold('\nCodeGraph MCP server\n')); + console.error("This is the MCP server your AI agent (Claude Code, Cursor, Codex, opencode, …)"); + console.error("starts automatically — you don't run it yourself."); + console.error(`\nIt's already wired up by ${chalk.cyan('codegraph install')}. To check on things:`); + console.error(` ${chalk.cyan('codegraph status')} ${chalk.dim('— is this project indexed and healthy?')}`); + console.error(` ${chalk.cyan('codegraph daemon')} ${chalk.dim('— list or stop background MCP servers')}`); + console.error(chalk.dim('\n(Running it directly only does something when an MCP client drives it over stdin.)')); + return; + } + // Start MCP server - it handles initialization lazily based on rootUri from client + const { MCPServer } = await import('../mcp/index'); + const server = new MCPServer(projectPath); + await server.start(); + // Server will run until terminated } - // Start MCP server - it handles initialization lazily based on rootUri from client - const { MCPServer } = await import('../mcp/index'); - const server = new MCPServer(projectPath); - await server.start(); - // Server will run until terminated } else { // Default: show info about MCP mode. // Use stderr so stdout stays clean for any piped/stdio usage. diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts new file mode 100644 index 000000000..204ea44d1 --- /dev/null +++ b/src/mcp/http-server.ts @@ -0,0 +1,320 @@ +import * as http from 'http'; +import type { AddressInfo } from 'net'; +import { MCPEngine } from './engine'; +import { MCPSession } from './session'; +import { + ErrorCodes, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, + JsonRpcTransport, + MessageHandler, +} from './transport'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_ENDPOINT = '/mcp'; +const MAX_BODY_BYTES = 1024 * 1024; + +interface PendingResult { + status: number; + body: JsonRpcResponse | null; +} + +class SingleRequestHttpTransport implements JsonRpcTransport { + private response: JsonRpcResponse | null = null; + private done: Promise; + private resolveDone!: (result: PendingResult) => void; + private settled = false; + + constructor(private message: JsonRpcRequest | JsonRpcNotification) { + this.done = new Promise((resolve) => { + this.resolveDone = resolve; + }); + } + + start(handler: MessageHandler): void { + void this.run(handler); + } + + stop(): void { + this.finish(); + } + + send(response: JsonRpcResponse): void { + this.response = response; + } + + notify(_method: string, _params?: unknown): void { + // The minimal Streamable HTTP mode does not keep a server-to-client stream. + } + + request(method: string, _params?: unknown, _timeoutMs?: number): Promise { + return Promise.reject(new Error(`Server-initiated request "${method}" is not available over JSON HTTP responses`)); + } + + sendResult(id: string | number, result: unknown): void { + this.send({ jsonrpc: '2.0', id, result }); + } + + sendError(id: string | number | null, code: number, message: string, data?: unknown): void { + this.send({ jsonrpc: '2.0', id, error: { code, message, data } }); + } + + result(): Promise { + return this.done; + } + + private async run(handler: MessageHandler): Promise { + try { + await handler(this.message); + } catch (err) { + if ('id' in this.message) { + this.sendError( + this.message.id, + ErrorCodes.InternalError, + `Internal error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } finally { + this.finish(); + } + } + + private finish(): void { + if (this.settled) return; + this.settled = true; + this.resolveDone({ + status: this.response ? 200 : 202, + body: this.response, + }); + } +} + +export interface MCPHttpServerOptions { + projectPath?: string; + host?: string; + port?: number; + endpoint?: string; +} + +export class MCPHttpServer { + private server: http.Server | null = null; + private engine = new MCPEngine(); + private endpoint: string; + private host: string; + private port: number; + private projectPath: string; + + constructor(options: MCPHttpServerOptions = {}) { + this.host = options.host ?? DEFAULT_HOST; + this.port = options.port ?? 0; + this.endpoint = normalizeEndpoint(options.endpoint ?? DEFAULT_ENDPOINT); + this.projectPath = options.projectPath ?? process.cwd(); + } + + async start(): Promise { + this.engine.setProjectPathHint(this.projectPath); + void this.engine.ensureInitialized(this.projectPath); + + this.server = http.createServer((req, res) => { + void this.handleRequest(req, res); + }); + + await new Promise((resolve, reject) => { + const server = this.server!; + const onError = (err: Error) => { + server.off('listening', onListening); + reject(err); + }; + const onListening = () => { + server.off('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(this.port, this.host); + }); + + const address = this.server.address() as AddressInfo; + return `http://${formatHost(this.host)}:${address.port}${this.endpoint}`; + } + + stop(): void { + this.engine.stop(); + if (this.server) { + this.server.close(); + this.server = null; + } + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + if (!this.isEndpoint(req.url)) { + writeText(res, 404, 'Not Found'); + return; + } + + if (!originAllowed(req.headers.origin)) { + writeJson(res, 403, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Forbidden: invalid Origin header')); + return; + } + + if (req.method === 'GET') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (req.method !== 'POST') { + writeText(res, 405, 'Method Not Allowed', { Allow: 'POST' }); + return; + } + + if (!acceptsStreamableHttp(req.headers.accept)) { + writeJson(res, 406, jsonRpcError(null, ErrorCodes.InvalidRequest, 'Not Acceptable: expected application/json and text/event-stream')); + return; + } + + let body: string; + try { + body = await readBody(req); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + writeJson(res, 413, jsonRpcError(null, ErrorCodes.InvalidRequest, message)); + return; + } + + const message = parseJsonRpcMessage(body); + if (!message.ok) { + writeJson(res, 400, jsonRpcError(null, message.code, message.message)); + return; + } + + if (!('method' in message.value)) { + writeEmpty(res, 202); + return; + } + + if (!('id' in message.value)) { + const transport = new SingleRequestHttpTransport(message.value); + const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectPath }); + session.start(); + await transport.result(); + writeEmpty(res, 202); + return; + } + + const transport = new SingleRequestHttpTransport(message.value); + const session = new MCPSession(transport, this.engine, { explicitProjectPath: this.projectPath }); + session.start(); + const result = await transport.result(); + if (!result.body) { + writeJson(res, 500, jsonRpcError(message.value.id, ErrorCodes.InternalError, 'Request produced no response')); + return; + } + writeJson(res, result.status, result.body); + } + + private isEndpoint(rawUrl: string | undefined): boolean { + if (!rawUrl) return false; + const path = rawUrl.split('?', 1)[0] || '/'; + return path === this.endpoint; + } +} + +function normalizeEndpoint(endpoint: string): string { + if (!endpoint.startsWith('/')) return `/${endpoint}`; + return endpoint; +} + +function formatHost(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} + +function originAllowed(origin: string | undefined): boolean { + if (!origin) return true; + try { + const url = new URL(origin); + return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); + } catch { + return false; + } +} + +function acceptsStreamableHttp(accept: string | undefined): boolean { + if (!accept) return false; + const lower = accept.toLowerCase(); + return lower.includes('application/json') && lower.includes('text/event-stream'); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +type ParseResult = + | { ok: true; value: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse } + | { ok: false; code: number; message: string }; + +function parseJsonRpcMessage(body: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return { ok: false, code: ErrorCodes.ParseError, message: 'Parse error: invalid JSON' }; + } + + if (typeof parsed !== 'object' || parsed === null) { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a JSON-RPC object' }; + } + + const obj = parsed as Record; + if (obj.jsonrpc !== '2.0') { + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; + } + + if (typeof obj.method === 'string') { + return { ok: true, value: obj as unknown as JsonRpcRequest | JsonRpcNotification }; + } + + if ('id' in obj && ('result' in obj || 'error' in obj)) { + return { ok: true, value: obj as unknown as JsonRpcResponse }; + } + + return { ok: false, code: ErrorCodes.InvalidRequest, message: 'Invalid Request: not a valid JSON-RPC 2.0 message' }; +} + +function jsonRpcError(id: string | number | null, code: number, message: string): JsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function writeJson(res: http.ServerResponse, status: number, body: JsonRpcResponse): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function writeEmpty(res: http.ServerResponse, status: number): void { + res.writeHead(status); + res.end(); +} + +function writeText( + res: http.ServerResponse, + status: number, + body: string, + headers: Record = {}, +): void { + res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers }); + res.end(body); +} diff --git a/vitest.config.ts b/vitest.config.ts index 9caca1e73..b3b6cb2e0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config'; +const wasmRuntimeFlags = ['--liftoff-only']; + export default defineConfig({ test: { globals: true, @@ -28,6 +30,10 @@ export default defineConfig({ */ CODEGRAPH_TELEMETRY: '0', }, + poolOptions: { + forks: { execArgv: wasmRuntimeFlags }, + threads: { execArgv: wasmRuntimeFlags }, + }, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'],