diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 73d622c4e..3631cd231 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1267,6 +1267,9 @@ pub struct CallToolResult { /// Whether this result represents an error condition #[serde(skip_serializing_if = "Option::is_none")] pub is_error: Option, + /// Optional protocol-level metadata for this result + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, } impl CallToolResult { @@ -1276,6 +1279,7 @@ impl CallToolResult { content, structured_content: None, is_error: Some(false), + meta: None, } } /// Create an error tool result with unstructured content @@ -1284,6 +1288,7 @@ impl CallToolResult { content, structured_content: None, is_error: Some(true), + meta: None, } } /// Create a successful tool result with structured content @@ -1305,6 +1310,7 @@ impl CallToolResult { content: vec![Content::text(value.to_string())], structured_content: Some(value), is_error: Some(false), + meta: None, } } /// Create an error tool result with structured content @@ -1330,6 +1336,7 @@ impl CallToolResult { content: vec![Content::text(value.to_string())], structured_content: Some(value), is_error: Some(true), + meta: None, } } @@ -1377,6 +1384,9 @@ impl<'de> Deserialize<'de> for CallToolResult { structured_content: Option, #[serde(skip_serializing_if = "Option::is_none")] is_error: Option, + /// Accept `_meta` during deserialization + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option, } let helper = CallToolResultHelper::deserialize(deserializer)?; @@ -1384,6 +1394,7 @@ impl<'de> Deserialize<'de> for CallToolResult { content: helper.content.unwrap_or_default(), structured_content: helper.structured_content, is_error: helper.is_error, + meta: helper.meta, }; // Validate mutual exclusivity diff --git a/crates/rmcp/src/model/content.rs b/crates/rmcp/src/model/content.rs index 52d345233..dfc90ed12 100644 --- a/crates/rmcp/src/model/content.rs +++ b/crates/rmcp/src/model/content.rs @@ -27,6 +27,8 @@ pub type ImageContent = Annotated; #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct RawEmbeddedResource { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, pub resource: ResourceContents, } pub type EmbeddedResource = Annotated; @@ -88,15 +90,20 @@ impl RawContent { } pub fn resource(resource: ResourceContents) -> Self { - RawContent::Resource(RawEmbeddedResource { resource }) + RawContent::Resource(RawEmbeddedResource { + meta: None, + resource, + }) } pub fn embedded_text, T: Into>(uri: S, content: T) -> Self { RawContent::Resource(RawEmbeddedResource { + meta: None, resource: ResourceContents::TextResourceContents { uri: uri.into(), mime_type: Some("text".to_string()), text: content.into(), + meta: None, }, }) } diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index b0673dae9..06a74ca4f 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -98,7 +98,8 @@ variant_extension! { PromptListChangedNotification } } -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(transparent)] pub struct Meta(pub JsonObject); const PROGRESS_TOKEN_FIELD: &str = "progressToken"; diff --git a/crates/rmcp/src/model/prompt.rs b/crates/rmcp/src/model/prompt.rs index 138a7442e..7b8eff337 100644 --- a/crates/rmcp/src/model/prompt.rs +++ b/crates/rmcp/src/model/prompt.rs @@ -149,12 +149,14 @@ impl PromptMessage { uri, mime_type: Some(mime_type), text: text.unwrap_or_default(), + meta: None, }; Self { role, content: PromptMessageContent::Resource { resource: RawEmbeddedResource { + meta: None, resource: resource_contents, } .optional_annotate(annotations), diff --git a/crates/rmcp/src/model/resource.rs b/crates/rmcp/src/model/resource.rs index cc8cb0bb0..3e9923db6 100644 --- a/crates/rmcp/src/model/resource.rs +++ b/crates/rmcp/src/model/resource.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use super::Annotated; +use super::{Annotated, Meta}; /// Represents a resource in the extension with metadata #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -51,6 +51,8 @@ pub enum ResourceContents { #[serde(skip_serializing_if = "Option::is_none")] mime_type: Option, text: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option, }, #[serde(rename_all = "camelCase")] BlobResourceContents { @@ -58,6 +60,8 @@ pub enum ResourceContents { #[serde(skip_serializing_if = "Option::is_none")] mime_type: Option, blob: String, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + meta: Option, }, } @@ -67,6 +71,7 @@ impl ResourceContents { uri: uri.into(), mime_type: Some("text".into()), text: text.into(), + meta: None, } } } @@ -114,6 +119,7 @@ mod tests { uri: "file:///test.txt".to_string(), mime_type: Some("text/plain".to_string()), text: "Hello world".to_string(), + meta: None, }; let json = serde_json::to_string(&text_contents).unwrap(); diff --git a/crates/rmcp/tests/test_embedded_resource_meta.rs b/crates/rmcp/tests/test_embedded_resource_meta.rs new file mode 100644 index 000000000..77dae459e --- /dev/null +++ b/crates/rmcp/tests/test_embedded_resource_meta.rs @@ -0,0 +1,122 @@ +use rmcp::model::{AnnotateAble, Content, Meta, RawContent, ResourceContents}; +use serde_json::json; + +#[test] +fn serialize_embedded_text_resource_with_meta() { + // Inner contents meta + let mut inner_meta = Meta::new(); + inner_meta.insert("inner".to_string(), json!(2)); + + // Top-level embedded resource meta + let mut top_meta = Meta::new(); + top_meta.insert("top".to_string(), json!(1)); + + let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { + meta: Some(top_meta), + resource: ResourceContents::TextResourceContents { + uri: "str://example".to_string(), + mime_type: Some("text/plain".to_string()), + text: "hello".to_string(), + meta: Some(inner_meta), + }, + }) + .no_annotation(); + + let v = serde_json::to_value(&content).unwrap(); + + let expected = json!({ + "type": "resource", + "_meta": {"top": 1}, + "resource": { + "uri": "str://example", + "mimeType": "text/plain", + "text": "hello", + "_meta": {"inner": 2} + } + }); + + assert_eq!(v, expected); +} + +#[test] +fn serialize_embedded_text_resource_without_meta_omits_fields() { + let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { + meta: None, + resource: ResourceContents::TextResourceContents { + uri: "str://no-meta".to_string(), + mime_type: Some("text/plain".to_string()), + text: "hi".to_string(), + meta: None, + }, + }) + .no_annotation(); + + let v = serde_json::to_value(&content).unwrap(); + + assert_eq!(v.get("_meta"), None); + let inner = v.get("resource").and_then(|r| r.as_object()).unwrap(); + assert_eq!(inner.get("_meta"), None); +} + +#[test] +fn deserialize_embedded_text_resource_with_meta() { + let raw = json!({ + "type": "resource", + "_meta": {"x": true}, + "resource": { + "uri": "str://from-json", + "text": "ok", + "_meta": {"y": 42} + } + }); + + let content: Content = serde_json::from_value(raw).unwrap(); + + let raw = match &content.raw { + RawContent::Resource(er) => er, + _ => panic!("expected resource"), + }; + + // top-level _meta + let top = raw.meta.as_ref().expect("top-level meta missing"); + assert_eq!(top.get("x").unwrap(), &json!(true)); + + // inner contents _meta + match &raw.resource { + ResourceContents::TextResourceContents { + meta, uri, text, .. + } => { + assert_eq!(uri, "str://from-json"); + assert_eq!(text, "ok"); + let inner = meta.as_ref().expect("inner meta missing"); + assert_eq!(inner.get("y").unwrap(), &json!(42)); + } + _ => panic!("expected text resource contents"), + } +} + +#[test] +fn serialize_embedded_blob_resource_with_meta() { + let mut inner_meta = Meta::new(); + inner_meta.insert("blob_inner".to_string(), json!(true)); + + let mut top_meta = Meta::new(); + top_meta.insert("blob_top".to_string(), json!("t")); + + let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource { + meta: Some(top_meta), + resource: ResourceContents::BlobResourceContents { + uri: "str://blob".to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: "Zm9v".to_string(), + meta: Some(inner_meta), + }, + }) + .no_annotation(); + + let v = serde_json::to_value(&content).unwrap(); + + assert_eq!(v.get("_meta").unwrap(), &json!({"blob_top": "t"})); + let inner = v.get("resource").unwrap(); + assert_eq!(inner.get("_meta").unwrap(), &json!({"blob_inner": true})); +} diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index ce59d36f6..31823dff9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -892,6 +892,13 @@ "RawEmbeddedResource": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resource": { "$ref": "#/definitions/ResourceContents" } @@ -1252,6 +1259,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "mimeType": { "type": [ "string", @@ -1273,6 +1287,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "blob": { "type": "string" }, diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index ce59d36f6..31823dff9 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -892,6 +892,13 @@ "RawEmbeddedResource": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resource": { "$ref": "#/definitions/ResourceContents" } @@ -1252,6 +1259,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "mimeType": { "type": [ "string", @@ -1273,6 +1287,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "blob": { "type": "string" }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 4736b5ba8..d05c366ea 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -181,6 +181,13 @@ "Annotated3": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "annotations": { "anyOf": [ { @@ -319,6 +326,14 @@ "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", @@ -1380,6 +1395,13 @@ "RawEmbeddedResource": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resource": { "$ref": "#/definitions/ResourceContents" } @@ -1531,6 +1553,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "mimeType": { "type": [ "string", @@ -1552,6 +1581,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "blob": { "type": "string" }, diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 4736b5ba8..d05c366ea 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -181,6 +181,13 @@ "Annotated3": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "annotations": { "anyOf": [ { @@ -319,6 +326,14 @@ "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", "properties": { + "_meta": { + "description": "Optional protocol-level metadata for this result", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "content": { "description": "The content returned by the tool (text, images, etc.)", "type": "array", @@ -1380,6 +1395,13 @@ "RawEmbeddedResource": { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "resource": { "$ref": "#/definitions/ResourceContents" } @@ -1531,6 +1553,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "mimeType": { "type": [ "string", @@ -1552,6 +1581,13 @@ { "type": "object", "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, "blob": { "type": "string" }, diff --git a/crates/rmcp/tests/test_tool_result_meta.rs b/crates/rmcp/tests/test_tool_result_meta.rs new file mode 100644 index 000000000..78e1809ef --- /dev/null +++ b/crates/rmcp/tests/test_tool_result_meta.rs @@ -0,0 +1,45 @@ +use rmcp::model::{CallToolResult, Content, Meta}; +use serde_json::{Value, json}; + +#[test] +fn serialize_tool_result_with_meta() { + let content = vec![Content::text("ok")]; + let mut meta = Meta::new(); + meta.insert("foo".to_string(), json!("bar")); + let result = CallToolResult { + content, + structured_content: None, + is_error: Some(false), + meta: Some(meta), + }; + let v = serde_json::to_value(&result).unwrap(); + let expected = json!({ + "content": [{"type":"text","text":"ok"}], + "isError": false, + "_meta": {"foo":"bar"} + }); + assert_eq!(v, expected); +} + +#[test] +fn deserialize_tool_result_with_meta() { + let raw: Value = json!({ + "content": [{"type":"text","text":"hello"}], + "isError": true, + "_meta": {"a": 1, "b": "two"} + }); + let result: CallToolResult = serde_json::from_value(raw).unwrap(); + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.content.len(), 1); + let meta = result.meta.expect("meta should exist"); + assert_eq!(meta.get("a").unwrap(), &json!(1)); + assert_eq!(meta.get("b").unwrap(), &json!("two")); +} + +#[test] +fn serialize_tool_result_without_meta_omits_field() { + let result = CallToolResult::success(vec![Content::text("no meta")]); + let v = serde_json::to_value(&result).unwrap(); + // Ensure _meta is omitted + assert!(v.get("_meta").is_none()); +}