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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions