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:
parent
d5c46b3fe0
commit
6f8dcde04f
2 changed files with 73 additions and 0 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
Loading…
Reference in a new issue