use crate::{nonce_attr, DefinitionError, Tool};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Csrf {
pub source: String,
pub name: String,
pub header: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transport {
#[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(())
}
}
#[derive(Clone, Debug)]
pub struct ScriptTagOptions {
pub autostart: bool,
pub nonce: Option<String>,
}
impl Default for ScriptTagOptions {
fn default() -> Self {
Self {
autostart: true,
nonce: None,
}
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct Manifest(Value);
impl Manifest {
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}),
))
}
pub fn as_value(&self) -> &Value {
&self.0
}
pub fn to_json(&self) -> String {
serde_json::to_string(&self.0).expect("validated JSON value")
}
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())
)
}
}
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))
}