Spec conformance: meta support and spec updates (#415)

* feat: add _meta to content blocks and embedded resources; update schemas

* feat: set default protocol version; add _meta to content blocks/resources; update schemas

* chore: format content.rs via rustfmt

* chore(protocol): keep LATEST at 2025-03-26 per review until full 2025-06-18 compliance

* feat(prompt): add constructors with optional meta for image and resource

- Keep text helper; meta is currently ignored for text until schema supports it.

* refactor(prompt): simplify constructors so meta is optional; remove duplicate non-meta variants

* fix: modify code comment about version

* refactor(prompt): rename meta parameters in new_resource function for clarity
This commit is contained in:
Andrew Harvard 2025-09-04 14:23:36 -04:00 • committed by GitHub
commit 4eb413b6c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 3427 additions and 47 deletions

View file

@ -149,6 +149,7 @@ impl ProtocolVersion {
pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05"));
// Keep LATEST at 2025-03-26 until full 2025-06-18 compliance and automated testing are in place.
pub const LATEST: Self = Self::V_2025_03_26; pub const LATEST: Self = Self::V_2025_03_26;
} }

View file

@ -16,8 +16,8 @@ pub struct Annotations {
pub audience: Option<Vec<Role>>, pub audience: Option<Vec<Role>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<f32>, pub priority: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none", rename = "lastModified")]
pub timestamp: Option<DateTime<Utc>>, pub last_modified: Option<DateTime<Utc>>,
} }
impl Annotations { impl Annotations {
@ -30,7 +30,7 @@ impl Annotations {
); );
Annotations { Annotations {
priority: Some(priority), priority: Some(priority),
timestamp: Some(timestamp), last_modified: Some(timestamp),
audience: None, audience: None,
} }
} }
@ -72,7 +72,7 @@ impl<T: AnnotateAble> Annotated<T> {
self.annotations.as_ref().and_then(|a| a.priority) self.annotations.as_ref().and_then(|a| a.priority)
} }
pub fn timestamp(&self) -> Option<DateTime<Utc>> { pub fn timestamp(&self) -> Option<DateTime<Utc>> {
self.annotations.as_ref().and_then(|a| a.timestamp) self.annotations.as_ref().and_then(|a| a.last_modified)
} }
pub fn with_audience(self, audience: Vec<Role>) -> Annotated<T> pub fn with_audience(self, audience: Vec<Role>) -> Annotated<T>
where where
@ -92,7 +92,7 @@ impl<T: AnnotateAble> Annotated<T> {
annotations: Some(Annotations { annotations: Some(Annotations {
audience: Some(audience), audience: Some(audience),
priority: None, priority: None,
timestamp: None, last_modified: None,
}), }),
} }
} }
@ -114,7 +114,7 @@ impl<T: AnnotateAble> Annotated<T> {
raw: self.raw, raw: self.raw,
annotations: Some(Annotations { annotations: Some(Annotations {
priority: Some(priority), priority: Some(priority),
timestamp: None, last_modified: None,
audience: None, audience: None,
}), }),
} }
@ -128,7 +128,7 @@ impl<T: AnnotateAble> Annotated<T> {
Annotated { Annotated {
raw: self.raw, raw: self.raw,
annotations: Some(Annotations { annotations: Some(Annotations {
timestamp: Some(timestamp), last_modified: Some(timestamp),
..annotations ..annotations
}), }),
} }
@ -136,7 +136,7 @@ impl<T: AnnotateAble> Annotated<T> {
Annotated { Annotated {
raw: self.raw, raw: self.raw,
annotations: Some(Annotations { annotations: Some(Annotations {
timestamp: Some(timestamp), last_modified: Some(timestamp),
priority: None, priority: None,
audience: None, audience: None,
}), }),
@ -211,7 +211,7 @@ pub trait AnnotateAble: sealed::Sealed {
Self: Sized, Self: Sized,
{ {
self.annotate(Annotations { self.annotate(Annotations {
timestamp: Some(timestamp), last_modified: Some(timestamp),
..Default::default() ..Default::default()
}) })
} }

View file

@ -11,6 +11,9 @@ use super::{AnnotateAble, Annotated, resource::ResourceContents};
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RawTextContent { pub struct RawTextContent {
pub text: String, pub text: String,
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
} }
pub type TextContent = Annotated<RawTextContent>; pub type TextContent = Annotated<RawTextContent>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -20,6 +23,9 @@ pub struct RawImageContent {
/// The base64-encoded image /// The base64-encoded image
pub data: String, pub data: String,
pub mime_type: String, pub mime_type: String,
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
} }
pub type ImageContent = Annotated<RawImageContent>; pub type ImageContent = Annotated<RawImageContent>;
@ -27,6 +33,7 @@ pub type ImageContent = Annotated<RawImageContent>;
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct RawEmbeddedResource { pub struct RawEmbeddedResource {
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>, pub meta: Option<super::Meta>,
pub resource: ResourceContents, pub resource: ResourceContents,
@ -79,13 +86,17 @@ impl RawContent {
} }
pub fn text<S: Into<String>>(text: S) -> Self { pub fn text<S: Into<String>>(text: S) -> Self {
RawContent::Text(RawTextContent { text: text.into() }) RawContent::Text(RawTextContent {
text: text.into(),
meta: None,
})
} }
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self { pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
RawContent::Image(RawImageContent { RawContent::Image(RawImageContent {
data: data.into(), data: data.into(),
mime_type: mime_type.into(), mime_type: mime_type.into(),
meta: None,
}) })
} }
@ -209,6 +220,7 @@ mod tests {
let image_content = RawImageContent { let image_content = RawImageContent {
data: "base64data".to_string(), data: "base64data".to_string(),
mime_type: "image/png".to_string(), mime_type: "image/png".to_string(),
meta: None,
}; };
let json = serde_json::to_string(&image_content).unwrap(); let json = serde_json::to_string(&image_content).unwrap();

View file

@ -114,49 +114,59 @@ impl PromptMessage {
content: PromptMessageContent::Text { text: text.into() }, content: PromptMessageContent::Text { text: text.into() },
} }
} }
/// Create a new image message. `meta` and `annotations` are optional.
#[cfg(feature = "base64")] #[cfg(feature = "base64")]
pub fn new_image( pub fn new_image(
role: PromptMessageRole, role: PromptMessageRole,
data: &[u8], data: &[u8],
mime_type: &str, mime_type: &str,
meta: Option<crate::model::Meta>,
annotations: Option<Annotations>, annotations: Option<Annotations>,
) -> Self { ) -> Self {
let mime_type = mime_type.into();
let base64 = BASE64_STANDARD.encode(data); let base64 = BASE64_STANDARD.encode(data);
Self { Self {
role, role,
content: PromptMessageContent::Image { content: PromptMessageContent::Image {
image: RawImageContent { image: RawImageContent {
data: base64, data: base64,
mime_type, mime_type: mime_type.into(),
meta,
} }
.optional_annotate(annotations), .optional_annotate(annotations),
}, },
} }
} }
/// Create a new resource message /// 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,
uri: String, uri: String,
mime_type: String, mime_type: Option<String>,
text: Option<String>, text: Option<String>,
resource_meta: Option<crate::model::Meta>,
resource_content_meta: Option<crate::model::Meta>,
annotations: Option<Annotations>, annotations: Option<Annotations>,
) -> Self { ) -> Self {
let resource_contents = ResourceContents::TextResourceContents { let resource_contents = match text {
uri, Some(t) => ResourceContents::TextResourceContents {
mime_type: Some(mime_type), uri,
text: text.unwrap_or_default(), mime_type,
meta: None, text: t,
meta: resource_content_meta,
},
None => ResourceContents::BlobResourceContents {
uri,
mime_type,
blob: String::new(),
meta: resource_content_meta,
},
}; };
Self { Self {
role, role,
content: PromptMessageContent::Resource { content: PromptMessageContent::Resource {
resource: RawEmbeddedResource { resource: RawEmbeddedResource {
meta: None, meta: resource_meta,
resource: resource_contents, resource: resource_contents,
} }
.optional_annotate(annotations), .optional_annotate(annotations),
@ -164,6 +174,16 @@ impl PromptMessage {
} }
} }
/// Note: PromptMessage text content does not carry protocol-level _meta per current schema.
/// This function exists for API symmetry but ignores the meta parameter.
pub fn new_text_with_meta<S: Into<String>>(
role: PromptMessageRole,
text: S,
_meta: Option<crate::model::Meta>,
) -> Self {
Self::new_text(role, text)
}
/// Create a new resource link message /// Create a new resource link message
pub fn new_resource_link(role: PromptMessageRole, resource: super::resource::Resource) -> Self { pub fn new_resource_link(role: PromptMessageRole, resource: super::resource::Resource) -> Self {
Self { Self {
@ -184,6 +204,7 @@ mod tests {
let image_content = RawImageContent { let image_content = RawImageContent {
data: "base64data".to_string(), data: "base64data".to_string(),
mime_type: "image/png".to_string(), mime_type: "image/png".to_string(),
meta: None,
}; };
let json = serde_json::to_string(&image_content).unwrap(); let json = serde_json::to_string(&image_content).unwrap();

View file

@ -4,20 +4,20 @@ use serde_json::json;
#[test] #[test]
fn serialize_embedded_text_resource_with_meta() { fn serialize_embedded_text_resource_with_meta() {
// Inner contents meta // Inner contents meta
let mut inner_meta = Meta::new(); let mut resource_content_meta = Meta::new();
inner_meta.insert("inner".to_string(), json!(2)); resource_content_meta.insert("inner".to_string(), json!(2));
// Top-level embedded resource meta // Top-level embedded resource meta
let mut top_meta = Meta::new(); let mut resource_meta = Meta::new();
top_meta.insert("top".to_string(), json!(1)); resource_meta.insert("top".to_string(), json!(1));
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
meta: Some(top_meta), meta: Some(resource_meta),
resource: ResourceContents::TextResourceContents { resource: ResourceContents::TextResourceContents {
uri: "str://example".to_string(), uri: "str://example".to_string(),
mime_type: Some("text/plain".to_string()), mime_type: Some("text/plain".to_string()),
text: "hello".to_string(), text: "hello".to_string(),
meta: Some(inner_meta), meta: Some(resource_content_meta),
}, },
}) })
.no_annotation(); .no_annotation();
@ -97,19 +97,19 @@ fn deserialize_embedded_text_resource_with_meta() {
#[test] #[test]
fn serialize_embedded_blob_resource_with_meta() { fn serialize_embedded_blob_resource_with_meta() {
let mut inner_meta = Meta::new(); let mut resource_content_meta = Meta::new();
inner_meta.insert("blob_inner".to_string(), json!(true)); resource_content_meta.insert("blob_inner".to_string(), json!(true));
let mut top_meta = Meta::new(); let mut resource_meta = Meta::new();
top_meta.insert("blob_top".to_string(), json!("t")); resource_meta.insert("blob_top".to_string(), json!("t"));
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
meta: Some(top_meta), meta: Some(resource_meta),
resource: ResourceContents::BlobResourceContents { resource: ResourceContents::BlobResourceContents {
uri: "str://blob".to_string(), uri: "str://blob".to_string(),
mime_type: Some("application/octet-stream".to_string()), mime_type: Some("application/octet-stream".to_string()),
blob: "Zm9v".to_string(), blob: "Zm9v".to_string(),
meta: Some(inner_meta), meta: Some(resource_content_meta),
}, },
}) })
.no_annotation(); .no_annotation();

View file

@ -176,19 +176,19 @@
"$ref": "#/definitions/Role" "$ref": "#/definitions/Role"
} }
}, },
"lastModified": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"priority": { "priority": {
"type": [ "type": [
"number", "number",
"null" "null"
], ],
"format": "float" "format": "float"
},
"timestamp": {
"type": [
"string",
"null"
],
"format": "date-time"
} }
} }
}, },
@ -859,6 +859,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": { "_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [ "type": [
"object", "object",
"null" "null"
@ -876,6 +877,14 @@
"RawImageContent": { "RawImageContent": {
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [
"object",
"null"
],
"additionalProperties": true
},
"data": { "data": {
"description": "The base64-encoded image", "description": "The base64-encoded image",
"type": "string" "type": "string"
@ -933,6 +942,14 @@
"RawTextContent": { "RawTextContent": {
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [
"object",
"null"
],
"additionalProperties": true
},
"text": { "text": {
"type": "string" "type": "string"
} }

File diff suppressed because it is too large Load diff

View file

@ -168,6 +168,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": { "_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [ "type": [
"object", "object",
"null" "null"
@ -292,19 +293,19 @@
"$ref": "#/definitions/Role" "$ref": "#/definitions/Role"
} }
}, },
"lastModified": {
"type": [
"string",
"null"
],
"format": "date-time"
},
"priority": { "priority": {
"type": [ "type": [
"number", "number",
"null" "null"
], ],
"format": "float" "format": "float"
},
"timestamp": {
"type": [
"string",
"null"
],
"format": "date-time"
} }
} }
}, },
@ -1232,6 +1233,14 @@
"description": "Image content with base64-encoded data", "description": "Image content with base64-encoded data",
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [
"object",
"null"
],
"additionalProperties": true
},
"annotations": { "annotations": {
"anyOf": [ "anyOf": [
{ {
@ -1362,6 +1371,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": { "_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [ "type": [
"object", "object",
"null" "null"
@ -1379,6 +1389,14 @@
"RawImageContent": { "RawImageContent": {
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [
"object",
"null"
],
"additionalProperties": true
},
"data": { "data": {
"description": "The base64-encoded image", "description": "The base64-encoded image",
"type": "string" "type": "string"
@ -1436,6 +1454,14 @@
"RawTextContent": { "RawTextContent": {
"type": "object", "type": "object",
"properties": { "properties": {
"_meta": {
"description": "Optional protocol-level metadata for this content block",
"type": [
"object",
"null"
],
"additionalProperties": true
},
"text": { "text": {
"type": "string" "type": "string"
} }

File diff suppressed because it is too large Load diff