fix: allow empty content in CallToolResult (#681)

Per the MCP spec [1] and the TypeScript schema [2],
`CallToolResult.content` is typed as `ContentBlock[]`, so it is a
required array with no minimum length constraint.

MCP server libraries use such a representation in practice: for example,
FastMCP returns responses with no `structuredContent` and an empty
`content` array when tools return `None`.

[1]: https://modelcontextprotocol.io/specification/2025-11-25/server/tools
[2]: https://github.com/modelcontextprotocol/specification/blob/main/schema/2025-11-25/schema.ts
This commit is contained in:
Anish Athalye 2026-02-23 19:09:57 -08:00 committed by GitHub
parent 085470025f
commit 98eef440c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 60 additions and 41 deletions

View file

@ -2196,7 +2196,7 @@ pub type ElicitationCompletionNotification =
///
/// Contains the content returned by the tool execution and an optional
/// flag indicating whether the operation resulted in an error.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CallToolResult {
@ -2310,45 +2310,6 @@ impl CallToolResult {
}
}
// Custom deserialize implementation to validate mutual exclusivity
impl<'de> Deserialize<'de> for CallToolResult {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CallToolResultHelper {
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<Content>>,
#[serde(skip_serializing_if = "Option::is_none")]
structured_content: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
/// Accept `_meta` during deserialization
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: Option<Meta>,
}
let helper = CallToolResultHelper::deserialize(deserializer)?;
let result = CallToolResult {
content: helper.content.unwrap_or_default(),
structured_content: helper.structured_content,
is_error: helper.is_error,
meta: helper.meta,
};
// Validate mutual exclusivity
if result.content.is_empty() && result.structured_content.is_none() {
return Err(serde::de::Error::custom(
"CallToolResult must have either content or structured_content",
));
}
Ok(result)
}
}
const_string!(ListToolsRequestMethod = "tools/list");
/// Request to list all available tools from a server
pub type ListToolsRequest = RequestOptionalParam<ListToolsRequestMethod, PaginatedRequestParams>;

View file

@ -2,7 +2,7 @@
use rmcp::{
Json, ServerHandler,
handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters},
model::{CallToolResult, Content, Tool},
model::{CallToolResult, Content, ServerResult, Tool},
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
@ -280,3 +280,61 @@ async fn test_output_schema_requires_structured_content() {
assert!(call_result.structured_content.is_some());
assert!(!call_result.content.is_empty());
}
#[tokio::test]
async fn test_empty_content_array_deserializes() {
let raw = json!({ "content": [] });
let result: CallToolResult = serde_json::from_value(raw).unwrap();
assert!(result.content.is_empty());
assert!(result.structured_content.is_none());
assert!(result.is_error.is_none());
}
#[tokio::test]
async fn test_empty_content_array_with_is_error() {
let raw = json!({ "content": [], "isError": false });
let result: CallToolResult = serde_json::from_value(raw).unwrap();
assert!(result.content.is_empty());
assert_eq!(result.is_error, Some(false));
}
#[tokio::test]
async fn test_missing_content_is_rejected() {
let raw = json!({ "isError": false });
let result: Result<CallToolResult, _> = serde_json::from_value(raw);
assert!(result.is_err());
}
#[tokio::test]
async fn test_missing_content_with_structured_content_is_rejected() {
let raw = json!({ "structuredContent": {"key": "value"}, "isError": false });
let result: Result<CallToolResult, _> = serde_json::from_value(raw);
assert!(result.is_err());
}
#[tokio::test]
async fn test_empty_content_deserializes_as_call_tool_result_variant() {
let raw = json!({ "content": [] });
let result: ServerResult = serde_json::from_value(raw).unwrap();
match result {
ServerResult::CallToolResult(call_result) => {
assert!(call_result.content.is_empty());
assert!(call_result.structured_content.is_none());
}
other => panic!("Expected CallToolResult, got {:?}", other),
}
}
#[tokio::test]
async fn test_empty_content_roundtrip() {
let result = CallToolResult {
content: vec![],
structured_content: None,
is_error: Some(false),
meta: None,
};
let v = serde_json::to_value(&result).unwrap();
assert_eq!(v["content"], json!([]));
let deserialized: CallToolResult = serde_json::from_value(v).unwrap();
assert_eq!(deserialized, result);
}