use serde_json::{Value, json};
use super::documents::SiteFacts;
pub const WEBMCP_JS_PATH: &str = "/_autumn/webmcp.js";
#[must_use]
pub(crate) fn script(facts: &SiteFacts, csrf_header: &str) -> String {
let (endpoint, tools) = facts.mcp.as_ref().map_or_else(
|| (String::new(), Vec::new()),
|mcp| {
let tools = mcp
.tools
.iter()
.filter(|_| mcp.public_tools)
.filter(|t| valid_tool_name(&t.name))
.filter(|t| !facts.webmcp_projected.contains(&t.name))
.filter(|t| marked_safe(&t.annotations))
.map(|t| {
json!({
"name": t.name,
"description": t.description.clone().unwrap_or_else(|| t.name.clone()),
"inputSchema": t.input_schema,
"annotations": webmcp_annotations(&t.annotations),
})
})
.collect();
(mcp.path.clone(), tools)
},
);
SCRIPT
.replace("__ENDPOINT__", &js_json(&json!(endpoint)))
.replace("__CSRF_HEADER__", &js_json(&json!(csrf_header)))
.replace("__CSRF_REQUIRED__", &js_json(&json!(facts.csrf_required)))
.replace("__TOOLS__", &js_json(&Value::Array(tools)))
}
fn js_json(value: &Value) -> String {
serde_json::to_string(value)
.unwrap_or_else(|_| "null".to_owned())
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('&', "\\u0026")
}
const SCRIPT: &str = r#"// Autumn WebMCP: register this site's MCP tools with in-browser agents.
(() => {
"use strict";
const mc = document.modelContext || navigator.modelContext;
if (!mc || typeof mc.registerTool !== "function") return;
// This script is served at `{base}/_autumn/webmcp.js`, so the endpoint
// sits under the same base: an app behind a path prefix keeps it.
const here = document.currentScript && document.currentScript.src;
const endpoint = here ? new URL(".." + __ENDPOINT__, here).href : __ENDPOINT__;
const csrfHeader = __CSRF_HEADER__;
const meta = document.querySelector(
'meta[name="csrf-token"], meta[name="autumn-csrf-token"]'
);
const token = meta && meta.content;
// A write needs the CSRF token. Without one, only read-only tools, and
// none when CSRF protection checks every POST to the endpoint.
const csrfRequired = __CSRF_REQUIRED__;
const tools = __TOOLS__.filter(
(t) => token || (!csrfRequired && t.annotations.readOnlyHint)
);
let nextId = 0;
const parse = (text) => {
try {
return JSON.parse(text);
} catch (_) {
const data = text.split("\n").filter((l) => l.startsWith("data:"));
return JSON.parse(data[data.length - 1].slice(5));
}
};
for (const t of tools) {
const execute = async (input) => {
const headers = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"mcp-protocol-version": "2025-06-18",
};
if (token) headers[csrfHeader] = token;
const res = await fetch(endpoint, {
method: "POST",
credentials: "same-origin",
headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: ++nextId,
method: "tools/call",
params: { name: t.name, arguments: input || {} },
}),
});
let msg = null;
try {
msg = parse(await res.text());
} catch (_) {}
if (msg && typeof msg === "object" && msg.error) {
throw new Error(msg.error.message || "MCP error");
}
// A layer in front of /mcp (auth, CSRF, a rate limit) answers with
// its own status and body: that is a failure, not a result.
if (!res.ok || !msg || typeof msg !== "object" || !("result" in msg)) {
const why = (msg && (msg.detail || msg.title)) || "HTTP " + res.status;
throw new Error("MCP request failed: " + why);
}
return msg.result;
};
try {
const done = mc.registerTool({ ...t, execute });
if (done && typeof done.catch === "function") done.catch(() => {});
} catch (_) {}
}
})();
"#;
#[must_use]
pub(crate) fn valid_tool_name(name: &str) -> bool {
(1..=128).contains(&name.len())
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
}
#[must_use]
pub(crate) fn webmcp_annotations(mcp: &Value) -> Value {
let read_only = hint(mcp, "readOnlyHint") == Some(true);
json!({
"readOnlyHint": read_only,
"consequentialHint": !read_only || hint(mcp, "destructiveHint") == Some(true),
})
}
fn marked_safe(mcp: &Value) -> bool {
hint(mcp, "destructiveHint") != Some(true)
&& (hint(mcp, "readOnlyHint") == Some(true) || hint(mcp, "destructiveHint") == Some(false))
}
fn hint(mcp: &Value, key: &str) -> Option<bool> {
mcp.get(key).and_then(Value::as_bool)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn head_tags(csp_nonce: Option<&str>, csrf_token: Option<&str>) -> maud::Markup {
head_tags_at("", csp_nonce, csrf_token)
}
#[cfg(feature = "maud")]
#[must_use]
pub fn head_tags_at(
base_path: &str,
csp_nonce: Option<&str>,
csrf_token: Option<&str>,
) -> maud::Markup {
let catalog = format!("{base_path}{}", super::AI_CATALOG_PATH);
let script = format!("{base_path}{WEBMCP_JS_PATH}");
maud::html! {
@if let Some(token) = csrf_token {
meta name="csrf-token" content=(token);
}
link rel="ai-catalog" href=(catalog) type="application/json";
script src=(script) defer nonce=[csp_nonce] {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aeo::documents::{McpFacts, ToolFacts};
fn facts(public: bool) -> SiteFacts {
SiteFacts {
mcp: Some(McpFacts {
path: "/mcp".to_owned(),
tools: vec![
ToolFacts {
name: "list_todos".to_owned(),
description: Some("List </script> todos".to_owned()),
input_schema: json!({"type": "object"}),
annotations: json!({"readOnlyHint": true, "destructiveHint": false}),
},
ToolFacts {
name: "bad name!".to_owned(),
description: None,
input_schema: json!({}),
annotations: json!({}),
},
],
public_tools: public,
}),
..SiteFacts::default()
}
}
#[test]
fn without_a_token_csrf_keeps_every_tool_off_the_page() {
let mut f = facts(true);
assert!(script(&f, "x-csrf-token").contains("const csrfRequired = false;"));
f.csrf_required = true;
let js = script(&f, "x-csrf-token");
assert!(js.contains("const csrfRequired = true;"), "{js}");
assert!(
js.contains("token || (!csrfRequired && t.annotations.readOnlyHint)"),
"{js}"
);
}
#[test]
fn script_registers_each_valid_tool() {
let js = script(&facts(true), "x-csrf-token");
assert!(js.contains("registerTool"), "{js}");
assert!(
js.contains("document.modelContext || navigator.modelContext"),
"{js}"
);
assert!(js.contains("\"list_todos\""), "{js}");
assert!(!js.contains("bad name!"), "invalid names are skipped: {js}");
assert!(js.contains("\"/mcp\""), "{js}");
assert!(js.contains("\"x-csrf-token\""), "{js}");
assert!(
!js.contains("</script>"),
"no raw end tag in the script: {js}"
);
}
#[test]
fn destructive_tools_are_not_registered() {
let mut f = facts(true);
f.mcp.as_mut().unwrap().tools[0].annotations = json!({"destructiveHint": true});
assert!(!script(&f, "x-csrf-token").contains("list_todos"));
}
#[test]
fn only_tools_marked_safe_are_registered() {
let mut f = facts(true);
f.mcp.as_mut().unwrap().tools[0].annotations = json!({"readOnlyHint": false});
assert!(!script(&f, "x-csrf-token").contains("list_todos"));
f.mcp.as_mut().unwrap().tools[0].annotations =
json!({"readOnlyHint": false, "destructiveHint": false});
let js = script(&f, "x-csrf-token");
assert!(js.contains("list_todos"), "{js}");
assert!(
js.contains("\"consequentialHint\":true"),
"a write needs consent: {js}"
);
}
#[test]
fn script_lines_never_open_a_string_across_a_newline() {
for line in script(&facts(true), "x-csrf-token").lines() {
assert_eq!(line.matches('"').count() % 2, 0, "{line}");
}
}
#[test]
fn a_rejected_call_is_an_error_not_a_result() {
let js = script(&facts(true), "x-csrf-token");
assert!(js.contains("!res.ok"), "{js}");
assert!(js.contains(r#"!("result" in msg)"#), "{js}");
}
#[test]
fn script_is_a_no_op_without_public_tools() {
for f in [facts(false), SiteFacts::default()] {
let js = script(&f, "x-csrf-token");
assert!(js.contains("const tools = [].filter("), "{js}");
}
}
#[test]
fn projected_tools_are_left_to_the_webmcp_shim() {
let mut f = facts(true);
f.webmcp_projected = vec!["list_todos".to_owned()];
assert!(!script(&f, "x-csrf-token").contains("list_todos"));
}
#[test]
fn tool_names() {
assert!(valid_tool_name("search_flights.v2-a"));
assert!(!valid_tool_name(""));
assert!(!valid_tool_name("Search flights"));
assert!(!valid_tool_name(&"a".repeat(129)));
}
#[test]
fn annotations_map() {
assert_eq!(
webmcp_annotations(&json!({"readOnlyHint": true, "destructiveHint": true})),
json!({"readOnlyHint": true, "consequentialHint": true})
);
assert_eq!(
webmcp_annotations(&json!({"readOnlyHint": true})),
json!({"readOnlyHint": true, "consequentialHint": false})
);
assert_eq!(
webmcp_annotations(&json!({})),
json!({"readOnlyHint": false, "consequentialHint": true})
);
}
#[cfg(feature = "maud")]
#[test]
fn head_tags_render_the_script_and_the_ard_link() {
let html = head_tags(Some("abc"), None).into_string();
assert!(
html.contains("<script src=\"/_autumn/webmcp.js\" defer nonce=\"abc\">"),
"{html}"
);
assert!(html.contains("rel=\"ai-catalog\""), "{html}");
assert!(!html.contains("csrf-token"), "{html}");
let html = head_tags(None, Some("t<1")).into_string();
assert!(
html.contains("<meta name=\"csrf-token\" content=\"t<1\">"),
"{html}"
);
}
#[test]
fn a_base_path_prefixes_the_tags_and_the_endpoint() {
let html = head_tags_at("/shop", None, None).into_string();
assert!(html.contains("src=\"/shop/_autumn/webmcp.js\""), "{html}");
assert!(
html.contains("href=\"/shop/.well-known/ai-catalog.json\""),
"{html}"
);
let js = script(&facts(true), "x-csrf-token");
assert!(js.contains("new URL(\"..\" + \"/mcp\", here)"), "{js}");
}
#[test]
fn write_tools_wait_for_a_csrf_token() {
let js = script(&facts(true), "x-csrf-token");
assert!(
js.contains("token || (!csrfRequired && t.annotations.readOnlyHint)"),
"{js}"
);
}
}