diff --git a/.changeset/add-tool-annotations.md b/.changeset/add-tool-annotations.md new file mode 100644 index 0000000..5789c3f --- /dev/null +++ b/.changeset/add-tool-annotations.md @@ -0,0 +1,31 @@ +--- +"mcp-lite": minor +--- + +Add tool annotations support per MCP specification 2025-06-18. + +Tools can now include optional `annotations` field with behavioral hints and metadata: + +- **Behavioral hints**: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` - Help clients understand tool behavior and potential side effects +- **Audience targeting**: `audience` array to specify intended users (assistant, user, or both) +- **Priority**: Optional `priority` field (0-1) for relative importance hints +- **Timestamps**: `lastModified` for tracking tool updates +- **Display name**: `title` field as alternative to top-level title + +Example usage: + +```typescript +server.tool("deleteDatabase", { + description: "Permanently deletes the database", + annotations: { + destructiveHint: true, + audience: ["user"], + priority: 0.3, + }, + handler: async (args) => { + // implementation + } +}); +``` + +All annotation fields are optional. Tools without annotations continue to work unchanged (backwards compatible). diff --git a/packages/core/README.md b/packages/core/README.md index e6ded71..ab04243 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -496,6 +496,72 @@ server.tool("experimental-feature", { The `_meta` and `title` from the definition appear in `tools/list` responses. Tool handlers can also return `_meta` in the result for per-call metadata like execution time or cache status. +### Tool with Annotations + +Annotations provide metadata about tool behavior and usage hints to help clients make informed decisions. Annotations include behavioral hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), audience targeting, priority levels, and timestamps. + +```typescript +// Read-only tool +server.tool("getConfig", { + description: "Retrieves configuration settings", + annotations: { + readOnlyHint: true, + audience: ["assistant"], + priority: 0.8, + }, + handler: () => ({ + content: [{ type: "text", text: JSON.stringify(config) }], + }), +}); + +// Destructive tool +server.tool("deleteDatabase", { + description: "Permanently deletes the database", + annotations: { + destructiveHint: true, + readOnlyHint: false, + audience: ["user"], + priority: 0.3, + openWorldHint: true, + }, + inputSchema: z.object({ confirm: z.boolean() }), + handler: async (args) => { + if (args.confirm) { + await dropDatabase(); + } + return { content: [{ type: "text", text: "Database deleted" }] }; + }, +}); + +// Idempotent tool +server.tool("setConfig", { + description: "Updates a configuration value", + annotations: { + idempotentHint: true, + readOnlyHint: false, + priority: 0.5, + lastModified: "2025-01-15T10:00:00Z", + }, + inputSchema: z.object({ key: z.string(), value: z.string() }), + handler: (args) => ({ + content: [{ type: "text", text: `Set ${args.key} to ${args.value}` }], + }), +}); +``` + +**Annotation Fields:** + +- **`readOnlyHint`** (boolean): Tool only reads data without making modifications +- **`destructiveHint`** (boolean): Tool makes potentially irreversible changes +- **`idempotentHint`** (boolean): Repeated calls produce the same result +- **`openWorldHint`** (boolean): Tool interacts with external systems beyond the server's control +- **`audience`** (string[]): Intended users - `["assistant"]`, `["user"]`, or both +- **`priority`** (number): Importance level from 0 (lowest) to 1 (highest) +- **`lastModified`** (string): ISO 8601 timestamp of last modification +- **`title`** (string): Human-readable display name (alternative to top-level `title`) + +Annotations appear in `tools/list` responses and help clients understand tool capabilities, risks, and appropriate usage patterns. See the [MCP specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) for more details. + ### Resources Resources are URI-identified content. diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index c0ae738..6f550c7 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -39,6 +39,7 @@ import type { ResourceVarValidators, SchemaAdapter, Tool, + ToolAnnotations, ToolCallResult, ToolEntry, } from "./types.js"; @@ -441,6 +442,14 @@ export class McpServer { * @param def - Tool definition with schema, description, handler, and optional metadata * @param def.description - Human-readable description of what the tool does * @param def.title - Optional display title for the tool + * @param def.annotations - Optional annotations for tool behavior and usage hints + * @param def.annotations.audience - Intended users (e.g., ["assistant"], ["user"], or ["assistant", "user"]) + * @param def.annotations.priority - Importance level from 0 (lowest) to 1 (highest) + * @param def.annotations.title - Alternative display title (prioritized over def.title) + * @param def.annotations.readOnlyHint - Whether the tool only reads data without modifications + * @param def.annotations.destructiveHint - Whether the tool makes potentially irreversible changes + * @param def.annotations.idempotentHint - Whether repeated calls produce the same result + * @param def.annotations.openWorldHint - Whether the tool interacts with external systems * @param def._meta - Optional arbitrary metadata object passed through to clients via tools/list * @param def.inputSchema - Schema for validating input arguments (JSON Schema or Standard Schema) * @param def.outputSchema - Schema for validating structured output (JSON Schema or Standard Schema) @@ -520,6 +529,42 @@ export class McpServer { * }) * }); * ``` + * + * @example With annotations (read-only tool) + * ```typescript + * server.tool("getConfig", { + * description: "Retrieves configuration settings", + * annotations: { + * readOnlyHint: true, + * audience: ["assistant"], + * priority: 0.8 + * }, + * handler: () => ({ + * content: [{ type: "text", text: JSON.stringify(config) }] + * }) + * }); + * ``` + * + * @example With annotations (destructive tool) + * ```typescript + * server.tool("deleteDatabase", { + * description: "Permanently deletes the database", + * annotations: { + * destructiveHint: true, + * readOnlyHint: false, + * audience: ["user"], + * priority: 0.3, + * openWorldHint: true + * }, + * inputSchema: z.object({ confirm: z.boolean() }), + * handler: async (args) => { + * if (args.confirm) { + * await dropDatabase(); + * } + * return { content: [{ type: "text", text: "Database deleted" }] }; + * } + * }); + * ``` */ // Overload 1: Both input and output are Standard Schema (full type inference) tool< @@ -530,6 +575,7 @@ export class McpServer { def: { description?: string; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; inputSchema: SInput; outputSchema: SOutput; @@ -548,6 +594,7 @@ export class McpServer { def: { description?: string; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; inputSchema: S; outputSchema?: unknown; @@ -564,6 +611,7 @@ export class McpServer { def: { description?: string; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; inputSchema?: unknown; outputSchema: S; @@ -582,6 +630,7 @@ export class McpServer { def: { description?: string; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; inputSchema?: unknown; outputSchema?: unknown; @@ -598,6 +647,7 @@ export class McpServer { def: { description?: string; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; inputSchema?: unknown | StandardSchemaV1; outputSchema?: unknown | StandardSchemaV1; @@ -631,6 +681,9 @@ export class McpServer { if (def.title) { metadata.title = def.title; } + if (def.annotations) { + metadata.annotations = def.annotations; + } if (def._meta) { metadata._meta = def._meta; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 87aaa06..f5953c1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -283,6 +283,7 @@ export interface Tool { inputSchema: unknown; outputSchema?: unknown; title?: string; + annotations?: ToolAnnotations; _meta?: { [key: string]: unknown }; } @@ -385,6 +386,14 @@ export interface Annotations { priority?: number; } +export interface ToolAnnotations extends Annotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + export type TextResourceContents = { _meta?: { [key: string]: unknown }; uri: string; diff --git a/packages/core/tests/integration/tool-annotations.test.ts b/packages/core/tests/integration/tool-annotations.test.ts new file mode 100644 index 0000000..392c54e --- /dev/null +++ b/packages/core/tests/integration/tool-annotations.test.ts @@ -0,0 +1,531 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { z } from "zod"; +import { McpServer, StreamableHttpTransport } from "../../src/index.js"; + +interface JsonRpcResponse { + jsonrpc: string; + id: string | number | null; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; +} + +function createTestHandler() { + const mcp = new McpServer({ + name: "annotations-test-server", + version: "1.0.0", + schemaAdapter: (s) => z.toJSONSchema(s as z.ZodType), + }); + + // Read-only tool with annotations + mcp.tool("getConfig", { + description: "Retrieves configuration settings", + annotations: { + readOnlyHint: true, + audience: ["assistant"], + priority: 0.8, + title: "Get Configuration", + }, + inputSchema: z.object({}), + handler: () => ({ + content: [{ type: "text", text: JSON.stringify({ setting: "value" }) }], + }), + }); + + // Destructive tool with annotations + mcp.tool("deleteDatabase", { + description: "Permanently deletes the database", + annotations: { + destructiveHint: true, + readOnlyHint: false, + audience: ["user"], + priority: 0.3, + openWorldHint: true, + idempotentHint: false, + }, + inputSchema: z.object({ confirm: z.boolean() }), + handler: (args) => ({ + content: [ + { + type: "text", + text: args.confirm ? "Database deleted" : "Deletion cancelled", + }, + ], + }), + }); + + // Idempotent tool with annotations + mcp.tool("setConfig", { + description: "Updates a configuration value", + annotations: { + idempotentHint: true, + readOnlyHint: false, + priority: 0.5, + lastModified: "2025-01-15T10:00:00Z", + }, + inputSchema: z.object({ key: z.string(), value: z.string() }), + handler: (args) => ({ + content: [{ type: "text", text: `Set ${args.key} to ${args.value}` }], + }), + }); + + // Tool with only base annotations (no behavioral hints) + mcp.tool("queryData", { + description: "Queries data from external source", + annotations: { + audience: ["assistant", "user"], + priority: 0.7, + }, + inputSchema: z.object({ query: z.string() }), + handler: (args) => ({ + content: [{ type: "text", text: `Query results for: ${args.query}` }], + }), + }); + + // Tool without any annotations (backwards compatibility) + mcp.tool("simpleEcho", { + description: "Simple echo without annotations", + inputSchema: z.object({ message: z.string() }), + handler: (args) => ({ + content: [{ type: "text", text: args.message }], + }), + }); + + // Tool with open world hint + mcp.tool("fetchWebPage", { + description: "Fetches content from a web page", + annotations: { + openWorldHint: true, + readOnlyHint: true, + audience: ["assistant"], + }, + inputSchema: z.object({ url: z.string() }), + handler: (args) => ({ + content: [{ type: "text", text: `Content from ${args.url}` }], + }), + }); + + const transport = new StreamableHttpTransport(); + + return transport.bind(mcp); +} + +describe("Tool Annotations Tests", () => { + let handler: (request: Request) => Promise; + + beforeEach(() => { + handler = createTestHandler(); + }); + + describe("Tool annotations in tools/list", () => { + it("should include full annotations for read-only tool", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + description?: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + audience?: string[]; + priority?: number; + lastModified?: string; + title?: string; + }; + }>; + } + ).tools; + + const getConfigTool = tools.find((t) => t.name === "getConfig"); + expect(getConfigTool).toBeDefined(); + expect(getConfigTool?.annotations).toEqual({ + readOnlyHint: true, + audience: ["assistant"], + priority: 0.8, + title: "Get Configuration", + }); + }); + + it("should include annotations for destructive tool", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + audience?: string[]; + priority?: number; + }; + }>; + } + ).tools; + + const deleteTool = tools.find((t) => t.name === "deleteDatabase"); + expect(deleteTool).toBeDefined(); + expect(deleteTool?.annotations).toEqual({ + destructiveHint: true, + readOnlyHint: false, + audience: ["user"], + priority: 0.3, + openWorldHint: true, + idempotentHint: false, + }); + }); + + it("should include annotations with lastModified timestamp", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { + idempotentHint?: boolean; + readOnlyHint?: boolean; + priority?: number; + lastModified?: string; + }; + }>; + } + ).tools; + + const setConfigTool = tools.find((t) => t.name === "setConfig"); + expect(setConfigTool).toBeDefined(); + expect(setConfigTool?.annotations).toEqual({ + idempotentHint: true, + readOnlyHint: false, + priority: 0.5, + lastModified: "2025-01-15T10:00:00Z", + }); + }); + + it("should include annotations with multiple audience values", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { + audience?: string[]; + priority?: number; + }; + }>; + } + ).tools; + + const queryTool = tools.find((t) => t.name === "queryData"); + expect(queryTool).toBeDefined(); + expect(queryTool?.annotations).toEqual({ + audience: ["assistant", "user"], + priority: 0.7, + }); + }); + + it("should include openWorldHint annotation", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { + openWorldHint?: boolean; + readOnlyHint?: boolean; + audience?: string[]; + }; + }>; + } + ).tools; + + const fetchTool = tools.find((t) => t.name === "fetchWebPage"); + expect(fetchTool).toBeDefined(); + expect(fetchTool?.annotations).toEqual({ + openWorldHint: true, + readOnlyHint: true, + audience: ["assistant"], + }); + }); + }); + + describe("Backwards compatibility", () => { + it("should allow tools without annotations", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: unknown; + }>; + } + ).tools; + + const simpleEcho = tools.find((t) => t.name === "simpleEcho"); + expect(simpleEcho).toBeDefined(); + expect(simpleEcho?.annotations).toBeUndefined(); + }); + }); + + describe("Tool execution with annotations", () => { + it("should execute read-only tool successfully", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "call-tool", + method: "tools/call", + params: { + name: "getConfig", + arguments: {}, + }, + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const toolResult = result.result as { + content: Array<{ type: string; text: string }>; + }; + expect(toolResult.content[0].text).toContain("setting"); + }); + + it("should execute destructive tool successfully", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "call-tool", + method: "tools/call", + params: { + name: "deleteDatabase", + arguments: { confirm: true }, + }, + }), + }), + ); + + expect(response.status).toBe(200); + const result = (await response.json()) as JsonRpcResponse; + expect(result.error).toBeUndefined(); + + const toolResult = result.result as { + content: Array<{ type: string; text: string }>; + }; + expect(toolResult.content[0].text).toBe("Database deleted"); + }); + }); + + describe("Annotation field types", () => { + it("should accept boolean hints", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + const result = (await response.json()) as JsonRpcResponse; + const tools = (result.result as { tools: Array<{ name: string }> }).tools; + + expect(tools.length).toBeGreaterThan(0); + }); + + it("should accept priority as number", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + const result = (await response.json()) as JsonRpcResponse; + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { priority?: number }; + }>; + } + ).tools; + + const getConfigTool = tools.find((t) => t.name === "getConfig"); + expect(typeof getConfigTool?.annotations?.priority).toBe("number"); + expect(getConfigTool?.annotations?.priority).toBe(0.8); + }); + + it("should accept audience as array of strings", async () => { + const response = await handler( + new Request("http://localhost:3000/", { + method: "POST", + headers: { + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "list-tools", + method: "tools/list", + }), + }), + ); + + const result = (await response.json()) as JsonRpcResponse; + const tools = ( + result.result as { + tools: Array<{ + name: string; + annotations?: { audience?: string[] }; + }>; + } + ).tools; + + const getConfigTool = tools.find((t) => t.name === "getConfig"); + expect(Array.isArray(getConfigTool?.annotations?.audience)).toBe(true); + expect(getConfigTool?.annotations?.audience).toEqual(["assistant"]); + }); + }); +});