WebMCP: your page as a tool surface
So here's the WebMCP pitch: your page already has buttons, and behind each one is a function that knows how to do the thing — add to cart, book the room, file the return. WebMCP lets you hand those functions to an AI agent as named, callable tools, so the agent drives your app inside the user's own logged-in tab. No public API. No scraper pretending to be a user. No second auth handshake. You register the tools on document.modelContext, and in this lesson you expose a small cart, then point an in-page Gemini Nano agent at it and watch it do the clicking.
What you'll build
- Register tools on
document.modelContextand feature-detect it before you touch it. - Describe a page action as a tool descriptor:
name,description,inputSchema, and anexecutehandler. - Register the whole set under one
AbortControllerand tear it all down with a singleabort(). - Wire an in-page
LanguageModelagent that calls those exact tools through the intent loop. - Gate the feature honestly, because WebMCP is a flag most of your users have never flipped.
Desktop Chrome with built-in AI. WebMCP is a draft API behind a flag — enable chrome://flags/#enable-webmcp-testing (Chrome 149+ ships it as a public origin trial). This lesson builds on the intent loop from structured output & tool calling; if LanguageModel isn't there yet, start with Setup & the availability lifecycle, and for which APIs are stable on which Chrome, keep the compatibility matrix open.
Expose the page as a tool surface
For twenty-five years, automating a web app meant one of three miserable options: build a public API with its own auth, or drive a Selenium script that pretends to be a user, or scrape the rendered pixels and guess where to click. WebMCP adds a fourth. The page declares its own actions, and any agent — an extension, an OS-level assistant, or the LanguageModel sitting in the same tab — calls them by name. The execute handler runs in the page's context, with the user's cookies and session, exactly as if they'd clicked the button themselves. The page is the trust boundary.
The apps that want this most are the transactional ones — checkout, filing, booking, a listing — where "the agent does my work" means a real side effect, not a summary. And the reason it beats pixel-peeping is boring: a schema-validated, named tool call just doesn't fail the way guessing-from-a-screenshot fails. The agent stops reverse-engineering a UI and calls the function, against a fraction of the tokens.
One thing first. The page's tools live on document.modelContext — feature-detect it, and bail to a banner if it's missing.
- JavaScript
- TypeScript
// WebMCP tools live on document.modelContext. Feature-detect it before you touch it.
const modelContext = document.modelContext;
if (!modelContext) {
// No flag, or an http page — WebMCP needs a secure context. Degrade:
// keep the page usable and point people at the flag.
showFlagBanner('Enable chrome://flags/#enable-webmcp-testing to expose tools.');
} else {
await registerTools(modelContext); // defined below — registration is async
}
// document.modelContext is ambient — Chrome ships no public types yet, so
// declare the ModelContext shape you use.
const modelContext: ModelContext | undefined = document.modelContext;
if (!modelContext) {
showFlagBanner('Enable chrome://flags/#enable-webmcp-testing to expose tools.');
} else {
await registerTools(modelContext);
}
Describe a page action as a tool
A tool is a descriptor. Not a class, not a server, not a route — just an object with four fields. The name is how the agent addresses it. The description is the primary documentation the agent reads to decide when to call it, so lead with the verb and spell out the side effect. The inputSchema is a JSON Schema for the arguments — the same type/properties/required you met in the tools lesson, with additionalProperties: false so a hallucinated key gets rejected. And execute is the handler that runs in your page.
One difference from native tools on the Prompt API: there, execute had to resolve to a string. Here it resolves to Promise<unknown> — return an object, a string, whatever, and the browser serializes it back to the agent. Set annotations.readOnlyHint to true on the tools that don't mutate, so a well-behaved agent knows it can reorder or cache them.
- JavaScript
- TypeScript
// A tool is a plain descriptor object. addItem mutates the page's cart array.
const addItem = {
name: 'addItem',
description: 'Add an item to the cart. If it is already there, increase its quantity. qty defaults to 1.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Item name, e.g. "milk"' },
qty: { type: 'integer', minimum: 1, description: '(optional) defaults to 1' },
},
required: ['name'],
additionalProperties: false,
},
annotations: { readOnlyHint: false }, // it mutates — tell honest agents so
// Runs in the page, with the user's session. Resolves to unknown (not a
// string) — the browser serializes whatever you return back to the agent.
async execute({ name, qty = 1 }) {
cart.push({ name, qty });
renderCart();
return `Added ${qty} of "${name}".`;
},
};
// listItems doesn't mutate — readOnlyHint: true tells an agent it can reorder
// or cache the call. Return an object; the browser serializes it back.
const listItems = {
name: 'listItems',
description: 'List the current cart contents. Read-only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
annotations: { readOnlyHint: true },
async execute() {
return { items: cart, count: cart.length };
},
};
// ModelContextTool is the descriptor shape; execute resolves to Promise<unknown>.
const addItem: ModelContextTool = {
name: 'addItem',
description: 'Add an item to the cart. If it is already there, increase its quantity. qty defaults to 1.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Item name, e.g. "milk"' },
qty: { type: 'integer', minimum: 1, description: '(optional) defaults to 1' },
},
required: ['name'],
additionalProperties: false,
},
annotations: { readOnlyHint: false },
async execute(input: Record<string, unknown>): Promise<unknown> {
const name = String(input.name ?? '').trim();
const qty = Number(input.qty ?? 1);
cart.push({ name, qty });
renderCart();
return `Added ${qty} of "${name}".`;
},
};
// Read-only tool: readOnlyHint: true. Returns an object, serialized back.
const listItems: ModelContextTool = {
name: 'listItems',
description: 'List the current cart contents. Read-only.',
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
annotations: { readOnlyHint: true },
async execute(): Promise<unknown> {
return { items: cart, count: cart.length };
},
};
Register the set and tear it down
Registration is the whole handshake. There is no connect step, no session to open — you await registerTool per tool and they're live. registerTool returns a Promise, so await it: on a duplicate name it rejects with InvalidStateError — an async rejection a synchronous try/catch never sees. Pass the same AbortController signal to every one, and you buy yourself a single off switch: abort once, and the whole set unregisters. That signal is the only teardown path — there is no unregisterTool(name) and no clearContext().
Where does the abort go? In a plain page, beforeunload. In React, the useEffect cleanup — and there it matters more than it looks, because StrictMode mounts twice, and re-registering a name already taken rejects with InvalidStateError.
- JavaScript
- TypeScript
// CART_TOOLS is the source of truth — the same array feeds both consumers.
const CART_TOOLS = [addItem, removeItem, listItems];
// One controller governs every registration…
async function registerTools(mc) {
const controller = new AbortController();
for (const tool of CART_TOOLS) {
// await — registerTool REJECTS on a duplicate name; a sync try/catch misses it.
await mc.registerTool(tool, { signal: controller.signal });
}
// …so one abort() unregisters the whole set. Do it on teardown.
window.addEventListener('beforeunload', () => controller.abort());
return controller;
}
const CART_TOOLS: ModelContextTool[] = [addItem, removeItem, listItems];
async function registerTools(mc: ModelContext): Promise<AbortController> {
const controller = new AbortController();
for (const tool of CART_TOOLS) {
await mc.registerTool(tool, { signal: controller.signal });
}
window.addEventListener('beforeunload', () => controller.abort());
return controller;
}
Let the page's own agent call the tools
Registering tools makes them visible to external agents. To let the page's own model call them, you bridge — because on today's Chrome a LanguageModel session doesn't automatically inherit the WebMCP registry. And the bridge is a loop you already wrote in the tools lesson.
Same intent loop, same reasons. Create the session with a system prompt that lists the tools, then loop: prompt with a responseConstraint schema of { toolName, args, reply }, parse, run the named tool, feed the result back. The one WebMCP-specific line is the coercion — execute resolves to unknown, and the loop feeds text, so you stringify anything that isn't already a string. The dispatch looks each tool up in the same CART_TOOLS array you registered. One definition, two consumers.
- JavaScript
- TypeScript
// Reuse the intent loop from the tools lesson and dispatch into CART_TOOLS —
// the exact array registered on document.modelContext above.
const session = await LanguageModel.create({
expectedInputs: [{ type: 'text', languages: ['en'] }],
expectedOutputs: [{ type: 'text', languages: ['en'] }],
initialPrompts: [{ role: 'system', content: SYSTEM_PROMPT }], // lists the tools
});
let next = question; // e.g. "add milk and a dozen eggs, then show the cart"
for (let i = 0; i < 8; i++) {
// responseConstraint — the { toolName, args, reply } schema — rides on prompt().
const step = parseJson(await session.prompt(next, { responseConstraint: INTENT_SCHEMA })); // fence-stripping parse
if (!step || step.toolName === 'done') {
render(step?.reply ?? 'Done.');
break;
}
const tool = CART_TOOLS.find((t) => t.name === step.toolName);
const raw = tool ? await tool.execute(step.args ?? {}) : { error: 'unknown tool' };
const result = typeof raw === 'string' ? raw : JSON.stringify(raw); // coerce unknown → text
next = `Result of ${step.toolName}: ${result}. Call the next tool, or emit {"toolName":"done"}.`;
}
session.destroy(); // free the model; controller.abort() frees the tools
type Step = { toolName: string; args?: Record<string, unknown>; reply?: string };
const session = await LanguageModel.create({
expectedInputs: [{ type: 'text', languages: ['en'] }],
expectedOutputs: [{ type: 'text', languages: ['en'] }],
initialPrompts: [{ role: 'system', content: SYSTEM_PROMPT }],
});
let next = question;
for (let i = 0; i < 8; i++) {
const step = parseJson<Step>(await session.prompt(next, { responseConstraint: INTENT_SCHEMA }));
if (!step || step.toolName === 'done') {
render(step?.reply ?? 'Done.');
break;
}
const tool = CART_TOOLS.find((t) => t.name === step.toolName);
const raw = tool ? await tool.execute(step.args ?? {}) : { error: 'unknown tool' };
const result = typeof raw === 'string' ? raw : JSON.stringify(raw);
next = `Result of ${step.toolName}: ${result}. Call the next tool, or emit {"toolName":"done"}.`;
}
session.destroy();
Gate it behind the flag, honestly
Now the part the demo videos skip. WebMCP is not a shipped API. It's a W3C Draft Community Group Report that is still moving, exposed for local dev behind chrome://flags/#enable-webmcp-testing and for a deployed origin behind an origin-trial token. Which means: on the machines your actual users run, document.modelContext is usually undefined.
So you never make it a hard dependency. Feature-detect both surfaces, register the tools when they're there, and keep the page fully usable when they aren't — the cart still adds, removes, and lists whether or not an agent is watching. Build with it to learn where the API is going. Don't bet a product on a draft.
- JavaScript
- TypeScript
// Local dev uses the flag. A deployed origin registers an origin-trial token in
// a <meta http-equiv="origin-trial"> tag, so document.modelContext exists for
// visitors who never touch chrome://flags. Either way, gate both surfaces.
const modelContext = document.modelContext;
const canAgent = typeof LanguageModel !== 'undefined';
if (!modelContext) showFlagBanner(); // tools can't register — the cart still works
if (!canAgent) disableAgent(); // no in-page agent — external agents can still call
const modelContext: ModelContext | undefined = document.modelContext;
const canAgent = typeof LanguageModel !== 'undefined';
if (!modelContext) showFlagBanner();
if (!canAgent) disableAgent();
Run it locally: open 12-webmcp/index.html from the
chrome-ai-course repo in desktop
Chrome with chrome://flags/#enable-webmcp-testing on. Or use the hosted demo:
WebMCP Recipe Workbench (with the
API walkthrough).
Expected: the tools panel shows three tools registered on
document.modelContext; type "add milk and a dozen eggs, then show the cart" and
the in-page agent calls addItem twice and listItems once, logging each call,
while the cart updates live.
Requires: desktop Chrome with WebMCP enabled and Gemini Nano available — see Setup & the availability lifecycle.
Gotchas & troubleshooting
Symptom: registration runs on your machine and throws on a colleague's.
Cause: document.modelContext only exists where WebMCP is switched on — your flag
is set and theirs isn't, or their page isn't a secure context. Fix: feature-detect
document.modelContext and bail to a banner; never assume the surface is there
just because it was on your machine.
Symptom: a second registerTool for the same name rejects with
InvalidStateError: Duplicate tool name. Cause: you registered that name twice
without aborting the first — in React, StrictMode double-invokes your effect.
Fix: the rejection is async, so await the call (or .catch() it) — a
synchronous try/catch won't see it. Keep one AbortController, abort the prior
registration before re-registering, and treat the duplicate as already-registered.
Symptom: Cannot read properties of undefined. Cause: no flag, or the page is
on http — WebMCP needs a secure context, and the entry point is simply absent.
Fix: feature-detect document.modelContext and bail to a banner before you call
anything; serve over https or localhost.
Symptom: it works for you and registers nothing for everyone else. Cause: WebMCP is a flag and origin trial, off by default — a draft API, not a shipped one. Fix: don't ship it as a hard dependency. Register an origin-trial token to light it up on your origin, and keep the page fully usable without it.
Symptom: the in-page session replies with text, or returns fenced JSON the loop
can't parse. Cause: LanguageModel.create({ tools }) is unreliable on recent
Canary — the model narrates instead of emitting a tool call. Fix: drive the tools
with the intent loop, and keep a fence-stripping parse for the occasional fenced reply.
Recap
- The entry point is
document.modelContext— feature-detect it, bail to a banner. - A tool is a descriptor:
name,description,inputSchema,execute— andexecuteruns in the page with the user's session and resolves toPromise<unknown>. registerToolreturns a Promise that rejects withInvalidStateErroron a duplicate name —awaitit. OneAbortControlleracross the set means oneabort()unregisters everything; that signal is the only teardown path.- The in-page agent is the same intent loop from the tools lesson, dispatching into the same registered
CART_TOOLS— one definition, two consumers. - WebMCP is a moving draft behind a flag; gate it, never ship it as a hard dependency.
Registering a tool is just handing an agent a function you already wrote and trust. The page is the boundary, the AbortSignal is the off switch, and the loop is the tools lesson pointed at your own buttons. The alternative is paying a model by the token to guess where that button is from a screenshot.
Next steps
- Generative UI (MCP Apps) — let a tool return an interactive UI, not just a serialized result.
- Structured output & tool calling — the intent loop this lesson reuses, and the native
toolspath it works around. - Shipping & compatibility — versions, flags, and how to ship a feature that leans on a draft API.
- WebMCP API walkthrough — the hosted reference for
registerTooland the descriptor shape.
Next: Generative UI (MCP Apps)