fix(elicitation): preserve enumNames through ElicitationSchema serde round-trip (#905)

* fix(elicitation): preserve enumNames through ElicitationSchema serde round-trip

UntitledSingleSelectEnumSchema lacked deny_unknown_fields, so a legacy
enum payload containing enumNames was silently matched by that variant
(ignoring the field) rather than falling through to LegacyEnumSchema.
The enumNames array was lost on re-serialization.

Add deny_unknown_fields to UntitledSingleSelectEnumSchema so that any
unknown field (including enumNames) causes serde to try the next
untagged variant, reaching LegacyEnumSchema correctly.

Also add skip_serializing_if = "Option::is_none" to
LegacyEnumSchema::enum_names so that an untitled legacy enum without
enumNames does not serialize "enumNames": null.

Fixes #903

* test(elicitation): regenerate server schema snapshot for deny_unknown_fields

deny_unknown_fields on UntitledSingleSelectEnumSchema makes schemars
emit additionalProperties: false for that definition. Regenerate the
golden schema fixtures to match (UPDATE_SCHEMA=1 cargo test -p rmcp
--test test_message_schema --all-features).
This commit is contained in:
Abdoul 2026-06-20 13:55:51 -07:00 committed by GitHub
parent bf71eb8b09
commit 4fd4986b62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 43 additions and 1 deletions

View file

@ -544,12 +544,13 @@ pub struct LegacyEnumSchema {
pub description: Option<Cow<'static, str>>,
#[serde(rename = "enum")]
pub enum_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enum_names: Option<Vec<String>>,
}
/// Untitled single-select
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct UntitledSingleSelectEnumSchema {
@ -1742,6 +1743,45 @@ mod tests {
Ok(())
}
#[test]
fn test_legacy_enum_schema_roundtrip_preserves_enum_names() -> anyhow::Result<()> {
// Regression test for: legacy enum payload with `enumNames` was silently
// deserialized as `UntitledSingleSelectEnumSchema` (which has no `enumNames`
// field), causing the array to be dropped on re-serialization.
let input = serde_json::json!({
"type": "object",
"properties": {
"choice": {
"type": "string",
"enum": ["opt1", "opt2", "opt3"],
"enumNames": ["Option One", "Option Two", "Option Three"]
}
}
});
let schema: ElicitationSchema = serde_json::from_value(input.clone())?;
let output = serde_json::to_value(&schema)?;
assert_eq!(
output["properties"]["choice"]["enumNames"],
serde_json::json!(["Option One", "Option Two", "Option Three"]),
);
Ok(())
}
#[test]
fn test_legacy_enum_schema_no_enum_names_omits_field() -> anyhow::Result<()> {
// `LegacyEnumSchema` with `enum_names: None` must not serialize `"enumNames": null`.
let schema = EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: vec!["a".to_string(), "b".to_string()],
enum_names: None,
});
let json = serde_json::to_value(&schema)?;
assert!(!json.as_object().unwrap().contains_key("enumNames"));
Ok(())
}
#[test]
fn test_enum_schema_titled_multi_select_serialization() -> anyhow::Result<()> {
let schema = EnumSchema::builder(vec!["US".to_string(), "UK".to_string()])

View file

@ -3642,6 +3642,7 @@
"$ref": "#/definitions/StringTypeConst"
}
},
"additionalProperties": false,
"required": [
"type",
"enum"

View file

@ -3642,6 +3642,7 @@
"$ref": "#/definitions/StringTypeConst"
}
},
"additionalProperties": false,
"required": [
"type",
"enum"