mcp-tool-annotations.test.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. /**
  2. * Read-only MCP ToolAnnotations on every codegraph tool (issue #1018).
  3. *
  4. * Every codegraph tool is query-only — it reads the pre-built index and never
  5. * mutates the workspace. Clients gate on this: Cursor's Ask mode refuses any MCP
  6. * tool that doesn't advertise `readOnlyHint: true`, so without annotations the
  7. * codegraph tools were blocked there even though they only read.
  8. *
  9. * These tests pin that the read-only contract is present on the master tool
  10. * array AND survives every transform that builds a `tools/list` response — the
  11. * static proxy surface (`getStaticTools`), the live surface (`getTools`, which
  12. * rewrites codegraph_explore's description via spread), and the no-default-
  13. * project surface (`withRequiredProjectPath`, which clones the schema). A drop in
  14. * any of those would silently re-block the tools in Ask mode.
  15. *
  16. * `codegraph_explore`'s `_meta` (`anthropic/alwaysLoad`, #1696) rides the same
  17. * spreads, so each surface is checked for it here too.
  18. */
  19. import { describe, it, expect, afterEach, beforeEach } from 'vitest';
  20. import * as fs from 'fs';
  21. import * as path from 'path';
  22. import * as os from 'os';
  23. import { ToolHandler, getStaticTools, tools, type ToolDefinition } from '../src/mcp/tools';
  24. import { CodeGraph } from '../src';
  25. const ENV = 'CODEGRAPH_MCP_TOOLS';
  26. const ALL_TOOLS = tools.map((t) => t.name).join(',');
  27. /** Assert a single tool advertises the full read-only contract from #1018. */
  28. function expectReadOnly(tool: ToolDefinition): void {
  29. expect(tool.annotations, `${tool.name} is missing annotations`).toBeDefined();
  30. // The hint Cursor Ask mode (and other clients) gate on.
  31. expect(tool.annotations!.readOnlyHint).toBe(true);
  32. // The exact triplet the issue asks for, plus the honest closed-world hint.
  33. expect(tool.annotations!.destructiveHint).toBe(false);
  34. expect(tool.annotations!.idempotentHint).toBe(true);
  35. expect(tool.annotations!.openWorldHint).toBe(false);
  36. }
  37. /** Assert the explore tool in a `tools/list` surface is marked always-load for Claude Code (#1696). */
  38. function expectExploreAlwaysLoad(surface: ToolDefinition[]): void {
  39. const explore = surface.find((t) => t.name === 'codegraph_explore');
  40. expect(explore, 'codegraph_explore is missing from the surface').toBeDefined();
  41. expect(explore!._meta).toEqual({ 'anthropic/alwaysLoad': true });
  42. }
  43. describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
  44. const original = process.env[ENV];
  45. afterEach(() => {
  46. if (original === undefined) delete process.env[ENV];
  47. else process.env[ENV] = original;
  48. });
  49. it('every tool in the master array is annotated read-only', () => {
  50. expect(tools.length).toBeGreaterThan(0);
  51. for (const tool of tools) expectReadOnly(tool);
  52. expectExploreAlwaysLoad(tools);
  53. });
  54. it('the static proxy surface carries annotations on every exposed tool', () => {
  55. // getStaticTools() answers tools/list before any project opens (proxy path).
  56. process.env[ENV] = ALL_TOOLS;
  57. const got = getStaticTools();
  58. expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
  59. for (const tool of got) expectReadOnly(tool);
  60. expectExploreAlwaysLoad(got);
  61. });
  62. it('the no-default-project surface keeps annotations through the schema clone', () => {
  63. // withRequiredProjectPath (null cg) clones each tool's inputSchema — the
  64. // top-level annotations field must ride along on the spread.
  65. process.env[ENV] = ALL_TOOLS;
  66. const got = new ToolHandler(null).getTools();
  67. expect(got.length).toBe(tools.length);
  68. for (const tool of got) {
  69. expectReadOnly(tool);
  70. // Sanity: this IS the clone path (projectPath got marked required).
  71. expect(tool.inputSchema.required ?? []).toContain('projectPath');
  72. }
  73. expectExploreAlwaysLoad(got);
  74. });
  75. });
  76. describe('Live tool surface keeps annotations with a project open (#1018)', () => {
  77. let tempDir: string;
  78. let cg: CodeGraph;
  79. const original = process.env[ENV];
  80. beforeEach(async () => {
  81. tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-annot-'));
  82. fs.writeFileSync(
  83. path.join(tempDir, 'pay.ts'),
  84. 'export function processPayment(amount: number): boolean { return amount > 0; }\n'
  85. );
  86. cg = await CodeGraph.init(tempDir, { index: true });
  87. });
  88. afterEach(() => {
  89. cg.close();
  90. fs.rmSync(tempDir, { recursive: true, force: true });
  91. if (original === undefined) delete process.env[ENV];
  92. else process.env[ENV] = original;
  93. });
  94. it('getTools() keeps annotations, incl. codegraph_explore whose description is rebuilt', () => {
  95. process.env[ENV] = ALL_TOOLS;
  96. const got = new ToolHandler(cg).getTools();
  97. expect(got.length).toBeGreaterThan(0);
  98. for (const tool of got) expectReadOnly(tool);
  99. // explore's description is regenerated with a per-repo budget suffix via
  100. // object spread; the annotation must survive that rewrite.
  101. const explore = got.find((t) => t.name === 'codegraph_explore');
  102. expect(explore).toBeDefined();
  103. expect(explore!.description).toMatch(/Budget: make at most/);
  104. expectReadOnly(explore!);
  105. expectExploreAlwaysLoad(got);
  106. });
  107. });