webmcp 0.1.2

Server-side WebMCP toolkit: validated tool definitions, script-safe manifests, shared browser runtime, Origin-Trial helpers, optional tower layer.
Documentation
use crate::DefinitionError;
use serde_json::{Map, Value};

pub(crate) const MAX_INTEGER: u64 = 1 << 53;
pub(crate) const RESERVED: &[&str] = &["__proto__", "constructor", "prototype"];

pub(crate) fn object<'a>(
    value: &'a Value,
    label: &str,
) -> Result<&'a Map<String, Value>, DefinitionError> {
    value
        .as_object()
        .ok_or_else(|| DefinitionError::new(format!("{label} must be an object")))
}

fn keys<'a>(
    value: &'a Value,
    allowed: &[&str],
    label: &str,
) -> Result<&'a Map<String, Value>, DefinitionError> {
    let map = object(value, label)?;
    for key in map.keys() {
        if !allowed.contains(&key.as_str()) {
            return Err(DefinitionError::new(format!(
                "unsupported {label} key: {key}"
            )));
        }
    }
    Ok(map)
}

pub(crate) fn validate_metadata(value: &Value) -> Result<(), DefinitionError> {
    match value {
        Value::Object(map) => {
            for item in map.values() {
                validate_metadata(item)?;
            }
        }
        Value::Array(items) => {
            for item in items {
                validate_metadata(item)?;
            }
        }
        Value::Number(number) => {
            let valid = number
                .as_i64()
                .map(|n| n.unsigned_abs() <= MAX_INTEGER)
                .or_else(|| number.as_u64().map(|n| n <= MAX_INTEGER))
                .unwrap_or(false);
            if !valid {
                return Err(DefinitionError::new(
                    "metadata numbers must be integers within +/-2^53; floats are not supported",
                ));
            }
        }
        _ => {}
    }
    Ok(())
}

pub(crate) fn validate(schema: &Value) -> Result<(), DefinitionError> {
    validate_metadata(schema)?;
    let root = keys(
        schema,
        &["type", "properties", "required", "description"],
        "input_schema",
    )?;
    if schema["type"] != "object" {
        return Err(DefinitionError::new("input_schema type must be object"));
    }
    let empty = Map::new();
    let properties = match root.get("properties") {
        Some(properties) => object(properties, "properties")?,
        None => &empty,
    };
    for (name, property) in properties {
        if RESERVED.contains(&name.as_str()) {
            return Err(DefinitionError::new(format!(
                "reserved property name: {name}"
            )));
        }
        validate_property(property, name, false)?;
    }
    if root.get("description").is_some_and(|v| !v.is_string()) {
        return Err(DefinitionError::new("schema description must be a string"));
    }
    if let Some(required) = root.get("required") {
        let mut seen = std::collections::BTreeSet::new();
        let valid = required.as_array().is_some_and(|items| {
            items.iter().all(|v| {
                v.as_str()
                    .is_some_and(|name| properties.contains_key(name) && seen.insert(name))
            })
        });
        if !valid {
            return Err(DefinitionError::new(
                "required must contain unique declared property names",
            ));
        }
    }
    Ok(())
}

fn validate_property(
    property: &Value,
    label: &str,
    scalar_only: bool,
) -> Result<(), DefinitionError> {
    let map = keys(
        property,
        &[
            "type",
            "enum",
            "description",
            "default",
            "minimum",
            "maximum",
            "maxLength",
            "maxItems",
            "items",
        ],
        label,
    )?;
    let kind = property["type"].as_str().unwrap_or("");
    if !["string", "number", "integer", "boolean"].contains(&kind)
        && (scalar_only || kind != "array")
    {
        return Err(DefinitionError::new(format!(
            "{label}: only scalar properties and arrays of scalars are supported"
        )));
    }
    if kind == "array" {
        validate_property(&property["items"], &format!("{label}.items"), true)?;
    } else if map.contains_key("items") {
        return Err(DefinitionError::new(format!(
            "{label}: items requires array type"
        )));
    }
    if map.get("description").is_some_and(|v| !v.is_string()) {
        return Err(DefinitionError::new(format!(
            "{label}: description must be a string"
        )));
    }
    for key in ["minimum", "maximum", "maxLength", "maxItems"] {
        if let Some(value) = map.get(key) {
            if value.as_i64().is_none()
                || (["maxLength", "maxItems"].contains(&key) && value.as_u64().is_none())
            {
                return Err(DefinitionError::new(format!(
                    "{label}: {key} must be an integer (lengths must be nonnegative)"
                )));
            }
        }
    }
    if let Some(values) = map.get("enum") {
        if !values.as_array().is_some_and(|items| {
            !items.is_empty() && items.iter().all(|item| matches(item, property))
        }) {
            return Err(DefinitionError::new(format!(
                "{label}: enum must be a nonempty array matching the property type"
            )));
        }
    }
    if map
        .get("default")
        .is_some_and(|value| !matches(value, property))
    {
        return Err(DefinitionError::new(format!(
            "{label}: default must match the property type"
        )));
    }
    Ok(())
}

fn matches(value: &Value, schema: &Value) -> bool {
    match schema["type"].as_str() {
        Some("string") => value.is_string(),
        Some("number" | "integer") => value.is_i64() || value.is_u64(),
        Some("boolean") => value.is_boolean(),
        Some("array") => value
            .as_array()
            .is_some_and(|items| items.iter().all(|item| matches(item, &schema["items"]))),
        _ => false,
    }
}