webmcp 0.1.0

WebMCP manifest v1 definitions, safe HTML helpers, browser runtime, and optional Tower middleware.
Documentation
# webmcp

0.1.0 · Spec baseline: **WebMCP Draft CG Report 2026-10-02**. Rust unit and fixture tests do not establish live browser, CSP, or production compatibility.

## 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`.

```toml
[dependencies]
webmcp = "0.1.0"
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.0", 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;
let app: Router = Router::new().route("/", get(|| async { "ok" }))
    .layer(OriginTrialLayer::new("YOUR_TOKEN")?.warn_on_oac_opt_out(true));
```

## 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