mcp-subproject-adoption.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. /**
  2. * MCP workspace sub-project adoption + no-default diagnostics (#1606, #1607).
  3. *
  4. * When an MCP host launches the server from a workspace root whose indexed
  5. * projects live in CHILD directories (a repo container, a monorepo root), the
  6. * upward walk finds nothing. The server now runs the same bounded down-scan
  7. * the front-load hook uses:
  8. * - exactly ONE indexed sub-project → adopted as the session's default;
  9. * - zero or several → no default, but the state is SAID:
  10. * stderr names what was searched/found, and tool calls list the indexed
  11. * sub-projects so the agent can pass one as `projectPath`;
  12. * - non-workspace base (no manifest, no .git) → no scan at all.
  13. *
  14. * Same real-subprocess harness as mcp-roots.test.ts — no mocking.
  15. */
  16. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  17. import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
  18. import * as fs from 'fs';
  19. import * as path from 'path';
  20. import * as os from 'os';
  21. import { CodeGraph } from '../src';
  22. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  23. function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
  24. // --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
  25. // session in direct mode so no detached daemon outlives the test.
  26. return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
  27. cwd,
  28. stdio: ['pipe', 'pipe', 'pipe'],
  29. env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
  30. }) as ChildProcessWithoutNullStreams;
  31. }
  32. function collectMessages(child: ChildProcessWithoutNullStreams): Array<Record<string, any>> {
  33. const messages: Array<Record<string, any>> = [];
  34. let buf = '';
  35. child.stdout.on('data', (chunk) => {
  36. buf += chunk.toString('utf8');
  37. let idx;
  38. while ((idx = buf.indexOf('\n')) !== -1) {
  39. const line = buf.slice(0, idx).trim();
  40. buf = buf.slice(idx + 1);
  41. if (!line) continue;
  42. try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
  43. }
  44. });
  45. return messages;
  46. }
  47. function collectStderr(child: ChildProcessWithoutNullStreams): { text: () => string } {
  48. let buf = '';
  49. child.stderr.on('data', (chunk) => { buf += chunk.toString('utf8'); });
  50. return { text: () => buf };
  51. }
  52. function waitForMessage(
  53. messages: ReadonlyArray<Record<string, any>>,
  54. predicate: (m: Record<string, any>) => boolean,
  55. timeoutMs: number,
  56. ): Promise<Record<string, any>> {
  57. return new Promise((resolve, reject) => {
  58. const started = Date.now();
  59. const tick = () => {
  60. const hit = messages.find(predicate);
  61. if (hit) return resolve(hit);
  62. if (Date.now() - started > timeoutMs) {
  63. return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
  64. }
  65. setTimeout(tick, 20);
  66. };
  67. tick();
  68. });
  69. }
  70. function send(child: ChildProcessWithoutNullStreams, msg: object): void {
  71. child.stdin.write(JSON.stringify(msg) + '\n');
  72. }
  73. const CLIENT_INFO = { name: 'test', version: '0.0.0' };
  74. /** Create ws/<name> with one source file and an initialized .codegraph/. */
  75. async function makeIndexedChild(ws: string, name: string): Promise<string> {
  76. const dir = path.join(ws, name);
  77. fs.mkdirSync(dir, { recursive: true });
  78. fs.writeFileSync(path.join(dir, 'a.ts'), `export function hello_${name}() { return 1; }\n`);
  79. const cg = await CodeGraph.init(dir);
  80. cg.close();
  81. return dir;
  82. }
  83. /** initialize (no rootUri, no roots capability) → initialized → codegraph_status. */
  84. async function driveStatusCall(
  85. child: ChildProcessWithoutNullStreams,
  86. messages: Array<Record<string, any>>,
  87. ): Promise<{ initResult: Record<string, any>; statusText: string }> {
  88. send(child, {
  89. jsonrpc: '2.0', id: 0, method: 'initialize',
  90. params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
  91. });
  92. const initResult = await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
  93. send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
  94. send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
  95. const resp = await waitForMessage(messages, (m) => m.id === 1, 10000);
  96. return { initResult, statusText: resp.result.content[0].text as string };
  97. }
  98. describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (#1607)', () => {
  99. let ws: string;
  100. let child: ChildProcessWithoutNullStreams | null = null;
  101. beforeEach(() => {
  102. ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-'));
  103. });
  104. afterEach(() => {
  105. if (child && !child.killed) {
  106. child.kill('SIGKILL');
  107. child = null;
  108. }
  109. fs.rmSync(ws, { recursive: true, force: true });
  110. });
  111. it('adopts the single indexed sub-project below a workspace root as the default project', async () => {
  112. fs.mkdirSync(path.join(ws, '.git')); // workspace marker — no manifest needed
  113. await makeIndexedChild(ws, 'service-a');
  114. child = spawnServer(ws);
  115. const messages = collectMessages(child);
  116. const stderr = collectStderr(child);
  117. const { initResult, statusText } = await driveStatusCall(child, messages);
  118. // The default project works without any projectPath.
  119. expect(statusText).toContain('CodeGraph Status');
  120. expect(statusText).not.toContain('No CodeGraph project is loaded');
  121. // The adoption is announced on stderr (#1607 discoverability).
  122. expect(stderr.text()).toContain('adopted the single indexed sub-project');
  123. expect(stderr.text()).toContain('service-a');
  124. // Instructions match what the engine adopted: the FULL single-project
  125. // playbook, not the per-project variant.
  126. const instructions = initResult.result.instructions as string;
  127. expect(instructions).not.toContain('per-project; pass projectPath');
  128. }, 20000);
  129. it('lists several indexed sub-projects instead of adopting one, in stderr and in tool responses', async () => {
  130. fs.mkdirSync(path.join(ws, '.git'));
  131. await makeIndexedChild(ws, 'service-a');
  132. await makeIndexedChild(ws, 'service-b');
  133. child = spawnServer(ws);
  134. const messages = collectMessages(child);
  135. const stderr = collectStderr(child);
  136. const { initResult, statusText } = await driveStatusCall(child, messages);
  137. // No default was adopted — ambiguous — but the state is said, not silent.
  138. expect(statusText).toContain('No CodeGraph project is loaded');
  139. // Protocol-reachable listing (#1607): the tool response names what IS there.
  140. expect(statusText).toContain('Indexed sub-projects were found below it');
  141. expect(statusText).toContain('service-a');
  142. expect(statusText).toContain('service-b');
  143. expect(statusText).toContain('projectPath');
  144. // stderr carries the same facts for the host's log.
  145. expect(stderr.text()).toContain('no default project, live sync disabled');
  146. expect(stderr.text()).toContain('Indexed sub-projects found:');
  147. // Ambiguous root → per-project instructions variant.
  148. const instructions = initResult.result.instructions as string;
  149. expect(instructions).toContain('per-project; pass projectPath');
  150. }, 20000);
  151. it('does not scan below a base that is not a workspace (no manifest, no .git)', async () => {
  152. // NO .git and no manifest at ws — the gate must keep the scan off even
  153. // though an indexed child exists.
  154. await makeIndexedChild(ws, 'service-a');
  155. child = spawnServer(ws);
  156. const messages = collectMessages(child);
  157. const stderr = collectStderr(child);
  158. const { statusText } = await driveStatusCall(child, messages);
  159. expect(statusText).toContain('No CodeGraph project is loaded');
  160. expect(statusText).not.toContain('Indexed sub-projects were found below it');
  161. expect(stderr.text()).toContain('no default project, live sync disabled');
  162. expect(stderr.text()).not.toContain('Indexed sub-projects found:');
  163. }, 20000);
  164. });