Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions crates/rmcp/src/model/elicitation_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cow<'static, str>>,

/// Always "object" for elicitation schemas
#[serde(rename = "type")]
pub type_: ObjectTypeConst,
Expand Down Expand Up @@ -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<Cow<'static, str>>,
#[serde(rename = "type")]
type_: ObjectTypeConst,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -1158,6 +1168,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()),
Expand All @@ -1183,6 +1194,7 @@ impl From<ElicitationSchema> for ElicitationSchemaWire {
properties.extend(remaining);

Self {
schema: schema.schema,
type_: schema.type_,
title: schema.title,
properties,
Expand All @@ -1206,6 +1218,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,
Expand Down Expand Up @@ -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<Cow<'static, str>>) -> Self {
self.schema = Some(schema.into());
self
}

/// Create a builder for constructing elicitation schemas fluently
pub fn builder() -> ElicitationSchemaBuilder {
ElicitationSchemaBuilder::new()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down