webmcp 0.1.0

WebMCP manifest v1 definitions, safe HTML helpers, browser runtime, and optional Tower middleware.
Documentation
use crate::{nonce_attr, DefinitionError, Tool};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};

/// CSRF token location, read by the browser runtime on each invocation.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Csrf {
    /// `meta` or `cookie`.
    pub source: String,
    /// Meta-tag or cookie name; nonempty and without control characters.
    pub name: String,
    /// Request header name; nonempty and without control characters.
    pub header: String,
}

/// Explicit transport configuration. Rust supplies no implicit CSRF defaults.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transport {
    /// Optional token location. Configure it for non-GET endpoints.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "deserialize_csrf"
    )]
    pub csrf: Option<Csrf>,
}

fn deserialize_csrf<'de, D: serde::Deserializer<'de>>(
    deserializer: D,
) -> Result<Option<Csrf>, D::Error> {
    Csrf::deserialize(deserializer).map(Some)
}

impl Transport {
    pub(crate) fn validate(&self) -> Result<(), DefinitionError> {
        if let Some(csrf) = &self.csrf {
            if !["meta", "cookie"].contains(&csrf.source.as_str())
                || [&csrf.name, &csrf.header]
                    .iter()
                    .any(|s| s.is_empty() || s.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}'))
            {
                return Err(DefinitionError::new("csrf requires source meta/cookie and nonempty name/header without control characters"));
            }
        }
        Ok(())
    }
}

/// Script tag options. Autostart marking is enabled by default.
#[derive(Clone, Debug)]
pub struct ScriptTagOptions {
    /// Emit `data-webmcp-autostart`; use false for manually owned mounting.
    pub autostart: bool,
    /// Optional CSP nonce, always HTML-escaped.
    pub nonce: Option<String>,
}
impl Default for ScriptTagOptions {
    fn default() -> Self {
        Self {
            autostart: true,
            nonce: None,
        }
    }
}

/// An immutable manifest containing only the tools passed to [`Manifest::build`].
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct Manifest(Value);
impl Manifest {
    /// Validate transport and build version 1 entries, rejecting duplicate names.
    pub fn build(tools: &[&Tool], transport: Transport) -> Result<Self, DefinitionError> {
        transport.validate()?;
        let mut names = BTreeSet::new();
        let mut entries = Vec::with_capacity(tools.len());
        for tool in tools {
            if !names.insert(tool.name()) {
                return Err(DefinitionError::new("duplicate tool names in manifest"));
            }
            entries.push(tool.entry(&transport));
        }
        Ok(Self(
            json!({"webmcpManifestVersion": 1, "transport": transport, "tools": entries}),
        ))
    }
    /// Inspect the parsed manifest without allowing post-validation mutation.
    pub fn as_value(&self) -> &Value {
        &self.0
    }
    /// Compact JSON for transport outside an HTML script element.
    pub fn to_json(&self) -> String {
        serde_json::to_string(&self.0).expect("validated JSON value")
    }
    /// Render JSON with all five script-breakout characters escaped.
    /// Fingerprints are calculated from unescaped canonical JSON before rendering.
    pub fn to_script_tag(&self, options: &ScriptTagOptions) -> String {
        let json = self
            .to_json()
            .replace('<', "\\u003c")
            .replace('>', "\\u003e")
            .replace('&', "\\u0026")
            .replace('\u{2028}', "\\u2028")
            .replace('\u{2029}', "\\u2029");
        format!(
            "<script type=\"application/json\" id=\"webmcp-manifest\"{}{}>{json}</script>",
            if options.autostart {
                " data-webmcp-autostart"
            } else {
                ""
            },
            nonce_attr(options.nonce.as_deref())
        )
    }
}

// Serialize BTreeMaps directly so fingerprint sorting remains correct even if a
// downstream dependency enables serde_json's preserve_order via feature unification.
struct Canonical<'a>(&'a Value);
impl Serialize for Canonical<'_> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        match self.0 {
            Value::Object(map) => map
                .iter()
                .map(|(key, value)| (key, Canonical(value)))
                .collect::<BTreeMap<_, _>>()
                .serialize(serializer),
            Value::Array(items) => items
                .iter()
                .map(Canonical)
                .collect::<Vec<_>>()
                .serialize(serializer),
            value => value.serialize(serializer),
        }
    }
}

pub(crate) fn fingerprint(value: &Value) -> String {
    let preimage = serde_json::to_vec(&Canonical(value)).expect("validated JSON value");
    format!("sha256:{:x}", Sha256::digest(preimage))
}