Skip to content

WebMCP (experimental)

Browser agents (the agent built into the browser, or an extension) usually operate a page the way a person does: they read the accessibility tree, then click and type. WebMCP lets a page offer them tools instead: named functions with a description and an input schema, registered with document.modelContext.registerTool(). A tool call is faster and far more reliable than a sequence of clicks, and the page decides what an agent may do.

A Sygnal app already says what agents may do: its components’ agent statics. One call offers those tools through WebMCP.

main.js
import { run } from 'sygnal'
import { experimentalExposeWebMcp } from 'sygnal/ai'
import TodoApp from './TodoApp.jsx'
const app = run(TodoApp)
const webMcp = experimentalExposeWebMcp(app)
// webMcp.available: false where the browser has no WebMCP (the call did nothing)
// webMcp(): unregister every tool

Every tool the agent layer offers is registered: one per declared action of a component on the page (todos_add, todo_toggle with its id parameter, …), plus a read tool per declaration with a read (todos_read). The registrations follow the app: a tool whose when turns false is unregistered, a component that leaves the page takes its tools with it, and a tool whose description or schema changed (a new Collection key, a new state summary) is registered again. Each call goes through the call rules: validation, confirmation, cause: 'agent', no-op detection.

Where there is no WebMCP, experimentalExposeWebMcp does nothing and returns a handle with available: false. It is safe to call in every browser.

Options:

OptionDefaultWhat it does
confirma dialogConsequential calls: true runs them, false declines them, a function (info) => boolean | Promise<boolean> asks your own UI. See Confirmation
exposedToOther origins the tools are exposed to (passed to registerTool where the browser supports it). Leave it out unless a partner’s embedding page needs them
prefixPrepended to every tool name: shop_ gives shop_todos_add
modelContextdocument.modelContextThe WebMCP object to register with (a test double, a polyfill instance)

Chrome 153 has WebMCP behind a flag: enable WebMCP for testing at chrome://flags/#enable-webmcp-testing (the same as launching with --enable-features=WebMCP), or join the origin trial for your site. The API is document.modelContext; the older navigator.modelContext is deprecated, and Sygnal uses it only when the document one is missing.

Other browsers: the polyfill. @mcp-b/webmcp-polyfill installs a document.modelContext in any browser, so an agent extension that speaks it can call your tools. Load it before experimentalExposeWebMcp, in development only:

main.js (development)
import { run } from 'sygnal'
import { experimentalExposeWebMcp } from 'sygnal/ai'
import TodoApp from './TodoApp.jsx'
if (import.meta.env.DEV && !document.modelContext) {
const { installWebMCP } = await import('@mcp-b/webmcp-polyfill')
installWebMCP()
}
experimentalExposeWebMcp(run(TodoApp))

Sygnal never depends on the polyfill; you install it yourself (npm install -D @mcp-b/webmcp-polyfill). Sygnal’s own browser tests run the same app against Chrome’s native WebMCP and against the polyfill in Chromium, Firefox and WebKit.

Context. An agent works much better when it knows the current state (why). WebMCP has no channel for it other than tools, so Sygnal offers it two ways: the read tool (todos_read), and a short summary of the declaration’s read projection at the end of each of its tool descriptions (“Current state: …”), registered again when the projection changes. The agent sees the state without having to call anything first.

For a declaration with user-entered text (untrusted: true, or inferred), the descriptions carry no summary. Its Collection key parameters list the ids without their labels, and so do those of any item declaration with a label (labels count as user text unless the item declaration says untrusted: false). Agents read descriptions as instructions, so text a user typed never goes there; the contents come only from the read tool, whose results are marked untrusted. An item tool whose ids come without labels names that read tool: “Move the card to another column. Call board_read first to find the card’s id.” Without the sentence, small models guess an id rather than read first.

Each tool carries WebMCP annotations:

AnnotationOn
readOnlyHintthe read tools
consequentialHintactions with consequential: true
untrustedContentHintthe tools of a declaration whose projection holds user-entered text: untrusted: true, or inferred from its strings (SYG244 in development, until you declare it either way)

Hints are advice to the agent. Chrome 153 drops consequentialHint, so it is not a safeguard: confirmation is.

Chrome’s guidance sets size limits that neither Chrome 153 nor the polyfill enforce, so Sygnal does: a tool name at most 30 characters (letters, digits, _, -, .), a description at most 500, a parameter description at most 150, a result at most 1,500 characters of JSON. Going over is SYG242 in development, then the part is cut safely: a long name keeps a hash of the whole so names stay unique, and a long result keeps ok and error and shortens state. Short names on your declarations (todos, not todoListManager) leave room for the action names; prefix counts too.

Results are always objects, and a tool never throws: an error is { ok: false, error }, which the agent can read and act on.

A consequential call is confirmed in the page before it runs. By default Sygnal shows a native modal <dialog>, added to document.body outside your app’s tree: “Allow the AI agent to do this?”, the action’s description and the item’s label, and Deny (focused) and Allow. The rest of the page is inert while it is open, and Escape denies.

Your own UI instead: pass confirm, a function that resolves true to run the call.

experimentalExposeWebMcp(app, {
confirm: (info) => window.confirm(`Allow the AI agent to: ${info.description}${info.label ? ` (${info.label})` : ''}?`),
})

info is { tool, component, action, description, input, key?, label? }: input is the validated input, key and label name a Collection item. A declined call answers { ok: false, error: 'the user declined' }.

For a call to todo_remove on the item labelled “buy milk”, the dialog is:

<dialog aria-labelledby="sygnal-webmcp-confirm-1-t" aria-describedby="sygnal-webmcp-confirm-1-d" data-sygnal-webmcp open>
<h2 id="sygnal-webmcp-confirm-1-t">Allow the AI agent to do this?</h2>
<p id="sygnal-webmcp-confirm-1-d">Delete the todo (buy milk)</p>
<div>
<button type="button" autofocus>Deny</button>
<button type="button">Allow</button>
</div>
</dialog>
  • It is appended to document.body, outside your app’s mount point, and opened with showModal(): the role is dialog, its accessible name is the heading and its description the action’s description plus the item’s label in parentheses. For an action with an input schema, a <pre> after the paragraph shows the validated input as JSON (cut at 300 characters). The number in the ids counts up per dialog.
  • Deny has the focus. Deny, Escape and webMcp() (unregistering) decline; Allow runs the call. Either way the dialog is removed and the focus goes back to the element that had it.
  • It has no styles of its own: style it with dialog[data-sygnal-webmcp].

To replace it, pass confirm (above): true runs every consequential call, false declines them all, a function asks your UI. The dialog is only made when confirm is left out.

Testing it. In a browser test (Playwright), the dialog is found by its role and name:

const dialog = page.getByRole('dialog', { name: 'Allow the AI agent to do this?' })
await expect(dialog).toContainText('Delete the todo (buy milk)')
await dialog.getByRole('button', { name: 'Allow' }).click() // or 'Deny', or page.keyboard.press('Escape')

In Vitest with a DOM (jsdom), call the tool through a stand-in modelContext (Testing) and click the button:

const result = tools.get('todo_remove').execute({ id: 1 })
const dialog = await vi.waitFor(() => document.querySelector('dialog[data-sygnal-webmcp]') ?? Promise.reject(new Error('no dialog yet')))
;[...dialog.querySelectorAll('button')].find((b) => b.textContent === 'Deny').click()
expect(await result).toEqual({ ok: false, error: 'the user declined' })

WebMCP also has a declarative form: a <form> with toolname and tooldescription attributes is a tool, and its fields are the parameters. The agent fills the fields and submits. The form behavior already knows the schema and the labels, so it can write those attributes for you:

import { form } from 'sygnal'
import { formTool } from 'sygnal/ai'
import { z } from 'zod'
const schema = z.object({
email: z.string().trim().toLowerCase().email('Enter a valid email address'),
plan: z.enum(['free', 'pro']),
})
function Signup({ state }) {
const f = state.form.fields
return (
<form className="signup">
<label>Email address <input name="email" type="email" value={f.email.value} /></label>
<label>Plan
<select name="plan" value={f.plan.value}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
</label>
<p role="alert">{state.form.error}</p>
<button type="submit">Sign up</button>
</form>
)
}
Signup.uses = {
form: form(schema, {
values: { email: '', plan: 'free' },
submit: 'SIGN_UP',
tool: formTool({ name: 'sign_up', description: 'Create an account' }),
}),
}
Signup.model = {
SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
}
  • The <form> gets toolname="sign_up" and tooldescription, and each named field a toolparamdescription from its label: a wrapping <label>, a <label for>, then its aria-label. Attributes you write yourself win (attrs-toolparamdescription="..."; written plainly in JSX, toolname would become a DOM property the browser ignores, which sygnal-check points out).
  • An agent’s submit is the form’s usual submit: the same validation, the same SIGN_UP. The agent gets { ok: true, values } (the schema’s output; for a submit that sends a request, after form.DONE), or { ok: false, errors } with the field errors by name (schema, check and server errors), or { ok: false, error } when a submit was already running or the form went away.
  • The user submits. By default (autosubmit: false) the agent’s call fills the fields and waits: the user reviews the form and presses the button, and then the agent gets its answer. formTool({ ..., autosubmit: true }) lets the agent submit by itself; use it only for forms whose submit is harmless.
  • A user’s own submit is unchanged.

formTool comes from sygnal/ai, so a form without it carries none of this. A plain object as tool is SYG245.

Most browser agents today don’t use WebMCP at all: they read the accessibility tree, the same structure screen readers use, and act on roles, names and states. An app that is accessible is most of the way to an app that agents can operate, and Lighthouse’s Agentic Browsing audits check the same things. As of October 2026 they cover accessible names, stable layout, registered WebMCP tools and forms without declarative tools; the category is new, so check the linked page for the current audits and how they are scored.

sygnal-check’s accessibility rules (Accessibility) cover the common gaps, with two aimed at agents:

  • SYG730: an action reachable only by hovering (mouseenter / mouseover). Agents, keyboards and touch screens can’t reach it.
  • SYG731: a toggle whose on/off state shows only as a class (className={{ done: state.done }}). Add aria-pressed, aria-checked or aria-expanded, so the state is in the tree.

The tools WebMCP registers are the agent layer’s, so the t.tools() / t.callTool() tests cover them. To test the registration itself, pass a stand-in modelContext:

import { it, expect } from 'vitest'
import { run } from 'sygnal'
import { experimentalExposeWebMcp } from 'sygnal/ai'
import TodoApp from './TodoApp.jsx'
it('registers the todo tools', async () => {
const tools = new Map()
const modelContext = {
registerTool: async (tool, { signal }) => {
tools.set(tool.name, tool)
signal.addEventListener('abort', () => tools.delete(tool.name))
},
}
const app = run(TodoApp, {}, { mountPoint: document.body })
await app.__runtime.flushed()
const stop = experimentalExposeWebMcp(app, { modelContext, confirm: false })
await Promise.resolve()
expect([...tools.keys()]).toContain('todos_add')
expect(await tools.get('todos_add').execute({ value: 'buy milk' })).toMatchObject({ ok: true })
stop()
app.dispose()
})