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")]
|
#[serde(rename_all = "camelCase", into = "ElicitationSchemaWire")]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
pub struct ElicitationSchema {
|
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
|
/// Always "object" for elicitation schemas
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub type_: ObjectTypeConst,
|
pub type_: ObjectTypeConst,
|
||||||
|
|
@ -1144,6 +1153,8 @@ pub struct ElicitationSchema {
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct ElicitationSchemaWire {
|
struct ElicitationSchemaWire {
|
||||||
|
#[serde(rename = "$schema", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
schema: Option<Cow<'static, str>>,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
type_: ObjectTypeConst,
|
type_: ObjectTypeConst,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -1158,6 +1169,7 @@ struct ElicitationSchemaWire {
|
||||||
impl From<ElicitationSchemaWire> for ElicitationSchema {
|
impl From<ElicitationSchemaWire> for ElicitationSchema {
|
||||||
fn from(schema: ElicitationSchemaWire) -> Self {
|
fn from(schema: ElicitationSchemaWire) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
schema: schema.schema,
|
||||||
type_: schema.type_,
|
type_: schema.type_,
|
||||||
title: schema.title,
|
title: schema.title,
|
||||||
property_order: Some(schema.properties.keys().cloned().collect()),
|
property_order: Some(schema.properties.keys().cloned().collect()),
|
||||||
|
|
@ -1183,6 +1195,7 @@ impl From<ElicitationSchema> for ElicitationSchemaWire {
|
||||||
properties.extend(remaining);
|
properties.extend(remaining);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
schema: schema.schema,
|
||||||
type_: schema.type_,
|
type_: schema.type_,
|
||||||
title: schema.title,
|
title: schema.title,
|
||||||
properties,
|
properties,
|
||||||
|
|
@ -1206,6 +1219,7 @@ impl ElicitationSchema {
|
||||||
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
|
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
|
||||||
let property_order = Some(properties.keys().cloned().collect());
|
let property_order = Some(properties.keys().cloned().collect());
|
||||||
Self {
|
Self {
|
||||||
|
schema: None,
|
||||||
type_: ObjectTypeConst,
|
type_: ObjectTypeConst,
|
||||||
title: None,
|
title: None,
|
||||||
properties,
|
properties,
|
||||||
|
|
@ -1301,6 +1315,12 @@ impl ElicitationSchema {
|
||||||
self
|
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
|
/// Create a builder for constructing elicitation schemas fluently
|
||||||
pub fn builder() -> ElicitationSchemaBuilder {
|
pub fn builder() -> ElicitationSchemaBuilder {
|
||||||
ElicitationSchemaBuilder::new()
|
ElicitationSchemaBuilder::new()
|
||||||
|
|
@ -1703,6 +1723,7 @@ impl ElicitationSchemaBuilder {
|
||||||
|
|
||||||
let property_order = Some(self.properties.keys().cloned().collect());
|
let property_order = Some(self.properties.keys().cloned().collect());
|
||||||
Ok(ElicitationSchema {
|
Ok(ElicitationSchema {
|
||||||
|
schema: None,
|
||||||
type_: ObjectTypeConst,
|
type_: ObjectTypeConst,
|
||||||
title: self.title,
|
title: self.title,
|
||||||
properties: self.properties,
|
properties: self.properties,
|
||||||
|
|
@ -1902,6 +1923,54 @@ mod tests {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> {
|
fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> {
|
||||||
// `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`.
|
// `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```",
|
"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",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
||||||
"description": "Optional description of what this schema represents",
|
"description": "Optional description of what this schema represents",
|
||||||
"type": [
|
"type": [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue