Skip to content

Commit 58ff2a9

Browse files
Brandon BennettBrandon Bennett
authored andcommitted
feat(rmcp): relax outputSchema to accept non-object JSON Schema types (SEP-2106)
Split validate_and_strip into validate_and_strip_input (keeps type:"object" check for inputSchema) and validate_and_strip_output (accepts any JSON Schema root type per SEP-2106). Update schema_for_output to use the new output variant. Flip test_schema_for_output_rejects_primitive to assert Ok and rename to test_schema_for_output_accepts_primitive. Add test_schema_for_output_accepts_array and test_schema_for_output_strips_title_for_primitive. Update with_output_schema doc comment to remove incorrect "root type object" panic reference.
1 parent f1ef2ec commit 58ff2a9

2 files changed

Lines changed: 41 additions & 15 deletions

File tree

crates/rmcp/src/handler/server/common.rs

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ pub fn schema_for_type<T: JsonSchema + std::any::Any>() -> Arc<JsonObject> {
5050
})
5151
}
5252

53-
/// Validate that the schema root is `type: "object"` (per MCP spec) and strip top-level
54-
/// `title`/`description` (the wrapper type name and doc, which are noise to the LLM).
55-
fn validate_and_strip(raw: &Arc<JsonObject>, purpose: &str) -> Result<Arc<JsonObject>, String> {
53+
/// Validate that the schema root is `type: "object"` (per MCP spec for inputSchema) and
54+
/// strip top-level `title`/`description` (the wrapper type name and doc, which are noise to the LLM).
55+
fn validate_and_strip_input(raw: &Arc<JsonObject>) -> Result<Arc<JsonObject>, String> {
5656
match raw.get("type") {
5757
Some(serde_json::Value::String(t)) if t == "object" => {
5858
let mut object = raw.as_ref().clone();
@@ -61,17 +61,27 @@ fn validate_and_strip(raw: &Arc<JsonObject>, purpose: &str) -> Result<Arc<JsonOb
6161
Ok(Arc::new(object))
6262
}
6363
Some(serde_json::Value::String(t)) => Err(format!(
64-
"MCP specification requires tool {purpose} to have root type 'object', but found '{t}'."
65-
)),
66-
None => Err(format!(
67-
"Schema is missing 'type' field. MCP specification requires {purpose} to have root type 'object'."
64+
"MCP specification requires tool inputSchema to have root type 'object', but found '{t}'."
6865
)),
66+
None => Err(
67+
"Schema is missing 'type' field. MCP specification requires inputSchema to have root type 'object'.".to_string()
68+
),
6969
Some(other) => Err(format!(
7070
"Schema 'type' field has unexpected format: {other:?}. Expected \"object\"."
7171
)),
7272
}
7373
}
7474

75+
/// Strip top-level `title`/`description` from a JSON schema for outputSchema.
76+
/// Unlike inputSchema, outputSchema may have any JSON Schema 2020-12 root type
77+
/// (objects, arrays, primitives, compositions) per SEP-2106.
78+
fn validate_and_strip_output(raw: &Arc<JsonObject>) -> Arc<JsonObject> {
79+
let mut object = raw.as_ref().clone();
80+
object.remove("title");
81+
object.remove("description");
82+
Arc::new(object)
83+
}
84+
7585
/// Generate, validate, and strip a JSON schema for inputSchema (must have root type "object";
7686
/// top-level "title" and "description" are removed).
7787
pub fn schema_for_input<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> {
@@ -86,7 +96,7 @@ pub fn schema_for_input<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObjec
8696
{
8797
return result.clone();
8898
}
89-
let result = validate_and_strip(&schema_for_type::<T>(), "inputSchema");
99+
let result = validate_and_strip_input(&schema_for_type::<T>());
90100
cache
91101
.write()
92102
.expect("input schema cache lock poisoned")
@@ -106,7 +116,10 @@ pub fn schema_for_empty_input() -> Arc<JsonObject> {
106116
EMPTY.clone()
107117
}
108118

109-
/// Generate a JSON schema for outputSchema (must have root type "object"; top-level "title" and "description" are removed)
119+
/// Generate and strip a JSON schema for outputSchema.
120+
/// Unlike inputSchema, outputSchema accepts any JSON Schema 2020-12 root type
121+
/// (objects, arrays, primitives, compositions) per SEP-2106.
122+
/// Top-level "title" and "description" are always removed.
110123
pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> {
111124
thread_local! {
112125
static CACHE_FOR_OUTPUT: std::sync::RwLock<HashMap<TypeId, Result<Arc<JsonObject>, String>>> = Default::default();
@@ -122,10 +135,8 @@ pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObje
122135
return result.clone();
123136
}
124137

125-
// Generate, validate, and strip unnecessary top-level fields
126-
let result = validate_and_strip(&schema_for_type::<T>(), "outputSchema");
138+
let result = Ok(validate_and_strip_output(&schema_for_type::<T>()));
127139

128-
// Cache the result (both success and error cases)
129140
cache
130141
.write()
131142
.expect("output schema cache lock poisoned")
@@ -306,9 +317,24 @@ mod tests {
306317
}
307318

308319
#[test]
309-
fn test_schema_for_output_rejects_primitive() {
320+
fn test_schema_for_output_accepts_primitive() {
310321
let result = schema_for_output::<i32>();
311-
assert!(result.is_err(),);
322+
assert!(result.is_ok());
323+
}
324+
325+
#[test]
326+
fn test_schema_for_output_accepts_array() {
327+
let result = schema_for_output::<Vec<i32>>();
328+
assert!(result.is_ok());
329+
let schema = result.unwrap();
330+
assert_eq!(schema.get("type"), Some(&serde_json::json!("array")));
331+
assert!(schema.contains_key("items"));
332+
}
333+
334+
#[test]
335+
fn test_schema_for_output_strips_title_for_primitive() {
336+
let schema = schema_for_output::<i32>().unwrap();
337+
assert!(!schema.contains_key("title"));
312338
}
313339

314340
#[test]

crates/rmcp/src/model/tool.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ impl Tool {
318318
///
319319
/// # Panics
320320
///
321-
/// Panics if the generated schema does not have root type "object" as required by MCP specification.
321+
/// Panics if output schema generation fails.
322322
#[cfg(feature = "server")]
323323
pub fn with_output_schema<T: JsonSchema + 'static>(mut self) -> Self {
324324
let schema = crate::handler::server::tool::schema_for_output::<T>()

0 commit comments

Comments
 (0)