autumn-web 0.9.0

An opinionated, convention-over-configuration web framework for Rust
Documentation
//! `WebMCP`: let in-browser AI agents call this site's MCP tools.
//!
//! Autumn serves a small script at [`WEBMCP_JS_PATH`]. On page load it calls
//! `document.modelContext.registerTool()` once for each public MCP tool. A
//! tool call goes to the MCP endpoint with the page's cookies and CSRF token,
//! so the normal route pipeline (auth, CSRF, rate limits) applies.
//!
//! Add the script to the page `<head>` with [`head_tags`]. The scaffolded
//! layout does this. Pass the page's CSRF token to [`head_tags`], which
//! writes the `<meta name="csrf-token">` tag. When CSRF protection checks the
//! MCP endpoint (the production default), every tool call is a checked
//! `POST`, so without a token the script registers no tools, read-only ones
//! included. Only when CSRF does not check the endpoint does a page without a
//! token still get the read-only tools.

use serde_json::{Value, json};

use super::documents::SiteFacts;

/// Path of the `WebMCP` script.
pub const WEBMCP_JS_PATH: &str = "/_autumn/webmcp.js";

/// The `WebMCP` script for `facts`. With no public MCP tools it does nothing.
#[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))
                // The `WebMCP` projection registers these itself.
                .filter(|t| !facts.webmcp_projected.contains(&t.name))
                // An in-browser agent runs with the visitor's session, so
                // only a tool marked safe goes on the page.
                .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)))
}

/// JSON safe to put inside a `<script>`: `<`, `>`, and `&` are escaped, so
/// no data can close the script element.
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 (_) {}
  }
})();
"#;

/// `true` for a valid `WebMCP` tool name: 1–128 of `A-Z a-z 0-9 _ . -`.
#[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'-'))
}

/// Map MCP annotations to `WebMCP` annotations.
#[must_use]
pub(crate) fn webmcp_annotations(mcp: &Value) -> Value {
    let read_only = hint(mcp, "readOnlyHint") == Some(true);
    json!({
        "readOnlyHint": read_only,
        // A write needs the visitor's consent unless it is read-only.
        "consequentialHint": !read_only || hint(mcp, "destructiveHint") == Some(true),
    })
}

/// `true` when the tool is read-only, or says it is not destructive. A
/// missing hint is not safety.
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)
}

/// `<head>` tags for agents: the ARD link and the `WebMCP` script.
///
/// Pass the request's CSP nonce when the page uses a nonce-based policy.
/// Pass the request's CSRF token (from the
/// [`CsrfToken`](crate::security::CsrfToken) extractor) to let in-browser
/// agents call tools: when CSRF checks the MCP endpoint, as it does by
/// default in production, no tool is registered without it. It is written as
/// `<meta name="csrf-token">`, which the htmx CSRF helper reads too.
///
/// ```rust,ignore
/// html! { head { (autumn_web::aeo::webmcp::head_tags(None, csrf.as_ref().map(CsrfToken::token))) } }
/// ```
#[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)
}

/// [`head_tags`] for an app served below a path prefix (`/shop` for
/// `[seo] base_url = "https://example.com/shop"`; see
/// [`base_path`](super::base_path)): the ARD link and the script URL sit
/// under it.
///
/// ```rust,ignore
/// let base = autumn_web::aeo::base_path(&config);
/// html! { head { (autumn_web::aeo::webmcp::head_tags_at(&base, None, 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() {
        // A POST tool has no `destructiveHint`: absence is not safety.
        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() {
        // A JS string cannot hold a raw newline. An odd count of `"` on a
        // line means one string runs past the line end.
        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() {
        // A Problem Details body from a layer in front of /mcp parses as
        // JSON, so the status and the JSON-RPC shape are both checked.
        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&lt;1\">"),
            "{html}"
        );
    }

    /// Codex review (P2): an app below a path prefix serves the script, the
    /// ARD and the MCP endpoint under it.
    #[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}"
        );
        // The endpoint resolves against the script's own URL, which carries
        // the prefix.
        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}"
        );
    }
}