1
0

mcp-unindexed.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * Unindexed-workspace session policy tests.
  3. *
  4. * An MCP session attached to a workspace with no .codegraph/ must go quiet
  5. * rather than fail loudly: `initialize` returns the short "inactive"
  6. * instructions variant (not the full playbook), `tools/list` returns an
  7. * EMPTY list, and a tool call that still arrives (cross-project
  8. * `projectPath`, or a host that skips tools/list) answers with a
  9. * SUCCESS-shaped guidance message — never `isError: true`. One or two early
  10. * isError responses teach an agent to abandon codegraph for the whole
  11. * session; that observed failure mode is what this suite guards.
  12. */
  13. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  14. import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
  15. import * as fs from 'fs';
  16. import * as path from 'path';
  17. import * as os from 'os';
  18. import { CodeGraph } from '../src';
  19. import { ToolHandler } from '../src/mcp/tools';
  20. const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
  21. function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
  22. return spawn(process.execPath, [BIN, 'serve', '--mcp'], {
  23. cwd,
  24. stdio: ['pipe', 'pipe', 'pipe'],
  25. // Direct (in-process) mode — the unindexed path never has a daemon
  26. // anyway (the daemon socket lives in .codegraph/), and this keeps the
  27. // suite from leaking a detached daemon in the indexed test.
  28. env: { ...process.env, CODEGRAPH_NO_DAEMON: '1' },
  29. }) as ChildProcessWithoutNullStreams;
  30. }
  31. /** Send a JSON-RPC request and resolve with the response matching its id. */
  32. function request(
  33. child: ChildProcessWithoutNullStreams,
  34. msg: { id: number; method: string; params?: unknown },
  35. timeoutMs = 15000
  36. ): Promise<Record<string, unknown>> {
  37. return new Promise((resolve, reject) => {
  38. let buf = '';
  39. const timer = setTimeout(() => {
  40. child.stdout.off('data', onData);
  41. reject(new Error(`timeout waiting for response id=${msg.id}`));
  42. }, timeoutMs);
  43. const onData = (chunk: Buffer) => {
  44. buf += chunk.toString();
  45. let idx: number;
  46. while ((idx = buf.indexOf('\n')) !== -1) {
  47. const line = buf.slice(0, idx).trim();
  48. buf = buf.slice(idx + 1);
  49. if (!line) continue;
  50. try {
  51. const parsed = JSON.parse(line) as Record<string, unknown>;
  52. if (parsed.id === msg.id) {
  53. clearTimeout(timer);
  54. child.stdout.off('data', onData);
  55. resolve(parsed);
  56. return;
  57. }
  58. } catch {
  59. // non-JSON noise on stdout — ignore
  60. }
  61. }
  62. };
  63. child.stdout.on('data', onData);
  64. child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n');
  65. });
  66. }
  67. function initializeParams(projectPath: string) {
  68. return {
  69. protocolVersion: '2025-11-25',
  70. capabilities: {},
  71. clientInfo: { name: 'test', version: '0.0.0' },
  72. rootUri: `file://${projectPath}`,
  73. };
  74. }
  75. describe('Unindexed-workspace session policy', () => {
  76. let tempDir: string;
  77. let child: ChildProcessWithoutNullStreams | null = null;
  78. beforeEach(() => {
  79. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-unindexed-'));
  80. });
  81. afterEach(() => {
  82. if (child) {
  83. child.kill('SIGKILL');
  84. child = null;
  85. }
  86. fs.rmSync(tempDir, { recursive: true, force: true });
  87. });
  88. it('initialize returns the short "inactive" instructions, not the playbook', async () => {
  89. fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n');
  90. child = spawnServer(tempDir);
  91. const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  92. const instructions = (res.result as { instructions: string }).instructions;
  93. expect(instructions).toMatch(/inactive/i);
  94. expect(instructions).toMatch(/codegraph init/);
  95. // The full playbook must NOT be sent into a session where every call fails
  96. expect(instructions).not.toMatch(/Tool selection by intent/);
  97. expect(instructions).not.toMatch(/codegraph_explore/);
  98. });
  99. it('tools/list returns an EMPTY list when the workspace has no index', async () => {
  100. child = spawnServer(tempDir);
  101. await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  102. const res = await request(child, { id: 1, method: 'tools/list' });
  103. expect((res.result as { tools: unknown[] }).tools).toEqual([]);
  104. });
  105. it('an INDEXED workspace still gets the full playbook and all tools', async () => {
  106. fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n');
  107. const cg = await CodeGraph.init(tempDir, { index: true });
  108. cg.close();
  109. child = spawnServer(tempDir);
  110. const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
  111. const instructions = (init.result as { instructions: string }).instructions;
  112. expect(instructions).toMatch(/Tool selection by intent/);
  113. expect(instructions).not.toMatch(/inactive/i);
  114. const list = await request(child, { id: 1, method: 'tools/list' });
  115. const tools = (list.result as { tools: Array<{ name: string }> }).tools;
  116. // A 1-file project triggers the pre-existing tiny-repo tool gating (a
  117. // reduced core set) — the contract under test is "indexed → tools are
  118. // PRESENT", in contrast to the unindexed empty list above.
  119. expect(tools.length).toBeGreaterThanOrEqual(3);
  120. expect(tools.map((t) => t.name)).toContain('codegraph_explore');
  121. });
  122. });
  123. describe('No-error policy on expected conditions', () => {
  124. let tempDir: string;
  125. beforeEach(() => {
  126. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-noerror-'));
  127. });
  128. afterEach(() => {
  129. fs.rmSync(tempDir, { recursive: true, force: true });
  130. });
  131. it('cross-project query to an unindexed path is SUCCESS-shaped guidance, not isError', async () => {
  132. const res = await new ToolHandler(null).execute('codegraph_search', {
  133. query: 'anything',
  134. projectPath: tempDir,
  135. });
  136. expect(res.isError).toBeUndefined();
  137. expect(res.content[0]!.text).toMatch(/isn't indexed/);
  138. expect(res.content[0]!.text).toMatch(/codegraph init/);
  139. expect(res.content[0]!.text).toMatch(/built-in tools/);
  140. });
  141. it('no-default-project (working-directory detection miss) is SUCCESS-shaped guidance', async () => {
  142. const res = await new ToolHandler(null).execute('codegraph_search', { query: 'anything' });
  143. expect(res.isError).toBeUndefined();
  144. expect(res.content[0]!.text).toMatch(/No CodeGraph project is loaded/);
  145. expect(res.content[0]!.text).toMatch(/projectPath/);
  146. });
  147. it.runIf(process.platform !== 'win32')(
  148. 'sensitive-path refusal stays a hard error (no retry encouragement)',
  149. async () => {
  150. const res = await new ToolHandler(null).execute('codegraph_search', {
  151. query: 'anything',
  152. projectPath: '/etc',
  153. });
  154. expect(res.isError).toBe(true);
  155. expect(res.content[0]!.text).not.toMatch(/retry the call once/);
  156. }
  157. );
  158. });
  159. describe('search kind filter', () => {
  160. let tempDir: string;
  161. let cg: CodeGraph;
  162. beforeEach(async () => {
  163. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-kind-'));
  164. fs.writeFileSync(
  165. path.join(tempDir, 'types.ts'),
  166. 'export type PaymentMethod = { id: string };\nexport function pay(): void {}\n'
  167. );
  168. cg = await CodeGraph.init(tempDir, { index: true });
  169. });
  170. afterEach(() => {
  171. cg.close();
  172. fs.rmSync(tempDir, { recursive: true, force: true });
  173. });
  174. it("kind: 'type' (the advertised enum value) finds type aliases", async () => {
  175. const res = await new ToolHandler(cg).execute('codegraph_search', {
  176. query: 'PaymentMethod',
  177. kind: 'type',
  178. });
  179. expect(res.isError).toBeUndefined();
  180. expect(res.content[0]!.text).toMatch(/PaymentMethod/);
  181. expect(res.content[0]!.text).not.toMatch(/No results found/);
  182. });
  183. });