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:
parent
321ab14f67
commit
d83b1566d0
3 changed files with 99 additions and 67 deletions
|
|
@ -158,7 +158,10 @@ pub enum PromptMessageContent {
|
||||||
image: ImageContent,
|
image: ImageContent,
|
||||||
},
|
},
|
||||||
/// Embedded server-side resource
|
/// Embedded server-side resource
|
||||||
Resource { resource: EmbeddedResource },
|
Resource {
|
||||||
|
#[serde(flatten)]
|
||||||
|
resource: EmbeddedResource,
|
||||||
|
},
|
||||||
/// A link to a resource that can be fetched separately
|
/// A link to a resource that can be fetched separately
|
||||||
ResourceLink {
|
ResourceLink {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
|
|
@ -321,6 +324,57 @@ mod tests {
|
||||||
assert!(json.contains("\"name\":\"test.txt\""));
|
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]
|
#[test]
|
||||||
fn test_prompt_message_content_resource_link_deserialization() {
|
fn test_prompt_message_content_resource_link_deserialization() {
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
|
|
|
||||||
|
|
@ -140,35 +140,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Annotated2": {
|
"Annotated2": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"_meta": {
|
|
||||||
"description": "Optional protocol-level metadata for this content block",
|
|
||||||
"type": [
|
|
||||||
"object",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"additionalProperties": true
|
|
||||||
},
|
|
||||||
"annotations": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Annotations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resource": {
|
|
||||||
"$ref": "#/definitions/ResourceContents"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"resource"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Annotated3": {
|
|
||||||
"description": "Represents a resource in the extension with metadata",
|
"description": "Represents a resource in the extension with metadata",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -244,7 +215,7 @@
|
||||||
"name"
|
"name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Annotated4": {
|
"Annotated3": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"annotations": {
|
"annotations": {
|
||||||
|
|
@ -1481,7 +1452,7 @@
|
||||||
"resourceTemplates": {
|
"resourceTemplates": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/Annotated4"
|
"$ref": "#/definitions/Annotated3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1508,7 +1479,7 @@
|
||||||
"resources": {
|
"resources": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/Annotated3"
|
"$ref": "#/definitions/Annotated2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2148,8 +2119,26 @@
|
||||||
"description": "Embedded server-side resource",
|
"description": "Embedded server-side resource",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"_meta": {
|
||||||
|
"description": "Optional protocol-level metadata for this content block",
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Annotations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"resource": {
|
"resource": {
|
||||||
"$ref": "#/definitions/Annotated2"
|
"$ref": "#/definitions/ResourceContents"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -140,35 +140,6 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Annotated2": {
|
"Annotated2": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"_meta": {
|
|
||||||
"description": "Optional protocol-level metadata for this content block",
|
|
||||||
"type": [
|
|
||||||
"object",
|
|
||||||
"null"
|
|
||||||
],
|
|
||||||
"additionalProperties": true
|
|
||||||
},
|
|
||||||
"annotations": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/definitions/Annotations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resource": {
|
|
||||||
"$ref": "#/definitions/ResourceContents"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"resource"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"Annotated3": {
|
|
||||||
"description": "Represents a resource in the extension with metadata",
|
"description": "Represents a resource in the extension with metadata",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -244,7 +215,7 @@
|
||||||
"name"
|
"name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Annotated4": {
|
"Annotated3": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"annotations": {
|
"annotations": {
|
||||||
|
|
@ -1481,7 +1452,7 @@
|
||||||
"resourceTemplates": {
|
"resourceTemplates": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/Annotated4"
|
"$ref": "#/definitions/Annotated3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1508,7 +1479,7 @@
|
||||||
"resources": {
|
"resources": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/Annotated3"
|
"$ref": "#/definitions/Annotated2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -2148,8 +2119,26 @@
|
||||||
"description": "Embedded server-side resource",
|
"description": "Embedded server-side resource",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"_meta": {
|
||||||
|
"description": "Optional protocol-level metadata for this content block",
|
||||||
|
"type": [
|
||||||
|
"object",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Annotations"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"resource": {
|
"resource": {
|
||||||
"$ref": "#/definitions/Annotated2"
|
"$ref": "#/definitions/ResourceContents"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue