use serde_json::{json, Value};
use webmcp::*;
fn definition() -> ToolDef {
let mut d = ToolDef::new(
"list_tasks",
"List my tasks.",
json!({"type":"object","properties":{"title":{"type":"string"},"limit":{"type":"integer"}}}),
Endpoint::new("/api/tasks", "GET"),
);
d.annotations.insert("read_only".into(), true);
d
}
fn invalid(d: ToolDef, message: &str) {
let error = Tool::new(d).unwrap_err().to_string();
assert!(
error.contains(message),
"{error:?} does not contain {message:?}"
);
}
fn entry(d: ToolDef, transport: Transport) -> Value {
Manifest::build(&[&Tool::new(d).unwrap()], transport)
.unwrap()
.as_value()["tools"][0]
.clone()
}
fn csrf() -> Transport {
Transport {
csrf: Some(Csrf {
source: "meta".into(),
name: "csrf-token".into(),
header: "X-CSRF-Token".into(),
}),
}
}
#[test]
fn names_and_description_follow_normative_limits() {
for name in ["", "has space", "a/b", "a\n", "é", &"x".repeat(129)] {
let mut d = definition();
d.name = name.into();
invalid(d, "tool name");
}
for name in ["a", "A_Z.z-9", &"x".repeat(128)] {
let mut d = definition();
d.name = name.into();
assert!(Tool::new(d).is_ok());
}
let mut d = definition();
d.description.clear();
invalid(d, "description");
let mut d = definition();
d.description = "가".repeat(501);
assert!(Tool::new(d).is_ok());
}
#[test]
fn serde_rejects_wrong_types_and_unknown_definition_fields() {
for (key, value) in [
("name", json!(12)),
("description", Value::Null),
("title", json!(false)),
("annotations", json!({"read_only":"true"})),
("annotations", Value::Null),
("max_response_chars", json!(-1)),
("max_response_chars", json!(1.5)),
("unexpected", json!(true)),
] {
let mut d = serde_json::to_value(definition()).unwrap();
d[key] = value;
assert!(serde_json::from_value::<ToolDef>(d).is_err(), "{key}");
}
for value in [
json!({"path":"/","method":"GET","extra":true}),
json!({"path":"/","method":"GET","param_map":null}),
] {
assert!(serde_json::from_value::<Endpoint>(value).is_err());
}
}
#[test]
fn annotations_are_explicit_true_only_and_read_only_post_is_allowed() {
for key in [
"readOnlyHint",
"destructiveHint",
"idempotentHint",
"openWorldHint",
"title",
] {
let mut d = definition();
d.annotations.insert(key.into(), true);
invalid(d, "annotation sets differ");
}
let mut d = definition();
d.annotations.clear();
invalid(d.clone(), "GET endpoints require");
d.annotations.insert("read_only".into(), false);
invalid(d, "GET endpoints require");
for method in ["post", "patch", "put", "delete"] {
let mut d = definition();
d.endpoint.method = method.into();
assert_eq!(
entry(d.clone(), Transport::default())["annotations"],
json!({"readOnlyHint":true})
);
d.annotations.clear();
assert_eq!(entry(d, Transport::default())["annotations"], json!({}));
}
let mut d = definition();
d.annotations.extend([
("untrusted_content".into(), true),
("consequential".into(), true),
("debugging".into(), false),
]);
assert_eq!(
entry(d, Transport::default())["annotations"],
json!({"readOnlyHint":true,"untrustedContentHint":true,"consequentialHint":true})
);
}
#[test]
fn endpoints_reject_unsafe_paths_and_methods() {
for path in [
"",
"relative",
"//evil.example/x",
"https://evil.example",
"/\\evil",
"/a:b",
"/x\0",
"/x\n",
"/x\u{1f}",
"/x\u{7f}",
] {
let mut d = definition();
d.endpoint.path = path.into();
invalid(d, "endpoint path");
}
for path in ["/", "/a/b?q=hello%20world", "/한글"] {
let mut d = definition();
d.endpoint.path = path.into();
assert!(Tool::new(d).is_ok());
}
for method in ["HEAD", "OPTIONS", "", " GET", "GET\n"] {
let mut d = definition();
d.endpoint.method = method.into();
invalid(d, "endpoint method");
}
}
#[test]
fn parameter_maps_validate_keys_destinations_and_identity_collisions() {
let mut d = definition();
d.endpoint.param_map.insert("absent".into(), "other".into());
invalid(d, "not in schema");
for destination in [
"",
"2name",
"x-y",
"x.y",
"a\n",
"é",
"_method",
"authenticity_token",
"csrfmiddlewaretoken",
"__proto__",
"constructor",
"prototype",
] {
let mut d = definition();
d.endpoint
.param_map
.insert("title".into(), destination.into());
invalid(d, "destination");
}
let mut d = definition();
d.endpoint.param_map.insert("title".into(), "limit".into());
invalid(d, "collide");
let mut d = definition();
d.endpoint.param_map.extend([
("title".into(), "same".into()),
("limit".into(), "same".into()),
]);
invalid(d, "collide");
let mut d = definition();
d.endpoint.param_map.extend([
("title".into(), "limit".into()),
("limit".into(), "title".into()),
]);
assert!(Tool::new(d).is_ok());
}
#[test]
fn schema_rejects_unsupported_root_and_property_shapes() {
for schema in [
Value::Null,
json!([]),
json!({}),
json!({"type":"string"}),
json!({"type":"object","properties":null}),
json!({"type":"object","description":12}),
json!({"type":"object","required":["missing"]}),
] {
let mut d = definition();
d.input_schema = schema;
assert!(Tool::new(d).is_err());
}
for key in [
"$ref",
"$schema",
"allOf",
"anyOf",
"oneOf",
"dependentRequired",
"patternProperties",
"additionalProperties",
] {
let mut d = definition();
d.input_schema[key] = json!({});
invalid(d, "unsupported");
}
for name in ["__proto__", "constructor", "prototype"] {
let mut d = definition();
d.input_schema["properties"][name] = json!({"type":"string"});
invalid(d, "reserved property");
}
for property in [
Value::Null,
json!({"type":"object"}),
json!({"type":"null"}),
json!({"type":["string","null"]}),
json!({"type":"string","items":{"type":"string"}}),
json!({"type":"string","$ref":"x"}),
json!({"type":"array"}),
json!({"type":"array","items":{"type":"array","items":{"type":"string"}}}),
json!({"type":"array","items":{"type":"object"}}),
json!({"type":"string","description":1}),
] {
let mut d = definition();
d.input_schema["properties"]["title"] = property;
assert!(Tool::new(d).is_err());
}
for required in [
json!("title"),
json!(["title", "title"]),
json!([1]),
Value::Null,
] {
let mut d = definition();
d.input_schema["required"] = required;
invalid(d, "required");
}
}
#[test]
fn metadata_types_bounds_defaults_and_enums_are_validated() {
for property in [
json!({"type":"integer","maximum":1.0}),
json!({"type":"integer","minimum":9007199254740993_u64}),
json!({"type":"integer","minimum":-9007199254740993_i64}),
json!({"type":"string","maxLength":-1}),
json!({"type":"string","maxItems":"2"}),
json!({"type":"string","enum":[]}),
json!({"type":"boolean","enum":[true,"false"]}),
json!({"type":"string","default":null}),
json!({"type":"number","default":0.5}),
json!({"type":"array","items":{"type":"integer"},"default":[1,null]}),
json!({"type":"array","items":{"type":"string"},"enum":[["a"],[3]]}),
] {
let mut d = definition();
d.input_schema["properties"]["title"] = property;
assert!(Tool::new(d).is_err());
}
let mut d = definition();
d.input_schema = json!({"type":"object","description":"Valid subset","required":["tags"],"properties":{
"tags":{"type":"array","items":{"type":"string","description":"tag"},"default":["a"],"enum":[[],["a"]],"maxItems":2},
"count":{"type":"number","minimum":-9007199254740992_i64,"maximum":9007199254740992_u64,"default":0},
"active":{"type":"boolean","enum":[true,false],"default":true},
"text":{"type":"string","maxLength":0,"default":""}}});
assert!(Tool::new(d).is_ok());
}
#[test]
fn response_budget_is_positive_integer_in_metadata_range() {
for limit in [0, (1 << 53) + 1, u64::MAX] {
let mut d = definition();
d.max_response_chars = Some(limit);
invalid(d, "positive integer");
}
for limit in [1, 1500, 1 << 53] {
let mut d = definition();
d.max_response_chars = Some(limit);
assert!(Tool::new(d).is_ok());
}
}
#[test]
fn get_arrays_default_to_repeat_and_explicit_formats_are_preserved() {
let mut d = definition();
d.input_schema["properties"]["tags"] = json!({"type":"array","items":{"type":"string"}});
assert_eq!(
entry(d.clone(), Transport::default())["endpoint"]["arrayFormat"],
"repeat"
);
for format in ["brackets", "repeat"] {
d.endpoint.array_format = Some(format.into());
assert_eq!(
entry(d.clone(), Transport::default())["endpoint"]["arrayFormat"],
format
);
}
d.endpoint.array_format = Some("comma".into());
invalid(d.clone(), "array_format");
d.endpoint.array_format = None;
d.endpoint.method = "POST".into();
assert!(entry(d, Transport::default())["endpoint"]
.get("arrayFormat")
.is_none());
}
#[test]
fn get_array_round_trip_uses_repeated_form_keys() {
let encoded = "k=v1&k=v2";
let parsed: Vec<_> = form_urlencoded::parse(encoded.as_bytes())
.filter(|(key, _)| key == "k")
.map(|(_, value)| value.into_owned())
.collect();
assert_eq!(parsed, ["v1", "v2"]);
let values = ["space here", "a&b", "한글", ""];
let mut encoder = form_urlencoded::Serializer::new(String::new());
for value in values {
encoder.append_pair("tags", value);
}
let encoded = encoder.finish();
let parsed: Vec<_> = form_urlencoded::parse(encoded.as_bytes())
.map(|(key, value)| {
assert_eq!(key, "tags");
value.into_owned()
})
.collect();
assert_eq!(parsed, values);
}
#[test]
fn transport_is_explicit_and_validated() {
assert_eq!(
Manifest::build(&[], Transport::default())
.unwrap()
.as_value()["transport"],
json!({})
);
for source in ["meta", "cookie"] {
let mut t = csrf();
t.csrf.as_mut().unwrap().source = source.into();
assert!(Manifest::build(&[], t).is_ok());
}
for source in ["", "header", "META"] {
let mut t = csrf();
t.csrf.as_mut().unwrap().source = source.into();
assert!(Manifest::build(&[], t).is_err());
}
for value in ["", "bad\r\nheader", "bad\0", "bad\u{7f}"] {
let mut t = csrf();
t.csrf.as_mut().unwrap().name = value.into();
assert!(Manifest::build(&[], t).is_err());
let mut t = csrf();
t.csrf.as_mut().unwrap().header = value.into();
assert!(Manifest::build(&[], t).is_err());
}
for value in [
json!({"other":true}),
json!({"csrf":null}),
json!({"csrf":{}}),
json!({"csrf":{"source":"meta","name":"x","header":"x","other":true}}),
] {
assert!(serde_json::from_value::<Transport>(value).is_err());
}
}
#[test]
fn absent_optionals_are_omitted_and_manifest_is_serializable() {
let tool = Tool::new(definition()).unwrap();
let manifest = Manifest::build(&[&tool], Transport::default()).unwrap();
let value = serde_json::to_value(&manifest).unwrap();
assert_eq!(&value, manifest.as_value());
let entry = &value["tools"][0];
assert!(entry.get("title").is_none());
assert!(entry.get("maxResponseChars").is_none());
assert!(entry["endpoint"].get("paramMap").is_none());
assert!(entry["endpoint"].get("arrayFormat").is_none());
}
#[test]
fn registry_exposure_is_explicit_and_duplicate_names_fail() {
let tool = Tool::new(definition()).unwrap();
assert!(Manifest::build(&[&tool, &tool], Transport::default()).is_err());
let mut registry = Registry::new();
registry.register(tool.clone()).unwrap();
assert!(registry.register(tool).is_err());
assert!(registry.select(&[]).unwrap().is_empty());
assert!(registry.select(&["missing"]).is_err());
assert!(registry.select(&["list_tasks", "list_tasks"]).is_err());
assert_eq!(
registry.select(&["list_tasks"]).unwrap()[0].name(),
"list_tasks"
);
registry.freeze();
let mut d = definition();
d.name = "another".into();
assert!(registry
.register(Tool::new(d).unwrap())
.unwrap_err()
.to_string()
.contains("frozen"));
}
#[test]
fn immutable_tools_own_their_metadata() {
let mut d = definition();
let tool = Tool::new(d.clone()).unwrap();
d.name = "changed".into();
d.input_schema["type"] = json!("string");
assert_eq!(tool.name(), "list_tasks");
assert_eq!(tool.definition().input_schema["type"], "object");
}
#[test]
fn fingerprint_covers_effective_contract_and_transport() {
let d = definition();
let original = entry(d.clone(), Transport::default());
assert_ne!(
original["fingerprint"],
entry(d.clone(), csrf())["fingerprint"]
);
for field in ["path", "mapping", "title", "description", "schema", "cap"] {
let mut changed = d.clone();
match field {
"path" => changed.endpoint.path = "/other".into(),
"mapping" => {
changed
.endpoint
.param_map
.insert("title".into(), "content".into());
}
"title" => changed.title = Some("Display".into()),
"description" => changed.description = "Changed".into(),
"schema" => changed.input_schema["properties"]["limit"]["maximum"] = json!(20),
"cap" => changed.max_response_chars = Some(1500),
_ => unreachable!(),
}
assert_ne!(
original["fingerprint"],
entry(changed, Transport::default())["fingerprint"],
"{field}"
);
}
let mut equivalent = d;
equivalent.annotations.insert("debugging".into(), false);
equivalent.endpoint.method = "get".into();
assert_eq!(original, entry(equivalent, Transport::default()));
}
#[test]
fn script_json_escapes_all_five_characters_without_changing_values() {
let vector = "</ScRiPt><!--&\u{2028}\u{2029}한글 <already-safe>";
let mut d = definition();
d.description = vector.into();
d.title = Some(vector.into());
d.input_schema["properties"]["title"]["description"] = json!(vector);
let manifest = Manifest::build(&[&Tool::new(d).unwrap()], Transport::default()).unwrap();
let html = manifest.to_script_tag(&ScriptTagOptions::default());
assert!(html.starts_with(
"<script type=\"application/json\" id=\"webmcp-manifest\" data-webmcp-autostart>"
));
let body = html
.split_once('>')
.unwrap()
.1
.strip_suffix("</script>")
.unwrap();
for c in ['<', '>', '&', '\u{2028}', '\u{2029}'] {
assert!(!body.contains(c));
}
for escape in ["\\u003c", "\\u003e", "\\u0026", "\\u2028", "\\u2029"] {
assert!(body.contains(escape));
}
assert_eq!(
serde_json::from_str::<Value>(body).unwrap(),
*manifest.as_value()
);
let html = manifest.to_script_tag(&ScriptTagOptions {
autostart: false,
nonce: Some("\"<&'".into()),
});
assert!(!html.contains("data-webmcp-autostart"));
assert!(html.contains("nonce=\""<&'\""));
}
#[test]
fn html_helpers_escape_even_preescaped_values_and_use_only_normative_attributes() {
let text = "<&\"'> <safe>";
let escaped = "<&"'> &lt;safe&gt;";
assert_eq!(
form_attrs("my_tool", text, true).unwrap(),
format!("toolname=\"my_tool\" tooldescription=\"{escaped}\" toolautosubmit")
);
assert!(!form_attrs("my_tool", text, false)
.unwrap()
.contains("toolautosubmit"));
assert!(form_attrs("bad name", text, false).is_err());
assert!(form_attrs("good", "", false).is_err());
assert_eq!(
param_attr(text),
format!("toolparamdescription=\"{escaped}\"")
);
assert_eq!(
origin_trial_meta_tag(text),
format!("<meta http-equiv=\"origin-trial\" content=\"{escaped}\">")
);
assert_eq!(origin_trial_meta_tag(""), "");
assert_eq!(
runtime_script_tag(text, Some(text)),
format!("<script type=\"module\" src=\"{escaped}\" nonce=\"{escaped}\"></script>")
);
assert_eq!(
runtime_script_tag("/runtime.js", None),
"<script type=\"module\" src=\"/runtime.js\"></script>"
);
}
#[test]
fn plain_origin_trial_header_semantics_and_legacy_api() {
assert_eq!(
origin_trial_header("abc"),
(ORIGIN_TRIAL_HEADER, "abc".into())
);
let mut headers = vec![];
apply_origin_trial_headers(&mut headers, "");
assert!(headers.is_empty());
apply_origin_trial_headers(&mut headers, "abc");
apply_origin_trial_headers(&mut headers, "def");
assert_eq!(headers, [("Origin-Trial".into(), "abc".into())]);
let mut headers = vec![
("oRiGiN-tRiAl".into(), "".into()),
("Origin-Agent-Cluster".into(), "?0".into()),
];
let before = headers.clone();
apply_origin_trial_headers(&mut headers, "abc");
assert_eq!(headers, before);
}
#[test]
fn unicode_and_html_fingerprint_matches_independent_python_vector() {
let mut d = definition();
d.name = "unicode_tool".into();
d.description = "</ScRiPt><!--&\u{2028}\u{2029}한글".into();
d.input_schema = json!({"type":"object","properties":{
"z":{"type":"integer","default":9007199254740992_u64},
"a":{"type":"string","default":"é"}}});
assert_eq!(
entry(d, Transport::default())["fingerprint"],
"sha256:41a6dbe65d7d93d092be1a82689959cd9109bd8df6200b42aac0871527714cb9"
);
}
#[cfg(not(feature = "http"))]
#[test]
fn default_feature_apply_helper_uses_plain_headers() {
let mut headers = vec![];
apply_origin_trial(&mut headers, "abc");
assert_eq!(headers[0].1, "abc");
}