@web-ai-sdk/webmcp
This package wraps the W3C WebMCP API at document.modelContext. It also reads the previous navigator.modelContext shape for compatibility.
The package provides typed registration, cleanup, discovery, and execution. It has no framework dependency.
Status
Section titled “Status”Chrome provides a public origin trial from Chrome 149. For local development, enable chrome://flags/#enable-webmcp-testing. Microsoft Learn Edge 155 release notes list WebMCP under Origin trials. The Edge 153 and Edge 154 notes list the same trial. The listing does not establish that Edge matches each Chrome build detail in this document.
Without WebMCP, registration is a no-op and discovery returns an empty list. Execution throws WebMCPUnavailableError.
Current registerTool implementations can be synchronous or asynchronous. The package supports both forms.
Browser API providers and agent consumers
Section titled “Browser API providers and agent consumers”Browser API support and agent-consumer support are separate compatibility dimensions. Chrome and Edge document browser API trials for document.modelContext. Those trials do not establish behavior in another browser product.
ChatGPT Desktop documents Site tools as an implemented WebMCP consumer in its built-in browser. This is not Chrome browser support.
Site-tool availability depends on account access, the selected model, the current page, and user permission. Tools do not carry to another page. Closing the page makes its tools unavailable. Users can turn Site tools off in the desktop app.
isAvailable() detects whether the current document exposes document.modelContext or the legacy navigator.modelContext. It cannot determine whether an external agent will discover or invoke a registered tool.
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", title: "List blog posts", description: "List published blog posts.", readOnly: true, annotations: { untrustedContentHint: true }, execute: async (_input, options) => { const res = await fetch("/api/posts.json", { signal: options?.signal, }); 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, options) => { const res = await fetch("/api/send-email", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), signal: options?.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { ok: true }; }, },];
const cleanups = tools.map(registerTool);const cleanup = () => cleanups.forEach((c) => c());
// Remove the tools when the page state they act on no longer exists.cleanup();registerTool(tool, options?) registers a single tool and returns the cleanup. Re-registering a tool with the same name is safe; the previous registration is dropped first.
Register tools only while their page state exists. Call the cleanup when that state changes or the page tears down. A native registration or successful feature check does not prove external-agent discovery or invocation.
The browser passes execution options as an optional second callback argument. Forward options?.signal to cancellable work. Older trial browsers can omit the options.
Registration cleanup does not cancel an execution that already started. Application code decides how to handle the browser’s execution signal.
import { useWebMCP } from "@web-ai-sdk/webmcp/react";import { z } from "zod"; // Zod 4; other Standard Schema libraries work too
const SearchPostsInput = z.object({ query: z.string().min(1) });
export function WebMCP({ isSignedIn }: { isSignedIn: boolean }) { const { tools, status } = useWebMCP( { name: "search_blog_posts", description: "Search published blog posts.", readOnly: true, input: SearchPostsInput, inputSchema: z.toJSONSchema(SearchPostsInput, { io: "input", target: "draft-2020-12", }), execute: async ({ query }, options) => { const res = await fetch( `/api/posts.json?q=${encodeURIComponent(query)}`, { signal: options?.signal }, ); return { results: await res.json() }; }, }, { enabled: isSignedIn }, );
return <p>{status === "ready" ? `${tools.length} tools` : status}</p>;}useWebMCP accepts one tool or a readonly array. It registers on mount and unregisters on unmount. Setting enabled to false also removes the registration. Define a tool only while its page state exists.
Registration follows tool metadata and exposedTo values, not object identity. execute always uses the latest callback. Inline objects and arrays are safe. Metadata changes rebuild the registration. Memoize large schemas and tool arrays to reduce comparison work.
The hook also retrieves tools exposed to the current document. It refreshes after native toolchange events. Call refresh() for an explicit read.
For retrieval only, call useWebMCP({ fromOrigins }) without tool definitions. Inline fromOrigins arrays are safe; discovery restarts only when their values change.
registerTool(tool, options?): () => void
Section titled “registerTool(tool, options?): () => void”Register a single tool. Returns a cleanup function. No-op on unsupported browsers.
Native registration is asynchronous. The returned cleanup is synchronous and can abort registration before it finishes.
To register many at once, map and combine:
const cleanups = tools.map(registerTool);const cleanup = () => cleanups.forEach((c) => c());Use exposedTo to let descendant documents at specific origins discover the tool:
const cleanup = registerTool(tool, { exposedTo: ["https://agent.example"],});The SDK forwards the array with its own AbortSignal. The browser validates each origin. The wrapper logs validation failures without throwing.
The owning document does not need exposure. List only origins that need access.
getTools(options?): Promise<RegisteredTool[]>
Section titled “getTools(options?): Promise<RegisteredTool[]>”Discover tools exposed to the current document. The SDK preserves browser ordering, each tool, its registering window and origin, and its annotations.
The current WebMCP draft defines inputSchema as an object. Chrome switched discovery to objects in 155.0.8051.0. Earlier supported trial builds may return serialized strings.
inputSchema is optional and typed as object | string. The SDK preserves native values without parsing, stringifying, normalizing, or cloning them. Omitted schemas stay omitted. Malformed legacy strings remain observable.
import { getTools } from "@web-ai-sdk/webmcp";
const sameOriginTools = await getTools();const toolsAcrossFrames = await getTools({ fromOrigins: ["https://agent.example"],});
for (const tool of sameOriginTools) { const schema = tool.inputSchema; if (typeof schema === "string") { console.log("Legacy schema string", schema); } else if (schema !== undefined) { console.log("Schema object", schema); }}Use this type guard when supporting mixed trial generations. useWebMCP() exposes the same RegisteredTool type through tools and refresh().
The browser includes eligible same-origin tools. fromOrigins also requests tools from listed secure origins. Those tools must expose themselves to the caller’s origin.
Unsupported browsers return []. Native permission, origin, and document-state errors reject the promise.
executeTool(tool, input?, options?): Promise<string | null>
Section titled “executeTool(tool, input?, options?): Promise<string | null>”Execute a RegisteredTool returned by getTools(). Pass an object or array. Omitted or undefined input defaults to {}.
import { executeTool, getTools } from "@web-ai-sdk/webmcp";
const tools = await getTools();const echo = tools.find((tool) => tool.name === "echo_message");if (echo) { const result = await executeTool(echo, { message: "hello" }); console.log(result);}The native serialized string is returned unchanged. null means tool execution triggered a navigation. Pass { signal } to cancel an in-flight call. Unsupported browsers reject with WebMCPUnavailableError.
The SDK rejects null and primitives with TypeError. Circular data, BigInt values, and toJSON() failures reject during serialization. Values that serialize to undefined also reject.
Chrome switched execution to object input in 155.0.8052.0. Chrome docs deprecate string arguments from Chrome 155. Earlier supported trial hosts still require serialized strings.
The SDK reads native executeTool.length before invocation: 1 selects the original object, and 2 selects serialized JSON. These arities were verified in Chrome 153.0.8010.37 and Canary 155.0.8053.0. Other arities reject with WebMCPUnavailableError without invoking a tool.
Serialization runs once: inside the browser for object input, or inside the SDK for legacy string input. Native errors remain observable. The SDK never retries execution or detects browser versions.
Migration: replace primitive input with an object that matches the tool schema, such as { message: "hello" }. Remove caller-side JSON.stringify(). The signature is executeTool(tool: RegisteredTool, input?: object, options?: ExecuteToolOptions): Promise<string | null>.
WebMCP remains a Trial capability. See Browser support for the compatibility range.
subscribeToToolChanges(listener): () => void
Section titled “subscribeToToolChanges(listener): () => void”Listen for native toolchange events and return an idempotent cleanup function:
const unsubscribe = subscribeToToolChanges(() => { void getTools().then(renderTools);});On unsupported browsers, subscription and cleanup are no-ops.
RegisteredTool
Section titled “RegisteredTool”interface RegisteredTool { name: string; title?: string; description: string; inputSchema?: object | string; window: Window; origin: string; annotations?: ToolAnnotations;}isAvailable(): boolean
Section titled “isAvailable(): boolean”Feature-detect helper.
Tool<TInput, TOutput>
Section titled “Tool<TInput, TOutput>”interface ToolExecuteCallbackOptions { signal: AbortSignal;}
interface Tool<TInput = unknown, TOutput = unknown> { name: string; title?: string; // human-readable host UI label 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, options?: ToolExecuteCallbackOptions, ) => Promise<TOutput> | TOutput;}Plain Tool objects remain supported. Use ToolDefinition when declaring a
schema-aware tool separately from its registration call.
interface ToolDefinition< InputSchema extends StandardSchemaV1 | undefined = undefined, TOutput = unknown, OutputSchema extends StandardSchemaV1 | undefined = undefined,> { name: string; title?: string; description: string; input?: InputSchema; output?: OutputSchema; inputSchema?: object; readOnly?: boolean; destructive?: boolean; annotations?: ToolAnnotations; execute: ( input: InputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<InputSchema> : unknown, options?: ToolExecuteCallbackOptions, ) => | Promise< OutputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<OutputSchema> : TOutput > | (OutputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<OutputSchema> : TOutput);}title is for human-facing host UI. description is consumed by the agent host; write it as an instruction to an LLM about when to call the tool.
The current WebMCP draft defines readOnlyHint, untrustedContentHint, consequentialHint, and debugging. The SDK also retains destructiveHint, idempotentHint, openWorldHint, and the destructive shorthand as source-compatible passthroughs for MCP-shaped and earlier WebMCP hosts; current-draft browsers may ignore those compatibility fields.
interface ToolAnnotations { readOnlyHint?: boolean; untrustedContentHint?: boolean; consequentialHint?: boolean; debugging?: boolean; destructiveHint?: boolean; // compatibility idempotentHint?: boolean; // compatibility openWorldHint?: boolean; // compatibility}Standard Schema definitions
Section titled “Standard Schema definitions”import { registerTool } from "@web-ai-sdk/webmcp";import { z } from "zod";
const SendContactEmailInput = z.object({ name: z.string().min(1), email: z.string().email(), subject: z.string().min(1), message: z.string().min(1),});
const cleanup = registerTool({ name: "send_contact_email", title: "Send contact email", description: "Send a contact email on behalf of the visitor.", destructive: true, // Standard Schema validates input before application code runs. execute // receives the schema's parsed or transformed output type. input: SendContactEmailInput, // Output schemas validate every resolved result and may transform it. output: z.object({ ok: z.literal(true) }), // Derive the browser-facing JSON Schema from the same source of truth. inputSchema: z.toJSONSchema(SendContactEmailInput, { io: "input", target: "draft-2020-12", }), async execute({ name, email, subject, message }, options) { // `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 }), signal: options?.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return { ok: true }; },});registerTool and useWebMCP accept any Standard Schema V1 validator (Zod 3.24+, Valibot, ArkType, Effect, …) directly, with no SDK dependency on a validation library. Heterogeneous readonly arrays are supported without casting them to Tool[].
Standard Schema validation and Standard JSON Schema conversion are orthogonal capabilities. The SDK keeps inputSchema explicit so consumers choose the converter and JSON Schema dialect. The example uses Zod 4’s converter; other libraries can provide a Standard JSON Schema converter or pass an explicit JSON Schema object.
Input validation: supplying input validates before application code runs and passes the schema’s parsed or transformed value to execute. Invalid input throws ToolValidationError; its toolName and issues fields identify the tool and preserve the Standard Schema issues.
Output validation: supplying output always validates the resolved sync or async execute result and returns the schema’s parsed value, including transformations. Invalid output throws ToolOutputValidationError; its toolName and issues fields identify the tool and preserve the Standard Schema issues. The output schema is SDK-only because WebMCP has no outputSchema field.
Output validation only checks the rules encoded in the schema. It does not prove that a result is fresh, trustworthy, or factually correct.
The SDK-only input and output fields are never forwarded to the native
WebMCP host. inputSchema, metadata, annotations, and registration options keep
their native forwarding behavior.
defineTool({...}): Tool — deprecated compatibility wrapper
Section titled “defineTool({...}): Tool — deprecated compatibility wrapper”defineTool() remains available for migration compatibility, including its
historical opt-in input validation through validate: true. New code should
pass schema-aware definitions directly to registerTool() or useWebMCP().
The wrapper will only be removed in a documented breaking release.
Permissions Policy
Section titled “Permissions Policy”The WebMCP draft gates its APIs behind the tools policy-controlled feature. Its default allowlist is self. Chrome documents that this default allows top-level and same-origin documents.
To disable WebMCP in a document that must not expose tools, send this response header with the document:
Permissions-Policy: tools=()The empty allowlist disables tools for the document and all descendant frames, including same-origin and cross-origin frames. The browser enforces the policy before page scripts run.
Under the draft, native registration, discovery, and execution reject with NotAllowedError in a disabled document. registerTool() logs the error. getTools() and executeTool() reject with it. isAvailable() checks only for the API and does not read the policy.
The draft’s security section presents this header as defense in depth. It blocks WebMCP use by injected scripts or compromised dependencies in that document. It does not:
- authorize a tool call or user;
- request user confirmation;
- make tool content trusted;
- prevent prompt injection in documents that keep WebMCP enabled.
The SDK does not set response headers. Keep the application controls in Safety.
Safety
Section titled “Safety”Set annotations.consequentialHint: true for tools that can cause significant real-world or difficult-to-reverse effects. A compatible host can use this hint when deciding whether to request confirmation.
The hint is not authorization and does not guarantee a confirmation prompt. Applications must enforce authentication, authorization, validation, origin controls, and rate limits. The SDK does not add confirmation behavior.
The SDK forwards explicit true and false values and leaves the property absent when omitted. Discovery preserves the metadata returned by the host. There is no shorthand, and the SDK does not infer this value from destructiveHint.
Set annotations.debugging: true for tools intended for debugging and developer tooling rather than end-user interactions. The field categorizes a tool. It does not grant permission or bypass application checks. Apply the same authentication, authorization, and validation as other tools.
The SDK forwards explicit true and false values and leaves the property absent when omitted. Discovery preserves the metadata returned by the host. There is no shorthand. The SDK does not filter tools by this field or infer other annotations from it.
Set annotations.untrustedContentHint: true when results contain external, user-generated, or otherwise untrusted content. It is a trust-boundary signal for the host, not validation of the result’s shape, truth, freshness, or safety.
The compatibility shorthand destructive: true communicates mutating intent to hosts that understand destructiveHint. Raw annotations values merge on top of shorthand values.
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