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('&', "&")
78 .replace('<', "<")
79 .replace('>', ">")
80 .replace('"', """)
81 .replace('\'', "'")
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}