Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-tool-annotations.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export type {
InferToolOutputsUnion,
ManualTool,
McpBranded,
McpToolAnnotations,
NextTurnParamsContext,
NextTurnParamsFunctions,
ParsedToolCall,
Expand Down
15 changes: 15 additions & 0 deletions packages/agent/src/lib/tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,8 +1062,23 @@ export function isServerTool(tool: Tool): tool is ServerToolBase {
*/
export type McpBranded<T extends Tool = Tool> = 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.
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/lib/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
HITLTool,
ManualTool,
McpBranded,
McpToolAnnotations,
NextTurnParamsFunctions,
ServerTool,
ServerToolConfig,
Expand Down Expand Up @@ -1100,16 +1101,23 @@ export function serverTool<T extends ServerToolType, TId extends string = `serve
* the only injection point for MCP tools, whose remote definitions cannot
* carry client-side functions. Like every other tool hook it is a computed
* function over the call's arguments (or `false` to exempt).
*
* `options.annotations` records the server's `tools/list` annotations on the
* branded tool, so a consumer can read them back after discovery.
*/
export function markMcp<T extends Tool>(
toolToMark: T,
options?: {
loopKey?: ToolLoopKey<Record<string, unknown>>;
annotations?: McpToolAnnotations;
},
): McpBranded<T> {
const marked = {
...toolToMark,
_mcp: true as const,
...(options?.annotations !== undefined && {
annotations: options.annotations,
}),
};
if (options?.loopKey !== undefined && isClientTool(marked)) {
marked.function = {
Expand Down
28 changes: 27 additions & 1 deletion packages/agent/src/mcp/cache/cache-types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -7,6 +8,8 @@ export interface SerializedMCPToolDef {
description?: string;
inputSchema: Readonly<Record<string, unknown>>;
outputSchema?: Readonly<Record<string, unknown>>;
/** 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;
}
Expand Down Expand Up @@ -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')
);
}

Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/mcp/cache/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export async function serializeServer(input: SerializeInput): Promise<Serialized
...(def.outputSchema !== undefined && {
outputSchema: def.outputSchema,
}),
...(def.annotations !== undefined && {
annotations: def.annotations,
}),
...(def.loopKey !== undefined && {
loopKey: def.loopKey,
}),
Expand Down
30 changes: 29 additions & 1 deletion packages/agent/src/mcp/handle.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Tool } from '../lib/tool-types.js';
import type { McpToolAnnotations, Tool } from '../lib/tool-types.js';
import type { BuildToolsOptions } from './build-tools.js';
import { buildTools } from './build-tools.js';
import type { SerializedMCPServer } from './cache/cache-types.js';
Expand Down Expand Up @@ -111,6 +111,9 @@ export async function listToolDefs(
...(t.outputSchema !== undefined && {
outputSchema: t.outputSchema,
}),
...(t.annotations !== undefined && {
annotations: toToolAnnotations(t.annotations),
}),
...(loopKey !== undefined && {
loopKey,
}),
Expand All @@ -127,6 +130,31 @@ export async function listToolDefs(
return collected;
}

/** Copies the defined hints only, so an absent hint stays absent on the tool and in a snapshot. */
function toToolAnnotations(
reported: {
[K in keyof McpToolAnnotations]?: McpToolAnnotations[K] | undefined;
},
): McpToolAnnotations {
return {
...(reported.title !== undefined && {
title: reported.title,
}),
...(reported.readOnlyHint !== undefined && {
readOnlyHint: reported.readOnlyHint,
}),
...(reported.destructiveHint !== undefined && {
destructiveHint: reported.destructiveHint,
}),
...(reported.idempotentHint !== undefined && {
idempotentHint: reported.idempotentHint,
}),
...(reported.openWorldHint !== undefined && {
openWorldHint: reported.openWorldHint,
}),
};
}

function serverHasResources(connection: MCPConnection): boolean {
const caps = connection.client.getServerCapabilities();
return caps?.resources !== undefined;
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/mcp/rehydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ function snapshotToToolDefs(snapshot: SerializedMCPServer): McpToolDef[] {
...t.outputSchema,
},
}),
...(t.annotations !== undefined && {
annotations: {
...t.annotations,
},
}),
...(t.loopKey !== undefined && {
loopKey: t.loopKey,
}),
Expand Down
18 changes: 11 additions & 7 deletions packages/agent/src/mcp/tool-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Client, Progress } from '@modelcontextprotocol/client';
import * as z from 'zod';
import { markMcp, tool } from '../lib/tool.js';
import type { McpBranded, ToolLoopKey } from '../lib/tool-types.js';
import type { McpBranded, McpToolAnnotations, ToolLoopKey } from '../lib/tool-types.js';
import type { RawCallToolResult } from './result-mapper.js';
import { mapCallToolResult } from './result-mapper.js';
import { isJsonSchemaObject } from './schema/json-schema-guards.js';
Expand All @@ -14,6 +14,8 @@ export interface McpToolDef {
description?: string;
inputSchema: Record<string, unknown>;
outputSchema?: Record<string, unknown>;
/** 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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading