@web-ai-sdk/webmcp
web-ai-sdk building block for the W3C WebMCP API exposed at document.modelContext. (For backward compatibility with the previous shape of the API, this package also reads from navigator.modelContext.)
An ergonomic, framework-agnostic adapter over the native browser API, with safe register/unregister cleanup and a feature-detected no-op fallback for non-supporting browsers.
Status
Section titled “Status”WebMCP shipped as an early preview in Chrome 146+ behind chrome://flags/#enable-webmcp-testing; a public origin trial opens in Chrome 149. Edge added support in 147+ behind the matching edge://flags/ toggle. On any browser that doesn’t expose document.modelContext (or the legacy navigator.modelContext), this library is a no-op. Your app stays callable, and no tools get registered. A WebMCP spec update changed registerTool to return a Promise (cross-origin iframe tool sharing made registration asynchronous); this adapter normalizes both the legacy synchronous shape and the async shape, so consumer code is unchanged.
Install
Section titled “Install”pnpm add @web-ai-sdk/webmcp# or: npm i @web-ai-sdk/webmcp / bun add @web-ai-sdk/webmcpReact adapter is shipped as a subpath export, with no extra install. react is a peer dependency only when you import the /react entry.
Vanilla TypeScript / DOM
Section titled “Vanilla TypeScript / DOM”import { registerTool } from "@web-ai-sdk/webmcp";
const tools = [ { name: "list_blog_posts", description: "List published blog posts.", readOnly: true, execute: async () => { const res = await fetch("/api/posts.json"); return { results: await res.json() }; }, }, { name: "send_contact_email", description: "Send a contact email on behalf of the visitor. Confirm the body with the user before invoking.", destructive: true, inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1 }, email: { type: "string", format: "email" }, subject: { type: "string", minLength: 1 }, message: { type: "string", minLength: 1 }, }, required: ["name", "email", "subject", "message"], }, execute: async (input) => { const res = await fetch("/api/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { ok: true }; }, },];
const cleanups = tools.map(registerTool);const cleanup = () => cleanups.forEach((c) => c());
// later, e.g. on page teardowncleanup();registerTool(tool) registers a single tool and returns the cleanup. Re-registering a tool with the same name is safe; the previous registration is dropped first.
import { useWebMCP, type Tool } from "@web-ai-sdk/webmcp/react";import { useMemo } from "react";
const TOOLS: Tool[] = [ { name: "list_blog_posts", description: "List published blog posts.", readOnly: true, execute: async () => { const res = await fetch("/api/posts.json"); return { results: await res.json() }; }, },];
export function WebMCP() { // Stable reference: keep tools outside the component or wrap in useMemo, // otherwise the hook will unregister/re-register on every render. const tools = useMemo(() => TOOLS, []); useWebMCP(tools); return null;}The hook registers on mount, unregisters on unmount, and re-registers when the array reference changes.
registerTool(tool): () => void
Section titled “registerTool(tool): () => void”Register a single tool. Returns a cleanup function. No-op on unsupported browsers.
Registration is asynchronous under the hood (per the current spec); the returned cleanup is synchronous and safe to call before registration settles — it aborts the in-flight registration cleanly.
To register many at once, map and combine:
const cleanups = tools.map(registerTool);const cleanup = () => cleanups.forEach((c) => c());isAvailable(): boolean
Section titled “isAvailable(): boolean”Feature-detect helper.
Tool<TInput, TOutput>
Section titled “Tool<TInput, TOutput>”interface Tool<TInput = unknown, TOutput = unknown> { name: string; description: string; inputSchema?: object; // JSON Schema readOnly?: boolean; // shorthand for annotations.readOnlyHint destructive?: boolean; // shorthand for annotations.destructiveHint annotations?: ToolAnnotations; // raw passthrough, merged on top execute: (input: TInput) => Promise<TOutput> | TOutput;}description is consumed by the agent host (Cursor / Claude / Chrome agent / etc.). Write it as an instruction to an LLM about when to call the tool.
defineTool({...}): Tool — typed schema adapter (Standard Schema)
Section titled “defineTool({...}): Tool — typed schema adapter (Standard Schema)”import { defineTool } from "@web-ai-sdk/webmcp";import { z } from "zod"; // or valibot, arktype, effect, …
const sendEmail = defineTool({ name: "send_contact_email", description: "Send a contact email on behalf of the visitor.", destructive: true, // Standard Schema (https://standardschema.dev): used to narrow execute's // input type. Validation at runtime is opt-in via `validate: true`. input: z.object({ name: z.string().min(1), email: z.string().email(), subject: z.string().min(1), message: z.string().min(1), }), // The host still wants raw JSON Schema for tool dispatch; pass it explicitly. // Standard Schema does not emit JSON Schema, so we don't bridge between // them — keeping both lets you choose your validator without coupling. inputSchema: { type: "object", properties: { name: { type: "string", minLength: 1 }, email: { type: "string", format: "email" }, subject: { type: "string", minLength: 1 }, message: { type: "string", minLength: 1 }, }, required: ["name", "email", "subject", "message"], }, async execute({ name, email, subject, message }) { // `name`, `email`, etc. are typed from the Zod schema. const res = await fetch("/api/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, email, subject, message }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { ok: true }; },});
// `sendEmail` is a plain Tool and can be passed to registerTool or useWebMCP.defineTool accepts any Standard Schema V1 validator (Zod 3.24+, Valibot, ArkType, Effect, …) — no SDK dependency on any specific library. The returned object is a plain Tool, so it composes with the rest of the API unchanged.
Validation: off by default (validate: false). Most WebMCP hosts validate against inputSchema themselves; running the Standard Schema validator on top is opt-in via validate: true, which throws ToolValidationError on bad input. With validate: false the schema is type-only.
Safety
Section titled “Safety”Mark mutating tools destructive: true. The host (browser, agent) is responsible for gating destructive tools on explicit user approval; @web-ai-sdk/webmcp only forwards the annotation. For sensitive operations also defend server-side (origin allowlist, rate limit, validation).
Troubleshooting
Section titled “Troubleshooting”- Inspector / agent doesn’t see the tools. The WebMCP entry point is per-document; each frame, including iframes, has its own
document.modelContext. Tools registered inside an<iframe>are scoped to that frame and invisible to extensions hooked into the top page. Register from the top-level document, not from an embedded frame.
License
Section titled “License”MIT © Beto Muniz