webmcp 0.1.0

WebMCP manifest v1 definitions, safe HTML helpers, browser runtime, and optional Tower middleware.
Documentation
use crate::{schema, Transport};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

/// Invalid tool, schema, transport, registry, or form metadata.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DefinitionError(String);
impl DefinitionError {
    pub(crate) fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}
impl fmt::Display for DefinitionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}
impl std::error::Error for DefinitionError {}

/// An explicit browser-to-server endpoint projection, validated by [`Tool::new`].
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Endpoint {
    /// Same-origin path beginning with `/`.
    pub path: String,
    /// GET, POST, PATCH, PUT or DELETE (normalized to uppercase).
    pub method: String,
    /// Browser property names mapped to endpoint parameter names.
    #[serde(default)]
    pub param_map: BTreeMap<String, String>,
    /// `brackets` or `repeat`; GET arrays default to `repeat`.
    #[serde(default)]
    pub array_format: Option<String>,
}
impl Endpoint {
    /// Construct an endpoint with no parameter renaming or array-format override.
    pub fn new(path: impl Into<String>, method: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            method: method.into(),
            param_map: BTreeMap::new(),
            array_format: None,
        }
    }
}

/// Mutable input to [`Tool::new`]; not itself a validated or exposed tool.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolDef {
    /// Normative name: 1..128 ASCII letters, digits, underscores, dots or hyphens.
    pub name: String,
    /// Nonempty trusted description.
    pub description: String,
    /// Strict manifest-v1 input schema subset.
    pub input_schema: Value,
    /// The existing application endpoint to invoke.
    pub endpoint: Endpoint,
    /// Only `read_only`, `untrusted_content`, `consequential`, and `debugging`.
    #[serde(default)]
    pub annotations: BTreeMap<String, bool>,
    /// Optional display title.
    #[serde(default)]
    pub title: Option<String>,
    /// Positive integer output budget, at most 2^53.
    #[serde(default)]
    pub max_response_chars: Option<u64>,
}
impl ToolDef {
    /// Create definition inputs with no title, annotations, or response cap.
    pub fn new(
        name: impl Into<String>,
        description: impl Into<String>,
        input_schema: Value,
        endpoint: Endpoint,
    ) -> Self {
        Self {
            name: name.into(),
            description: description.into(),
            input_schema,
            endpoint,
            annotations: BTreeMap::new(),
            title: None,
            max_response_chars: None,
        }
    }
}

const ANNOTATIONS: &[(&str, &str)] = &[
    ("read_only", "readOnlyHint"),
    ("untrusted_content", "untrustedContentHint"),
    ("consequential", "consequentialHint"),
    ("debugging", "debugging"),
];

pub(crate) fn validate_name(name: &str) -> Result<(), DefinitionError> {
    if name.is_empty()
        || name.len() > 128
        || !name
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c))
    {
        return Err(DefinitionError::new(
            "tool name must be 1..128 characters from A-Z, a-z, 0-9, _, . and -",
        ));
    }
    Ok(())
}

/// An owned, immutable, validated browser tool. Construction never registers it.
#[derive(Clone, Debug)]
pub struct Tool {
    definition: ToolDef,
}
impl Tool {
    /// Validate and own a definition; emit advisory budget warnings to stderr.
    pub fn new(mut definition: ToolDef) -> Result<Self, DefinitionError> {
        validate_name(&definition.name)?;
        if definition.description.is_empty() {
            return Err(DefinitionError::new(
                "description must be a nonempty string",
            ));
        }
        schema::validate(&definition.input_schema)?;
        if definition
            .annotations
            .keys()
            .any(|key| !ANNOTATIONS.iter().any(|(name, _)| name == key))
        {
            return Err(DefinitionError::new("MCP and WebMCP annotation sets differ; WebMCP accepts only read_only, untrusted_content, consequential, debugging; MCP destructive/idempotent/openWorld hints are not mapped"));
        }
        if definition
            .max_response_chars
            .is_some_and(|v| v == 0 || v > schema::MAX_INTEGER)
        {
            return Err(DefinitionError::new(
                "max_response_chars must be a positive integer within 2^53",
            ));
        }
        let endpoint = &mut definition.endpoint;
        if !endpoint.path.starts_with('/')
            || endpoint.path.starts_with("//")
            || endpoint
                .path
                .chars()
                .any(|c| c == '\\' || c == ':' || c <= '\u{1f}' || c == '\u{7f}')
        {
            return Err(DefinitionError::new("endpoint path must be same-origin, start with /, and contain no // prefix, backslash, colon or control character"));
        }
        endpoint.method = endpoint.method.to_ascii_uppercase();
        if !["GET", "POST", "PATCH", "PUT", "DELETE"].contains(&endpoint.method.as_str()) {
            return Err(DefinitionError::new(
                "endpoint method must be GET, POST, PATCH, PUT or DELETE",
            ));
        }
        if endpoint.method == "GET" && definition.annotations.get("read_only") != Some(&true) {
            return Err(DefinitionError::new(
                "GET endpoints require read_only: true",
            ));
        }
        let empty = serde_json::Map::new();
        let properties = definition
            .input_schema
            .get("properties")
            .and_then(Value::as_object)
            .unwrap_or(&empty);
        for (key, destination) in &endpoint.param_map {
            if !properties.contains_key(key) {
                return Err(DefinitionError::new(format!(
                    "param_map key is not in schema properties: {key}"
                )));
            }
            if !destination
                .as_bytes()
                .first()
                .is_some_and(u8::is_ascii_alphabetic)
                || !destination
                    .bytes()
                    .all(|c| c.is_ascii_alphanumeric() || c == b'_')
                || ["_method", "authenticity_token", "csrfmiddlewaretoken"]
                    .contains(&destination.as_str())
                || schema::RESERVED.contains(&destination.as_str())
            {
                return Err(DefinitionError::new(format!(
                    "invalid or reserved param_map destination: {destination}"
                )));
            }
        }
        let mut destinations = BTreeSet::new();
        for key in properties.keys() {
            if !destinations.insert(endpoint.param_map.get(key).unwrap_or(key)) {
                return Err(DefinitionError::new("param_map destinations collide"));
            }
        }
        if endpoint
            .array_format
            .as_deref()
            .is_some_and(|v| !["brackets", "repeat"].contains(&v))
        {
            return Err(DefinitionError::new(
                "array_format must be brackets or repeat",
            ));
        }
        if endpoint.array_format.is_none()
            && endpoint.method == "GET"
            && properties.values().any(|v| v["type"] == "array")
        {
            endpoint.array_format = Some("repeat".into());
        }
        if definition.name.len() > 30 {
            eprintln!(
                "WebMCP name exceeds Chrome's recommended 30-character budget: {}",
                definition.name
            );
        }
        if definition.description.chars().count() > 500 {
            eprintln!(
                "WebMCP description exceeds Chrome's recommended 500-character budget: {}",
                definition.name
            );
        }
        Ok(Self { definition })
    }

    /// The tool's validated identity.
    pub fn name(&self) -> &str {
        &self.definition.name
    }
    /// Read-only access to normalized definition metadata.
    pub fn definition(&self) -> &ToolDef {
        &self.definition
    }

    pub(crate) fn entry(&self, transport: &Transport) -> Value {
        let d = &self.definition;
        let annotations: BTreeMap<_, _> = ANNOTATIONS
            .iter()
            .filter(|(name, _)| d.annotations.get(*name) == Some(&true))
            .map(|(_, key)| (*key, true))
            .collect();
        let mut entry = json!({"name": d.name, "description": d.description, "inputSchema": d.input_schema,
            "annotations": annotations, "endpoint": {"path": d.endpoint.path, "method": d.endpoint.method}});
        if let Some(title) = &d.title {
            entry["title"] = json!(title);
        }
        if let Some(limit) = d.max_response_chars {
            entry["maxResponseChars"] = json!(limit);
        }
        if !d.endpoint.param_map.is_empty() {
            entry["endpoint"]["paramMap"] = json!(d.endpoint.param_map);
        }
        if let Some(format) = &d.endpoint.array_format {
            entry["endpoint"]["arrayFormat"] = json!(format);
        }
        let mut preimage = entry.clone();
        preimage["transport"] = json!(transport);
        entry["fingerprint"] = json!(crate::manifest::fingerprint(&preimage));
        entry
    }
}

/// A boot-time registry. Selection is explicit; registration alone exposes nothing.
#[derive(Debug, Default)]
pub struct Registry {
    tools: BTreeMap<String, Tool>,
    frozen: bool,
}
impl Registry {
    /// Create an empty registry.
    pub fn new() -> Self {
        Self::default()
    }
    /// Register one validated tool, rejecting duplicate names and frozen registries.
    pub fn register(&mut self, tool: Tool) -> Result<(), DefinitionError> {
        if self.frozen {
            return Err(DefinitionError::new("registry is frozen"));
        }
        if self.tools.contains_key(tool.name()) {
            return Err(DefinitionError::new(format!(
                "duplicate tool name: {}",
                tool.name()
            )));
        }
        self.tools.insert(tool.name().to_owned(), tool);
        Ok(())
    }
    /// Prevent subsequent registrations after application initialization.
    pub fn freeze(&mut self) {
        self.frozen = true;
    }
    /// Look up a tool without exposing it on a page.
    pub fn get(&self, name: &str) -> Option<&Tool> {
        self.tools.get(name)
    }
    /// Select precisely the given names, in order; unknown or duplicate names fail.
    pub fn select(&self, names: &[&str]) -> Result<Vec<&Tool>, DefinitionError> {
        let mut seen = BTreeSet::new();
        names
            .iter()
            .map(|name| {
                if !seen.insert(*name) {
                    return Err(DefinitionError::new(format!("duplicate tool name: {name}")));
                }
                self.get(name)
                    .ok_or_else(|| DefinitionError::new(format!("unknown tool name: {name}")))
            })
            .collect()
    }
}