Skip to content

Commit 4c88e25

Browse files
committed
rmcp: support optional _meta on EmbeddedResource and ResourceContents (text/blob); add tests; update schemas; ensure CallToolResult _meta stays optional
1 parent 1a20f8a commit 4c88e25

11 files changed

Lines changed: 308 additions & 3 deletions

crates/rmcp/src/model.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,9 @@ pub struct CallToolResult {
12671267
/// Whether this result represents an error condition
12681268
#[serde(skip_serializing_if = "Option::is_none")]
12691269
pub is_error: Option<bool>,
1270+
/// Optional protocol-level metadata for this result
1271+
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
1272+
pub meta: Option<Meta>,
12701273
}
12711274

12721275
impl CallToolResult {
@@ -1276,6 +1279,7 @@ impl CallToolResult {
12761279
content,
12771280
structured_content: None,
12781281
is_error: Some(false),
1282+
meta: None,
12791283
}
12801284
}
12811285
/// Create an error tool result with unstructured content
@@ -1284,6 +1288,7 @@ impl CallToolResult {
12841288
content,
12851289
structured_content: None,
12861290
is_error: Some(true),
1291+
meta: None,
12871292
}
12881293
}
12891294
/// Create a successful tool result with structured content
@@ -1305,6 +1310,7 @@ impl CallToolResult {
13051310
content: vec![Content::text(value.to_string())],
13061311
structured_content: Some(value),
13071312
is_error: Some(false),
1313+
meta: None,
13081314
}
13091315
}
13101316
/// Create an error tool result with structured content
@@ -1330,6 +1336,7 @@ impl CallToolResult {
13301336
content: vec![Content::text(value.to_string())],
13311337
structured_content: Some(value),
13321338
is_error: Some(true),
1339+
meta: None,
13331340
}
13341341
}
13351342

@@ -1377,13 +1384,17 @@ impl<'de> Deserialize<'de> for CallToolResult {
13771384
structured_content: Option<Value>,
13781385
#[serde(skip_serializing_if = "Option::is_none")]
13791386
is_error: Option<bool>,
1387+
/// Accept `_meta` during deserialization
1388+
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
1389+
meta: Option<Meta>,
13801390
}
13811391

13821392
let helper = CallToolResultHelper::deserialize(deserializer)?;
13831393
let result = CallToolResult {
13841394
content: helper.content.unwrap_or_default(),
13851395
structured_content: helper.structured_content,
13861396
is_error: helper.is_error,
1397+
meta: helper.meta,
13871398
};
13881399

13891400
// Validate mutual exclusivity

crates/rmcp/src/model/content.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pub type ImageContent = Annotated<RawImageContent>;
2727
#[serde(rename_all = "camelCase")]
2828
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
2929
pub struct RawEmbeddedResource {
30+
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
31+
pub meta: Option<super::Meta>,
3032
pub resource: ResourceContents,
3133
}
3234
pub type EmbeddedResource = Annotated<RawEmbeddedResource>;
@@ -88,15 +90,17 @@ impl RawContent {
8890
}
8991

9092
pub fn resource(resource: ResourceContents) -> Self {
91-
RawContent::Resource(RawEmbeddedResource { resource })
93+
RawContent::Resource(RawEmbeddedResource { meta: None, resource })
9294
}
9395

9496
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
9597
RawContent::Resource(RawEmbeddedResource {
98+
meta: None,
9699
resource: ResourceContents::TextResourceContents {
97100
uri: uri.into(),
98101
mime_type: Some("text".to_string()),
99102
text: content.into(),
103+
meta: None,
100104
},
101105
})
102106
}

crates/rmcp/src/model/meta.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ variant_extension! {
9898
PromptListChangedNotification
9999
}
100100
}
101-
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
101+
#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq)]
102+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
102103
#[serde(transparent)]
103104
pub struct Meta(pub JsonObject);
104105
const PROGRESS_TOKEN_FIELD: &str = "progressToken";

crates/rmcp/src/model/prompt.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,14 @@ impl PromptMessage {
149149
uri,
150150
mime_type: Some(mime_type),
151151
text: text.unwrap_or_default(),
152+
meta: None,
152153
};
153154

154155
Self {
155156
role,
156157
content: PromptMessageContent::Resource {
157158
resource: RawEmbeddedResource {
159+
meta: None,
158160
resource: resource_contents,
159161
}
160162
.optional_annotate(annotations),

crates/rmcp/src/model/resource.rs

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

3-
use super::Annotated;
3+
use super::{Annotated, Meta};
44

55
/// Represents a resource in the extension with metadata
66
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
@@ -51,13 +51,17 @@ pub enum ResourceContents {
5151
#[serde(skip_serializing_if = "Option::is_none")]
5252
mime_type: Option<String>,
5353
text: String,
54+
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
55+
meta: Option<Meta>,
5456
},
5557
#[serde(rename_all = "camelCase")]
5658
BlobResourceContents {
5759
uri: String,
5860
#[serde(skip_serializing_if = "Option::is_none")]
5961
mime_type: Option<String>,
6062
blob: String,
63+
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
64+
meta: Option<Meta>,
6165
},
6266
}
6367

@@ -67,6 +71,7 @@ impl ResourceContents {
6771
uri: uri.into(),
6872
mime_type: Some("text".into()),
6973
text: text.into(),
74+
meta: None,
7075
}
7176
}
7277
}
@@ -114,6 +119,7 @@ mod tests {
114119
uri: "file:///test.txt".to_string(),
115120
mime_type: Some("text/plain".to_string()),
116121
text: "Hello world".to_string(),
122+
meta: None,
117123
};
118124

119125
let json = serde_json::to_string(&text_contents).unwrap();
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
use serde_json::json;
2+
use rmcp::model::AnnotateAble;
3+
4+
use rmcp::model::{Content, Meta, RawContent, ResourceContents};
5+
6+
#[test]
7+
fn serialize_embedded_text_resource_with_meta() {
8+
// Inner contents meta
9+
let mut inner_meta = Meta::new();
10+
inner_meta.insert("inner".to_string(), json!(2));
11+
12+
// Top-level embedded resource meta
13+
let mut top_meta = Meta::new();
14+
top_meta.insert("top".to_string(), json!(1));
15+
16+
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
17+
meta: Some(top_meta),
18+
resource: ResourceContents::TextResourceContents {
19+
uri: "str://example".to_string(),
20+
mime_type: Some("text/plain".to_string()),
21+
text: "hello".to_string(),
22+
meta: Some(inner_meta),
23+
},
24+
})
25+
.no_annotation();
26+
27+
let v = serde_json::to_value(&content).unwrap();
28+
29+
let expected = json!({
30+
"type": "resource",
31+
"_meta": {"top": 1},
32+
"resource": {
33+
"uri": "str://example",
34+
"mimeType": "text/plain",
35+
"text": "hello",
36+
"_meta": {"inner": 2}
37+
}
38+
});
39+
40+
assert_eq!(v, expected);
41+
}
42+
43+
#[test]
44+
fn serialize_embedded_text_resource_without_meta_omits_fields() {
45+
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
46+
meta: None,
47+
resource: ResourceContents::TextResourceContents {
48+
uri: "str://no-meta".to_string(),
49+
mime_type: Some("text/plain".to_string()),
50+
text: "hi".to_string(),
51+
meta: None,
52+
},
53+
})
54+
.no_annotation();
55+
56+
let v = serde_json::to_value(&content).unwrap();
57+
58+
assert_eq!(v.get("_meta"), None);
59+
let inner = v.get("resource").and_then(|r| r.as_object()).unwrap();
60+
assert_eq!(inner.get("_meta"), None);
61+
}
62+
63+
#[test]
64+
fn deserialize_embedded_text_resource_with_meta() {
65+
let raw = json!({
66+
"type": "resource",
67+
"_meta": {"x": true},
68+
"resource": {
69+
"uri": "str://from-json",
70+
"text": "ok",
71+
"_meta": {"y": 42}
72+
}
73+
});
74+
75+
let content: Content = serde_json::from_value(raw).unwrap();
76+
77+
let raw = match &content.raw {
78+
RawContent::Resource(er) => er,
79+
_ => panic!("expected resource"),
80+
};
81+
82+
// top-level _meta
83+
let top = raw.meta.as_ref().expect("top-level meta missing");
84+
assert_eq!(top.get("x").unwrap(), &json!(true));
85+
86+
// inner contents _meta
87+
match &raw.resource {
88+
ResourceContents::TextResourceContents { meta, uri, text, .. } => {
89+
assert_eq!(uri, "str://from-json");
90+
assert_eq!(text, "ok");
91+
let inner = meta.as_ref().expect("inner meta missing");
92+
assert_eq!(inner.get("y").unwrap(), &json!(42));
93+
}
94+
_ => panic!("expected text resource contents"),
95+
}
96+
}
97+
98+
#[test]
99+
fn serialize_embedded_blob_resource_with_meta() {
100+
let mut inner_meta = Meta::new();
101+
inner_meta.insert("blob_inner".to_string(), json!(true));
102+
103+
let mut top_meta = Meta::new();
104+
top_meta.insert("blob_top".to_string(), json!("t"));
105+
106+
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
107+
meta: Some(top_meta),
108+
resource: ResourceContents::BlobResourceContents {
109+
uri: "str://blob".to_string(),
110+
mime_type: Some("application/octet-stream".to_string()),
111+
blob: "Zm9v".to_string(),
112+
meta: Some(inner_meta),
113+
},
114+
})
115+
.no_annotation();
116+
117+
let v = serde_json::to_value(&content).unwrap();
118+
119+
assert_eq!(v.get("_meta").unwrap(), &json!({"blob_top": "t"}));
120+
let inner = v.get("resource").unwrap();
121+
assert_eq!(inner.get("_meta").unwrap(), &json!({"blob_inner": true}));
122+
}

crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,13 @@
892892
"RawEmbeddedResource": {
893893
"type": "object",
894894
"properties": {
895+
"_meta": {
896+
"type": [
897+
"object",
898+
"null"
899+
],
900+
"additionalProperties": true
901+
},
895902
"resource": {
896903
"$ref": "#/definitions/ResourceContents"
897904
}
@@ -1252,6 +1259,13 @@
12521259
{
12531260
"type": "object",
12541261
"properties": {
1262+
"_meta": {
1263+
"type": [
1264+
"object",
1265+
"null"
1266+
],
1267+
"additionalProperties": true
1268+
},
12551269
"mimeType": {
12561270
"type": [
12571271
"string",
@@ -1273,6 +1287,13 @@
12731287
{
12741288
"type": "object",
12751289
"properties": {
1290+
"_meta": {
1291+
"type": [
1292+
"object",
1293+
"null"
1294+
],
1295+
"additionalProperties": true
1296+
},
12761297
"blob": {
12771298
"type": "string"
12781299
},

crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,13 @@
892892
"RawEmbeddedResource": {
893893
"type": "object",
894894
"properties": {
895+
"_meta": {
896+
"type": [
897+
"object",
898+
"null"
899+
],
900+
"additionalProperties": true
901+
},
895902
"resource": {
896903
"$ref": "#/definitions/ResourceContents"
897904
}
@@ -1252,6 +1259,13 @@
12521259
{
12531260
"type": "object",
12541261
"properties": {
1262+
"_meta": {
1263+
"type": [
1264+
"object",
1265+
"null"
1266+
],
1267+
"additionalProperties": true
1268+
},
12551269
"mimeType": {
12561270
"type": [
12571271
"string",
@@ -1273,6 +1287,13 @@
12731287
{
12741288
"type": "object",
12751289
"properties": {
1290+
"_meta": {
1291+
"type": [
1292+
"object",
1293+
"null"
1294+
],
1295+
"additionalProperties": true
1296+
},
12761297
"blob": {
12771298
"type": "string"
12781299
},

0 commit comments

Comments
 (0)