webmcp
Framework-agnostic server-side WebMCP toolkit for Rust, with an optional tower layer.
WebMCP is a W3C Community Group
proposal that lets a web page register tools an in-browser AI agent can call
through document.modelContext. This crate is the server side of that: you define
tools where your app already knows its routes, sessions and permissions, and a
small browser runtime registers them on the pages you choose. When an agent calls
a tool, the runtime calls your existing same-origin endpoint with the user's
session and CSRF token, so authentication and authorization stay in your app.
- Tool definitions —
Tool::new(ToolDef)andRegistry, validated against the spec's naming, annotation and schema rules. - Manifests —
Manifest::buildandManifest::to_script_tagfor script-safe embedding. - Browser runtime —
RUNTIME_JS(viainclude_str!) andruntime_script_tag(CSP nonce aware). - Declarative forms —
form_attrsandparam_attrreturn escaped attribute strings. - Origin Trial —
origin_trial_meta_tag, header helpers, andOriginTrialLayerbehind thetowerfeature.
[]
= "0.1"
Status: 0.x, tracking the WebMCP Draft CG Report of 2026-10-02. WebMCP runs
behind a Chrome origin trial (Chrome 149–156, extension requested to 162) or the
chrome://flags/#enable-webmcp-testing flag. The shared runtime is tested in real
Chrome 154 by the Ruby reference suite
(CSRF-protected writes, blocked redirects, HTTP errors, Turbo navigation, strict CSP).
| Language | Package | Registry |
|---|---|---|
| Ruby / Rails (reference) | webmcp |
RubyGems |
Go (net/http) |
webmcp-go |
pkg.go.dev |
| Python / Django | webmcp-django |
PyPI |
| Rust | webmcp |
crates.io |
All four emit the same manifest v1 (checked against shared conformance fixtures, fingerprints included) and ship the byte-identical browser runtime.
Coding agents: start with AGENTS.md. LLM summary: llms.txt.
Intent: share identity, project the rest explicitly
Surfaces are intentionally different; share identity, project the rest explicitly. A server MCP tool and its browser counterpart can represent the same feature while intentionally having different schemas, limits, and execution paths.
| Difference | Server MCP | Browser WebMCP | Reason |
|---|---|---|---|
| Field names | content, id |
title, task_id |
Browser agents benefit from names matching visible labels. |
| Dates | ISO 8601 | YYYY-MM-DD HH:MM in the user's timezone |
Match what the user sees. |
| Result limit | 500 | 20 | Keep results useful within the browser's response budget and tab context. |
| Execution path | Service object → database | Session cookies → existing web endpoint | Preserve session, CSRF and authorization checks. |
Identity and descriptions can start from one source. Schema changes, annotations,
limits and endpoints are explicit projections. MCP and WebMCP annotations are
different sets: destructiveHint does not automatically mean consequentialHint.
Even readOnlyHint must be declared again for the browser endpoint.
Quick start
Rust 1.71+, edition 2021. The minimum Rust version rises from 1.70 to 1.71
for the current serde derive/serde_json dependencies. Default runtime dependencies
are only serde, serde_json (without preserve_order), and sha2.
[]
= "0.1"
= "1"
use json;
use ;
Definitions own immutable, validated metadata. Construct them at boot. Duplicate
names, unsupported schemas, unsafe endpoints, and colliding parameter maps fail
with DefinitionError. Advisory name (>30 characters) and description (>500
characters) budget warnings go to stderr. ToolDef and Endpoint also support
serde deserialization with snake_case field names and unknown fields rejected.
Only selected tools appear in a manifest. registry.select(&[]) exposes nothing;
unknown or duplicate names fail. Manifest::build(&[&tool], transport) also works
without a registry. Manifest::to_json() returns ordinary JSON;
Manifest::to_script_tag() performs the additional HTML-script escaping.
Browser runtime and CSP
Serve webmcp::RUNTIME_JS as JavaScript at a same-origin asset URL, or copy the
shipped runtime/webmcp-runtime.js through your framework's asset pipeline.
RUNTIME_SHA256 identifies its bytes. runtime_script_tag(src, Some(nonce))
renders an external module tag with an escaped CSP nonce. For manifest nonces:
use ScriptTagOptions;
let options = ScriptTagOptions ;
The default manifest tag includes data-webmcp-autostart. Loading the runtime
module automatically mounts that manifest once the document is ready, without
inline bootstrap code. The handle is available as globalThis.WebMCPRuntime.handle
and in the webmcp:mounted event detail. For manual lifecycle ownership, set
autostart: false and initialize it once from your application's external entry:
import from "/assets/webmcp-runtime.js";
const handle = ;
// After replacing the manifest in an SPA:
await handle.;
// When the owner is removed:
handle.;
Turbo refresh is handled by the runtime. Other SPA frameworks must call
refresh() themselves. Unsupported browsers are a no-op. The module uses no
inline executable JavaScript; your application must configure its CSP and asset
serving. Always provide CSRF transport for non-GET tools and render the matching
meta token (or configure a cookie): Rust has no implicit transport defaults, and
the copied runtime only checks missing tokens when CSRF transport is configured.
Declarative forms and Origin Trial
use ;
let attrs = form_attrs?;
let input_attrs = param_attr;
let meta = origin_trial_meta_tag;
let mut headers = Vecnew;
apply_origin_trial_headers;
Form helpers emit only toolname, tooldescription, toolautosubmit (a boolean
attribute when true), and toolparamdescription. Every attribute value is
escaped, including already escaped strings. Rust has no bypass for template
"safe" markers: pass their string contents and they will be escaped again.
The core apply_origin_trial_headers helper fills only a missing header,
case-insensitively, preserving existing empty values. An empty token is a no-op.
Without the http feature, apply_origin_trial is an alias for this helper.
With http (also enabled by tower), apply_origin_trial accepts
&mut http::HeaderMap and returns an error for invalid header values.
The original origin_trial_header(token) pair helper remains available.
The meta helper returns an empty string for an empty token.
Enable webmcp = { version = "0.1", features = ["tower"] } for response
middleware. OriginTrialLayer::new(token)? validates the token;
.warn_on_oac_opt_out(true) opts into a stderr warning for
Origin-Agent-Cluster: ?0, emitted once across clones of that layer. It never
rewrites the header or forces ?1. Empty tokens disable both insertion and
warnings; existing Origin-Trial headers are preserved.
The axum feature enables Tower support and this documentation example only;
your application supplies its own axum dependency:
use ;
use OriginTrialLayer;
let app: Router = new.route
.layer;
Troubleshooting
| Symptom (exact text) | Cause | Fix |
|---|---|---|
document.modelContext is undefined |
WebMCP is unavailable or the page is not a secure context | Chrome 149+ with the origin trial token (header or <meta http-equiv="origin-trial">) or chrome://flags/#enable-webmcp-testing; serve over HTTPS or localhost |
NotAllowedError from registerTool |
The document may not use the tools Permissions Policy feature (default allowlist self) |
For cross-origin frames delegate with allow="tools" and make sure ancestor Permissions-Policy headers permit it |
UnknownError: Failed to parse input arguments from executeTool |
Chrome 154 and earlier accept only a JSON string input; object input ships in Chrome 155 | Agent side: pass JSON.stringify(input) on Chrome ≤ 154. The runtime's execute receives an object either way |
SecurityError from registerTool on an older trial build |
The response sent Origin-Agent-Cluster: ?0 (requirement removed from the spec on 2026-09-30, still enforced by older builds) |
Stop sending ?0; enable the OAC opt-out warning to find it |
Console: WebMCP: could not register tool "<name>" with InvalidStateError |
Another script in the same document already registered that name | Use unique names; names are unique per document, not per site |
Console: WebMCP: unsupported manifest version; no tools registered. |
Runtime and manifest come from different package versions | Upgrade so both use manifest v1 and the same runtime |
Tool result error.code: "csrf_token_missing" |
A non-GET tool (including a read-only POST) has CSRF transport configured but no readable token on the page | Render the configured token (Rails csrf_meta_tags, Django {% webmcp_csrf_meta %}, Go/Rust: your own escaped <meta name="csrf-token">) or configure a readable cookie source. Without CSRF transport the runtime skips this check |
error.code: "invalid_input" |
The agent sent an undeclared parameter, a missing required one, or the wrong scalar type | Fix the schema or descriptions; the runtime forwards declared parameters only |
error.code: "unknown_outcome" |
A write request failed after dispatch: network error, abort, or a redirect (redirects are never followed) | Make the endpoint answer without redirecting (e.g. 401 JSON instead of redirecting to sign-in); never retry automatically |
error.code: "network_error" on a read |
Network failure or redirect rejection after dispatch (an aborted read returns aborted) |
Check connectivity and answer JSON without redirects; reads may be retried |
error.code: "response_too_large" |
Read response exceeded maxResponseChars |
Narrow the query or raise the limit; responses are never truncated |
dataOmitted: "invalid_response" on a write |
The endpoint returned 2xx with a non-JSON body | Return JSON from write endpoints |
| Tools from the previous page remain, or none appear, after client-side navigation | Turbo is handled automatically; other SPAs (Inertia, React routers) are not | After the SPA replaces or removes #webmcp-manifest, await WebMCPRuntime.handle.refresh(). webmcp:mounted only delivers the initial handle. If the first page has no manifest, mount the runtime from your app entry (mount()) and keep that handle |
A string-returning tool yields hi instead of "hi" |
Chrome 154 does not JSON-quote string results (spec says it should) | The shared runtime always returns an envelope object, so this only affects hand-written tools |
Definition error at boot such as GET endpoints require read_only: true |
The definition violates a rule above | Fix the definition; errors are raised at boot on purpose |
Security model
The server remains the security boundary. Tools call existing same-origin
endpoints with the current session; those endpoints must enforce authorization,
CSRF, input validation, range limits and result caps. A schema maximum is
metadata, not server enforcement. Annotations are hints, not security controls.
- Endpoint paths must begin with
/; protocol-relative URLs, backslashes, colons and control characters are rejected. The runtime also checks the resolved origin and uses same-origin mode/credentials with redirects rejected. - GET requires
read_only: true; read-only POST is allowed. Other methods need a CSRF token read at invocation time. Missing tokens stop the request. - Only declared input keys are sent. Prototype-related keys are rejected.
Explicit
param_mapdestinations cannot collide or target reserved transport fields such as_method,authenticity_token, orcsrfmiddlewaretoken. - JSON embedding escapes
<,>,&, U+2028 and U+2029. This prevents script breakout, including mixed-case closing tags and HTML comments. Nonces and HTML attributes are independently escaped. - The runtime returns structured success/error envelopes and never retries.
An ambiguous write result is
unknown_outcome: verify with the user before retrying. A successful write with unreadable/oversized output stays successful withdataOmitted; an oversized read returnsresponse_too_large. Responses are never silently truncated.
Tool metadata is agent-visible, not just display text. An AI agent reads
tooldescription / toolparamdescription as part of its instructions for what
the tool does. Do not build these strings from unvalidated user input (profile
fields, query params, uploaded file names, etc.); a user-controlled value rendered
into tool metadata is a prompt-injection vector that can hijack the agent's
behavior. Keep tool names and descriptions as literal strings you write, not
values derived at request time from data a visitor controls. Define tools at boot
and freeze the registry; HTML escaping alone does not prevent prompt injection.
Conformance
Shared fixtures are copied verbatim from the Ruby
reference. Tests load each definition and compare parsed manifest entries,
including independently computed fingerprints. Expected results are never
regenerated with this crate. The contract defines
canonicalization: recursively sorted keys, compact UTF-8 JSON, integer metadata
within +/-2^53, no HTML escaping in the hash preimage, SHA-256 with a sha256:
prefix. A tool fingerprint covers its full emitted entry without fingerprint,
plus a transport member. Rendering then escapes <, >, &, U+2028 and U+2029.
Rust GET arrays default to explicit arrayFormat: "repeat" (k=v1&k=v2). Set
endpoint.array_format = Some("brackets".into()) for endpoints expecting
k[]=v1&k[]=v2. The shared read-array fixture explicitly selects brackets.
Tests parse repeated query keys using form_urlencoded, and verify the runtime
copy against conformance/RUNTIME.sha256. Canonical runtime maintenance belongs
to the Ruby reference; update its copy and checksum together, without editing
runtime contents locally.
Limitations
This is a 0.x subset. Root schemas allow type: "object", properties,
required, and description. Properties are string, number, integer,
boolean, or arrays of those scalar types. Metadata supports enum,
description, default, minimum, maximum, maxLength, and maxItems.
Nested objects/arrays, $ref, composition, and other keywords fail at definition
time. Metadata numbers must be integers within +/-2^53; runtime number inputs
may be fractional. The endpoint must enforce enum, bounds, lengths, defaults,
authorization, and response caps. The browser runtime checks declared input
keys, required keys, and scalar/array types.
There is no cross-origin exposure, automatic response truncation, or MCP SDK
bridge yet. from_mcp-style projection exists in the Ruby reference and is
planned for Rust; this phase accepts explicit browser definitions without SDK
dependencies. Live Chrome registration, CSP, and production asset serving are
separate integration checks, not claims established by Rust tests.
Sibling packages: Ruby, Go, Django, Rust.
License
MIT