Skip to content

Commit 0a95c3b

Browse files
bricefclaude
andauthored
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>
1 parent 2522036 commit 0a95c3b

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

crates/rmcp/src/model/prompt.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
22

33
use super::{
44
AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource,
5-
content::{EmbeddedResource, ImageContent},
5+
content::{AudioContent, EmbeddedResource, ImageContent},
66
resource::ResourceContents,
77
};
88

@@ -157,6 +157,11 @@ pub enum PromptMessageContent {
157157
#[serde(flatten)]
158158
image: ImageContent,
159159
},
160+
/// Audio content with base64-encoded data
161+
Audio {
162+
#[serde(flatten)]
163+
audio: AudioContent,
164+
},
160165
/// Embedded server-side resource
161166
Resource {
162167
#[serde(flatten)]
@@ -230,6 +235,29 @@ impl PromptMessage {
230235
}
231236
}
232237

238+
/// Create a new audio message. `annotations` is optional.
239+
#[cfg(feature = "base64")]
240+
pub fn new_audio(
241+
role: PromptMessageRole,
242+
data: &[u8],
243+
mime_type: &str,
244+
annotations: Option<Annotations>,
245+
) -> Self {
246+
use base64::{Engine, prelude::BASE64_STANDARD};
247+
248+
let base64 = BASE64_STANDARD.encode(data);
249+
Self {
250+
role,
251+
content: PromptMessageContent::Audio {
252+
audio: crate::model::RawAudioContent {
253+
data: base64,
254+
mime_type: mime_type.into(),
255+
}
256+
.optional_annotate(annotations),
257+
},
258+
}
259+
}
260+
233261
/// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional.
234262
pub fn new_resource(
235263
role: PromptMessageRole,
@@ -307,6 +335,56 @@ mod tests {
307335
assert!(!json.contains("mime_type"));
308336
}
309337

338+
#[test]
339+
fn test_prompt_message_audio_serialization_and_deserialization() {
340+
// Audio is part of the spec's ContentBlock union for prompt messages
341+
// (text | image | audio | resource_link | resource). Ensure the Audio
342+
// variant serializes to the flat, spec-compliant shape
343+
// `{ "type": "audio", "data", "mimeType" }` and parses back.
344+
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
345+
let content = PromptMessageContent::Audio {
346+
audio: crate::model::RawAudioContent {
347+
data: "YXVkaW8=".to_string(),
348+
mime_type: "audio/wav".to_string(),
349+
}
350+
.no_annotation(),
351+
};
352+
353+
let value = serde_json::to_value(&content).unwrap();
354+
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio"));
355+
assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8="));
356+
assert_eq!(
357+
value.get("mimeType").and_then(|v| v.as_str()),
358+
Some("audio/wav"),
359+
"expected camelCase mimeType, got: {value:#?}"
360+
);
361+
362+
// Regression: a spec-valid audio content block must deserialize into
363+
// the Audio variant (previously failed with "unknown variant `audio`").
364+
let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#;
365+
let parsed: PromptMessageContent = serde_json::from_str(json).unwrap();
366+
assert_eq!(parsed, content);
367+
}
368+
369+
#[test]
370+
#[cfg(feature = "base64")]
371+
fn test_prompt_message_new_audio_constructor() {
372+
let message =
373+
PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None);
374+
let value = serde_json::to_value(&message).unwrap();
375+
let content = value.get("content").expect("content present");
376+
assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio"));
377+
assert_eq!(
378+
content.get("mimeType").and_then(|v| v.as_str()),
379+
Some("audio/wav")
380+
);
381+
// base64 of "hello"
382+
assert_eq!(
383+
content.get("data").and_then(|v| v.as_str()),
384+
Some("aGVsbG8=")
385+
);
386+
}
387+
310388
#[test]
311389
fn test_prompt_message_resource_link_serialization() {
312390
use super::super::resource::RawResource;

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,6 +2115,37 @@
21152115
"mimeType"
21162116
]
21172117
},
2118+
{
2119+
"description": "Audio content with base64-encoded data",
2120+
"type": "object",
2121+
"properties": {
2122+
"annotations": {
2123+
"anyOf": [
2124+
{
2125+
"$ref": "#/definitions/Annotations"
2126+
},
2127+
{
2128+
"type": "null"
2129+
}
2130+
]
2131+
},
2132+
"data": {
2133+
"type": "string"
2134+
},
2135+
"mimeType": {
2136+
"type": "string"
2137+
},
2138+
"type": {
2139+
"type": "string",
2140+
"const": "audio"
2141+
}
2142+
},
2143+
"required": [
2144+
"type",
2145+
"data",
2146+
"mimeType"
2147+
]
2148+
},
21182149
{
21192150
"description": "Embedded server-side resource",
21202151
"type": "object",

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,6 +2115,37 @@
21152115
"mimeType"
21162116
]
21172117
},
2118+
{
2119+
"description": "Audio content with base64-encoded data",
2120+
"type": "object",
2121+
"properties": {
2122+
"annotations": {
2123+
"anyOf": [
2124+
{
2125+
"$ref": "#/definitions/Annotations"
2126+
},
2127+
{
2128+
"type": "null"
2129+
}
2130+
]
2131+
},
2132+
"data": {
2133+
"type": "string"
2134+
},
2135+
"mimeType": {
2136+
"type": "string"
2137+
},
2138+
"type": {
2139+
"type": "string",
2140+
"const": "audio"
2141+
}
2142+
},
2143+
"required": [
2144+
"type",
2145+
"data",
2146+
"mimeType"
2147+
]
2148+
},
21182149
{
21192150
"description": "Embedded server-side resource",
21202151
"type": "object",

0 commit comments

Comments
 (0)