From 6bed00d9c4ccde32fe6871ae27c9e3155a1d26a4 Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:53:30 +1200 Subject: [PATCH 1/2] fix(rmcp): preserve elicitation requestedSchema $schema dialect ElicitationSchema round-trips through ElicitationSchemaWire, which had no field for the top-level `$schema` keyword. A dialect declared on an incoming requestedSchema (allowed since protocol revision 2025-11-25) was therefore accepted but silently dropped on re-serialization. Add a `schema` field to both the public struct and the wire bridge, thread it through the two `From` conversions, and expose a `with_schema` setter. The field is `skip_serializing_if = "Option::is_none"`, so schemas without a dialect serialize unchanged. Refs #1168 --- crates/rmcp/src/model/elicitation_schema.rs | 68 +++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 78f109365..54de05f37 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -1116,6 +1116,14 @@ impl EnumSchema { #[serde(rename_all = "camelCase", into = "ElicitationSchemaWire")] #[non_exhaustive] pub struct ElicitationSchema { + /// Optional JSON Schema dialect identifier (the `$schema` keyword). + /// + /// The 2025-11-25 protocol revision allows a `requestedSchema` to declare its + /// dialect. It is preserved verbatim so a declared dialect survives a + /// decode/re-encode round-trip instead of being silently dropped. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + pub schema: Option>, + /// Always "object" for elicitation schemas #[serde(rename = "type")] pub type_: ObjectTypeConst, @@ -1144,6 +1152,8 @@ pub struct ElicitationSchema { #[derive(Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct ElicitationSchemaWire { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + schema: Option>, #[serde(rename = "type")] type_: ObjectTypeConst, #[serde(skip_serializing_if = "Option::is_none")] @@ -1158,6 +1168,7 @@ struct ElicitationSchemaWire { impl From for ElicitationSchema { fn from(schema: ElicitationSchemaWire) -> Self { Self { + schema: schema.schema, type_: schema.type_, title: schema.title, property_order: Some(schema.properties.keys().cloned().collect()), @@ -1183,6 +1194,7 @@ impl From for ElicitationSchemaWire { properties.extend(remaining); Self { + schema: schema.schema, type_: schema.type_, title: schema.title, properties, @@ -1206,6 +1218,7 @@ impl ElicitationSchema { pub fn new(properties: BTreeMap) -> Self { let property_order = Some(properties.keys().cloned().collect()); Self { + schema: None, type_: ObjectTypeConst, title: None, properties, @@ -1301,6 +1314,12 @@ impl ElicitationSchema { self } + /// Set the JSON Schema dialect identifier (the `$schema` keyword). + pub fn with_schema(mut self, schema: impl Into>) -> Self { + self.schema = Some(schema.into()); + self + } + /// Create a builder for constructing elicitation schemas fluently pub fn builder() -> ElicitationSchemaBuilder { ElicitationSchemaBuilder::new() @@ -1703,6 +1722,7 @@ impl ElicitationSchemaBuilder { let property_order = Some(self.properties.keys().cloned().collect()); Ok(ElicitationSchema { + schema: None, type_: ObjectTypeConst, title: self.title, properties: self.properties, @@ -1902,6 +1922,54 @@ mod tests { Ok(()) } + #[test] + fn test_elicitation_schema_preserves_schema_dialect_roundtrip() -> anyhow::Result<()> { + // Regression test for #1168: a top-level `$schema` dialect declaration on a + // `requestedSchema` was silently dropped, because the wire bridge struct had + // no field to hold it. It must survive a decode/re-encode round-trip. + let input = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" } + } + }); + let schema: ElicitationSchema = serde_json::from_value(input.clone())?; + assert_eq!( + schema.schema.as_deref(), + Some("https://json-schema.org/draft/2020-12/schema"), + ); + let output = serde_json::to_value(&schema)?; + assert_eq!(output, input); + Ok(()) + } + + #[test] + fn test_elicitation_schema_omits_schema_dialect_when_absent() -> anyhow::Result<()> { + // A schema with no dialect must not emit a `$schema` key (no `"$schema": null`). + let input = serde_json::json!({ + "type": "object", + "properties": { "name": { "type": "string" } } + }); + let schema: ElicitationSchema = serde_json::from_value(input)?; + assert!(schema.schema.is_none()); + let json = serde_json::to_value(&schema)?; + assert!(json.get("$schema").is_none()); + Ok(()) + } + + #[test] + fn test_elicitation_schema_with_schema_setter_serializes_dialect() -> anyhow::Result<()> { + let schema = ElicitationSchema::new(BTreeMap::new()) + .with_schema("https://json-schema.org/draft/2020-12/schema"); + let json = serde_json::to_value(&schema)?; + assert_eq!( + json["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + Ok(()) + } + #[test] fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> { // `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`. From 312afb0b7a6918a68dff6bd03e04d88ed7e8e716 Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:37:19 +1200 Subject: [PATCH 2/2] test(rmcp): regenerate server message schema snapshot for elicitation $schema The $schema dialect field added to ElicitationSchema changes the schemars-generated ServerJsonRpcMessage schema. Regenerate the golden snapshot so test_server_json_rpc_message_schema passes. --- .../server_json_rpc_message_schema.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index ca0ef5611..03f14ebdb 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -963,6 +963,13 @@ "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis enforces the MCP 2025-06-18 specification that elicitation schemas\nmust be objects with primitive-typed properties.\n\n# Example\n\n```rust\nuse rmcp::model::*;\n\nlet schema = ElicitationSchema::builder()\n .required_email(\"email\")\n .required_integer(\"age\", 0, 150)\n .optional_bool(\"newsletter\", false)\n .build();\n```", "type": "object", "properties": { + "$schema": { + "description": "Optional JSON Schema dialect identifier (the `$schema` keyword).\n\nThe 2025-11-25 protocol revision allows a `requestedSchema` to declare its\ndialect. It is preserved verbatim so a declared dialect survives a\ndecode/re-encode round-trip instead of being silently dropped.", + "type": [ + "string", + "null" + ] + }, "description": { "description": "Optional description of what this schema represents", "type": [