Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<symbols or question>"` and `codegraph node <symbol-or-file>` — 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)
Expand Down
12 changes: 7 additions & 5 deletions __tests__/frameworks-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand All @@ -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');
Expand All @@ -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 () => {
Expand All @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
34 changes: 31 additions & 3 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -161,10 +162,36 @@ function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
}
}

async function waitForProcessTreeExit(...procs: ChildProcessWithoutNullStreams[]): Promise<void> {
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<boolean> {
return waitFor(() => !isAlive(pid), timeoutMs).then(() => true).catch(() => false);
}

async function rmDirWhenUnlocked(dir: string, timeoutMs = 5000): Promise<void> {
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
Expand All @@ -179,17 +206,18 @@ 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
// lockfile, and we must never SIGKILL the vitest worker.
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 () => {
Expand Down
187 changes: 187 additions & 0 deletions __tests__/mcp-http.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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);
});
26 changes: 22 additions & 4 deletions __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +35,23 @@ function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
}) as ChildProcessWithoutNullStreams;
}

function stopChild(child: ChildProcessWithoutNullStreams): Promise<void> {
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',
Expand Down Expand Up @@ -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 });
Expand Down
Loading