diff --git a/.changeset/mcp-tool-annotations.md b/.changeset/mcp-tool-annotations.md new file mode 100644 index 00000000..73753d7e --- /dev/null +++ b/.changeset/mcp-tool-annotations.md @@ -0,0 +1,5 @@ +--- +"@openrouter/agent": minor +--- + +MCP tools now keep the `annotations` their server reports in `tools/list` (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`). Read them from `tool.annotations` on a tool returned by `createMCPTools` or `rehydrateMCPTools`; cache snapshots store and restore them. The field is absent when the server sent none. diff --git a/packages/agent/README.md b/packages/agent/README.md index adf4067d..ae0aea76 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -67,6 +67,18 @@ pnpm add @openrouter/agent @modelcontextprotocol/client Existing `@openrouter/mcp` imports remain supported as compatibility facades. +A tool built by `createMCPTools` or `rehydrateMCPTools` carries the `annotations` its server reported in `tools/list`, and a cache snapshot keeps them. Only the five standard fields are kept (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title`); any other key a server adds is dropped. The field is absent when the server sent none. They are hints from the server, not guarantees, and they are not sent to the model. + +```typescript +import { isMcpTool } from '@openrouter/agent'; +import { createMCPTools } from '@openrouter/agent/mcp'; + +const handle = await createMCPTools({ url: 'https://mcp.example.com/mcp' }); +const readOnlyTools = handle.tools.filter( + (tool) => isMcpTool(tool) && tool.annotations?.readOnlyHint === true, +); +``` + ## Features ### Multiple Response Consumption Patterns diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index ca6853fa..4cd3d4c0 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -278,6 +278,7 @@ export type { InferToolOutputsUnion, ManualTool, McpBranded, + McpToolAnnotations, NextTurnParamsContext, NextTurnParamsFunctions, ParsedToolCall, diff --git a/packages/agent/src/lib/tool-types.ts b/packages/agent/src/lib/tool-types.ts index 90ce6671..78bcb2b1 100644 --- a/packages/agent/src/lib/tool-types.ts +++ b/packages/agent/src/lib/tool-types.ts @@ -1062,8 +1062,23 @@ export function isServerTool(tool: Tool): tool is ServerToolBase { */ export type McpBranded = T & { readonly _mcp: true; + /** The server's `annotations` for this tool from `tools/list`; absent when it sent none. */ + readonly annotations?: McpToolAnnotations; }; +/** + * Behaviour hints an MCP server may attach to a tool definition (MCP + * `ToolAnnotations`). Hints, not guarantees: a server can misreport them, so + * treat them as untrusted unless the server is. + */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + /** * Type guard: true if the tool carries the additive MCP brand (see * {@link McpBranded}). Structural check on `_mcp`, so no cast is needed. diff --git a/packages/agent/src/lib/tool.ts b/packages/agent/src/lib/tool.ts index e4916a32..3a893141 100644 --- a/packages/agent/src/lib/tool.ts +++ b/packages/agent/src/lib/tool.ts @@ -13,6 +13,7 @@ import type { HITLTool, ManualTool, McpBranded, + McpToolAnnotations, NextTurnParamsFunctions, ServerTool, ServerToolConfig, @@ -1100,16 +1101,23 @@ export function serverTool( toolToMark: T, options?: { loopKey?: ToolLoopKey>; + annotations?: McpToolAnnotations; }, ): McpBranded { const marked = { ...toolToMark, _mcp: true as const, + ...(options?.annotations !== undefined && { + annotations: options.annotations, + }), }; if (options?.loopKey !== undefined && isClientTool(marked)) { marked.function = { diff --git a/packages/agent/src/mcp/cache/cache-types.ts b/packages/agent/src/mcp/cache/cache-types.ts index 4540004d..e9265d95 100644 --- a/packages/agent/src/mcp/cache/cache-types.ts +++ b/packages/agent/src/mcp/cache/cache-types.ts @@ -1,3 +1,4 @@ +import type { McpToolAnnotations } from '../../lib/tool-types.js'; import { isJsonSchemaObject } from '../schema/json-schema-guards.js'; import type { MCPTransportKind } from '../transport-types.js'; @@ -7,6 +8,8 @@ export interface SerializedMCPToolDef { description?: string; inputSchema: Readonly>; outputSchema?: Readonly>; + /** The server's behaviour hints for the tool, if it reported any. */ + annotations?: McpToolAnnotations; /** Server-advertised doom-loop identity (field list or `false`), if any. */ loopKey?: readonly string[] | false; } @@ -87,7 +90,30 @@ function isSerializedToolDef(value: unknown): value is SerializedMCPToolDef { loopKey === false || (Array.isArray(loopKey) && loopKey.every((field) => typeof field === 'string')); return ( - typeof value['name'] === 'string' && isJsonSchemaObject(value['inputSchema']) && hasValidLoopKey + typeof value['name'] === 'string' && + isJsonSchemaObject(value['inputSchema']) && + hasValidLoopKey && + (value['annotations'] === undefined || isToolAnnotations(value['annotations'])) + ); +} + +const ANNOTATION_HINTS = [ + 'readOnlyHint', + 'destructiveHint', + 'idempotentHint', + 'openWorldHint', +] as const; + +/* A consumer may gate approval on a hint, so a hint of the wrong type rejects + * the entry instead of being read as truthy. */ +function isToolAnnotations(value: unknown): value is McpToolAnnotations { + if (!isJsonSchemaObject(value)) { + return false; + } + const { title } = value; + return ( + (title === undefined || typeof title === 'string') && + ANNOTATION_HINTS.every((hint) => value[hint] === undefined || typeof value[hint] === 'boolean') ); } diff --git a/packages/agent/src/mcp/cache/serialize.ts b/packages/agent/src/mcp/cache/serialize.ts index 1eb18734..fd1fc5b9 100644 --- a/packages/agent/src/mcp/cache/serialize.ts +++ b/packages/agent/src/mcp/cache/serialize.ts @@ -105,6 +105,9 @@ export async function serializeServer(input: SerializeInput): Promise; outputSchema?: Record; + /** The server's behaviour hints for the tool, as reported by `tools/list`. */ + annotations?: McpToolAnnotations; /** * Doom-loop identity advertised by the server via * `_meta['openrouter/loopKey']` on the tool definition: a field-name @@ -103,12 +105,14 @@ export function wrapMcpTool(def: McpToolDef, options: WrapToolOptions): McpBrand options.loopKeys !== undefined && Object.hasOwn(options.loopKeys, def.name) ? options.loopKeys[def.name] : def.loopKey; - const markOptions = - loopKey !== undefined - ? { - loopKey, - } - : undefined; + const markOptions = { + ...(loopKey !== undefined && { + loopKey, + }), + ...(def.annotations !== undefined && { + annotations: def.annotations, + }), + }; if (options.emitProgress === true) { return markMcp( diff --git a/packages/agent/tests/unit/mcp/annotations.test.ts b/packages/agent/tests/unit/mcp/annotations.test.ts new file mode 100644 index 00000000..757c186f --- /dev/null +++ b/packages/agent/tests/unit/mcp/annotations.test.ts @@ -0,0 +1,218 @@ +/** + * A server's `tools/list` annotations must survive discovery: a consumer reads + * them off the wrapped tool (for example to decide which calls need approval), + * and a cache snapshot has to restore the same hints it was written with. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SerializedMCPServer } from '../../../src/mcp/cache/cache-types.js'; +import type { ConnectOptions, MCPConnection } from '../../../src/mcp/mcp-connection.js'; + +type ListedTool = { + name: string; + inputSchema: Record; + annotations?: Record; +}; + +const INPUT_SCHEMA = { + type: 'object', + properties: {}, +}; + +/** What a consumer must read back for the tools `beforeEach` lists. */ +const REPORTED = { + search: { + title: 'Search', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + delete_record: { + destructiveHint: true, + }, + unannotated: undefined, +}; + +let listedTools: ListedTool[] = []; +let listToolsCalls = 0; + +vi.mock('../../../src/mcp/mcp-connection.js', () => ({ + connect: (_options: ConnectOptions): Promise => { + const connection: MCPConnection = { + client: { + getServerVersion: () => undefined, + getServerCapabilities: () => undefined, + listTools: () => { + listToolsCalls += 1; + return Promise.resolve({ + tools: listedTools, + }); + }, + } as never, + transport: 'streamableHttp', + setToolListChangedHandler: () => {}, + close: () => Promise.resolve(), + }; + return Promise.resolve(connection); + }, +})); + +const { createMCPTools } = await import('../../../src/mcp/create-mcp-tools.js'); +const { rehydrateMCPTools } = await import('../../../src/mcp/rehydrate.js'); +const { isSerializedMCPServer } = await import('../../../src/mcp/cache/cache-types.js'); +const { isMcpTool } = await import('../../../src/lib/tool-types.js'); +const { convertToolsToAPIFormat } = await import('../../../src/lib/tool-executor.js'); +const { MCPCacheError } = await import('../../../src/mcp/errors.js'); + +function annotationsByName( + tools: readonly import('../../../src/lib/tool-types.js').Tool[], +): Record { + return Object.fromEntries( + tools.flatMap((tool) => + isMcpTool(tool) && 'function' in tool + ? [ + [ + tool.function.name, + tool.annotations, + ], + ] + : [], + ), + ); +} + +function snapshotWith(annotations: unknown): unknown { + return { + version: 1, + url: 'https://mcp.example.com/mcp', + transport: 'streamableHttp', + tools: [ + { + name: 'delete_record', + inputSchema: INPUT_SCHEMA, + annotations, + }, + ], + cachedAt: 1234, + }; +} + +describe('MCP tool annotations', () => { + beforeEach(() => { + listToolsCalls = 0; + listedTools = [ + { + name: 'search', + inputSchema: INPUT_SCHEMA, + annotations: { + title: 'Search', + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + { + name: 'delete_record', + inputSchema: INPUT_SCHEMA, + annotations: { + destructiveHint: true, + }, + }, + { + name: 'unannotated', + inputSchema: INPUT_SCHEMA, + }, + ]; + }); + + it.each([ + [ + 'progress-emitting', + true, + ], + [ + 'plain', + false, + ], + ])('exposes each %s tool with the hints its server reported, and none when it reported none', async (_kind, emitProgress) => { + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + resources: false, + emitProgress, + }); + + expect(annotationsByName(handle.tools)).toStrictEqual(REPORTED); + }); + + it('keeps the hints out of the tool definitions sent to the model', async () => { + const handle = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + resources: false, + }); + + expect(JSON.stringify(convertToolsToAPIFormat(handle.tools))).not.toMatch( + /annotations|readOnlyHint|destructiveHint/, + ); + }); + + it('restores the same hints from a snapshot without listing the tools again', async () => { + const live = await createMCPTools({ + url: 'https://mcp.example.com/mcp', + resources: false, + }); + const snapshot: SerializedMCPServer = JSON.parse(JSON.stringify(await live.serialize())); + listToolsCalls = 0; + + const replayed = await rehydrateMCPTools({ + snapshot, + reconnectOnExpiry: false, + resources: false, + }); + + expect(listToolsCalls).toBe(0); + expect(annotationsByName(replayed.tools)).toStrictEqual(REPORTED); + }); + + it('refuses to build tools from a snapshot whose hint is not a boolean', async () => { + await expect( + rehydrateMCPTools({ + // @ts-expect-error a snapshot read from a store is untrusted; this one is malformed + snapshot: snapshotWith({ + readOnlyHint: 'false', + }), + reconnectOnExpiry: false, + resources: false, + }), + ).rejects.toBeInstanceOf(MCPCacheError); + }); + + it.each([ + [ + 'a hint that is not a boolean', + { + readOnlyHint: 'false', + }, + ], + [ + 'a title that is not a string', + { + title: 7, + }, + ], + [ + 'a non-object', + true, + ], + ])('rejects a snapshot whose annotations hold %s', (_label, annotations) => { + expect(isSerializedMCPServer(snapshotWith(annotations))).toBe(false); + }); + + it('accepts a snapshot whose annotations are well-formed', () => { + expect( + isSerializedMCPServer( + snapshotWith({ + readOnlyHint: false, + }), + ), + ).toBe(true); + }); +});