Files
codegraph/__tests__/mcp-subproject-adoption.test.ts
T
Colby MchenryandGitHub a5c2709e6d fix(mcp): adopt a single indexed sub-project below the server root + say when no project resolves (#1606, #1607) (#1614)
Fixes #1606 and #1607 together — the MCP server's root resolution never got the sub-project down-scan `planFrontload` gained in #964, and the resulting no-default state was completely silent.

## What changed

**Adoption (#1606).** A new `resolveServerRoot()` in `src/directory.ts` is the single resolution every server entry point now uses: up-walk first (`findNearestCodeGraphRoot`, the common case, unchanged), and when that misses, the existing bounded down-scan (`findIndexedSubprojectRoots` — depth 4, max 64, heavy dirs skipped). **Exactly one** indexed sub-project is unambiguous and is adopted as the default project — `open` → `startWatching` → `catchUpSync` → query pool, the full normal path. Zero or several candidates → no default, never a guess. Wired into:

- `MCPEngine.doInitialize()` and `retryInitializeSync()` — the retry path also picks up a child indexed *after* the server started (its down-scan is throttled to once per 5s so the persistent no-default state doesn't pay a directory walk per tool call; the up-walk still runs every time).
- `resolveDaemonRoot()` — the adopted root gets the shared daemon (one watcher, one writer, socket keyed on the child) instead of a direct-mode server per host, exactly as the issue suggested.
- `MCPSession.handleInitialize()` — the instructions variant is picked with the same resolution, so a workspace whose single child becomes the default gets the full single-project playbook. Race-free by construction: handshake and engine compute it independently, no ordering assumed.

**Workspace-root gate (the open question in #1606).** Decided deliberately: the down-scan runs only when the base has a workspace manifest (`looksLikeProjectRoot`, unchanged list) **or a `.git` entry** — the exact container shape that motivated the report — and never when the base is `$HOME` or the filesystem root. The gate lives in the new helper only; `planFrontload` and the prompt-hook are untouched, so #1454's surface is not widened.

**Diagnostics (#1607).** The no-root branch is no longer silent:

```
[CodeGraph MCP] No .codegraph/ at or above <searchFrom>: no default project, live sync disabled.
[CodeGraph MCP] Indexed sub-projects found: service-a, service-b. Pass `projectPath` per call, or launch with --path.
```

(second line only when the scan found candidates), plus one line naming the adopted child when adoption happens. The same fact is protocol-reachable: the "No CodeGraph project is loaded" tool response now lists the discovered sub-projects with `projectPath` guidance. The list is engine-maintained (initial resolve + throttled retry) — tool calls never scan — and the response stays SUCCESS-shaped (`NotIndexedError` → `textResult`, never `isError`).

## Tested

- New `__tests__/mcp-subproject-adoption.test.ts` (real spawned server over stdio, same harness as `mcp-roots.test.ts`): single child → tool call answers from it, full instructions, adoption stderr; two children → no default, both listed in the tool response and stderr, per-project instructions; no manifest/no `.git` → gate holds, no scan, plain one-line message.
- `mcp-subproject-adoption` + `mcp-roots` + `mcp-initialize` + `daemon-bind-failure`: **13/13 pass**.
- End-to-end repro harness against the built `dist/` on an unmodified-main build first (confirmed: empty stderr, no adoption, NO_ROOT instructions even with one adoptable child), then on this branch (all three shapes behave as above; catch-up sync runs on the adopted child).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
2026-08-26 10:38:56 -05:00

189 lines
7.7 KiB
TypeScript

/**
* MCP workspace sub-project adoption + no-default diagnostics (#1606, #1607).
*
* When an MCP host launches the server from a workspace root whose indexed
* projects live in CHILD directories (a repo container, a monorepo root), the
* upward walk finds nothing. The server now runs the same bounded down-scan
* the front-load hook uses:
* - exactly ONE indexed sub-project → adopted as the session's default;
* - zero or several → no default, but the state is SAID:
* stderr names what was searched/found, and tool calls list the indexed
* sub-projects so the agent can pass one as `projectPath`;
* - non-workspace base (no manifest, no .git) → no scan at all.
*
* Same real-subprocess harness as mcp-roots.test.ts — no mocking.
*/
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 {
// --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
// session in direct mode so no detached daemon outlives the test.
return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
}) as ChildProcessWithoutNullStreams;
}
function collectMessages(child: ChildProcessWithoutNullStreams): Array<Record<string, any>> {
const messages: Array<Record<string, any>> = [];
let buf = '';
child.stdout.on('data', (chunk) => {
buf += chunk.toString('utf8');
let idx;
while ((idx = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
}
});
return messages;
}
function collectStderr(child: ChildProcessWithoutNullStreams): { text: () => string } {
let buf = '';
child.stderr.on('data', (chunk) => { buf += chunk.toString('utf8'); });
return { text: () => buf };
}
function waitForMessage(
messages: ReadonlyArray<Record<string, any>>,
predicate: (m: Record<string, any>) => boolean,
timeoutMs: number,
): Promise<Record<string, any>> {
return new Promise((resolve, reject) => {
const started = Date.now();
const tick = () => {
const hit = messages.find(predicate);
if (hit) return resolve(hit);
if (Date.now() - started > timeoutMs) {
return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
}
setTimeout(tick, 20);
};
tick();
});
}
function send(child: ChildProcessWithoutNullStreams, msg: object): void {
child.stdin.write(JSON.stringify(msg) + '\n');
}
const CLIENT_INFO = { name: 'test', version: '0.0.0' };
/** Create ws/<name> with one source file and an initialized .codegraph/. */
async function makeIndexedChild(ws: string, name: string): Promise<string> {
const dir = path.join(ws, name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'a.ts'), `export function hello_${name}() { return 1; }\n`);
const cg = await CodeGraph.init(dir);
cg.close();
return dir;
}
/** initialize (no rootUri, no roots capability) → initialized → codegraph_status. */
async function driveStatusCall(
child: ChildProcessWithoutNullStreams,
messages: Array<Record<string, any>>,
): Promise<{ initResult: Record<string, any>; statusText: string }> {
send(child, {
jsonrpc: '2.0', id: 0, method: 'initialize',
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
});
const initResult = await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const resp = await waitForMessage(messages, (m) => m.id === 1, 10000);
return { initResult, statusText: resp.result.content[0].text as string };
}
describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (#1607)', () => {
let ws: string;
let child: ChildProcessWithoutNullStreams | null = null;
beforeEach(() => {
ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-'));
});
afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
child = null;
}
fs.rmSync(ws, { recursive: true, force: true });
});
it('adopts the single indexed sub-project below a workspace root as the default project', async () => {
fs.mkdirSync(path.join(ws, '.git')); // workspace marker — no manifest needed
await makeIndexedChild(ws, 'service-a');
child = spawnServer(ws);
const messages = collectMessages(child);
const stderr = collectStderr(child);
const { initResult, statusText } = await driveStatusCall(child, messages);
// The default project works without any projectPath.
expect(statusText).toContain('CodeGraph Status');
expect(statusText).not.toContain('No CodeGraph project is loaded');
// The adoption is announced on stderr (#1607 discoverability).
expect(stderr.text()).toContain('adopted the single indexed sub-project');
expect(stderr.text()).toContain('service-a');
// Instructions match what the engine adopted: the FULL single-project
// playbook, not the per-project variant.
const instructions = initResult.result.instructions as string;
expect(instructions).not.toContain('per-project; pass projectPath');
}, 20000);
it('lists several indexed sub-projects instead of adopting one, in stderr and in tool responses', async () => {
fs.mkdirSync(path.join(ws, '.git'));
await makeIndexedChild(ws, 'service-a');
await makeIndexedChild(ws, 'service-b');
child = spawnServer(ws);
const messages = collectMessages(child);
const stderr = collectStderr(child);
const { initResult, statusText } = await driveStatusCall(child, messages);
// No default was adopted — ambiguous — but the state is said, not silent.
expect(statusText).toContain('No CodeGraph project is loaded');
// Protocol-reachable listing (#1607): the tool response names what IS there.
expect(statusText).toContain('Indexed sub-projects were found below it');
expect(statusText).toContain('service-a');
expect(statusText).toContain('service-b');
expect(statusText).toContain('projectPath');
// stderr carries the same facts for the host's log.
expect(stderr.text()).toContain('no default project, live sync disabled');
expect(stderr.text()).toContain('Indexed sub-projects found:');
// Ambiguous root → per-project instructions variant.
const instructions = initResult.result.instructions as string;
expect(instructions).toContain('per-project; pass projectPath');
}, 20000);
it('does not scan below a base that is not a workspace (no manifest, no .git)', async () => {
// NO .git and no manifest at ws — the gate must keep the scan off even
// though an indexed child exists.
await makeIndexedChild(ws, 'service-a');
child = spawnServer(ws);
const messages = collectMessages(child);
const stderr = collectStderr(child);
const { statusText } = await driveStatusCall(child, messages);
expect(statusText).toContain('No CodeGraph project is loaded');
expect(statusText).not.toContain('Indexed sub-projects were found below it');
expect(stderr.text()).toContain('no default project, live sync disabled');
expect(stderr.text()).not.toContain('Indexed sub-projects found:');
}, 20000);
});