Skip to content

Bug: PromptMessageContent::Resource serializes with doubly-nested resource field (violates MCP spec) #842

@ynishi

Description

@ynishi

Summary

PromptMessageContent::Resource currently serializes with a doubly-nested resource object, producing a wire shape that does not conform to the MCP specification's Prompts → Embedded Resources definition.

Affected versions: at least rmcp-v1.4.0, rmcp-v1.5.0, rmcp-v1.6.0, and the current main branch.

Actual (current) JSON

{
  "type": "resource",
  "resource": {
    "resource": {
      "uri": "file:///example.md",
      "mimeType": "text/markdown",
      "text": "..."
    }
  }
}

Expected (MCP spec) JSON

Per the MCP spec, Prompts → Embedded Resources:
https://modelcontextprotocol.io/specification/2025-06-18/server/prompts

{
  "type": "resource",
  "resource": {
    "uri": "file:///example.md",
    "mimeType": "text/markdown",
    "text": "..."
  }
}

Root cause

In crates/rmcp/src/model/prompt.rs, the Resource variant of PromptMessageContent is the only variant missing #[serde(flatten)]:

pub enum PromptMessageContent {
    Text { text: String },
    Image {
        #[serde(flatten)]      // ← present
        image: ImageContent,
    },
    Resource { resource: EmbeddedResource },   // ← #[serde(flatten)] missing
    ResourceLink {
        #[serde(flatten)]      // ← present
        link: super::resource::Resource,
    },
}

Because EmbeddedResource = Annotated<RawEmbeddedResource> and Annotated<T> itself uses #[serde(flatten)] on its inner T, the inner RawEmbeddedResource field resource: ResourceContents is exposed inside the outer Resource { resource: ... } field, producing the doubly-nested wire shape.

The other three variants (Text, Image, ResourceLink) serialize flat. Resource is the only outlier.

Impact

Spec-conformant MCP clients (e.g. Claude Code, which uses Zod schema validation against the spec) reject prompts/get responses containing an embedded resource message:

ZodError: invalid_union at messages[N].content
  - expected resource.uri (string), received undefined

This breaks the embedded-resource path of prompts/get end-to-end with conformant clients. Other content types (text, image, resource_link) are unaffected.

Minimal reproducer

use rmcp::model::{PromptMessage, PromptMessageRole};

fn main() {
    let m = PromptMessage::new_resource(
        PromptMessageRole::User,
        "file:///example.md".to_string(),
        Some("text/markdown".to_string()),
        Some("# Hello".to_string()),
        None,
        None,
        None,
    );
    println!("{}", serde_json::to_string_pretty(&m).unwrap());
}

Observed output shows content.resource.resource.{uri,mimeType,text} instead of content.resource.{uri,mimeType,text}.

Notes on related history

Proposed fix

Add #[serde(flatten)] to the Resource variant's inner field, matching the existing pattern used by Image and ResourceLink in the same enum. Single-line change. Public Rust API (variant name, field name, field type) is unchanged; only the serde representation is corrected.

I have a fix ready on a fork branch (commits include a regression test and a regenerated schema snapshot) and will open a PR linked to this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions