Skip to main content

webmcp/
lib.rs

1//! Server-side toolkit for WebMCP, the W3C Community Group proposal that lets a
2//! web page register tools an in-browser AI agent can call through
3//! `document.modelContext` (`registerTool`, `getTools`, `executeTool`).
4//!
5//! WebMCP is not the server-to-server Model Context Protocol (MCP): MCP servers
6//! (for example with `rmcp`) are called by desktop or CLI agents, while WebMCP
7//! tools live in a browser tab and run with the signed-in user's session. This
8//! crate covers the server half of WebMCP and is framework-agnostic:
9//!
10//! - [`Tool::new`] validates a definition at boot: name 1–128 of `[A-Za-z0-9_.-]`,
11//!   WebMCP annotations `read_only`/`untrusted_content`/`consequential`/`debugging`,
12//!   a strict JSON Schema subset, a same-origin endpoint, GET only when read-only.
13//! - [`Registry`] selects the tools each page exposes; [`Manifest::to_script_tag`]
14//!   renders them as a script-safe `<script type="application/json">` element.
15//! - [`RUNTIME_JS`] is the shared browser runtime (zero dependencies). It registers
16//!   the manifest's tools and, when an agent calls one, calls the tool's endpoint
17//!   with the user's session cookies and CSRF token, without following redirects
18//!   and without retries. Serve it and reference it with [`runtime_script_tag`].
19//! - [`form_attrs`] and [`param_attr`] render the declarative form attributes.
20//! - Origin trial helpers, plus `OriginTrialLayer` behind the `tower` feature.
21//!
22//! Endpoints keep authorization, CSRF checks and input validation; annotations
23//! are hints, not security controls. The same manifest format and runtime ship in
24//! the Ruby (reference), Go and Django packages. Spec baseline: WebMCP Draft CG
25//! Report 2026-10-02.
26//!
27//! ```
28//! use serde_json::json;
29//! use webmcp::{Endpoint, Manifest, ScriptTagOptions, Tool, ToolDef, Transport};
30//! let mut definition = ToolDef::new("list_tasks", "List my tasks.",
31//!     json!({"type": "object"}), Endpoint::new("/api/tasks", "GET"));
32//! definition.annotations.insert("read_only".into(), true);
33//! let tool = Tool::new(definition)?;
34//! let manifest = Manifest::build(&[&tool], Transport::default())?;
35//! let html = manifest.to_script_tag(&ScriptTagOptions::default());
36//! assert!(html.contains("data-webmcp-autostart"));
37//! # Ok::<(), webmcp::DefinitionError>(())
38//! ```
39//!
40//! With `tower`, `OriginTrialLayer` fills only missing response headers.
41//! The `axum` feature enables Tower support for this documentation-only example;
42//! the application supplies its own axum dependency:
43//!
44//! ```ignore
45//! use axum::{routing::get, Router};
46//! use webmcp::OriginTrialLayer;
47//! fn main() -> Result<(), Box<dyn std::error::Error>> {
48//!     let app: Router = Router::new().route("/", get(|| async { "ok" }))
49//!         .layer(OriginTrialLayer::new("YOUR_TOKEN")?.warn_on_oac_opt_out(true));
50//!     # let _ = app;
51//!     Ok(())
52//! }
53//! ```
54
55#![deny(missing_docs)]
56#![deny(missing_debug_implementations)]
57
58mod definition;
59mod manifest;
60mod origin_trial;
61mod schema;
62
63pub use definition::{DefinitionError, Endpoint, Registry, Tool, ToolDef};
64pub use manifest::{Csrf, Manifest, ScriptTagOptions, Transport};
65pub use origin_trial::*;
66
67/// An error in a tool definition or declarative form's metadata.
68pub type Error = DefinitionError;
69
70/// The byte-for-byte copy of the shared browser runtime.
71pub const RUNTIME_JS: &str = include_str!("../runtime/webmcp-runtime.js");
72/// SHA-256 of the shipped runtime (also pinned in `conformance/RUNTIME.sha256`).
73pub const RUNTIME_SHA256: &str = "287200d4ef89e16ccd0f0bbb035d9b484c3a761503f3df40f8f1cf5f3baae452";
74
75pub(crate) fn escape_html(value: &str) -> String {
76    value
77        .replace('&', "&amp;")
78        .replace('<', "&lt;")
79        .replace('>', "&gt;")
80        .replace('"', "&quot;")
81        .replace('\'', "&#39;")
82}
83
84/// Render escaped, normative declarative form attributes.
85///
86/// Metadata must be trusted literals: HTML escaping does not stop prompt injection.
87pub fn form_attrs(tool: &str, description: &str, autosubmit: bool) -> Result<String, Error> {
88    definition::validate_name(tool)?;
89    if description.is_empty() {
90        return Err(DefinitionError::new(
91            "form description must be a nonempty string",
92        ));
93    }
94    Ok(format!(
95        "toolname=\"{}\" tooldescription=\"{}\"{}",
96        escape_html(tool),
97        escape_html(description),
98        if autosubmit { " toolautosubmit" } else { "" }
99    ))
100}
101
102/// Render an escaped `toolparamdescription` attribute, even for pre-escaped input.
103pub fn param_attr(description: &str) -> String {
104    format!("toolparamdescription=\"{}\"", escape_html(description))
105}
106
107/// Render an external module script tag with an optional escaped CSP nonce.
108/// Serve [`RUNTIME_JS`] at `src` using your framework's asset pipeline.
109pub fn runtime_script_tag(src: &str, nonce: Option<&str>) -> String {
110    format!(
111        "<script type=\"module\" src=\"{}\"{}></script>",
112        escape_html(src),
113        nonce_attr(nonce)
114    )
115}
116
117pub(crate) fn nonce_attr(nonce: Option<&str>) -> String {
118    nonce
119        .map(|value| format!(" nonce=\"{}\"", escape_html(value)))
120        .unwrap_or_default()
121}