WebMCP: let agents use your app
Register tools on your page with document.modelContext so an agent in the browser calls your own functions instead of clicking around, validate what it sends, mark consequential tools, and know who else can call them.
What you'll learn
When you finish, you can do these.- 1Tell who can call your page's tools, extensions included
- 2Register a tool, and remove it when it stops applying
- 3Mark tools with hints that tell an agent how careful to be
- 4Check what an agent sends, and return errors as text
Picture a to-do list page, and an AI agent, a program driven by an AI model, asked to add "Buy oat milk" to it. The agent works like a patient intern: it reads the screen, guesses which button does what, and clicks. Every step can go wrong. WebMCP, a proposed web standard, lets the page say what it can do instead: it registers tools, and the agent calls one by name.
You need one idea from Tools and structured output with the AI SDK: a tool is a function a model may ask your code to run, described by a name and the shape of its arguments. The Code tab holds the to-do list, its three tools, and, where WebMCP is on, the Agent stand-in, a panel that plays the agent so you can call them yourself.
What WebMCP is, and who can call your tools
WebMCP lives in the page, not on a server, and more than your own code can reach it. Its name and vocabulary come from MCP, the Model Context Protocol, which connects assistants to tools on servers. The page reaches it through document.modelContext, which exists only on secure pages: HTTPS or localhost.
Three kinds of caller can reach a tool: the browser's own agent, a JavaScript agent in the page or in a same-origin iframe (a page embedded in yours, from your scheme, host and port), and browser extensions. Any extension that can add a content script, a script of its own, to your page can list your tools and call them. Treat a tool like a button a stranger may press.
Register a tool
A tool is an object your page hands to the browser: a name, a description the model reads to decide when to use it, an inputSchema that describes the arguments in JSON Schema (a standard format for data shapes), optional annotations, and an execute function that does the work and returns text for the agent.
- Agentpicks a tool and arguments
- document.modelContextfinds the page's tool
- execute(args, { signal })your function runs
- UI updatesthe person sees it
Keep the real work in functions the buttons also use, so a tool can never do something the interface cannot. In todoTools.ts, the list lives in the ref todos, and the Add button and the add-todo tool both call addTodo(). useTool(), the starter's helper in useTool.ts, registers the tool:
useTool({
name: 'add-todo',
description: "Add an item to the user's to-do list",
inputSchema: z.toJSONSchema(AddInput),
async execute(args) {
const input = AddInput.safeParse(args)
if (!input.success) return `Not added. ${z.prettifyError(input.error)}`
addTodo(input.data.text)
return `Added "${input.data.text}"`
},
})
AddInput is a schema written with Zod, a library that checks data: it asks for text, a string of 1 to 80 characters. z.toJSONSchema() turns it into the JSON Schema the agent reads, and safeParse() checks what arrives.
In the Agent stand-in, the Run as the agent button calls this tool: a "Buy oat milk" row appears, and the panel shows The agent got back: Added "Buy oat milk".
In useTool.ts, modelContext holds document.modelContext, and its registerTool() adds the tool. registerTool() is asynchronous: its promise rejects on a problem, such as InvalidStateError: Duplicate tool name when two components register the same name. Its second argument takes a signal from an AbortController, the browser's object for cancelling work, and aborting that signal unregisters the tool.
useTool(tool, enabled) is a composable, a function that packs reactive logic for components to reuse. Its sync(on) aborts the old registration and registers again when on is true and the browser has WebMCP. Vue's watch calls it at once and whenever enabled changes, and onUnmounted, the hook that runs when a component leaves the page, aborts at the end:
async function sync(on: boolean) {
controller?.abort() // aborting the signal unregisters the tool
controller = undefined
if (!modelContext || !on) return
controller = new AbortController()
try {
await modelContext.registerTool(tool, { signal: controller.signal })
} catch (e) {
toolErrors.value.push(`${tool.name}: ${(e as Error).name}: ${(e as Error).message}`)
}
}
watch(() => toValue(enabled), sync, { immediate: true })
onUnmounted(() => controller?.abort())
Pass enabled to register a tool only while it applies, such as remove-todo while the list has items, so the agent's tool list matches the screen.
Hints, permissions and the server
annotations tell the agent how careful to be. readOnlyHint says a tool changes nothing. consequentialHint marks an action that is significant or cannot be undone, such as a payment or a deletion, so the agent or browser can ask the person first. untrustedContentHint says the result contains text from people or other sites, which an agent must treat as data, never as instructions; the starter's list-todos sets it, since people typed those texts.
A cross-origin iframe, a page from another site inside yours, may only register tools when your page embeds it with allow="tools", a Permissions Policy: the rule for which features a frame may use. exposedTo in registerTool() lists other origins that may see a tool. None of this replaces authorisation: a tool that calls your API runs with the person's session, so the server still decides what they may do.
The Agent stand-in is itself an in-page caller: it lists the tools with getTools(), runs one with executeTool(), and asks you first when a tool carries consequentialHint.
What goes wrong
The first mistake is trusting the arguments: the schema tells the agent what to send, but Chrome does not check what it sends.
async execute(args) {
addTodo(args.text)
return `Added "${args.text}"`
}
An agent that calls add-todo with {} gets Added "undefined" back, and the list grows an empty row. Validate inside execute, as the starter does:
const input = AddInput.safeParse(args)
if (!input.success) return `Not added. ${z.prettifyError(input.error)}`
The same call now gets the problem back as text, and no row is added.
The second mistake is throwing the failed check instead of returning it:
if (!input.success) throw new Error(z.prettifyError(input.error))
The agent receives UnknownError: Tool was executed but the invocation failed. For example, the script function threw an error, and your explanation only reaches the console.
if (!input.success) return `Not added. ${z.prettifyError(input.error)}`
A returned message reaches the agent, so the model can correct its arguments and try again.
What to remember
- The browser's own agent, scripts in the page and extensions with content scripts can all call your tools, and the server still authorises every request.
registerTool()is asynchronous, and aborting itssignalunregisters the tool, so register a tool only while it applies. Keep the action in functions the buttons also use.- Mark tools with
readOnlyHint,consequentialHintanduntrustedContentHint, so the agent knows how careful to be. - Validate the arguments inside
execute, and return errors as text the agent can act on.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In
todoTools.ts, at the Try it 1 comment, add auseTool({ … })call withname: 'clear-todos', adescriptionsuch as'Remove every to-do item',inputSchema: { type: 'object', properties: {} },annotations: { consequentialHint: true }and anexecutethat setstodos.value = []and returns'Cleared the list'. With WebMCP on, the Agent stand-in lists it with(consequentialHint). Putclear-todosin its Tool box and run it: a confirm dialog asks first, OK empties the list and showsThe agent got back: Cleared the list, Cancel showsYou said no. - Pass
() => todos.value.length > 0as the second argument of theremove-todocall touseTool. Remove every item with its Remove button:remove-tododisappears from the stand-in's list. Add one item and it comes back. - Replace the four lines inside the
add-todotool'sexecutewithaddTodo(args.text)andreturn `Added "${args.text}"`. Runadd-todowith{}: the stand-in showsAdded "undefined"and an empty row appears. Put the four lines back and the same call returnsNot added. ✖ Invalid input: expected string, received undefined → at text.