fix(rmcp): flatten Resource variant of PromptMessageContent (#843)

The Resource variant of PromptMessageContent was missing #[serde(flatten)],
causing the embedded resource content block to serialize as a double-nested
shape `{ "type": "resource", "resource": { "resource": {...} } }` instead of
the spec-compliant flat shape `{ "type": "resource", "resource": {uri, mimeType, text} }`.

This caused Zod-based MCP clients (e.g. Claude Code) to reject prompts/get
responses containing embedded resource messages with InvalidUnion errors.

The Image and ResourceLink variants already use #[serde(flatten)] correctly;
only Resource was missing it.

Fix: add #[serde(flatten)] so EmbeddedResource (=Annotated<RawEmbeddedResource>)
fields _meta / annotations / resource are flattened to the content-block level,
matching the MCP spec for prompts embedded resources.

Regression test: test_prompt_message_resource_serialization_is_flat verifies
content.resource.uri is reachable and content.resource.resource is absent.

Schema snapshots regenerated via UPDATE_SCHEMA=1.
This commit is contained in:
Yutaka Nishimura 2026-05-13 03:46:12 +09:00 • committed by GitHub
commit d83b1566d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -158,7 +158,10 @@ pub enum PromptMessageContent {
image: ImageContent,
},
/// Embedded server-side resource
Resource { resource: EmbeddedResource },
Resource {
#[serde(flatten)]
resource: EmbeddedResource,
},
/// A link to a resource that can be fetched separately
ResourceLink {
#[serde(flatten)]
@ -321,6 +324,57 @@ mod tests {
assert!(json.contains("\"name\":\"test.txt\""));
}
#[test]
fn test_prompt_message_resource_serialization_is_flat() {
// Regression test: PromptMessageContent::Resource must serialize to
// the spec-compliant flat shape `{ "type": "resource", "resource": { "uri", "mimeType", "text" } }`
// and NOT the double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }`.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let message = PromptMessage::new_resource(
PromptMessageRole::User,
"alc://packages/sc/narrative".to_string(),
Some("text/markdown".to_string()),
Some("# Hello".to_string()),
None,
None,
None,
);
let value: serde_json::Value = serde_json::to_value(&message).unwrap();
// Drill into content
let content = value.get("content").expect("content present");
assert_eq!(
content.get("type").and_then(|v| v.as_str()),
Some("resource")
);
let resource = content
.get("resource")
.expect("resource field present at content level");
// Spec-compliant: resource.uri / resource.mimeType / resource.text MUST be flat
assert_eq!(
resource.get("uri").and_then(|v| v.as_str()),
Some("alc://packages/sc/narrative"),
"expected flat resource.uri, got: {resource:#?}"
);
assert_eq!(
resource.get("mimeType").and_then(|v| v.as_str()),
Some("text/markdown")
);
assert_eq!(
resource.get("text").and_then(|v| v.as_str()),
Some("# Hello")
);
// Regression guard: content.resource MUST NOT contain a nested `resource` key.
assert!(
resource.get("resource").is_none(),
"double-nested resource detected (regression): {resource:#?}"
);
}
#[test]
fn test_prompt_message_content_resource_link_deserialization() {
let json = r#"{