use crate::{schema, Transport};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
#[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 {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Endpoint {
pub path: String,
pub method: String,
#[serde(default)]
pub param_map: BTreeMap<String, String>,
#[serde(default)]
pub array_format: Option<String>,
}
impl Endpoint {
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,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
pub endpoint: Endpoint,
#[serde(default)]
pub annotations: BTreeMap<String, bool>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub max_response_chars: Option<u64>,
}
impl ToolDef {
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(())
}
#[derive(Clone, Debug)]
pub struct Tool {
definition: ToolDef,
}
impl Tool {
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 })
}
pub fn name(&self) -> &str {
&self.definition.name
}
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
}
}
#[derive(Debug, Default)]
pub struct Registry {
tools: BTreeMap<String, Tool>,
frozen: bool,
}
impl Registry {
pub fn new() -> Self {
Self::default()
}
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(())
}
pub fn freeze(&mut self) {
self.frozen = true;
}
pub fn get(&self, name: &str) -> Option<&Tool> {
self.tools.get(name)
}
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()
}
}