fix(rmcp): preserve elicitation requestedSchema $schema dialect (#1176)

* 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

* 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.

* fix(rmcp): generate elicitation $schema as optional string in schemars

Use schemars(with = "String") on ElicitationSchema::$schema so
schemars generates "type": "string" instead of ["string", "null"].
This commit is contained in:
Recoordinate 2026-08-19 03:02:45 +12:00 • committed by GitHub
commit 6f8dcde04f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 0 deletions

View file

@ -1116,6 +1116,15 @@ 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", default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(with = "String"))]
pub schema: Option<Cow<'static, str>>,
/// Always "object" for elicitation schemas
#[serde(rename = "type")]
pub type_: ObjectTypeConst,
@ -1144,6 +1153,8 @@ pub struct ElicitationSchema {
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct ElicitationSchemaWire {
#[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
schema: Option<Cow<'static, str>>,
#[serde(rename = "type")]
type_: ObjectTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
@ -1158,6 +1169,7 @@ struct ElicitationSchemaWire {
impl From<ElicitationSchemaWire> 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 +1195,7 @@ impl From<ElicitationSchema> for ElicitationSchemaWire {
properties.extend(remaining);
Self {
schema: schema.schema,
type_: schema.type_,
title: schema.title,
properties,
@ -1206,6 +1219,7 @@ impl ElicitationSchema {
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
let property_order = Some(properties.keys().cloned().collect());
Self {
schema: None,
type_: ObjectTypeConst,
title: None,
properties,
@ -1301,6 +1315,12 @@ impl ElicitationSchema {
self
}
/// Set the JSON Schema dialect identifier (the `$schema` keyword).
pub fn with_schema(mut self, schema: impl Into<Cow<'static, str>>) -> Self {
self.schema = Some(schema.into());
self
}
/// Create a builder for constructing elicitation schemas fluently
pub fn builder() -> ElicitationSchemaBuilder {
ElicitationSchemaBuilder::new()
@ -1703,6 +1723,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 +1923,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`.

View file

@ -963,6 +963,10 @@
"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"
},
"description": {
"description": "Optional description of what this schema represents",
"type": [