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
This commit is contained in:
Andrew Harvard 2025-09-02 13:47:09 -04:00 • committed by GitHub
commit 7d46b39b49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 311 additions and 3 deletions

View file

@ -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}));
}

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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());
}