fix(rmcp): add Audio variant to PromptMessageContent (#865)

The spec's prompt-message ContentBlock union is
`text | image | audio | resource_link | resource`, but
PromptMessageContent omitted `Audio`. Because the enum is
`#[serde(tag = "type")]` with no catch-all, a spec-conformant
`{"type":"audio",...}` content block failed to deserialize with
"unknown variant `audio`", breaking prompts/get for any server that
returns audio prompt content (the audio analogue of #842 / #843).

The supporting AudioContent type already existed, and Audio was
already a variant of the general RawContent enum (tool results,
sampling) -- only PromptMessageContent lacked it.

Add the flattened Audio variant (mirroring Image), a
PromptMessage::new_audio constructor (mirroring new_image), and
serialization + deserialization regression tests.

Fixes #864.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brice Fernandes 2026-06-23 21:35:40 +01:00 committed by GitHub
parent 25220361d5
commit 0a95c3b608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 141 additions and 1 deletions

View file

@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::{ use super::{
AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource, AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource,
content::{EmbeddedResource, ImageContent}, content::{AudioContent, EmbeddedResource, ImageContent},
resource::ResourceContents, resource::ResourceContents,
}; };
@ -157,6 +157,11 @@ pub enum PromptMessageContent {
#[serde(flatten)] #[serde(flatten)]
image: ImageContent, image: ImageContent,
}, },
/// Audio content with base64-encoded data
Audio {
#[serde(flatten)]
audio: AudioContent,
},
/// Embedded server-side resource /// Embedded server-side resource
Resource { Resource {
#[serde(flatten)] #[serde(flatten)]
@ -230,6 +235,29 @@ impl PromptMessage {
} }
} }
/// Create a new audio message. `annotations` is optional.
#[cfg(feature = "base64")]
pub fn new_audio(
role: PromptMessageRole,
data: &[u8],
mime_type: &str,
annotations: Option<Annotations>,
) -> Self {
use base64::{Engine, prelude::BASE64_STANDARD};
let base64 = BASE64_STANDARD.encode(data);
Self {
role,
content: PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: base64,
mime_type: mime_type.into(),
}
.optional_annotate(annotations),
},
}
}
/// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional. /// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional.
pub fn new_resource( pub fn new_resource(
role: PromptMessageRole, role: PromptMessageRole,
@ -307,6 +335,56 @@ mod tests {
assert!(!json.contains("mime_type")); assert!(!json.contains("mime_type"));
} }
#[test]
fn test_prompt_message_audio_serialization_and_deserialization() {
// Audio is part of the spec's ContentBlock union for prompt messages
// (text | image | audio | resource_link | resource). Ensure the Audio
// variant serializes to the flat, spec-compliant shape
// `{ "type": "audio", "data", "mimeType" }` and parses back.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let content = PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: "YXVkaW8=".to_string(),
mime_type: "audio/wav".to_string(),
}
.no_annotation(),
};
let value = serde_json::to_value(&content).unwrap();
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio"));
assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8="));
assert_eq!(
value.get("mimeType").and_then(|v| v.as_str()),
Some("audio/wav"),
"expected camelCase mimeType, got: {value:#?}"
);
// Regression: a spec-valid audio content block must deserialize into
// the Audio variant (previously failed with "unknown variant `audio`").
let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#;
let parsed: PromptMessageContent = serde_json::from_str(json).unwrap();
assert_eq!(parsed, content);
}
#[test]
#[cfg(feature = "base64")]
fn test_prompt_message_new_audio_constructor() {
let message =
PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None);
let value = serde_json::to_value(&message).unwrap();
let content = value.get("content").expect("content present");
assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio"));
assert_eq!(
content.get("mimeType").and_then(|v| v.as_str()),
Some("audio/wav")
);
// base64 of "hello"
assert_eq!(
content.get("data").and_then(|v| v.as_str()),
Some("aGVsbG8=")
);
}
#[test] #[test]
fn test_prompt_message_resource_link_serialization() { fn test_prompt_message_resource_link_serialization() {
use super::super::resource::RawResource; use super::super::resource::RawResource;

View file

@ -2115,6 +2115,37 @@
"mimeType" "mimeType"
] ]
}, },
{
"description": "Audio content with base64-encoded data",
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/definitions/Annotations"
},
{
"type": "null"
}
]
},
"data": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"type": {
"type": "string",
"const": "audio"
}
},
"required": [
"type",
"data",
"mimeType"
]
},
{ {
"description": "Embedded server-side resource", "description": "Embedded server-side resource",
"type": "object", "type": "object",

View file

@ -2115,6 +2115,37 @@
"mimeType" "mimeType"
] ]
}, },
{
"description": "Audio content with base64-encoded data",
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/definitions/Annotations"
},
{
"type": "null"
}
]
},
"data": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"type": {
"type": "string",
"const": "audio"
}
},
"required": [
"type",
"data",
"mimeType"
]
},
{ {
"description": "Embedded server-side resource", "description": "Embedded server-side resource",
"type": "object", "type": "object",