diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 9dd04564e..fdf12e7eb 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -4163,9 +4163,9 @@ pub struct CallToolResult { // Custom Deserialize implementation that: // 1. Defaults `content` to `[]` when the field is missing (lenient per Postel's law) -// 2. Requires at least one known field to be present, so that `CallToolResult` doesn't -// greedily match arbitrary JSON objects when used inside `#[serde(untagged)]` enums -// (e.g. `ServerResult`), which would shadow `CustomResult`. +// 2. Requires a tool-specific field or a legacy `_meta`-only result. Unknown +// fields alongside `_meta` fall through to `CustomResult` when no tool-specific +// field identifies the response. Generic `resultType` does not identify a tool. // 3. Rejects non-`complete` result types so other `ServerResult` variants can match. // 4. Keeps a present `"structuredContent": null` as `Some(Value::Null)`, distinct // from an absent field, since structured content can be any JSON value. @@ -4185,6 +4185,8 @@ impl<'de> Deserialize<'de> for CallToolResult { is_error: Option, #[serde(rename = "_meta")] meta: Option, + #[serde(flatten)] + extra: serde_json::Map, } let helper = Helper::deserialize(deserializer)?; @@ -4202,11 +4204,11 @@ impl<'de> Deserialize<'de> for CallToolResult { if helper.content.is_none() && helper.structured_content.is_none() && helper.is_error.is_none() - && helper.meta.is_none() + && (helper.meta.is_none() || !helper.extra.is_empty()) { return Err(serde::de::Error::custom( - "expected at least one known CallToolResult field \ - (content, structuredContent, isError, or _meta)", + "expected a tool-specific CallToolResult field \ + (content, structuredContent, or isError), or _meta without unknown fields", )); } @@ -5649,6 +5651,41 @@ mod tests { assert_eq!(json, raw); } + #[test] + fn custom_server_result_with_generic_meta_is_not_a_tool_result() { + let raw = json!({ + "resultType": "complete", + "skills": [{ + "uri": "skill://example/SKILL.md", + "frontmatter": {"name": "example", "description": "example skill"} + }], + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "skills-server", + "version": "1.0.0" + } + } + }); + + let result: ServerResult = serde_json::from_value(raw.clone()).expect("custom result"); + match result { + ServerResult::CustomResult(CustomResult(value)) => assert_eq!(value, raw), + other => panic!("expected CustomResult, got {other:?}"), + } + } + + #[test] + fn call_tool_result_with_meta_and_tool_content_remains_a_tool_result() { + let raw = json!({ + "resultType": "complete", + "content": [], + "_meta": {"trace": "present"} + }); + + let result: ServerResult = serde_json::from_value(raw).expect("tool result"); + assert!(matches!(result, ServerResult::CallToolResult(_))); + } + #[test] fn test_request_conversion() { let raw = json!( { diff --git a/crates/rmcp/tests/test_result_type_wire.rs b/crates/rmcp/tests/test_result_type_wire.rs index a22933e35..e344441df 100644 --- a/crates/rmcp/tests/test_result_type_wire.rs +++ b/crates/rmcp/tests/test_result_type_wire.rs @@ -186,3 +186,64 @@ fn strip_preserves_custom_result_type() { let value = serde_json::to_value(result).expect("serialize ServerResult"); assert_eq!(value["resultType"], "streaming"); } + +#[test] +fn meta_only_tool_results_preserve_legacy_and_complete_wire_forms() { + for result_type in [None, Some("complete")] { + let mut raw = json!({"_meta": {"trace": "present"}}); + if let Some(result_type) = result_type { + raw["resultType"] = json!(result_type); + } + let tool: CallToolResult = + serde_json::from_value(raw.clone()).expect("meta-only tool result"); + assert!(tool.content.is_empty()); + assert_eq!(tool.meta, serde_json::from_value(raw["_meta"].clone()).ok()); + assert!(matches!( + serde_json::from_value::(raw).unwrap(), + ServerResult::CallToolResult(_) + )); + } +} + +#[test] +fn custom_fields_with_meta_preserve_the_complete_extension_response() { + for result_type in [None, Some("complete")] { + for extension in [ + json!({"skills": []}), + json!({"extension": null}), + json!({"extension": false}), + ] { + let mut raw = json!({"_meta": {"trace": "present"}}); + raw.as_object_mut() + .unwrap() + .extend(extension.as_object().unwrap().clone()); + if let Some(result_type) = result_type { + raw["resultType"] = json!(result_type); + } + let result: ServerResult = + serde_json::from_value(raw.clone()).expect("extension result"); + match result { + ServerResult::CustomResult(result) => assert_eq!(result.0, raw), + other => panic!("expected lossless custom result, got {other:?}"), + } + assert!(serde_json::from_value::(raw).is_err()); + } + } +} + +#[test] +fn tool_specific_fields_keep_tool_results_with_unknown_extensions() { + for tool_field in [ + json!({"content": []}), + json!({"structuredContent": null}), + json!({"isError": false}), + ] { + let mut raw = + json!({"resultType": "complete", "_meta": {"trace": "present"}, "extension": null}); + raw.as_object_mut() + .unwrap() + .extend(tool_field.as_object().unwrap().clone()); + let result: ServerResult = serde_json::from_value(raw).expect("tool result"); + assert!(matches!(result, ServerResult::CallToolResult(_))); + } +}