From fe6ff3abadfb4c5e8b7ea8c20e004c73d65bde26 Mon Sep 17 00:00:00 2001 From: stemirkhan Date: Mon, 27 Apr 2026 22:46:45 +0300 Subject: [PATCH] feat(mcp): add tool safety annotations --- gitnexus/src/mcp/server.ts | 1 + gitnexus/src/mcp/tools.ts | 37 ++++++++++++++++++++++++++ gitnexus/test/unit/server.test.ts | 17 ++++++++++++ gitnexus/test/unit/tools.test.ts | 44 +++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 4d540b8a2..be80f4c35 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -158,6 +158,7 @@ export function createMCPServer(backend: LocalBackend): Server { name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + annotations: tool.annotations, })), })); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 491c24557..fd139a936 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -5,9 +5,12 @@ * All tools support an optional `repo` parameter for multi-repo setups. */ +import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; + export interface ToolDefinition { name: string; description: string; + annotations: ToolAnnotations; inputSchema: { type: 'object'; properties: Record< @@ -27,6 +30,27 @@ export interface ToolDefinition { }; } +const READ_ONLY_TOOL_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}; + +const QUERY_TOOL_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +}; + +const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +}; + export const GITNEXUS_TOOLS: ToolDefinition[] = [ { name: 'list_repos', @@ -39,6 +63,7 @@ AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work wi When multiple repos are indexed, you MUST specify the "repo" parameter on other tools (query, context, impact, etc.) to target the correct one.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: {}, @@ -63,6 +88,7 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank GROUP MODE: set "repo" to "@" to search all member repos in that group (merged via RRF), or "@/" to run against a single member (same path keys as in group.yaml). If you use "@" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools). SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`, + annotations: QUERY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -156,6 +182,7 @@ TIPS: - Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy - Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId - Use heuristicLabel (not label) for human-readable community/process names`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -183,6 +210,7 @@ NOTE: ACCESSES edges (field read/write tracking) are included in context results GROUP MODE: set "repo" to "@" to run context in each member repo (aggregated list), or "@/" for one member. If you use "@" only, the member defaults to the lexicographically first key in group.yaml "repos". SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -226,6 +254,7 @@ WHEN TO USE: Before committing — to understand what your changes affect. Pre-c AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces. Returns: changed symbols, affected processes, and a risk summary.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -258,6 +287,7 @@ AFTER THIS: Run detect_changes() to verify no unexpected side effects. Each edit is tagged with confidence: - "graph": found via knowledge graph relationships (high confidence, safe to accept) - "text_search": found via regex text search (lower confidence, review carefully)`, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -311,6 +341,7 @@ Confidence: 1.0 = certain, <0.8 = fuzzy match GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -404,6 +435,7 @@ WHEN TO USE: Understanding API consumption patterns, finding orphaned routes. Fo AFTER THIS: Use impact() on specific route handlers to see full blast radius. Returns: route nodes with their handlers, middleware wrapper chains (e.g., withAuth, withRateLimit), and consumers.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -426,6 +458,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA WHEN TO USE: Understanding tool APIs, finding tool implementations, impact analysis for tool changes. Returns: tool nodes with their handler files and descriptions.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -443,6 +476,7 @@ WHEN TO USE: Detecting mismatches between what an API route returns and what con REQUIRES: Route nodes with responseKeys (extracted from .json({...}) calls during indexing). Returns routes that have both detected response keys AND consumers. Shows top-level keys each endpoint returns (e.g., data, pagination, error) and what keys each consumer accesses. Reports MISMATCH status when a consumer accesses keys not present in the route's response shape.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -467,6 +501,7 @@ WHEN TO USE: BEFORE modifying any API route handler. Shows what consumers depend Risk levels: LOW (0-3 consumers), MEDIUM (4-9 or any mismatches), HIGH (10+ consumers or mismatches with 4+ consumers). Mismatches with confidence "low" indicate the consumer file fetches multiple routes — property attribution is approximate. Returns: single route object when one match, or { routes: [...], total: N } for multiple matches. Combines route_map, shape_check, and impact data.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -482,6 +517,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for description: `List all configured repository groups, or return details for one group (repos, manifest links). WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`, + annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { @@ -495,6 +531,7 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links. WHEN TO USE: After changing group.yaml or re-indexing member repos.`, + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, inputSchema: { type: 'object', properties: { diff --git a/gitnexus/test/unit/server.test.ts b/gitnexus/test/unit/server.test.ts index c556b48cc..01fc44d9f 100644 --- a/gitnexus/test/unit/server.test.ts +++ b/gitnexus/test/unit/server.test.ts @@ -14,6 +14,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import { createMCPServer } from '../../src/mcp/server.js'; +import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js'; // ─── Mock backend ────────────────────────────────────────────────── @@ -52,6 +53,22 @@ describe('createMCPServer', () => { // The server has registered handlers — verify it was created without errors expect(server).toBeTruthy(); }); + + it('tools/list response includes tool annotations', async () => { + const backend = createMockBackend(); + const server = createMCPServer(backend); + const handler = (server as any)._requestHandlers.get('tools/list'); + + expect(typeof handler).toBe('function'); + + const response = await handler({ method: 'tools/list', params: {} }, {}); + expect(response.tools).toHaveLength(GITNEXUS_TOOLS.length); + + for (const tool of response.tools) { + const definition = GITNEXUS_TOOLS.find((t) => t.name === tool.name)!; + expect(tool.annotations).toEqual(definition.annotations); + } + }); }); // ─── getNextStepHint (tested indirectly via server tool handler) ────── diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 231f55e3c..e3a646c77 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from 'vitest'; import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js'; const GROUP_TOOLS = new Set(['group_list', 'group_sync']); +const MUTATING_TOOLS = new Set(['rename', 'group_sync']); describe('GITNEXUS_TOOLS', () => { it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => { @@ -39,6 +40,7 @@ describe('GITNEXUS_TOOLS', () => { expect(typeof tool.name).toBe('string'); expect(tool.description).toBeTruthy(); expect(typeof tool.description).toBe('string'); + expect(tool.annotations).toBeDefined(); expect(tool.inputSchema).toBeDefined(); expect(tool.inputSchema.type).toBe('object'); expect(tool.inputSchema.properties).toBeDefined(); @@ -46,6 +48,48 @@ describe('GITNEXUS_TOOLS', () => { } }); + it('each tool exposes all MCP safety annotations', () => { + for (const tool of GITNEXUS_TOOLS) { + expect(typeof tool.annotations.readOnlyHint).toBe('boolean'); + expect(typeof tool.annotations.destructiveHint).toBe('boolean'); + expect(typeof tool.annotations.idempotentHint).toBe('boolean'); + expect(typeof tool.annotations.openWorldHint).toBe('boolean'); + } + }); + + it('read-only tools are marked non-destructive and idempotent', () => { + for (const tool of GITNEXUS_TOOLS) { + if (MUTATING_TOOLS.has(tool.name)) continue; + + expect(tool.annotations.readOnlyHint).toBe(true); + expect(tool.annotations.destructiveHint).toBe(false); + expect(tool.annotations.idempotentHint).toBe(true); + expect(tool.annotations.openWorldHint).toBe(tool.name === 'query'); + } + }); + + it('query is marked open-world because it may use external embeddings', () => { + const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!; + expect(queryTool.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }); + }); + + it('rename and group_sync are marked mutating and non-idempotent', () => { + for (const name of ['rename', 'group_sync'] as const) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!; + expect(tool.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }); + } + }); + it('query tool requires "query" parameter', () => { const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!; expect(queryTool.inputSchema.required).toContain('query');