Forráskód Böngészése

feat(mcp): add readOnlyHint annotations so tools work in Cursor Ask mode (#1027)

All codegraph_* tools are query-only — they read the pre-built index and
never mutate the workspace — but they advertised no MCP annotations, so
Cursor's Ask mode (and any client that gates on read-only tools) blocked
every call with "you are in ask mode and cannot run non read-only tools."

Add a shared READ_ONLY_ANNOTATIONS constant (readOnlyHint: true,
destructiveHint: false, idempotentHint: true, openWorldHint: false) and
reference it from each of the 8 tool definitions. The field flows through
every tools/list path: the live getTools() (including explore's
spread-rewritten description), the static proxy getStaticTools(), and the
no-default withRequiredProjectPath schema clone.

The annotations field is additive, so it ships without bumping the
negotiated 2024-11-05 protocol version: clients that gate on it read it
regardless, and older clients ignore it.

Closes #1018

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby Mchenry 18 órája
szülő
commit
a79fa51816
3 módosított fájl, 160 hozzáadás és 0 törlés
  1. 1 0
      CHANGELOG.md
  2. 105 0
      __tests__/mcp-tool-annotations.test.ts
  3. 54 0
      src/mcp/tools.ts

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - CodeGraph no longer times out when many agents query it at once. The shared background server that serves all your editor and agent sessions used to run every query on a single thread, so a burst of concurrent requests — for example a swarm of subagents exploring a large monorepo together — queued up behind one another and, while the heavy ones ran, froze the connection so finished answers couldn't even be sent back until the whole batch drained. Past a handful of simultaneous callers that routinely surfaced as MCP request timeouts. The shared server now answers queries across a pool of worker threads, so concurrent requests run in parallel and the connection stays responsive the whole time; when it's genuinely saturated a call returns a brief "busy, retry shortly" note (not an error) instead of hanging past your client's timeout. The pool sizes itself to your machine — roughly one worker per core, leaving one for coordination — and a single editor session is unaffected (no pool, no overhead). Set `CODEGRAPH_QUERY_POOL_SIZE` to choose a specific number of workers, or `0` to revert to single-threaded in-process queries.
 - Indexing now parses files across multiple CPU cores instead of one, so building a project's graph — `codegraph index`, the first index of a project, and the background re-index after changes — is faster on multi-core machines, most noticeably on large or parse-heavy codebases. The graph it produces is identical to before and re-indexing stays deterministic: parsing runs in parallel, but results are still committed in a fixed order, so the same project always yields the same graph. CodeGraph sizes the pool to your machine automatically (leaving a core free for everything else); set `CODEGRAPH_PARSE_WORKERS` to choose a specific number of parse workers, or `CODEGRAPH_PARSE_WORKERS=1` to restore the previous single-core behavior. Peak memory is unchanged — workers reclaim parser memory independently, so it doesn't grow with the number of cores. (#1015, #320)
 - When CodeGraph's MCP server runs with no default project of its own — started outside any repository (for example behind an MCP gateway), or at a monorepo root whose indexes live in sub-projects — it now marks `projectPath` as a required argument on every tool call. Before, `projectPath` was always optional, so an agent talking to such a server would often omit it, get back guidance to pass it, and not reliably retry — you had to nudge it by hand every time. Now the requirement is part of the tool definition the agent sees, so it supplies the path to the project it's working on the first time. When the server does have a default project — the normal case, launched inside your repo — `projectPath` stays optional and a call without it falls back to that project exactly as before. Thanks @wauxhall for the report. (#993)
+- CodeGraph's MCP tools now work in Cursor's **Ask mode** (and any other client that only permits read-only tools). Every CodeGraph tool just reads your indexed code — it never changes your workspace — but it didn't advertise that, so Cursor's Ask mode blocked every call with "you are in ask mode and cannot run non read-only tools," and you had to switch to Agent mode to use CodeGraph at all. CodeGraph now declares all its tools read-only using the standard MCP tool annotations, so Cursor (and similar clients) allow them in read-only contexts. Nothing about how the tools behave changes. Thanks @CDsouza315 for the report. (#1018)
 
 ### Fixes
 

+ 105 - 0
__tests__/mcp-tool-annotations.test.ts

@@ -0,0 +1,105 @@
+/**
+ * Read-only MCP ToolAnnotations on every codegraph tool (issue #1018).
+ *
+ * Every codegraph tool is query-only — it reads the pre-built index and never
+ * mutates the workspace. Clients gate on this: Cursor's Ask mode refuses any MCP
+ * tool that doesn't advertise `readOnlyHint: true`, so without annotations the
+ * codegraph tools were blocked there even though they only read.
+ *
+ * These tests pin that the read-only contract is present on the master tool
+ * array AND survives every transform that builds a `tools/list` response — the
+ * static proxy surface (`getStaticTools`), the live surface (`getTools`, which
+ * rewrites codegraph_explore's description via spread), and the no-default-
+ * project surface (`withRequiredProjectPath`, which clones the schema). A drop in
+ * any of those would silently re-block the tools in Ask mode.
+ */
+import { describe, it, expect, afterEach, beforeEach } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { ToolHandler, getStaticTools, tools, type ToolDefinition } from '../src/mcp/tools';
+import { CodeGraph } from '../src';
+
+const ENV = 'CODEGRAPH_MCP_TOOLS';
+const ALL_TOOLS = tools.map((t) => t.name).join(',');
+
+/** Assert a single tool advertises the full read-only contract from #1018. */
+function expectReadOnly(tool: ToolDefinition): void {
+  expect(tool.annotations, `${tool.name} is missing annotations`).toBeDefined();
+  // The hint Cursor Ask mode (and other clients) gate on.
+  expect(tool.annotations!.readOnlyHint).toBe(true);
+  // The exact triplet the issue asks for, plus the honest closed-world hint.
+  expect(tool.annotations!.destructiveHint).toBe(false);
+  expect(tool.annotations!.idempotentHint).toBe(true);
+  expect(tool.annotations!.openWorldHint).toBe(false);
+}
+
+describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
+  const original = process.env[ENV];
+  afterEach(() => {
+    if (original === undefined) delete process.env[ENV];
+    else process.env[ENV] = original;
+  });
+
+  it('every tool in the master array is annotated read-only', () => {
+    expect(tools.length).toBeGreaterThan(0);
+    for (const tool of tools) expectReadOnly(tool);
+  });
+
+  it('the static proxy surface carries annotations on every exposed tool', () => {
+    // getStaticTools() answers tools/list before any project opens (proxy path).
+    process.env[ENV] = ALL_TOOLS;
+    const got = getStaticTools();
+    expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
+    for (const tool of got) expectReadOnly(tool);
+  });
+
+  it('the no-default-project surface keeps annotations through the schema clone', () => {
+    // withRequiredProjectPath (null cg) clones each tool's inputSchema — the
+    // top-level annotations field must ride along on the spread.
+    process.env[ENV] = ALL_TOOLS;
+    const got = new ToolHandler(null).getTools();
+    expect(got.length).toBe(tools.length);
+    for (const tool of got) {
+      expectReadOnly(tool);
+      // Sanity: this IS the clone path (projectPath got marked required).
+      expect(tool.inputSchema.required ?? []).toContain('projectPath');
+    }
+  });
+});
+
+describe('Live tool surface keeps annotations with a project open (#1018)', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+  const original = process.env[ENV];
+
+  beforeEach(async () => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-annot-'));
+    fs.writeFileSync(
+      path.join(tempDir, 'pay.ts'),
+      'export function processPayment(amount: number): boolean { return amount > 0; }\n'
+    );
+    cg = await CodeGraph.init(tempDir, { index: true });
+  });
+
+  afterEach(() => {
+    cg.close();
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    if (original === undefined) delete process.env[ENV];
+    else process.env[ENV] = original;
+  });
+
+  it('getTools() keeps annotations, incl. codegraph_explore whose description is rebuilt', () => {
+    process.env[ENV] = ALL_TOOLS;
+    const got = new ToolHandler(cg).getTools();
+    expect(got.length).toBeGreaterThan(0);
+    for (const tool of got) expectReadOnly(tool);
+
+    // explore's description is regenerated with a per-repo budget suffix via
+    // object spread; the annotation must survive that rewrite.
+    const explore = got.find((t) => t.name === 'codegraph_explore');
+    expect(explore).toBeDefined();
+    expect(explore!.description).toMatch(/Budget: make at most/);
+    expectReadOnly(explore!);
+  });
+});

+ 54 - 0
src/mcp/tools.ts

@@ -414,6 +414,34 @@ export interface ToolDefinition {
     properties: Record<string, PropertySchema>;
     required?: string[];
   };
+  /** Behavioral hints for clients (see {@link ToolAnnotations}). */
+  annotations?: ToolAnnotations;
+}
+
+/**
+ * MCP ToolAnnotations — behavioral hints a client MAY use to decide how, or
+ * whether, to run a tool (introduced in the 2025-03-26 spec, carried in
+ * 2025-06-18). They are advisory and never to be trusted for security, but
+ * clients gate on them: Cursor's Ask mode, for one, refuses any MCP tool that
+ * doesn't advertise `readOnlyHint: true` (issue #1018).
+ *
+ * The field is purely additive — a client that predates annotations ignores it
+ * — so codegraph advertises these even though `initialize` still negotiates the
+ * 2024-11-05 protocol version.
+ *
+ * https://modelcontextprotocol.io/specification/2025-06-18/schema#toolannotations
+ */
+export interface ToolAnnotations {
+  /** Human-readable title for the tool. */
+  title?: string;
+  /** If true, the tool does not modify its environment. Default (unset): false. */
+  readOnlyHint?: boolean;
+  /** Meaningful only when NOT read-only: may the tool perform destructive updates? */
+  destructiveHint?: boolean;
+  /** If true, repeat calls with the same arguments have no additional effect. */
+  idempotentHint?: boolean;
+  /** If true, the tool interacts with an open world of external entities. */
+  openWorldHint?: boolean;
 }
 
 interface PropertySchema {
@@ -442,6 +470,24 @@ const projectPathProperty: PropertySchema = {
   description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
 };
 
+/**
+ * EVERY codegraph tool is query-only: it reads the pre-built index and never
+ * mutates the workspace (indexing is the user's explicit CLI call, never the
+ * agent's). Advertising this read-only contract lets clients that gate on it run
+ * the tools where a possibly-mutating tool would be blocked — most concretely,
+ * Cursor's Ask mode, which rejects any MCP tool lacking `readOnlyHint: true`
+ * (issue #1018). `idempotentHint`: a repeated query has no additional effect.
+ * `openWorldHint: false`: the domain is the closed local index, not an open
+ * external world. Shared so the contract is declared once; a hypothetical
+ * mutating tool would simply not reference it.
+ */
+const READ_ONLY_ANNOTATIONS: ToolAnnotations = {
+  readOnlyHint: true,
+  destructiveHint: false,
+  idempotentHint: true,
+  openWorldHint: false,
+};
+
 /**
  * All CodeGraph MCP tools
  *
@@ -476,6 +522,7 @@ export const tools: ToolDefinition[] = [
       },
       required: ['query'],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_callers',
@@ -500,6 +547,7 @@ export const tools: ToolDefinition[] = [
       },
       required: ['symbol'],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_callees',
@@ -524,6 +572,7 @@ export const tools: ToolDefinition[] = [
       },
       required: ['symbol'],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_impact',
@@ -548,6 +597,7 @@ export const tools: ToolDefinition[] = [
       },
       required: ['symbol'],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_node',
@@ -589,6 +639,7 @@ export const tools: ToolDefinition[] = [
       },
       required: [],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_explore',
@@ -609,6 +660,7 @@ export const tools: ToolDefinition[] = [
       },
       required: ['query'],
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_status',
@@ -619,6 +671,7 @@ export const tools: ToolDefinition[] = [
         projectPath: projectPathProperty,
       },
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
   {
     name: 'codegraph_files',
@@ -652,6 +705,7 @@ export const tools: ToolDefinition[] = [
         projectPath: projectPathProperty,
       },
     },
+    annotations: READ_ONLY_ANNOTATIONS,
   },
 ];