Skip to content

Commit 7d46b39

Browse files
authored
feat(rmcp): add optional _meta to CallToolResult, EmbeddedResource, and ResourceContents (#386)
* feat(rmcp): support optional _meta on EmbeddedResource and ResourceContents - Add optional _meta to RawEmbeddedResource and ResourceContents (text/blob) - Update constructors/usages to set meta: None by default - Add tests to lock behavior (embedded text/blob + CallToolResult) - Update JSON Schemas to include optional _meta fields * style(rmcp): rustfmt after _meta changes and tests
1 parent 1a20f8a commit 7d46b39

11 files changed

+311
-3
lines changed

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: 8 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,20 @@ impl RawContent {
8890
}
8991

9092
pub fn resource(resource: ResourceContents) -> Self {
91-
RawContent::Resource(RawEmbeddedResource { resource })
93+
RawContent::Resource(RawEmbeddedResource {
94+
meta: None,
95+
resource,
96+
})
9297
}
9398

9499
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
95100
RawContent::Resource(RawEmbeddedResource {
101+
meta: None,
96102
resource: ResourceContents::TextResourceContents {
97103
uri: uri.into(),
98104
mime_type: Some("text".to_string()),
99105
text: content.into(),
106+
meta: None,
100107
},
101108
})
102109
}

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 rmcp::model::{AnnotateAble, Content, Meta, RawContent, ResourceContents};
2+
use serde_json::json;
3+
4+
#[test]
5+
fn serialize_embedded_text_resource_with_meta() {
6+
// Inner contents meta
7+
let mut inner_meta = Meta::new();
8+
inner_meta.insert("inner".to_string(), json!(2));
9+
10+
// Top-level embedded resource meta
11+
let mut top_meta = Meta::new();
12+
top_meta.insert("top".to_string(), json!(1));
13+
14+
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
15+
meta: Some(top_meta),
16+
resource: ResourceContents::TextResourceContents {
17+
uri: "str://example".to_string(),
18+
mime_type: Some("text/plain".to_string()),
19+
text: "hello".to_string(),
20+
meta: Some(inner_meta),
21+
},
22+
})
23+
.no_annotation();
24+
25+
let v = serde_json::to_value(&content).unwrap();
26+
27+
let expected = json!({
28+
"type": "resource",
29+
"_meta": {"top": 1},
30+
"resource": {
31+
"uri": "str://example",
32+
"mimeType": "text/plain",
33+
"text": "hello",
34+
"_meta": {"inner": 2}
35+
}
36+
});
37+
38+
assert_eq!(v, expected);
39+
}
40+
41+
#[test]
42+
fn serialize_embedded_text_resource_without_meta_omits_fields() {
43+
let content: Content = RawContent::Resource(rmcp::model::RawEmbeddedResource {
44+
meta: None,
45+
resource: ResourceContents::TextResourceContents {
46+
uri: "str://no-meta".to_string(),
47+
mime_type: Some("text/plain".to_string()),
48+
text: "hi".to_string(),
49+
meta: None,
50+
},
51+
})
52+
.no_annotation();
53+
54+
let v = serde_json::to_value(&content).unwrap();
55+
56+
assert_eq!(v.get("_meta"), None);
57+
let inner = v.get("resource").and_then(|r| r.as_object()).unwrap();
58+
assert_eq!(inner.get("_meta"), None);
59+
}
60+
61+
#[test]
62+
fn deserialize_embedded_text_resource_with_meta() {
63+
let raw = json!({
64+
"type": "resource",
65+
"_meta": {"x": true},
66+
"resource": {
67+
"uri": "str://from-json",
68+
"text": "ok",
69+
"_meta": {"y": 42}
70+
}
71+
});
72+
73+
let content: Content = serde_json::from_value(raw).unwrap();
74+
75+
let raw = match &content.raw {
76+
RawContent::Resource(er) => er,
77+
_ => panic!("expected resource"),
78+
};
79+
80+
// top-level _meta
81+
let top = raw.meta.as_ref().expect("top-level meta missing");
82+
assert_eq!(top.get("x").unwrap(), &json!(true));
83+
84+
// inner contents _meta
85+
match &raw.resource {
86+
ResourceContents::TextResourceContents {
87+
meta, uri, text, ..
88+
} => {
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)