diff --git a/crates/rmcp/src/handler/server/common.rs b/crates/rmcp/src/handler/server/common.rs index 8d3e4a8a1..6e8270e25 100644 --- a/crates/rmcp/src/handler/server/common.rs +++ b/crates/rmcp/src/handler/server/common.rs @@ -12,41 +12,61 @@ use crate::{ RoleServer, model::JsonObject, schemars::generate::SchemaSettings, service::RequestContext, }; -/// Generates a JSON schema for a type +/// Generates a JSON schema for a type, for the deserialize contract (what the type accepts). +/// Use this for tool and prompt inputs. pub fn schema_for_type() -> Arc { thread_local! { static CACHE_FOR_TYPE: std::sync::RwLock>> = Default::default(); }; - CACHE_FOR_TYPE.with(|cache| { + // explicitly to align json schema version to official specifications. + // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details. + // + // Note: AddNullable is intentionally NOT used here because the `nullable` keyword + // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would + // cause validation failures with strict JSON Schema validators. + cached_schema::(&CACHE_FOR_TYPE, SchemaSettings::draft2020_12()) +} + +/// Generates a JSON schema for a type, for the serialize contract (what the type writes). +/// Use this for tool outputs: a result is serialised by the server, so its schema must describe +/// what `Serialize` produces (an `Option` field with no `skip_serializing_if` is always present, +/// a `Decimal` is a string, a `#[serde(default)]` field is always written). +fn schema_for_serialized() -> Arc { + thread_local! { + static CACHE_FOR_SERIALIZED: std::sync::RwLock>> = Default::default(); + }; + cached_schema::( + &CACHE_FOR_SERIALIZED, + SchemaSettings::draft2020_12().for_serialize(), + ) +} + +fn cached_schema( + cache: &'static std::thread::LocalKey>>>, + settings: SchemaSettings, +) -> Arc { + cache.with(|cache| { if let Some(x) = cache .read() .expect("schema cache lock poisoned") .get(&TypeId::of::()) { - x.clone() - } else { - // explicitly to align json schema version to official specifications. - // refer to https://github.com/modelcontextprotocol/modelcontextprotocol/pull/655 for details. - let settings = SchemaSettings::draft2020_12(); - // Note: AddNullable is intentionally NOT used here because the `nullable` keyword - // is an OpenAPI 3.0 extension, not part of JSON Schema 2020-12. Using it would - // cause validation failures with strict JSON Schema validators. - let generator = settings.into_generator(); - let schema = generator.into_root_schema_for::(); - let object = serde_json::to_value(schema).expect("failed to serialize schema"); - let serde_json::Value::Object(object) = object else { - panic!( - "Schema serialization produced non-object value: expected JSON object but got {object:?}" - ); - }; - let schema = Arc::new(object); - cache - .write() - .expect("schema cache lock poisoned") - .insert(TypeId::of::(), schema.clone()); - - schema + return x.clone(); } + let generator = settings.into_generator(); + let schema = generator.into_root_schema_for::(); + let object = serde_json::to_value(schema).expect("failed to serialize schema"); + let serde_json::Value::Object(object) = object else { + panic!( + "Schema serialization produced non-object value: expected JSON object but got {object:?}" + ); + }; + let schema = Arc::new(object); + cache + .write() + .expect("schema cache lock poisoned") + .insert(TypeId::of::(), schema.clone()); + schema }) } @@ -116,8 +136,9 @@ fn strip_output(raw: &Arc) -> Arc { Arc::new(object) } -/// Generate and strip a JSON schema for outputSchema (top-level "title" and -/// "description" are removed; output schemas are not restricted to root type "object"). +/// Generate and strip a JSON schema for outputSchema, for the serialize contract (top-level +/// "title" and "description" are removed; output schemas are not restricted to root type +/// "object"). pub fn schema_for_output() -> Arc { thread_local! { static CACHE_FOR_OUTPUT: std::sync::RwLock>> = Default::default(); @@ -132,7 +153,7 @@ pub fn schema_for_output() -> Arc { return schema.clone(); } - let schema = strip_output(&schema_for_type::()); + let schema = strip_output(&schema_for_serialized::()); cache .write() @@ -250,6 +271,39 @@ mod tests { value: i32, } + #[derive(serde::Serialize, JsonSchema)] + struct OutputWithOption { + always: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sometimes: Option, + } + + #[derive(serde::Deserialize, JsonSchema)] + struct InputWithOption { + maybe: Option, + } + + /// An output schema describes what the type serialises: an `Option` field serde always + /// writes is required, one it skips when `None` is not. + #[test] + fn schema_for_output_describes_the_serialized_form() { + let schema = schema_for_output::(); + assert_eq!( + schema.get("required"), + Some(&serde_json::json!(["always"])), + "{schema:?}" + ); + } + + /// An input schema keeps describing what the type accepts: an `Option` field may be absent. + #[test] + fn schema_for_input_keeps_the_deserialize_form() { + let schema = schema_for_type::(); + assert_eq!(schema.get("required"), None, "{schema:?}"); + let parsed: InputWithOption = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(parsed.maybe, None); + } + #[derive(serde::Serialize, serde::Deserialize, JsonSchema)] struct AnotherTestObject { value: i32,