From 297deb90edc33705a909ee1b003c4e153868bc5f Mon Sep 17 00:00:00 2001 From: nightcityblade Date: Mon, 10 Aug 2026 11:13:59 +0800 Subject: [PATCH] fix(model): preserve elicitation property order metadata --- crates/rmcp/Cargo.toml | 1 + crates/rmcp/src/model/elicitation_schema.rs | 84 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 486fdf873..9dd27a251 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -51,6 +51,7 @@ serde_json = "1.0" thiserror = "2" tokio = { version = "1", features = ["sync", "macros", "rt", "time"] } futures = "0.3" +indexmap = { version = "2", features = ["serde"] } tracing = { version = "0.1" } tokio-util = { version = "0.7" } pin-project-lite = "0.2" diff --git a/crates/rmcp/src/model/elicitation_schema.rs b/crates/rmcp/src/model/elicitation_schema.rs index 3cc24ca4c..78f109365 100644 --- a/crates/rmcp/src/model/elicitation_schema.rs +++ b/crates/rmcp/src/model/elicitation_schema.rs @@ -18,7 +18,8 @@ use std::{borrow::Cow, collections::BTreeMap, marker::PhantomData}; -use serde::{Deserialize, Serialize}; +use indexmap::IndexMap; +use serde::{Deserialize, Deserializer, Serialize}; use crate::{const_string, model::ConstString}; @@ -1109,9 +1110,10 @@ impl EnumSchema { /// .optional_bool("newsletter", false) /// .build(); /// ``` -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", schemars(!into))] +#[serde(rename_all = "camelCase", into = "ElicitationSchemaWire")] #[non_exhaustive] pub struct ElicitationSchema { /// Always "object" for elicitation schemas @@ -1125,6 +1127,11 @@ pub struct ElicitationSchema { /// Property definitions (must be primitive types) pub properties: BTreeMap, + /// Property names in wire order. Schemas constructed from a `BTreeMap` + /// use the map's sorted key order. + #[serde(skip)] + pub property_order: Option>, + /// List of required property names #[serde(skip_serializing_if = "Option::is_none")] pub required: Option>, @@ -1134,13 +1141,75 @@ pub struct ElicitationSchema { pub description: Option>, } +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ElicitationSchemaWire { + #[serde(rename = "type")] + type_: ObjectTypeConst, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option>, + properties: IndexMap, + #[serde(skip_serializing_if = "Option::is_none")] + required: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option>, +} + +impl From for ElicitationSchema { + fn from(schema: ElicitationSchemaWire) -> Self { + Self { + type_: schema.type_, + title: schema.title, + property_order: Some(schema.properties.keys().cloned().collect()), + properties: schema.properties.into_iter().collect(), + required: schema.required, + description: schema.description, + } + } +} + +impl From for ElicitationSchemaWire { + fn from(schema: ElicitationSchema) -> Self { + let mut remaining = schema.properties; + let mut properties = IndexMap::with_capacity(remaining.len()); + + if let Some(property_order) = schema.property_order { + for name in property_order { + if let Some(definition) = remaining.remove(&name) { + properties.insert(name, definition); + } + } + } + properties.extend(remaining); + + Self { + type_: schema.type_, + title: schema.title, + properties, + required: schema.required, + description: schema.description, + } + } +} + +impl<'de> Deserialize<'de> for ElicitationSchema { + fn deserialize<__D>(__deserializer: __D) -> Result + where + __D: Deserializer<'de>, + { + ElicitationSchemaWire::deserialize(__deserializer).map(Into::into) + } +} + impl ElicitationSchema { /// Create a new elicitation schema with the given properties pub fn new(properties: BTreeMap) -> Self { + let property_order = Some(properties.keys().cloned().collect()); Self { type_: ObjectTypeConst, title: None, properties, + property_order, required: None, description: None, } @@ -1632,10 +1701,12 @@ impl ElicitationSchemaBuilder { } } + let property_order = Some(self.properties.keys().cloned().collect()); Ok(ElicitationSchema { type_: ObjectTypeConst, title: self.title, properties: self.properties, + property_order, required: if self.required.is_empty() { None } else { @@ -1821,6 +1892,13 @@ mod tests { output["properties"]["choice"]["enumNames"], serde_json::json!(["Option One", "Option Two", "Option Three"]), ); + let input = r#"{"type":"object","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string"}}}"#; + let ordered: ElicitationSchema = serde_json::from_str(input)?; + assert_eq!( + ordered.property_order.as_ref().unwrap().join(","), + "firstName,lastName,email", + ); + assert_eq!(serde_json::to_string(&ordered)?, input); Ok(()) }