From db9bf2afa1354eb831a1048332411f69d6603a97 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 19:03:20 +0200 Subject: [PATCH 1/7] test: native WebMCP E2E harness (polyfill self-test verified; native gate pending Chrome 150+) --- examples/native-harness/index.html | 12 ++ examples/native-harness/package.json | 23 ++++ examples/native-harness/src/App.tsx | 170 +++++++++++++++++++++++++ examples/native-harness/src/main.tsx | 9 ++ examples/native-harness/tsconfig.json | 19 +++ examples/native-harness/vite.config.ts | 12 ++ 6 files changed, 245 insertions(+) create mode 100644 examples/native-harness/index.html create mode 100644 examples/native-harness/package.json create mode 100644 examples/native-harness/src/App.tsx create mode 100644 examples/native-harness/src/main.tsx create mode 100644 examples/native-harness/tsconfig.json create mode 100644 examples/native-harness/vite.config.ts diff --git a/examples/native-harness/index.html b/examples/native-harness/index.html new file mode 100644 index 0000000..1a37180 --- /dev/null +++ b/examples/native-harness/index.html @@ -0,0 +1,12 @@ + + + + + + WebMCP Native Harness + + +
+ + + diff --git a/examples/native-harness/package.json b/examples/native-harness/package.json new file mode 100644 index 0000000..e67d6b8 --- /dev/null +++ b/examples/native-harness/package.json @@ -0,0 +1,23 @@ +{ + "name": "webmcp-react-native-harness", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.0", + "webmcp-react": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/examples/native-harness/src/App.tsx b/examples/native-harness/src/App.tsx new file mode 100644 index 0000000..5484579 --- /dev/null +++ b/examples/native-harness/src/App.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useState } from "react"; +import { WebMCPProvider, useMcpTool, useWebMCPStatus } from "webmcp-react"; +import { z } from "zod"; + +/** + * Registers the `echo` tool using a Zod input schema. + * Marked read-only and exposed only to https://example.com to exercise the + * `exposedTo` registration path. + */ +function EchoTool() { + useMcpTool({ + name: "echo", + title: "Echo", + description: "Echo the provided text back to the caller.", + input: z.object({ text: z.string() }), + annotations: { readOnlyHint: true }, + exposedTo: ["https://example.com"], + handler: (input) => ({ + content: [{ type: "text", text: input.text }], + }), + }); + return null; +} + +/** + * Registers the `add` tool using a raw JSON-Schema input definition. + */ +function AddTool() { + useMcpTool({ + name: "add", + title: "Add", + description: "Add two numbers and return the sum.", + inputSchema: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" }, + }, + required: ["a", "b"], + }, + handler: (args) => { + const a = args.a as number; + const b = args.b as number; + return { + content: [{ type: "text", text: String(a + b) }], + }; + }, + }); + return null; +} + +async function runSelfTest(log: (line: string) => void) { + const t = navigator.modelContextTesting; + if (!t) { + log("FAIL: navigator.modelContextTesting missing"); + return; + } + const names = t.listTools().map((x) => x.name); + log( + names.includes("echo") && names.includes("add") + ? "PASS: listTools" + : `FAIL: listTools ${names}`, + ); + const echoRaw = await t.executeTool("echo", JSON.stringify({ text: "hi" })); + const echo = echoRaw ? JSON.parse(echoRaw) : null; + log( + echo?.content?.[0]?.text?.includes("hi") + ? "PASS: execute echo" + : `FAIL: echo ${echoRaw}`, + ); + const addRaw = await t.executeTool("add", JSON.stringify({ a: 2, b: 3 })); + const add = addRaw ? JSON.parse(addRaw) : null; + log( + add?.content?.[0]?.text?.includes("5") + ? "PASS: execute add" + : `FAIL: add ${addRaw}`, + ); +} + +/** + * StatusPanel is rendered inside WebMCPProvider and uses useWebMCPStatus() to + * reactively detect when the polyfill is installed, then attaches listeners. + */ +function StatusPanel() { + const { available } = useWebMCPStatus(); + const [detection, setDetection] = useState<"native" | "polyfill" | "checking">( + "checking", + ); + const [toolchangeCount, setToolchangeCount] = useState(0); + + // Update detection once available + useEffect(() => { + if (available) { + const mc = document.modelContext; + setDetection( + mc && "__isWebMCPPolyfill" in mc ? "polyfill" : "native", + ); + } + }, [available]); + + // Attach toolchange listener only when available + useEffect(() => { + if (!available) return; + const mc = document.modelContext; + if (!mc) return; + const handler = () => setToolchangeCount((c) => c + 1); + mc.addEventListener("toolchange", handler); + return () => mc.removeEventListener("toolchange", handler); + }, [available]); + + return ( + <> +
+

Detection

+

{detection}

+
+ +
+

Toolchange events

+

{toolchangeCount}

+
+ + ); +} + +/** + * SelfTestPanel wraps the self-test button and output. + */ +function SelfTestPanel() { + const [selftestOutput, setSelftestOutput] = useState([]); + + const handleRunSelfTest = useCallback(() => { + setSelftestOutput([]); + void runSelfTest((line) => setSelftestOutput((lines) => [...lines, line])); + }, []); + + return ( +
+

Self-test

+ +
+        {selftestOutput.map((line, i) => (
+          
{line}
+ ))} +
+
+ ); +} + +function Harness() { + return ( +
+

WebMCP Native Harness

+ + +
+ ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/examples/native-harness/src/main.tsx b/examples/native-harness/src/main.tsx new file mode 100644 index 0000000..311d147 --- /dev/null +++ b/examples/native-harness/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/native-harness/tsconfig.json b/examples/native-harness/tsconfig.json new file mode 100644 index 0000000..e6c759c --- /dev/null +++ b/examples/native-harness/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "paths": { + "webmcp-react": ["../../src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/examples/native-harness/vite.config.ts b/examples/native-harness/vite.config.ts new file mode 100644 index 0000000..82169bb --- /dev/null +++ b/examples/native-harness/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "webmcp-react": path.resolve(__dirname, "../../src"), + }, + }, +}); From fb7a0a7226760bd7741d519a3feab19ca224c1f6 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 19:03:29 +0200 Subject: [PATCH 2/7] chore: update lockfile for native-harness example --- pnpm-lock.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c03d02..215cbaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,37 @@ importers: specifier: ^3.24.0 version: 3.25.76 + examples/native-harness: + dependencies: + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + webmcp-react: + specifier: workspace:* + version: link:../.. + zod: + specifier: ^3.24.0 + version: 3.25.76 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@6.4.1(@types/node@25.3.0)(yaml@2.8.2)) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1(@types/node@25.3.0)(yaml@2.8.2) + examples/nextjs: dependencies: next: From ad3b38df0db660b91466cd232ec7d2098cbba71f Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 19:06:10 +0200 Subject: [PATCH 3/7] docs: 0.2.0 API docs, README, AGENTS, CHANGELOG; bump version --- AGENTS.md | 8 +++- CHANGELOG.md | 68 ++++++++++++++++++++++++++++++++ README.md | 38 ++++++++++++------ docs/api.md | 107 ++++++++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 5 files changed, 186 insertions(+), 37 deletions(-) create mode 100644 CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 3fc1b53..8a9517e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ src/ ← core library (your focus) ├── context.tsx ← WebMCPProvider + useWebMCPStatus hook ├── hooks/ │ └── useMcpTool.ts ← main hook for tool registration -├── polyfill/ ← navigator.modelContext polyfill +├── polyfill/ ← document.modelContext polyfill │ ├── index.ts ← installPolyfill / cleanupPolyfill + polyfill marker │ ├── registry.ts ← in-memory tool storage │ ├── testing-shim.ts ← simulates MCP client calls @@ -64,7 +64,11 @@ These patterns look like they could be simplified but exist for specific reasons **`"use client"` banner**: Added at build time via `tsup.config.ts`, not in source files. This makes the library work with Next.js SSR. Don't add `"use client"` to source files. -**Native API detection**: The polyfill checks for native `navigator.modelContext` and skips installation if it exists. Don't remove this check — Chrome is shipping native WebMCP support. +**Native API detection**: The polyfill checks for native `document.modelContext` (document-only — it does not read `navigator.modelContext`) and skips installation if it exists. Don't remove this check — Chrome is shipping native WebMCP support. + +**AbortSignal-only unregistration**: There is no `unregisterTool`. Tools are removed by aborting the `AbortSignal` passed to `registerTool`. `useMcpTool` aborts its controller on cleanup; the registry's abort listener removes the tool. Don't reintroduce an imperative unregister method. + +**Single-arg execute/handler**: `descriptor.execute(input)` and the user `handler(args)` take a single argument. There is no `ModelContextClient` second argument. Both execution paths must stay mirrored. ## Testing diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..653d312 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to `webmcp-react` are documented here. The format is based on +[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). + +## 0.2.0 + +Realigns the library with the current [WebMCP](https://github.com/webmachinelearning/webmcp) +spec. This is a clean break — the public surface (``, `useMcpTool`, +`useWebMCPStatus`) is unchanged in shape, but the underlying WebMCP wiring has breaking +changes. + +### Breaking changes + +- **API moved to `document.modelContext`.** The registration API is now installed and detected + on `document.modelContext` instead of `navigator.modelContext`. Native detection is + document-only — `navigator.modelContext` is no longer read or written. (The testing/consumer + API stays on `navigator.modelContextTesting`.) +- **`registerTool` returns a `Promise`.** The polyfill's `registerTool(tool, options?)` now + returns a `Promise` that rejects on: + - an empty/missing name, a name longer than 128 chars, or a name not matching + `^[A-Za-z0-9_.-]+$`; + - a duplicate name already registered; + - an empty/missing description; + - an `execute` that is not a function; + - a non-serializable `inputSchema`; + - an `exposedTo` entry that is not a parseable, potentially-trustworthy origin; + - an already-aborted signal (rejects with the signal's abort reason). + + `useMcpTool` routes these rejections into `state.error` and `onError`, except `AbortError` + (lifecycle teardown), which is ignored. +- **Unregistration is AbortSignal-only.** There is no `unregisterTool`. Tools are removed by + aborting the `AbortSignal` passed via `registerTool`'s options; `useMcpTool` does this on + unmount. +- **Single-argument handlers.** Tool `execute(input)` and user `handler(args)` now take a single + argument. `ModelContextClient` and `requestUserInteraction` are removed entirely. +- **`ToolAnnotations` narrowed** to `{ readOnlyHint?, untrustedContentHint? }`. The classic MCP + hints (`destructiveHint`, `idempotentHint`, `openWorldHint`) and `annotations.title` are + removed. +- **Top-level `title?` added** to the tool config and descriptor (replacing the dropped + `annotations.title`). +- **`exposedTo?: string[]` added** to the tool config and `RegisterToolOptions` for cross-frame + origin visibility. Changing `exposedTo` re-registers the tool. +- **`toolchange` event.** `document.modelContext` is an `EventTarget` that fires a bare + `toolchange` event (no `detail`) on tool register/unregister. `addEventListener("toolchange", + ...)` and an `ontoolchange` handler are both supported. + +### Retained library extensions + +- **`outputSchema`** (Zod `output` / JSON-Schema `outputSchema`) is kept as a documented library + extension, not part of the spec descriptor. +- **`structuredContent`** on `CallToolResult` is retained. +- **Handlers always return a `CallToolResult`** with a `content` array (including error results + with `isError: true`) — a deliberate convention layered over the spec's looser return type so + results bridge cleanly to desktop MCP clients. + +### Notes + +- The **already-aborted-signal rejection** behavior is the library's current decision and is + **pending confirmation** against native Chrome 150+. The spec algorithm may instead skip + registration and resolve; whichever native does, the polyfill will match and the behavior is + documented here. + +## 0.1.0 + +- Initial release: ``, `useMcpTool`, and `useWebMCPStatus` with a + `navigator.modelContext` polyfill, Zod and JSON-Schema tool definitions, and a testing shim. diff --git a/README.md b/README.md index 3fdc700..3ee88b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # webmcp-react -React hooks for exposing typed tools on `navigator.modelContext`. +React hooks for exposing typed tools on `document.modelContext`, aligned with the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. [![npm version](https://img.shields.io/npm/v/webmcp-react)](https://www.npmjs.com/package/webmcp-react) [![license](https://img.shields.io/npm/l/webmcp-react)](./LICENSE) @@ -56,7 +56,7 @@ export default function App() { } ``` -That's it. The tool is registered on `navigator.modelContext` and can be called by WebMCP-compatible agents. +That's it. The tool is registered on `document.modelContext` and can be called by WebMCP-compatible agents. ### Using an AI agent? @@ -70,7 +70,7 @@ Works with Cursor, Claude Code, GitHub Copilot, Cline, and [18+ other agents](ht ## How it works -[WebMCP](https://github.com/webmachinelearning/webmcp) is an emerging web standard that adds `navigator.modelContext` to the browser, an API that lets any page expose typed, callable tools to AI agents. Native browser support is still experimental and may evolve quickly. Chrome recently [released it in Early Preview](https://developer.chrome.com/blog/webmcp-epp). +[WebMCP](https://github.com/webmachinelearning/webmcp) is an emerging web standard that adds `document.modelContext` to the browser, an API that lets any page expose typed, callable tools to AI agents. Native browser support is still experimental and may evolve quickly. Chrome recently [released it in Early Preview](https://developer.chrome.com/blog/webmcp-epp). This library provides React bindings for that API. `` installs a polyfill (skipped when native support exists), and each `useMcpTool` call registers a tool that agents can discover and execute. @@ -78,7 +78,7 @@ This library provides React bindings for that API. `` installs a ## Connect to AI clients -Desktop MCP clients like Claude Code and Cursor can't access `navigator.modelContext` directly. The [WebMCP Bridge extension](https://chromewebstore.google.com/detail/webmcp-bridge/chgjbookknohehmaocfijekhaocaanaf) connects your registered tools to any MCP client. +Desktop MCP clients like Claude Code and Cursor can't access `document.modelContext` directly. The [WebMCP Bridge extension](https://chromewebstore.google.com/detail/webmcp-bridge/chgjbookknohehmaocfijekhaocaanaf) connects your registered tools to any MCP client. 1. Install the extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/webmcp-bridge/chgjbookknohehmaocfijekhaocaanaf) 2. Configure your MCP client — see the [extension setup guide](./extension/README.md) for details @@ -115,20 +115,20 @@ function TranslateTool() { } ``` -### Tool annotations +### Title and annotations -Hint AI agents about tool behavior with annotations (supports the [full MCP annotation set](https://modelcontextprotocol.io/docs/concepts/tools#annotations)): +Give a tool a human-friendly display `title`, and hint AI agents about its behavior with `annotations`. Per the current WebMCP spec, annotations are limited to `readOnlyHint` and `untrustedContentHint`: ```tsx useMcpTool({ - name: "delete_user", - description: "Permanently delete a user account", - input: z.object({ userId: z.string() }), + name: "search_users", + title: "Search users", + description: "Find users by name or email", + input: z.object({ query: z.string() }), annotations: { - destructiveHint: true, - idempotentHint: true, + readOnlyHint: true, }, - handler: async ({ userId }) => { /* ... */ }, + handler: async ({ query }) => { /* ... */ }, }); ``` @@ -191,6 +191,20 @@ useMcpTool({ Works with Next.js, Remix, and any server-rendering framework out of the box. The build includes a `"use client"` banner, so no extra configuration is needed. +## Breaking changes in 0.2.0 + +0.2.0 realigns the library with the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. If you're upgrading from 0.1.0: + +- **API moved to `document.modelContext`.** Tools register on `document.modelContext` instead of `navigator.modelContext`. (The testing/consumer API stays on `navigator.modelContextTesting`.) +- **`registerTool` returns a `Promise`.** The polyfill's `registerTool(tool, options?)` returns a `Promise` that rejects on invalid input (bad/duplicate/empty name, empty description, non-function execute, non-serializable `inputSchema`, untrustworthy `exposedTo` origin, or an already-aborted signal). +- **Unregistration is AbortSignal-only.** There is no `unregisterTool` — pass `{ signal }` and abort it. `useMcpTool` does this for you on unmount. +- **Handlers take a single argument.** `handler(args)` / `execute(input)` no longer receive a second `client` argument; `ModelContextClient` and `requestUserInteraction` are removed. +- **`annotations` narrowed to `{ readOnlyHint, untrustedContentHint }`.** The classic hints (`destructiveHint`, `idempotentHint`, `openWorldHint`) and `annotations.title` are removed. A new top-level `title?` field replaces `annotations.title`. +- **New `exposedTo?: string[]`** controls cross-frame origin visibility (changing it re-registers the tool). +- **New `toolchange` event.** `document.modelContext` is an `EventTarget` that fires a bare `toolchange` event on tool register/unregister; an `ontoolchange` handler is also supported. + +`outputSchema` (Zod `output` / JSON-Schema `outputSchema`) and `structuredContent` on results are retained as documented library extensions. + ## API See the [full API reference](./docs/api.md). diff --git a/docs/api.md b/docs/api.md index d3f0301..d47b535 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,5 +1,7 @@ # API Reference +This library targets the current [WebMCP](https://github.com/webmachinelearning/webmcp) spec. The registration API lives on `document.modelContext` (an `EventTarget`), and the testing/consumer API lives on `navigator.modelContextTesting`. + ## `` Recommended root wrapper for apps using this library. @@ -10,9 +12,9 @@ Recommended root wrapper for apps using this library. | `version` | `string` | Your app's version | | `children` | `ReactNode` | React children | -On mount, the provider checks for native `navigator.modelContext`. If absent, it installs a minimal in-memory polyfill. It cleans up the polyfill when the last provider unmounts. +On mount, the provider checks for native `document.modelContext`. If absent, it installs a minimal in-memory polyfill. It cleans up the polyfill when the last provider unmounts (the polyfill is ref-counted across providers). -`useMcpTool` can still run outside the provider (with a warning), but registration depends on `navigator.modelContext` already being present. +`useMcpTool` can still run outside the provider (with a warning), but registration depends on `document.modelContext` already being present. ## `useWebMCPStatus()` @@ -24,31 +26,47 @@ const { available } = useWebMCPStatus(); | Field | Type | Description | | ----------- | --------- | ---------------------------------------------------------------------------- | -| `available` | `boolean` | `true` once `navigator.modelContext` is ready (always `false` on the server or outside provider) | +| `available` | `boolean` | `true` once `document.modelContext` is ready (always `false` on the server or outside a provider) | ## `useMcpTool(config)` -Registers a tool on `navigator.modelContext`. Automatically unregisters on unmount. +Registers a tool on `document.modelContext`. Automatically unregisters on unmount (via `AbortSignal` — there is no `unregisterTool`). ### Zod config | Field | Type | Description | | ------------- | ------------------------------- | -------------------------------------------------------------- | -| `name` | `string` | Tool name (must be unique) | -| `description` | `string` | Human-readable description | +| `name` | `string` | Tool name (must be unique, 1–128 chars, matching `^[A-Za-z0-9_.-]+$`) | +| `title` | `string` | Optional human-friendly display title | +| `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 | -| `handler` | `(args, client) => CallToolResult \| Promise` | Tool implementation | -| `annotations` | `ToolAnnotations` | Optional hints (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) | +| `output` | `z.ZodObject` | Optional Zod schema for outputs (library extension; see below) | +| `annotations` | `ToolAnnotations` | Optional behavior hints (`readOnlyHint`, `untrustedContentHint`) | +| `exposedTo` | `string[]` | Optional list of trustworthy origins this tool is exposed to across frames | +| `handler` | `(args) => CallToolResult \| Promise` | Tool implementation. Receives a single argument (the parsed input) | | `onSuccess` | `(result) => void` | Optional callback on success | | `onError` | `(error) => void` | Optional callback on error | -The `client` argument provides `requestUserInteraction(callback)` for prompting the user during tool execution. -When you call `execute()` directly, this simply invokes the callback. +The `handler` takes a **single argument** — the validated input object. There is no second `client` argument. ### JSON Schema config -Same as above, but replace `input` with `inputSchema: InputSchema` and `output` with `outputSchema: OutputSchema`. The handler receives `Record` instead of typed args. +Same as above, but replace `input` with `inputSchema: InputSchema` and (optionally) `output` with `outputSchema: InputSchema`. The handler receives `Record` instead of typed args. + +### `ToolAnnotations` + +```ts +interface ToolAnnotations { + readOnlyHint?: boolean; + untrustedContentHint?: boolean; +} +``` + +These are the only annotation fields supported. The classic MCP hints (`destructiveHint`, `idempotentHint`, `openWorldHint`) and `annotations.title` are **not** part of the current WebMCP spec — use the top-level `title` field for a display title. + +### `exposedTo` + +`exposedTo?: string[]` lets you control cross-frame origin visibility. Each entry must be a parseable, potentially-trustworthy origin (e.g. `https://example.com`). Changing `exposedTo` re-registers the tool. Invalid origins cause registration to reject (see below). ### Return value @@ -56,15 +74,60 @@ Same as above, but replace `input` with `inputSchema: InputSchema` and `output` const { state, execute, reset } = useMcpTool({ ... }); ``` -| Field | Type | Description | -| -------------------- | --------------------------------- | ---------------------------------- | -| `state.isExecuting` | `boolean` | `true` while the handler is running | -| `state.lastResult` | `CallToolResult \| null` | Most recent result | -| `state.error` | `Error \| null` | Most recent error | -| `state.executionCount` | `number` | Total successful executions | -| `execute(input?)` | `(input?) => Promise` | Manually invoke the tool | -| `reset()` | `() => void` | Reset state to initial values | +| Field | Type | Description | +| ---------------------- | ------------------------------------- | ---------------------------------- | +| `state.isExecuting` | `boolean` | `true` while the handler is running | +| `state.lastResult` | `CallToolResult \| null` | Most recent result | +| `state.error` | `Error \| null` | Most recent error | +| `state.executionCount` | `number` | Total successful executions | +| `execute(input?)` | `(input?) => Promise` | Manually invoke the tool | +| `reset()` | `() => void` | Reset state to initial values | + +`execute()` (the UI/direct path) throws if validation or handler logic fails. The agent/testing-shim path returns a `CallToolResult` with `isError: true` instead. Both paths update the same reactive state and fire the same `onSuccess`/`onError` callbacks. + +## Results: `CallToolResult` + +Handlers always return a `CallToolResult` with a `content` array — including error results, which set `isError: true`. This is a deliberate library convention layered over the spec's looser return type, so results bridge cleanly to desktop MCP clients. + +```ts +interface CallToolResult { + content: ContentBlock[]; + structuredContent?: Record; + isError?: boolean; +} +``` + +`structuredContent` is a library extension for returning structured (machine-readable) output alongside the human-readable `content` blocks. + +## Polyfill behavior + +When native WebMCP is unavailable, the provider installs a polyfill that exposes: + +- `document.modelContext` — the registration API (an `EventTarget`). `registerTool(tool, options?)` returns a `Promise` that **rejects** on invalid input (see below). Unregistration is **AbortSignal-only** — pass `{ signal }` and abort it to remove the tool. There is no `unregisterTool`. +- `navigator.modelContextTesting` — the consumer/testing API (`listTools()`, `executeTool(name, argsJson, options?)`, `registerToolsChangedCallback(cb)`, `getCrossDocumentScriptToolResult()`). Browser extensions and tests use this to discover and invoke tools. + +The native API is detected by reading `document.modelContext` only; the polyfill marks itself with `__isWebMCPPolyfill` so native support short-circuits installation. + +### `toolchange` event + +`document.modelContext` is an `EventTarget` that fires a bare `toolchange` event (no `detail`) whenever the set of registered tools changes (register or unregister). Notifications are microtask-batched. Both styles are supported: + +```ts +document.modelContext.addEventListener("toolchange", () => { /* ... */ }); +// or +document.modelContext.ontoolchange = () => { /* ... */ }; +``` + +### `registerTool` rejection cases + +`registerTool` rejects (with a `DOMException` or `TypeError`) when given: -`execute()` throws if validation or handler logic fails. +- an empty/missing name, a name longer than 128 chars, or a name not matching `^[A-Za-z0-9_.-]+$`; +- a duplicate name already registered; +- an empty/missing description; +- an `execute` that is not a function; +- a non-serializable `inputSchema`; +- an `exposedTo` entry that is not a parseable, potentially-trustworthy origin; +- an already-aborted signal (rejects with the signal's abort reason). _Note: this last behavior is the library's current decision and is pending confirmation against native Chrome 150+._ -The polyfill installs both `navigator.modelContext` (registration API) and `navigator.modelContextTesting` (consumer API with `executeTool()`, `listTools()`). Browser extensions and tests use `modelContextTesting` to discover and invoke tools. +The hook routes these rejections into `state.error` and fires `onError`, except `AbortError` (lifecycle teardown), which is ignored. diff --git a/package.json b/package.json index a3f9c28..b7d966b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webmcp-react", - "version": "0.1.0", + "version": "0.2.0", "description": "React hooks for exposing your app's functionality as WebMCP tools - transport-agnostic, SSR-safe, Strict Mode safe, W3C spec-aligned", "type": "module", "main": "./dist/index.cjs", From 0997c420d67cbd2886c78ad81860b966e84ab46e Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 20:25:16 +0200 Subject: [PATCH 4/7] test(harness): self-test reports native vs polyfill + probes already-aborted signal --- examples/native-harness/src/App.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/examples/native-harness/src/App.tsx b/examples/native-harness/src/App.tsx index 5484579..0297c60 100644 --- a/examples/native-harness/src/App.tsx +++ b/examples/native-harness/src/App.tsx @@ -50,6 +50,15 @@ function AddTool() { } async function runSelfTest(log: (line: string) => void) { + // Report which implementation backs document.modelContext. + const mc = document.modelContext; + if (!mc) { + log("FAIL: document.modelContext missing"); + return; + } + const isPolyfill = "__isWebMCPPolyfill" in mc; + log(isPolyfill ? "INFO: backend = polyfill" : "PASS: backend = native"); + const t = navigator.modelContextTesting; if (!t) { log("FAIL: navigator.modelContextTesting missing"); @@ -75,6 +84,23 @@ async function runSelfTest(log: (line: string) => void) { ? "PASS: execute add" : `FAIL: add ${addRaw}`, ); + + // Open spec question: does registerTool with an already-aborted signal + // reject (our current assumption) or resolve? Probe the live backend. + try { + const result = await mc.registerTool( + { + name: "abort_probe", + description: "Probe already-aborted signal behavior.", + execute: () => ({ content: [{ type: "text", text: "x" }] }), + }, + { signal: AbortSignal.abort(new DOMException("probe", "AbortError")) }, + ); + log(`INFO: already-aborted register RESOLVED (value=${String(result)})`); + } catch (err) { + const name = err instanceof Error ? err.name : String(err); + log(`INFO: already-aborted register REJECTED (${name})`); + } } /** From 60d88e91ff15a25432550ead9a14a501447cb7a9 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 20:34:03 +0200 Subject: [PATCH 5/7] fix: tolerate native registerTool that returns void and throws synchronously (Chrome 151) --- src/hooks/__tests__/useMcpTool.test.tsx | 110 ++++++++++++++++++++++++ src/hooks/useMcpTool.ts | 22 +++-- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/src/hooks/__tests__/useMcpTool.test.tsx b/src/hooks/__tests__/useMcpTool.test.tsx index 0086725..f93a092 100644 --- a/src/hooks/__tests__/useMcpTool.test.tsx +++ b/src/hooks/__tests__/useMcpTool.test.tsx @@ -1386,3 +1386,113 @@ describe("signal-only native API (Chrome 148+)", () => { expect(registered.filter((t) => t.name === "greet").length).toBe(1); }); }); + +describe("native registerTool compatibility (void return / sync throw)", () => { + function installNative(registerTool: () => unknown) { + const native = { registerTool }; + Object.defineProperty(document, "modelContext", { + value: native, + configurable: true, + enumerable: true, + writable: true, + }); + } + + function deleteModelContext() { + const desc = Object.getOwnPropertyDescriptor(document, "modelContext"); + if (desc) { + Object.defineProperty(document, "modelContext", { + value: undefined, + configurable: true, + writable: true, + }); + delete document.modelContext; + } + } + + afterEach(() => { + deleteModelContext(); + }); + + it("does not crash when native registerTool returns void (undefined)", async () => { + installNative(() => undefined); + + const onState = vi.fn<(state: ReturnType["state"]) => void>(); + + expect(() => { + renderWithProvider( + ({ content: [{ type: "text", text: "ok" }] }), + }} + onState={onState} + />, + ); + }).not.toThrow(); + + await act(async () => {}); + + const last = onState.mock.calls.at(-1)?.[0]; + expect(last?.error).toBeNull(); + }); + + it("routes a synchronous throw from native registerTool into state.error and onError", async () => { + installNative(() => { + throw new DOMException("Duplicate tool name", "InvalidStateError"); + }); + + const onError = vi.fn(); + const onState = vi.fn<(state: ReturnType["state"]) => void>(); + + renderWithProvider( + ({ content: [{ type: "text", text: "ok" }] }), + onError, + }} + onState={onState} + />, + ); + + await act(async () => {}); + + expect(onError).toHaveBeenCalled(); + const passed = onError.mock.calls[0]?.[0] as Error; + expect(passed.name).toBe("InvalidStateError"); + + const last = onState.mock.calls.at(-1)?.[0]; + expect(last?.error).not.toBeNull(); + expect(last?.error?.name).toBe("InvalidStateError"); + }); + + it("ignores a synchronous AbortError throw from native registerTool", async () => { + installNative(() => { + throw new DOMException("aborted", "AbortError"); + }); + + const onError = vi.fn(); + const onState = vi.fn<(state: ReturnType["state"]) => void>(); + + renderWithProvider( + ({ content: [{ type: "text", text: "ok" }] }), + onError, + }} + onState={onState} + />, + ); + + await act(async () => {}); + + expect(onError).not.toHaveBeenCalled(); + const last = onState.mock.calls.at(-1)?.[0]; + expect(last?.error).toBeNull(); + }); +}); diff --git a/src/hooks/useMcpTool.ts b/src/hooks/useMcpTool.ts index d875fba..55db0cb 100644 --- a/src/hooks/useMcpTool.ts +++ b/src/hooks/useMcpTool.ts @@ -244,10 +244,7 @@ export function useMcpTool( TOOL_OWNER_BY_NAME.set(cfg.name, ownerToken); } - mc.registerTool(descriptor, { - signal: controller.signal, - ...(cfg.exposedTo && { exposedTo: cfg.exposedTo }), - }).catch((thrown: unknown) => { + const handleRegistrationError = (thrown: unknown) => { const error = normalizeError(thrown); if (error.name === "AbortError") return; // lifecycle teardown — not a user error if (TOOL_OWNER_BY_NAME.get(cfg.name) === ownerToken) { @@ -257,7 +254,22 @@ export function useMcpTool( setState((prev) => ({ ...prev, error })); } onErrorRef.current?.(error); - }); + }; + + try { + // Native Chrome (<=151) returns undefined and throws synchronously on error; + // the spec (#200) and our polyfill return a Promise that rejects. + // Handle both shapes. + const result: unknown = mc.registerTool(descriptor, { + signal: controller.signal, + ...(cfg.exposedTo && { exposedTo: cfg.exposedTo }), + }); + if (result && typeof (result as { then?: unknown }).then === "function") { + (result as Promise).catch(handleRegistrationError); + } + } catch (thrown) { + handleRegistrationError(thrown); + } return () => { if (TOOL_OWNER_BY_NAME.get(cfg.name) !== ownerToken) { From b258f13bbc1230a5938ea07e4c9469377bcd54ab Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 20:39:59 +0200 Subject: [PATCH 6/7] fix: registerTool resolves (no-op) on already-aborted signal, matching native Chrome 151 --- CHANGELOG.md | 15 +++++++++------ docs/api.md | 5 +++-- src/polyfill/__tests__/registry.test.ts | 4 ++-- src/polyfill/registry.ts | 4 +++- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653d312..e46a89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,11 @@ changes. - an empty/missing description; - an `execute` that is not a function; - a non-serializable `inputSchema`; - - an `exposedTo` entry that is not a parseable, potentially-trustworthy origin; - - an already-aborted signal (rejects with the signal's abort reason). + - an `exposedTo` entry that is not a parseable, potentially-trustworthy origin. + + Already-aborted `AbortSignal` passed to `registerTool` resolves as a no-op (registration is + skipped), matching native Chrome 151. (Note: WebMCP spec PR #202 specifies rejection; native had + not shipped that as of Chrome 151. Revisit if native changes.) `useMcpTool` routes these rejections into `state.error` and `onError`, except `AbortError` (lifecycle teardown), which is ignored. @@ -57,10 +60,10 @@ changes. ### Notes -- The **already-aborted-signal rejection** behavior is the library's current decision and is - **pending confirmation** against native Chrome 150+. The spec algorithm may instead skip - registration and resolve; whichever native does, the polyfill will match and the behavior is - documented here. +- The **already-aborted-signal** behavior was confirmed against native Chrome 151: native skips + registration and resolves with `undefined` (a no-op) rather than rejecting, so the polyfill + matches. (WebMCP spec PR #202 specifies rejection; native had not shipped that as of Chrome 151. + Revisit if native changes.) ## 0.1.0 diff --git a/docs/api.md b/docs/api.md index d47b535..719bbef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -127,7 +127,8 @@ document.modelContext.ontoolchange = () => { /* ... */ }; - an empty/missing description; - an `execute` that is not a function; - a non-serializable `inputSchema`; -- an `exposedTo` entry that is not a parseable, potentially-trustworthy origin; -- an already-aborted signal (rejects with the signal's abort reason). _Note: this last behavior is the library's current decision and is pending confirmation against native Chrome 150+._ +- an `exposedTo` entry that is not a parseable, potentially-trustworthy origin. + +An already-aborted `AbortSignal` does **not** reject: `registerTool` resolves as a no-op (registration is skipped), matching native Chrome 151. _(WebMCP spec PR #202 specifies rejection; native had not shipped that as of Chrome 151. Revisit if native changes.)_ The hook routes these rejections into `state.error` and fires `onError`, except `AbortError` (lifecycle teardown), which is ignored. diff --git a/src/polyfill/__tests__/registry.test.ts b/src/polyfill/__tests__/registry.test.ts index 72e0564..d11a37b 100644 --- a/src/polyfill/__tests__/registry.test.ts +++ b/src/polyfill/__tests__/registry.test.ts @@ -91,11 +91,11 @@ describe("createRegistry", () => { ).rejects.toMatchObject({ name: "SecurityError" }); }); - it("rejects when the signal is already aborted", async () => { + it("resolves as a no-op when the signal is already aborted (matches native Chrome 151)", async () => { const registry = createRegistry(); await expect( registry.registerTool(makeTool(), { signal: AbortSignal.abort() }), - ).rejects.toMatchObject({ name: "AbortError" }); + ).resolves.toBeUndefined(); expect(registry.getTools().has("test_tool")).toBe(false); }); diff --git a/src/polyfill/registry.ts b/src/polyfill/registry.ts index df43d43..f91a9f1 100644 --- a/src/polyfill/registry.ts +++ b/src/polyfill/registry.ts @@ -64,7 +64,9 @@ export function createRegistry(): RegistryInternal { } } if (options?.signal?.aborted) { - return Promise.reject(options.signal.reason); + // Native Chrome (verified 151) resolves without registering when handed an + // already-aborted signal; match that rather than rejecting. + return Promise.resolve(undefined); } tools.set(tool.name, { From 91e9e125dfd5658a760941142004f1396f99f577 Mon Sep 17 00:00:00 2001 From: Kashish Hora Date: Tue, 23 Jun 2026 20:47:44 +0200 Subject: [PATCH 7/7] chore: migrate playground, nextjs example, and skills to 0.2.0 API --- .../playground/src/tools/GameStatusTool.tsx | 2 +- .../playground/src/tools/StartGameTool.tsx | 1 - skills/webmcp-add-tool/SKILL.md | 28 +++++++++---------- skills/webmcp-setup/SKILL.md | 4 +-- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/examples/playground/src/tools/GameStatusTool.tsx b/examples/playground/src/tools/GameStatusTool.tsx index aa84546..10ae925 100644 --- a/examples/playground/src/tools/GameStatusTool.tsx +++ b/examples/playground/src/tools/GameStatusTool.tsx @@ -12,7 +12,7 @@ export function GameStatusTool({ gameState }: Props) { name: "get_game_status", description: "Get the current status of the Wordle game, including guesses made, remaining attempts, and known letter positions.", input: z.object({}), - annotations: { readOnlyHint: true, idempotentHint: true }, + annotations: { readOnlyHint: true }, handler: async () => { const guessCount = gameState.guesses.length; const remaining = MAX_GUESSES - guessCount; diff --git a/examples/playground/src/tools/StartGameTool.tsx b/examples/playground/src/tools/StartGameTool.tsx index eaeed72..b0e363c 100644 --- a/examples/playground/src/tools/StartGameTool.tsx +++ b/examples/playground/src/tools/StartGameTool.tsx @@ -13,7 +13,6 @@ export function StartGameTool({ onStart }: Props) { input: z.object({ difficulty: z.enum(["normal", "hard"]).describe("Normal: any valid word is accepted each turn. Hard: confirmed letters (green in same position, yellow somewhere) must appear in all future guesses."), }), - annotations: { idempotentHint: true }, handler: async ({ difficulty }) => { onStart(difficulty); return { diff --git a/skills/webmcp-add-tool/SKILL.md b/skills/webmcp-add-tool/SKILL.md index 0a81999..52adcdb 100644 --- a/skills/webmcp-add-tool/SKILL.md +++ b/skills/webmcp-add-tool/SKILL.md @@ -85,32 +85,32 @@ export function MyTool() { } ``` -## Annotations +## Title and annotations -Set annotations to hint AI agents about tool behavior. Only include annotations that apply: +Use the top-level `title` for a human-friendly display name, and `annotations` to hint AI agents about tool behavior. Only include annotations that apply: ```tsx useMcpTool({ + name: "my_tool", + title: "Human-friendly title", // Optional display name for the tool // ... annotations: { - title: "Human-friendly title", // Display name for the tool - readOnlyHint: true, // Tool only reads data, no side effects - destructiveHint: true, // Tool deletes or irreversibly modifies data - idempotentHint: true, // Safe to call multiple times with same input - openWorldHint: true, // Tool interacts with external systems + readOnlyHint: true, // Tool only reads data, no side effects + untrustedContentHint: true, // Tool may return content from untrusted sources }, }); ``` -**Guideline for choosing annotations:** +`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. -| Tool behavior | Annotations to set | +**Guideline for choosing fields:** + +| Tool behavior | What to set | |---|---| -| Fetches/queries data | `readOnlyHint: true` | -| Creates a record | `idempotentHint: false` (or omit, false is default) | -| Deletes or overwrites data | `destructiveHint: true` | -| Calls an external API | `openWorldHint: true` | -| Can be retried safely | `idempotentHint: true` | +| Needs a display name | top-level `title: "..."` | +| 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) | ## Return format diff --git a/skills/webmcp-setup/SKILL.md b/skills/webmcp-setup/SKILL.md index 21838ff..a8d39df 100644 --- a/skills/webmcp-setup/SKILL.md +++ b/skills/webmcp-setup/SKILL.md @@ -7,7 +7,7 @@ description: Bootstraps webmcp-react into an existing React or Next.js app. Inst ## Overview -`webmcp-react` exposes React app functionality as typed tools on `navigator.modelContext` (the W3C WebMCP API). AI agents discover and call these tools. This skill bootstraps the full setup. +`webmcp-react` exposes React app functionality as typed tools on `document.modelContext` (the W3C WebMCP API). AI agents discover and call these tools. This skill bootstraps the full setup. ## Step 1: Install dependencies @@ -110,7 +110,7 @@ Render it inside the provider: ## Step 4: Connect to AI clients -Desktop MCP clients (Cursor, Claude Code) cannot access `navigator.modelContext` directly. A Chrome extension + local MCP server bridges the gap. +Desktop MCP clients (Cursor, Claude Code) cannot access `document.modelContext` directly. A Chrome extension + local MCP server bridges the gap. ### 4a. Install the Chrome extension