1
0

mcp-catchup-gate.test.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * MCP catch-up gate — first tool call blocks on the engine's post-open
  3. * filesystem reconcile so it never serves rows for files that were
  4. * deleted (or edited) while no MCP server was running.
  5. *
  6. * Background: `MCPEngine.catchUpSync()` fires `cg.sync()` in the background.
  7. * Before this fix it was fire-and-forget — a tool call could race past it
  8. * and return rows for files that no longer exist on disk. The per-file
  9. * staleness banner (`withStalenessNotice`) couldn't help, because
  10. * `getPendingFiles()` is populated by the watcher, not by catch-up.
  11. *
  12. * The fix: `catchUpSync()` pushes its promise into the `ToolHandler` via
  13. * `setCatchUpGate(p)`; the first `execute()` call awaits the gate and then
  14. * clears it. These tests exercise the gate directly (deterministic) and
  15. * the engine-driven path (proves the engine actually pokes the gate).
  16. */
  17. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  18. import * as fs from 'fs';
  19. import * as path from 'path';
  20. import * as os from 'os';
  21. import CodeGraph from '../src/index';
  22. import { ToolHandler } from '../src/mcp/tools';
  23. describe('MCP catch-up gate', () => {
  24. let testDir: string;
  25. let cg: CodeGraph;
  26. let handler: ToolHandler;
  27. beforeEach(async () => {
  28. testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-catchup-gate-'));
  29. fs.mkdirSync(path.join(testDir, 'src'));
  30. fs.writeFileSync(
  31. path.join(testDir, 'src', 'survivor.ts'),
  32. 'export function survivor() { return 1; }\n',
  33. );
  34. fs.writeFileSync(
  35. path.join(testDir, 'src', 'deleted-later.ts'),
  36. 'export function deletedLater() { return 2; }\n',
  37. );
  38. cg = CodeGraph.initSync(testDir, { config: { include: ['**/*.ts'], exclude: [] } });
  39. await cg.indexAll();
  40. handler = new ToolHandler(cg);
  41. });
  42. afterEach(() => {
  43. try { cg.unwatch(); } catch { /* ignore */ }
  44. try { cg.close(); } catch { /* ignore */ }
  45. if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
  46. });
  47. it('awaits the gate before serving the first tool call', async () => {
  48. let gateResolved = false;
  49. const gate = new Promise<void>((resolve) => {
  50. setTimeout(() => { gateResolved = true; resolve(); }, 80);
  51. });
  52. handler.setCatchUpGate(gate);
  53. const res = await handler.execute('codegraph_search', { query: 'survivor' });
  54. expect(gateResolved).toBe(true);
  55. expect(res.isError).toBeFalsy();
  56. expect(res.content[0].text).toMatch(/survivor/);
  57. });
  58. it('drops the gate after first await — second call does not re-wait', async () => {
  59. let awaitCount = 0;
  60. const gate = new Promise<void>((resolve) => {
  61. awaitCount++;
  62. setTimeout(resolve, 20);
  63. });
  64. handler.setCatchUpGate(gate);
  65. await handler.execute('codegraph_search', { query: 'survivor' });
  66. const before = awaitCount;
  67. await handler.execute('codegraph_search', { query: 'survivor' });
  68. // The promise body runs once when constructed; second execute never
  69. // resubscribes to a fresh promise because the gate field was nulled.
  70. expect(awaitCount).toBe(before);
  71. });
  72. it('catch-up reconciles a deleted file before the first tool call sees it', async () => {
  73. // Simulate the empty-project / deleted-files startup case: file is in
  74. // the DB (we indexed it above) but vanishes from disk before the MCP
  75. // server's first query. The catch-up sync, awaited via the gate,
  76. // must remove the row so the first tool call returns no hit.
  77. fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
  78. // Push the actual catch-up sync as the gate — same flow the MCP engine
  79. // uses (`cg.sync()` returns a Promise<SyncResult>, the wrapper voids it).
  80. handler.setCatchUpGate(cg.sync().then(() => undefined));
  81. const res = await handler.execute('codegraph_search', { query: 'deletedLater' });
  82. expect(res.isError).toBeFalsy();
  83. const text = res.content[0].text;
  84. expect(text).not.toMatch(/src\/deleted-later\.ts/);
  85. });
  86. it('catch-up that converges the project to 0 files clears all rows', async () => {
  87. // Worst case: every source file is gone between sessions. Without the
  88. // gate, the first tool call serves whatever was in the DB. With the
  89. // gate + the orchestrator's filesystem reconcile, the DB drains.
  90. fs.unlinkSync(path.join(testDir, 'src', 'survivor.ts'));
  91. fs.unlinkSync(path.join(testDir, 'src', 'deleted-later.ts'));
  92. handler.setCatchUpGate(cg.sync().then(() => undefined));
  93. const res = await handler.execute('codegraph_search', { query: 'survivor' });
  94. expect(res.isError).toBeFalsy();
  95. expect(cg.getStats().fileCount).toBe(0);
  96. });
  97. it('gate that rejects does not break the tool call', async () => {
  98. // A catch-up sync failure (lock contention, transient FS error) must
  99. // not poison tool dispatch — the engine logs it, the handler proceeds.
  100. handler.setCatchUpGate(Promise.reject(new Error('simulated sync failure')));
  101. const res = await handler.execute('codegraph_search', { query: 'survivor' });
  102. expect(res.isError).toBeFalsy();
  103. expect(res.content[0].text).toMatch(/survivor/);
  104. });
  105. });