# webmcp
[](https://crates.io/crates/webmcp) [](https://docs.rs/webmcp) [](https://github.com/seunghan91/webmcp-rust/actions/workflows/ci.yml) [](LICENSE)
Framework-agnostic server-side WebMCP toolkit for Rust, with an optional `tower` layer.
[WebMCP](https://github.com/webmachinelearning/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)` and `Registry`, validated against the spec's naming, annotation and schema rules.
- **Manifests** — `Manifest::build` and `Manifest::to_script_tag` for script-safe embedding.
- **Browser runtime** — `RUNTIME_JS` (via `include_str!`) and `runtime_script_tag` (CSP nonce aware).
- **Declarative forms** — `form_attrs` and `param_attr` return escaped attribute strings.
- **Origin Trial** — `origin_trial_meta_tag`, header helpers, and `OriginTrialLayer` behind the `tower` feature.
```toml
[dependencies]
webmcp = "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](https://github.com/seunghan91/webmcp/blob/main/test/integration/RESULTS.md)
(CSRF-protected writes, blocked redirects, HTTP errors, Turbo navigation, strict CSP).
| Ruby / Rails (reference) | [`webmcp`](https://github.com/seunghan91/webmcp) | [RubyGems](https://rubygems.org/gems/webmcp) |
| Go (`net/http`) | [`webmcp-go`](https://github.com/seunghan91/webmcp-go) | [pkg.go.dev](https://pkg.go.dev/github.com/seunghan91/webmcp-go) |
| Python / Django | [`webmcp-django`](https://github.com/seunghan91/webmcp-django) | [PyPI](https://pypi.org/project/webmcp-django/) |
| Rust | [`webmcp`](https://github.com/seunghan91/webmcp-rust) | [crates.io](https://crates.io/crates/webmcp) |
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](AGENTS.md). LLM summary: [llms.txt](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.
| 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`.
```toml
[dependencies]
webmcp = "0.1"
serde_json = "1"
```
```rust
use serde_json::json;
use webmcp::{Csrf, Endpoint, Manifest, Registry, ScriptTagOptions, Tool, ToolDef,
Transport, runtime_script_tag};
fn page() -> Result<String, webmcp::DefinitionError> {
let mut definition = ToolDef::new(
"list_tasks",
"List at most 20 tasks for the signed-in user.",
json!({
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}},
"completed": {"type": "boolean"}
}
}),
Endpoint::new("/api/tasks", "GET"),
);
definition.annotations.insert("read_only".into(), true);
definition.annotations.insert("untrusted_content".into(), true);
definition.max_response_chars = Some(1500);
// In an application, construct and freeze this registry once at boot.
let mut registry = Registry::new();
registry.register(Tool::new(definition)?)?;
registry.freeze();
let transport = Transport {
csrf: Some(Csrf {
source: "meta".into(), name: "csrf-token".into(),
header: "X-CSRF-Token".into(),
}),
};
let selected = registry.select(&["list_tasks"])?;
let manifest = Manifest::build(&selected, transport)?;
Ok(format!("{}{}",
manifest.to_script_tag(&ScriptTagOptions::default()),
runtime_script_tag("/assets/webmcp-runtime.js", None)))
}
```
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:
```rust
use webmcp::ScriptTagOptions;
let options = ScriptTagOptions {
autostart: false,
nonce: Some("YOUR_CSP_NONCE".into()),
};
```
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:
```javascript
import { mount } from "/assets/webmcp-runtime.js";
const handle = mount({ selector: "#webmcp-manifest" });
// After replacing the manifest in an SPA:
await handle.refresh();
// When the owner is removed:
handle.dispose();
```
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
```rust
use webmcp::{form_attrs, param_attr, origin_trial_meta_tag, apply_origin_trial_headers};
let attrs = form_attrs("create_task", "Create one task.", false)?;
let input_attrs = param_attr("Task title");
let meta = origin_trial_meta_tag("YOUR_ORIGIN_TRIAL_TOKEN");
let mut headers = Vec::new();
apply_origin_trial_headers(&mut headers, "YOUR_ORIGIN_TRIAL_TOKEN");
```
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:
```rust,ignore
use axum::{routing::get, Router};
use webmcp::OriginTrialLayer;
```
## Troubleshooting
| `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_map` destinations cannot collide or target reserved transport
fields such as `_method`, `authenticity_token`, or `csrfmiddlewaretoken`.
- 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
with `dataOmitted`; an oversized read returns `response_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](conformance/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](conformance/README.md) 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.
```sh
cargo fmt --check
cargo clippy --all-features -- -D warnings
cargo test --all-features
cargo package --allow-dirty --list
```
## 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](https://github.com/seunghan91/webmcp),
[Go](https://github.com/seunghan91/webmcp-go),
[Django](https://github.com/seunghan91/webmcp-django),
[Rust](https://github.com/seunghan91/webmcp-rust).
## License
MIT