WebMCP lets a page give tools to an AI agent that runs in the user's browser. Autumn makes these tools from your routes and your forms.
Status. WebMCP is a W3C community-group draft (2026-04-23). It is not a standard. Chrome has it behind a flag. Firefox and Safari do not have it. The page script tracks the draft and can change in a minor release. See STABILITY.md.
How it is different from /mcp
| Row label | /mcp (guide) | WebMCP |
|---|---|---|
| Agent | A server agent, on the other side of the network | An agent in the user's own tab |
| Credential | A bearer token (RequireApiToken) | The user's session cookie and CSRF token |
| Opt-in | #[api_doc(mcp)] | #[api_doc(webmcp)] |
| Transport | JSON-RPC to /mcp | A same-origin fetch to the route |
Both use the same tool derivation. A route tagged #[api_doc(mcp, webmcp)]
has the same name and the same inputSchema on both surfaces.
#[api_doc(mcp)] does not make a WebMCP tool. A tool for a server agent
is not always safe for a browser agent. The browser agent uses the session
of the user.
Route tools
-
Enable the
webmcpfeature. It enablesmcp.TOMLautumn-web = { version = "...", features = ["webmcp"] } -
Tag each route:
Rust#[get("/todos.json")] #[api_doc(webmcp, summary = "List the todos")] async fn list_todos(mut db: Db) -> AutumnResult<Json<Vec<Todo>>> { /* ... */ } -
Serve the tool list:
Rustautumn_web::app() .routes(routes![list_todos]) .mount_webmcp() .run() .await; -
Add the head tags to your layout:
Rusthtml! { head { (autumn_web::webmcp::head_tags(csrf.as_ref().map(CsrfToken::token))) } }This writes
<meta name="csrf-token">and the script tag. The default CSP (script-src 'self') allows the script. No nonce is necessary.
The rules for a route tool are the /mcp rules. The route returns
Json<T>, or an empty-body status (204/205). The tool gets its body
argument from the Json<T> extractor of the route. An HTML route is not a
tool. A stream route (#[api_doc(mcp, stream)]) is not a WebMCP tool.
| Form | Effect |
|---|---|
#[api_doc(webmcp)], Route::webmcp() | The route is a tool. |
#[api_doc(webmcp = false)], Route::webmcp_exclude() | The route is never a tool. |
.expose_all_as_webmcp() | Each eligible GET is a tool. A write still needs the tag. |
What a call does
The script reads /_autumn/webmcp/tools.json and registers each tool with
document.modelContext.registerTool() (or navigator.modelContext in older
builds). When the agent calls a tool, the script:
- puts the path parameters into the path,
- writes the
queryargument in theQuery<T>format, - sends the
bodyargument as JSON, - sends a same-origin
fetchwith the session cookie and the CSRF token.
So the real route pipeline runs: #[secured], policies, tenancy,
validation, and rate limits. An anonymous agent gets the same 401 as an
anonymous fetch. The result is the response text. A status that is not
2xx gives isError: true.
The script refuses a path parameter that is empty, . or ... The URL
parser would remove such a segment and send the call to a different route.
The script does not follow a redirect. A redirect gives an error result, so
the CSRF header cannot go to another origin. When the agent cancels a call,
the script stops the request.
A WebMCP call does not go through the /mcp dispatcher. So the grant rate
cap and the agent audit record of /mcp do not apply. Use /mcp for a tool
that needs them.
Asking the user first
A tool asks the user before it sends when its derived annotations say
destructiveHint: true. This is so for:
- a
DELETEroute, - a route with an
#[agent_operable]grant that is notreversible.
The script uses the agent's requestUserInteraction() when the agent gives
one. Otherwise it uses window.confirm(). When the user says no, or when
the script cannot ask, the tool gives an error result and sends nothing.
A write tool also gets the consequentialHint annotation, so the browser
can ask too.
Form tools
A form can be a tool. Put the marker in the form:
form.form_tag("/todos", "post", html! {
(autumn_web::webmcp::form_tool("add_todo", "Add an item to the todo list"))
label for="title" { "Title" }
input id="title" name="title" required maxlength="200";
button { "Add" }
})
The script makes the inputSchema from the form fields: the name, the type,
required, minlength, maxlength, pattern, min, max, the <select>
options, and the label text. It does not include hidden, file, password,
disabled, or read-only fields. mount_webmcp() is not necessary for form
tools.
When the agent calls the tool, the script fills the fields and submits the form. For an htmx form, the result is the text of the htmx response. For a plain form, the page loads the response. These give an error result and send nothing:
- an invalid form,
- a value that the field cannot hold (for example, a
<select>value with no option), - a second call to a form while its first call runs.
When the agent cancels a call, the script stops the htmx request. A call that the agent cancels before the submit sends nothing.
A DELETE form asks the user first. The method comes from hx-delete and
the other hx-* verbs, else from method. On a POST, the _method field
replaces it, also for hx-post.
For another form that the user cannot undo, use
webmcp::form_tool_confirm(name, description). The script asks before it
fills a field, so a "no" changes nothing on the page.
Form tools are not in the agent-authority manifest. The route that the form sends to still applies its own guards.
htmx swaps
A form tool lives as long as its form. Before htmx swaps content, the script unregisters the form tools in that content. It also unregisters the tool of a form that holds the content, because the fields can change. After the swap, it registers the forms again. So an agent cannot call a tool whose form is not on the page.
Authority
Run autumn agents manifest to see the tools. The manifest lists WebMCP
exposure apart from /mcp exposure:
- an action row has
"exposure": "webmcp-tool"when the route is a WebMCP tool and not an MCP tool, route.webmcp_toolistruefor each route that is a WebMCP tool,ungoverned_webmcp_toolslists each WebMCP tool with no grant.
--check fails on an ungoverned WebMCP route tool that can write, as it does
for /mcp. Such a tool runs in the session of the user, so give it a grant.
The manifest reads the webmcp tag. It lists the route also when the app
does not call mount_webmcp(). Form tools are not in the manifest. See
The Agent Authority Envelope.
AEO and /_autumn/webmcp.js
AEO has its own script, /_autumn/webmcp.js. It registers
the public /mcp tools and calls them through /mcp. When the app calls
mount_webmcp(), that script skips each name that the projection registers.
So no name is registered twice. Then add both head tags to the layout:
aeo::webmcp::head_tags and webmcp::head_tags.
tools.json
GET /_autumn/webmcp/tools.json is public: the script must read it. It
shows the name, schema, method, and path of each WebMCP tool to each
client, also when /openapi.json is off and /mcp has a guard. Do not tag
a route whose path or schema must stay secret. Its guards still apply to
each call.
The response has Cache-Control: private, no-cache and
X-Robots-Tag: noindex. A route of your own at the same path wins. With the
webmcp feature on and no mount_webmcp(), the list is empty. So a page
with only form tools gets no 404.
Not in this release
- Cross-origin tools (
exposedTo) andallow="tools"iframes. - Streaming results.
- A polyfill for browsers with no WebMCP. With no
modelContext, the script does nothing and writes nothing to the console.
Testing
- The tool derivation needs no browser. Test
tools.jsonwithTestAppand.mount_webmcp(). - Node runs the test cases for the script
(
autumn/tests/fixtures/webmcp/shim_harness.js). - The browser cases need Chromium and the
system-testsfeature. UseSystemTest::new().mount_webmcp().