diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9f876..3089d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to `webmcp-react` are documented here. The format is based o [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- **`consequentialHint` and `debugging` annotations.** `ToolAnnotations` now covers all four + hints in the WebMCP spec. Passing either one to `useMcpTool` used to fail to type-check. + The polyfill already forwarded annotations as-is, so nothing changes at runtime. + ## 1.2.1 ### Added diff --git a/README.md b/README.md index 3c92da8..9e087a4 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ useMcpTool({ ### Title and annotations -Give a tool a human-friendly display `title`, and describe its behavior to agents with `annotations`. The current WebMCP specification defines two annotations: `readOnlyHint` and `untrustedContentHint`. +Give a tool a human-friendly display `title`, and describe its behavior to agents with `annotations`. The current WebMCP specification defines four annotations: `readOnlyHint`, `untrustedContentHint`, `consequentialHint` and `debugging`. Set `consequentialHint` on tools that do something hard to undo, like placing an order, so agents know to check with the user first. ```tsx useMcpTool({ diff --git a/docs/api.md b/docs/api.md index 4d77163..efd9cc8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -41,7 +41,7 @@ Registers a tool on `document.modelContext`. Automatically unregisters on unmoun | `description` | `string` | Human-readable description (required, non-empty) | | `input` | `z.ZodObject` | Zod schema for inputs. Handler receives typed args | | `output` | `z.ZodObject` | Optional Zod schema for outputs (library extension; see below) | -| `annotations` | `ToolAnnotations` | Optional behavior hints (`readOnlyHint`, `untrustedContentHint`) | +| `annotations` | `ToolAnnotations` | Optional behavior hints (`readOnlyHint`, `untrustedContentHint`, `consequentialHint`, `debugging`) | | `exposedTo` | `string[]` | Optional list of trustworthy origins this tool is exposed to across frames | | `handler` | `(args, ctx) => CallToolResult \| Promise` | Tool implementation. Receives the parsed input and `ctx: { signal: AbortSignal }`; the signal aborts when the agent cancels the execution (Chrome 153+; otherwise a never-aborting substitute) | | `onSuccess` | `(result) => void` | Optional callback on success | @@ -57,8 +57,10 @@ Same as above, but replace `input` with `inputSchema: InputSchema` and (optional ```ts interface ToolAnnotations { - readOnlyHint?: boolean; - untrustedContentHint?: boolean; + readOnlyHint?: boolean; // only reads data, no side effects + untrustedContentHint?: boolean; // output may include content you don't control + consequentialHint?: boolean; // real-world or irreversible effects; agents may confirm with the user first + debugging?: boolean; // meant for debugging and developer tooling, not end users } ``` diff --git a/skills/webmcp-add-tool/SKILL.md b/skills/webmcp-add-tool/SKILL.md index 42b724b..2e22a62 100644 --- a/skills/webmcp-add-tool/SKILL.md +++ b/skills/webmcp-add-tool/SKILL.md @@ -97,11 +97,13 @@ useMcpTool({ annotations: { readOnlyHint: true, // Tool only reads data, no side effects untrustedContentHint: true, // Tool may return content from untrusted sources + consequentialHint: true, // Tool has real-world or irreversible effects + debugging: true, // Tool is for developers, not end users }, }); ``` -`ToolAnnotations` is only `{ readOnlyHint?: boolean; untrustedContentHint?: boolean }`. There is no `title` inside `annotations` (it is a top-level field), and the older `destructiveHint`, `idempotentHint`, and `openWorldHint` hints no longer exist. +`ToolAnnotations` is only `{ readOnlyHint?, untrustedContentHint?, consequentialHint?, debugging? }`, all booleans. There is no `title` inside `annotations` (it is a top-level field), and the older `destructiveHint`, `idempotentHint`, and `openWorldHint` hints no longer exist. **Guideline for choosing fields:** @@ -111,6 +113,8 @@ useMcpTool({ | Fetches/queries data, no side effects | `annotations: { readOnlyHint: true }` | | Returns content from untrusted sources (user input, external APIs) | `annotations: { untrustedContentHint: true }` | | Creates/updates/deletes data | omit `readOnlyHint` (a tool with side effects should not claim it is read-only) | +| Does something hard to undo (payments, bookings, sending messages, permanent deletes) | `annotations: { consequentialHint: true }` | +| Only useful to developers (state dumps, test helpers) | `annotations: { debugging: true }` | ## Return format diff --git a/src/__tests__/tool-annotations.test-d.ts b/src/__tests__/tool-annotations.test-d.ts new file mode 100644 index 0000000..ade9f49 --- /dev/null +++ b/src/__tests__/tool-annotations.test-d.ts @@ -0,0 +1,29 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { McpToolConfigJsonSchema, RegisteredTool, ToolAnnotations } from "../index"; + +describe("ToolAnnotations", () => { + it("has every annotation the spec defines", () => { + expectTypeOf().toEqualTypeOf<{ + readOnlyHint?: boolean; + untrustedContentHint?: boolean; + consequentialHint?: boolean; + debugging?: boolean; + }>(); + }); + + it("accepts consequentialHint and debugging in useMcpTool config", () => { + const config: McpToolConfigJsonSchema = { + name: "book_flight", + description: "Book the selected flight", + annotations: { consequentialHint: true, debugging: false }, + handler: () => ({ content: [] }), + }; + expectTypeOf(config.annotations).toEqualTypeOf(); + }); + + it("types them on tools returned by getTools()", () => { + type Annotations = NonNullable; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index eafb9be..5d62738 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -237,7 +237,12 @@ describe("registration lifecycle", () => { name: "greet", title: "Greeter", description, - annotations: { readOnlyHint: true, untrustedContentHint: true }, + annotations: { + readOnlyHint: true, + untrustedContentHint: true, + consequentialHint: true, + debugging: true, + }, handler: () => ({ content: [{ type: "text", text: "hi" }] }), }); return null; @@ -263,7 +268,12 @@ describe("registration lifecycle", () => { await waitFor(() => expect(spy).toHaveBeenCalled()); const descriptor = spy.mock.calls[0][0]; expect(descriptor.title).toBe("Greeter"); - expect(descriptor.annotations).toEqual({ readOnlyHint: true, untrustedContentHint: true }); + expect(descriptor.annotations).toEqual({ + readOnlyHint: true, + untrustedContentHint: true, + consequentialHint: true, + debugging: true, + }); }); it("removes tool on unmount", async () => { diff --git a/src/polyfill/__tests__/consumer-api.test.ts b/src/polyfill/__tests__/consumer-api.test.ts index 494a419..459ac7a 100644 --- a/src/polyfill/__tests__/consumer-api.test.ts +++ b/src/polyfill/__tests__/consumer-api.test.ts @@ -85,6 +85,15 @@ describe("document.modelContext.getTools (polyfill)", () => { expect(tool.annotations).toEqual({ readOnlyHint: true }); }); + it("passes consequentialHint and debugging through to getTools()", async () => { + installPolyfill(); + await mc().registerTool( + makeTool({ annotations: { consequentialHint: true, debugging: true } }), + ); + const [tool] = await mc().getTools(); + expect(tool.annotations).toEqual({ consequentialHint: true, debugging: true }); + }); + it("rejects SecurityError for an untrustworthy fromOrigins entry", async () => { installPolyfill(); await expect(mc().getTools({ fromOrigins: ["http://evil.example"] })).rejects.toThrow( diff --git a/src/types.ts b/src/types.ts index 02fbd91..c5e7e4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,8 +74,17 @@ export interface CallToolResult { } export interface ToolAnnotations { + /** The tool only reads data and has no side effects. */ readOnlyHint?: boolean; + /** The tool's output may include content the page author doesn't control. */ untrustedContentHint?: boolean; + /** + * Running the tool has significant real-world or irreversible effects, like booking a + * flight or sending money. Agents may ask the user to confirm before calling it. + */ + consequentialHint?: boolean; + /** The tool is meant for debugging and developer tooling, not end users. */ + debugging?: boolean; } export interface ToolDescriptor> {