mcp-unindexed.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. /**
  2. * No-root-index session policy tests (#964).
  3. *
  4. * A server whose own root has no .codegraph/ still exposes its tools — gating
  5. * tool AVAILABILITY on whether `./` is indexed broke monorepos (only
  6. * sub-projects indexed) and hid the tools from a session that started before
  7. * `codegraph init`. So `initialize` returns the per-project instructions
  8. * variant (not the full single-project playbook, and NOT an "inactive" note),
  9. * `tools/list` exposes the tool surface, and a query against an indexed project
  10. * by `projectPath` works even with no default project. Safety is preserved by
  11. * the response SHAPE, not by hiding tools: a call against an un-indexed path
  12. * returns SUCCESS-shaped guidance ("pass projectPath / run codegraph init"),
  13. * never `isError: true` — one or two early isError responses teach an agent to
  14. * abandon codegraph for the whole session, and that failure mode is still
  15. * guarded below.
  16. */
  17. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  18. import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
  19. import * as fs from 'fs';
  20. import * as path from 'path';
  21. import * as os from 'os';
  22. import { CodeGraph } from '../src';
  23. import { ToolHandler } from '../src/mcp/tools';
  24. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  25. function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
  26. return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
  27. cwd,
  28. stdio: ['pipe', 'pipe', 'pipe'],
  29. // Direct (in-process) mode — the unindexed path never has a daemon
  30. // anyway (the daemon socket lives in .codegraph/), and this keeps the
  31. // suite from leaking a detached daemon in the indexed test.
  32. // CODEGRAPH_WASM_RELAUNCHED skips the --liftoff-only re-exec: without
  33. // it the server runs as a GRANDCHILD that survives child.kill() on
  34. // Windows and holds the temp cwd/SQLite handles, failing teardown with
  35. // EPERM no matter how long rmSync retries (the class documented for
  36. // the mcp-initialize/mcp-roots suites).
  37. env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
  38. }) as ChildProcessWithoutNullStreams;
  39. }
  40. /** Send a JSON-RPC request and resolve with the response matching its id. */
  41. function request(
  42. child: ChildProcessWithoutNullStreams,
  43. msg: { id: number; method: string; params?: unknown },
  44. timeoutMs = 15000
  45. ): Promise<Record<string, unknown>> {
  46. return new Promise((resolve, reject) => {
  47. let buf = '';
  48. const timer = setTimeout(() => {
  49. child.stdout.off('data', onData);
  50. reject(new Error(`timeout waiting for response id=${msg.id}`));
  51. }, timeoutMs);
  52. const onData = (chunk: Buffer) => {
  53. buf += chunk.toString();
  54. let idx: number;
  55. while ((idx = buf.indexOf('\n')) !== -1) {
  56. const line = buf.slice(0, idx).trim();
  57. buf = buf.slice(idx + 1);
  58. if (!line) continue;
  59. try {
  60. const parsed = JSON.parse(line) as Record<string, unknown>;
  61. if (parsed.id === msg.id) {
  62. clearTimeout(timer);
  63. child.stdout.off('data', onData);
  64. resolve(parsed);
  65. return;
  66. }
  67. } catch {
  68. // non-JSON noise on stdout — ignore
  69. }
  70. }
  71. };
  72. child.stdout.on('data', onData);
  73. child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
  74. });
  75. }
  76. function initializeParams(projectPath: string) {
  77. return {
  78. protocolVersion: '2025-11-25',
  79. capabilities: {},
  80. clientInfo: { name: 'test', version: '0.0.0' },
  81. rootUri: `file://${projectPath}`,
  82. };
  83. }
  84. describe('No-root-index session policy', () => {
  85. let tempDir: string;
  86. let child: ChildProcessWithoutNullStreams | null = null;
  87. beforeEach(() => {
  88. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-'));
  89. });
  90. afterEach(async () => {
  91. if (child) {
  92. // Wait for the child to actually exit before removing its cwd — on
  93. // Windows a just-killed process briefly holds the directory/SQLite
  94. // handles, and an immediate rmSync fails the teardown with EPERM
  95. // (the documented file-locking class that fails the sibling
  96. // mcp-initialize/mcp-roots suites). kill + await exit + retried
  97. // removal keeps this suite green on Windows.
  98. const exited = new Promise<void>((resolve) => child!.once('exit', () => resolve()));
  99. child.kill('SIGKILL');
  100. await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]);
  101. child = null;
  102. }
  103. fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
  104. });
  105. it('initialize returns the per-project instructions (not "inactive", not the full playbook)', async () => {
  106. fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n');
  107. child = spawnServer(tempDir);
  108. const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  109. const instructions = (res.result as { instructions: string }).instructions;
  110. // No longer an "inactive, do nothing" note — the tools are available.
  111. expect(instructions).not.toMatch(/inactive/i);
  112. // It steers the agent to target a project explicitly via projectPath...
  113. expect(instructions).toMatch(/projectPath/);
  114. expect(instructions).toMatch(/codegraph_explore/);
  115. expect(instructions).toMatch(/codegraph init/);
  116. // ...but it is NOT the full single-project playbook (that's sent only when
  117. // the root itself is indexed — keeps the common case tight).
  118. expect(instructions).not.toMatch(/## How to query/);
  119. });
  120. it('tools/list exposes the tools even when the server root has no index (#964)', async () => {
  121. child = spawnServer(tempDir);
  122. await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  123. const res = await request(child, { id: 1, method: 'tools/list' });
  124. const tools = (res.result as { tools: Array<{ name: string }> }).tools;
  125. expect(tools.length).toBeGreaterThanOrEqual(1);
  126. expect(tools.map((t) => t.name)).toContain('codegraph_explore');
  127. });
  128. it('a query by projectPath reaches an INDEXED sub-project of an unindexed root (monorepo) (#964)', async () => {
  129. // The server root (tempDir) has no index; an indexed sub-project lives
  130. // under it — exactly the monorepo shape. The query must resolve to the
  131. // sub-project's .codegraph/ and return real results. Run through the real
  132. // spawned server (a second-project open can't be exercised in-process under
  133. // vitest — see mcp-toolhandler cache notes — but a child process can).
  134. const svc = path.join(tempDir, 'service_a');
  135. fs.mkdirSync(svc);
  136. fs.writeFileSync(
  137. path.join(svc, 'auth.ts'),
  138. 'export function validateToken(t: string): boolean { return !!t; }\n'
  139. );
  140. const cg = await CodeGraph.init(svc, { index: true });
  141. cg.close();
  142. child = spawnServer(tempDir);
  143. await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  144. const res = await request(child, {
  145. id: 1,
  146. method: 'tools/call',
  147. params: { name: 'codegraph_search', arguments: { query: 'validateToken', projectPath: svc } },
  148. });
  149. const result = res.result as { content: Array<{ text: string }>; isError?: boolean };
  150. expect(result.isError).toBeUndefined();
  151. expect(result.content[0]!.text).toMatch(/validateToken/);
  152. expect(result.content[0]!.text).not.toMatch(/isn't indexed/);
  153. });
  154. it('an INDEXED workspace still gets the full playbook and the explore tool', async () => {
  155. fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n');
  156. const cg = await CodeGraph.init(tempDir, { index: true });
  157. cg.close();
  158. child = spawnServer(tempDir);
  159. const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  160. const instructions = (init.result as { instructions: string }).instructions;
  161. expect(instructions).toMatch(/How to query/);
  162. expect(instructions).not.toMatch(/inactive/i);
  163. const list = await request(child, { id: 1, method: 'tools/list' });
  164. const tools = (list.result as { tools: Array<{ name: string }> }).tools;
  165. // The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the
  166. // contract under test is "indexed → tools are PRESENT", in contrast to the
  167. // unindexed empty list above.
  168. expect(tools.length).toBeGreaterThanOrEqual(1);
  169. expect(tools.map((t) => t.name)).toContain('codegraph_explore');
  170. });
  171. });
  172. describe('No-error policy on expected conditions', () => {
  173. let tempDir: string;
  174. beforeEach(() => {
  175. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-'));
  176. });
  177. afterEach(() => {
  178. fs.rmSync(tempDir, { recursive: true, force: true });
  179. });
  180. it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => {
  181. const res = await new ToolHandler(null).execute('codegraph_search', {
  182. query: 'anything',
  183. projectPath: tempDir,
  184. });
  185. expect(res.isError).toBeUndefined();
  186. expect(res.content[0]!.text).toMatch(/isn't indexed/);
  187. expect(res.content[0]!.text).toMatch(/codegraph init/);
  188. expect(res.content[0]!.text).toMatch(/built-in tools/);
  189. });
  190. it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => {
  191. const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' });
  192. expect(res.isError).toBeUndefined();
  193. expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/);
  194. expect(res.content[0]!.text).toMatch(/projectPath/);
  195. });
  196. it.runIf(process.platform !== 'win32')(
  197. 'sensitive-path refusal stays a hard error (no retry encouragement)',
  198. async () => {
  199. const res = await new ToolHandler(null).execute('codegraph_search', {
  200. query: 'anything',
  201. projectPath: '/etc',
  202. });
  203. expect(res.isError).toBe(true);
  204. expect(res.content[0]!.text).not.toMatch(/retry the call once/);
  205. }
  206. );
  207. });
  208. describe('search kind filter', () => {
  209. let tempDir: string;
  210. let cg: CodeGraph;
  211. beforeEach(async () => {
  212. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-'));
  213. fs.writeFileSync(
  214. path.join(tempDir, 'types.ts'),
  215. 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n'
  216. );
  217. cg = await CodeGraph.init(tempDir, { index: true });
  218. });
  219. afterEach(() => {
  220. cg.close();
  221. fs.rmSync(tempDir, { recursive: true, force: true });
  222. });
  223. it("kind: 'type' (the advertised enum value) finds type aliases", async () => {
  224. const res = await new ToolHandler(cg).execute('codegraph_search', {
  225. query: 'PaymentMethod',
  226. kind: 'type',
  227. });
  228. expect(res.isError).toBeUndefined();
  229. expect(res.content[0]!.text).toMatch(/PaymentMethod/);
  230. expect(res.content[0]!.text).not.toMatch(/No results found/);
  231. });
  232. });