| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- /**
- * MCP project-resolution regression tests (issue #196).
- *
- * When an MCP client launches the server outside the project directory AND
- * doesn't pass a `rootUri`/`workspaceFolders` in `initialize`, the server used
- * to fall straight back to `process.cwd()` — which for many IDE clients is the
- * wrong directory. Every tool call without an explicit `projectPath` then
- * failed with a misleading "CodeGraph not initialized. Run 'codegraph init'."
- *
- * The fix: when no explicit path is provided, the server asks the client for
- * its workspace root via the spec-blessed `roots/list` request (if the client
- * advertised the `roots` capability), and only falls back to cwd otherwise.
- * When it still can't resolve, the error now says exactly how to fix it.
- *
- * These tests drive the real stdio transport via a spawned subprocess — no
- * mocking — so they also exercise the new bidirectional request/response path.
- */
- 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 and avoids watcher startup noise.
- return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
- cwd,
- stdio: ['pipe', 'pipe', 'pipe'],
- }) as ChildProcessWithoutNullStreams;
- }
- /** Parse every JSON-RPC message the server writes to stdout into an array. */
- 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 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' };
- describe('MCP project resolution via roots/list (issue #196)', () => {
- let cwdDir: string; // where the server is launched — has NO .codegraph
- let projectDir: string; // the real indexed project the client reports
- let child: ChildProcessWithoutNullStreams | null = null;
- beforeEach(() => {
- cwdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-cwd-'));
- projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-proj-'));
- });
- afterEach(() => {
- if (child && !child.killed) {
- child.kill('SIGKILL');
- child = null;
- }
- fs.rmSync(cwdDir, { recursive: true, force: true });
- fs.rmSync(projectDir, { recursive: true, force: true });
- });
- it('resolves the project from the client roots/list when no rootUri is sent', async () => {
- const cg = await CodeGraph.init(projectDir);
- cg.close();
- child = spawnServer(cwdDir);
- const messages = collectMessages(child);
- // Advertise the roots capability but pass NO rootUri/workspaceFolders.
- send(child, {
- jsonrpc: '2.0', id: 0, method: 'initialize',
- params: { protocolVersion: '2025-11-25', capabilities: { roots: {} }, clientInfo: CLIENT_INFO },
- });
- await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
- send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
- // First tool call (no projectPath) drives the server to ask us for roots.
- send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
- const rootsReq = await waitForMessage(messages, (m) => m.method === 'roots/list', 5000);
- expect(typeof rootsReq.id).toBe('string'); // server-initiated id
- send(child, {
- jsonrpc: '2.0', id: rootsReq.id,
- result: { roots: [{ uri: `file://${projectDir}`, name: 'proj' }] },
- });
- // The status call now succeeds against the resolved project.
- const resp = await waitForMessage(messages, (m) => m.id === 1, 8000);
- const text = resp.result.content[0].text as string;
- expect(text).toContain('CodeGraph Status');
- expect(text).not.toContain('No CodeGraph project is loaded');
- }, 20000);
- it('returns an actionable error when there is no rootUri and no roots capability', async () => {
- child = spawnServer(cwdDir);
- const messages = collectMessages(child);
- send(child, {
- jsonrpc: '2.0', id: 0, method: 'initialize',
- params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
- });
- 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, 8000);
- const text = resp.result.content[0].text as string;
- expect(text).toContain('No CodeGraph project is loaded');
- expect(text).toContain('projectPath');
- expect(text).toContain('--path');
- // Names the directory it actually searched (the wrong cwd) so the user can
- // see why detection missed. basename survives any symlink realpath-ing.
- expect(text).toContain(path.basename(cwdDir));
- // It must not have hung waiting on roots/list — the client never offered it.
- expect(messages.some((m) => m.method === 'roots/list')).toBe(false);
- }, 20000);
- it('honors an explicit rootUri without asking the client for roots', async () => {
- const cg = await CodeGraph.init(projectDir);
- cg.close();
- child = spawnServer(cwdDir);
- const messages = collectMessages(child);
- send(child, {
- jsonrpc: '2.0', id: 0, method: 'initialize',
- params: {
- protocolVersion: '2025-11-25',
- capabilities: { roots: {} },
- clientInfo: CLIENT_INFO,
- rootUri: `file://${projectDir}`,
- },
- });
- 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, 8000);
- const text = resp.result.content[0].text as string;
- expect(text).toContain('CodeGraph Status');
- // rootUri is a stronger signal than roots — we never needed to ask.
- expect(messages.some((m) => m.method === 'roots/list')).toBe(false);
- }, 20000);
- });
|