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:
parent
25220361d5
commit
0a95c3b608
3 changed files with 141 additions and 1 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue