From b5ae6e6da86230d2da877408ed89b056b957eb84 Mon Sep 17 00:00:00 2001 From: Noah Shipley Date: Mon, 27 Jul 2026 14:41:59 -0500 Subject: [PATCH] fix(agent): emit a valid schema when capping oversized MCP tool schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cap_schema` replaced any tool schema over MAX_SCHEMA_BYTES with a bare `{}`. That is not a valid JSON Schema: Anthropic's /v1/messages requires `input_schema.type` and rejects the request with 400 invalid_request_error tools.N.custom.input_schema.type: Field required Because the API validates the whole `tools` array, a single oversized tool takes down *every* turn for that session rather than just becoming an unusable tool — the agent connects, lists tools, then fails on each prompt with no obviously related error. Observed against an MCP server with two large tool schemas (4376 and 7680 bytes): both were capped, and every subsequent prompt 400'd. Replace the bare `{}` with the smallest valid equivalent, `{"type":"object","properties":{}}`, preserving the existing intent (drop the oversized schema, keep the tool callable with no arguments) while remaining acceptable to providers. Adds a test module for `cap_schema` covering pass-through, replacement, the `<=` boundary at exactly MAX_SCHEMA_BYTES, that the replacement is itself within the cap, and that it declares `type: object` — the last of which fails against the previous implementation. Signed-off-by: Noah Shipley --- crates/buzz-agent/src/mcp.rs | 92 +++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index fa8815df50..3938e009b2 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -830,15 +830,30 @@ fn timeout_msg(stage: &str, name: &str, t: Duration) -> String { format!("{stage} {name}: timeout after {}s", t.as_secs()) } +/// Replace an oversized tool schema with the smallest schema that still +/// describes "an object taking no arguments". +/// +/// This must remain a *valid* JSON Schema: providers reject a bare `{}`. +/// Anthropic's `/v1/messages` requires `input_schema.type`, and answers a +/// missing one with `400 tools.N.custom.input_schema.type: Field required`, +/// which fails the whole request — so one oversized tool would otherwise +/// break every turn for that session, not just that tool. +fn empty_object_schema() -> Value { + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("object".to_string())); + schema.insert("properties".to_string(), Value::Object(Map::new())); + Value::Object(schema) +} + fn cap_schema(qname: &str, schema: Value) -> Value { let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0); if size <= MAX_SCHEMA_BYTES { return schema; } tracing::warn!( - "tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object" + "tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object schema" ); - Value::Object(Map::new()) + empty_object_schema() } #[cfg(unix)] @@ -1137,3 +1152,76 @@ mod content_tests { // The real protection is the cfg-gated production path in spawn_one(). } } + +#[cfg(test)] +mod cap_schema_tests { + use super::*; + + fn big_schema(bytes: usize) -> Value { + let mut props = Map::new(); + props.insert("blob".to_string(), Value::String("x".repeat(bytes))); + let mut schema = Map::new(); + schema.insert("type".to_string(), Value::String("object".to_string())); + schema.insert("properties".to_string(), Value::Object(props)); + Value::Object(schema) + } + + #[test] + fn under_the_cap_is_returned_unchanged() { + let schema = big_schema(16); + assert_eq!(cap_schema("srv__small", schema.clone()), schema); + } + + #[test] + fn oversized_schema_is_replaced() { + let schema = big_schema(MAX_SCHEMA_BYTES * 2); + let capped = cap_schema("srv__big", schema.clone()); + assert_ne!(capped, schema, "oversized schema must not pass through"); + } + + /// The replacement must stay a valid JSON Schema. A bare `{}` is rejected by + /// Anthropic with `input_schema.type: Field required`, which 400s the entire + /// request — so a single oversized tool would break every turn. + #[test] + fn replacement_declares_object_type() { + let capped = cap_schema("srv__big", big_schema(MAX_SCHEMA_BYTES * 2)); + let obj = capped + .as_object() + .expect("replacement must be a JSON object"); + assert_eq!( + obj.get("type").and_then(Value::as_str), + Some("object"), + "replacement schema must declare type=object" + ); + assert!( + obj.get("properties").map(Value::is_object).unwrap_or(false), + "replacement schema must carry an object `properties`" + ); + } + + #[test] + fn replacement_is_itself_within_the_cap() { + let capped = cap_schema("srv__big", big_schema(MAX_SCHEMA_BYTES * 2)); + let size = serde_json::to_vec(&capped).expect("serializable").len(); + assert!(size <= MAX_SCHEMA_BYTES, "replacement must fit the cap"); + } + + #[test] + fn exactly_at_the_cap_is_not_replaced() { + // Boundary: the guard is `<=`, so a schema landing exactly on the cap + // must survive untouched. + let mut filler = 1; + let schema = loop { + let candidate = big_schema(filler); + let size = serde_json::to_vec(&candidate).expect("serializable").len(); + if size == MAX_SCHEMA_BYTES { + break candidate; + } + if size > MAX_SCHEMA_BYTES { + panic!("could not construct a schema of exactly {MAX_SCHEMA_BYTES} bytes"); + } + filler += 1; + }; + assert_eq!(cap_schema("srv__edge", schema.clone()), schema); + } +}