From 071580ded181926b39fb61c6ef3e64344c23267c Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:21:46 -0400 Subject: [PATCH] fix!: omit resultType for legacy protocol sessions --- crates/rmcp-macros/src/prompt_handler.rs | 2 +- crates/rmcp-macros/src/tool_handler.rs | 2 +- crates/rmcp/src/handler/server.rs | 12 +- crates/rmcp/src/handler/server/prompt.rs | 8 +- crates/rmcp/src/model.rs | 174 +++++++++++++++--- .../server_json_rpc_message_schema.json | 82 +++++---- ...erver_json_rpc_message_schema_current.json | 82 +++++---- crates/rmcp/tests/test_result_type_version.rs | 82 +++++++++ crates/rmcp/tests/test_result_type_wire.rs | 103 ++++++++++- 9 files changed, 438 insertions(+), 109 deletions(-) create mode 100644 crates/rmcp/tests/test_result_type_version.rs diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 88d78f70c..086eb0d52 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -61,7 +61,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result Result { let prompts = #router_expr.list_all(); Ok(rmcp::model::ListPromptsResult { - result_type: Default::default(), + result_type: Some(rmcp::model::ResultType::COMPLETE), prompts, meta: #meta, next_cursor: None, diff --git a/crates/rmcp-macros/src/tool_handler.rs b/crates/rmcp-macros/src/tool_handler.rs index 7732687d0..7614668a9 100644 --- a/crates/rmcp-macros/src/tool_handler.rs +++ b/crates/rmcp-macros/src/tool_handler.rs @@ -69,7 +69,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result, ) -> Result { Ok(rmcp::model::ListToolsResult{ - result_type: Default::default(), + result_type: Some(rmcp::model::ResultType::COMPLETE), tools: #router.list_all(), meta: #result_meta, next_cursor: None, diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index bebbcb9a9..39b89da00 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -55,7 +55,8 @@ impl Service for H { ) -> Result<::Resp, McpError> { // `context` is moved into the dispatch below, so read the negotiated version first. let protocol_version = context.protocol_version(); - let mrtr_supported = protocol_version + // SEP-2322 (`resultType` discriminator, MRTR) exists from 2026-07-28. + let sep_2322_supported = protocol_version .as_ref() .is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); let requested_version = context.meta.protocol_version(); @@ -240,13 +241,18 @@ impl Service for H { .map(ServerResult::task_ack) } }; - let result = result.and_then(|result| { - if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported { + let result = result.and_then(|mut result| { + if matches!(result, ServerResult::InputRequiredResult(_)) && !sep_2322_supported { Err(McpError::invalid_request( "InputRequiredResult requires negotiated protocol version 2026-07-28 or newer", None, )) } else { + // Peers on protocol versions older than 2026-07-28 keep the + // legacy wire shape without `resultType: "complete"`. + if !sep_2322_supported { + result.strip_result_type_for_legacy_peer(); + } Ok(result) } }); diff --git a/crates/rmcp/src/handler/server/prompt.rs b/crates/rmcp/src/handler/server/prompt.rs index a75e02713..b5b3c469b 100644 --- a/crates/rmcp/src/handler/server/prompt.rs +++ b/crates/rmcp/src/handler/server/prompt.rs @@ -108,13 +108,7 @@ impl IntoGetPromptResult for InputRequiredResult { impl IntoGetPromptResult for Vec { fn into_get_prompt_result(self) -> Result { - Ok(GetPromptResult { - result_type: Default::default(), - description: None, - messages: self, - meta: None, - } - .into()) + Ok(GetPromptResult::new(self).into()) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6531e6ee5..307ce525f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -776,6 +776,13 @@ impl From for () { /// so unknown values are preserved rather than rejected. Servers implementing this /// protocol version MUST include `resultType` in every result. For backward /// compatibility, clients MUST treat an absent field as `"complete"`. +/// +/// Ordinary results model the field as `Option`: `None` means the +/// field is absent on the wire. Constructors default to `Some(COMPLETE)`, and +/// the server handler strips the `"complete"` discriminator before responding +/// to peers that negotiated a protocol version older than `2026-07-28`, so +/// legacy sessions keep their historical wire shape (see +/// [`ServerResult::strip_result_type_for_legacy_peer`]). #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ResultType(Cow<'static, str>); @@ -1473,14 +1480,23 @@ macro_rules! paginated_result { ($t:ident { $i_item: ident: $t_item: ty }) => { - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1502,10 +1518,16 @@ macro_rules! paginated_result { pub $i_item: $t_item, } + impl Default for $t { + fn default() -> Self { + Self::with_all_items(Default::default()) + } + } + impl $t { pub fn with_all_items(items: $t_item) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), meta: None, next_cursor: None, ttl_ms: None, @@ -1621,9 +1643,18 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct ReadResourceResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). /// Required by spec version 2026-07-28, but optional here to maintain compatibility /// with older spec versions. @@ -1648,7 +1679,7 @@ impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), ttl_ms: None, cache_scope: None, contents, @@ -3208,24 +3239,39 @@ impl CompletionInfo { } } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CompleteResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, pub completion: CompletionInfo, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, } +impl Default for CompleteResult { + fn default() -> Self { + Self::new(CompletionInfo::default()) + } +} + impl CompleteResult { /// Create a new CompleteResult with the given completion info. pub fn new(completion: CompletionInfo) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), completion, meta: None, } @@ -3674,14 +3720,23 @@ pub type CreateElicitationRequest = ElicitRequest; /// /// Contains the content returned by the tool execution and an optional /// flag indicating whether the operation resulted in an error. -#[derive(Default, Debug, Serialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct CallToolResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, /// The content returned by the tool (text, images, etc.) #[serde(default)] pub content: Vec, @@ -3710,7 +3765,7 @@ impl<'de> Deserialize<'de> for CallToolResult { #[serde(rename_all = "camelCase")] struct Helper { #[serde(default)] - result_type: ResultType, + result_type: Option, content: Option>, structured_content: Option, is_error: Option, @@ -3741,11 +3796,23 @@ impl<'de> Deserialize<'de> for CallToolResult { } } +impl Default for CallToolResult { + fn default() -> Self { + CallToolResult { + result_type: Some(ResultType::COMPLETE), + content: Vec::new(), + structured_content: None, + is_error: None, + meta: None, + } + } +} + impl CallToolResult { /// Create a successful tool result with unstructured content pub fn success(content: Vec) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content, structured_content: None, is_error: Some(false), @@ -3803,7 +3870,7 @@ impl CallToolResult { /// ``` pub fn error(content: Vec) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content, structured_content: None, is_error: Some(true), @@ -3826,7 +3893,7 @@ impl CallToolResult { /// ``` pub fn structured(value: Value) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(false), @@ -3853,7 +3920,7 @@ impl CallToolResult { /// ``` pub fn structured_error(value: Value) -> Self { CallToolResult { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), content: vec![ContentBlock::text(value.to_string())], structured_content: Some(value), is_error: Some(true), @@ -4041,14 +4108,23 @@ impl CreateMessageResult { } } -#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[non_exhaustive] pub struct GetPromptResult { - /// Result type discriminator. Absent values deserialize as `"complete"`. - #[serde(default)] - pub result_type: ResultType, + /// Result type discriminator (SEP-2322). Required by the [spec schema] + /// for servers implementing protocol version `2026-07-28`, but optional + /// here because this type also models results from older protocol + /// versions, which do not carry the field: `None` means absent on the + /// wire, and per the spec "the client MUST treat the absent field as + /// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`; + /// the server handler clears the field when responding to peers that + /// negotiated an older version. + /// + /// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, pub messages: Vec, @@ -4056,11 +4132,17 @@ pub struct GetPromptResult { pub meta: Option, } +impl Default for GetPromptResult { + fn default() -> Self { + Self::new(Vec::new()) + } +} + impl GetPromptResult { /// Create a new GetPromptResult with required fields. pub fn new(messages: Vec) -> Self { Self { - result_type: ResultType::default(), + result_type: Some(ResultType::COMPLETE), description: None, messages, meta: None, @@ -4424,6 +4506,42 @@ impl ServerResult { pub fn task_ack(_: ()) -> ServerResult { ServerResult::TaskAckResult(TaskAckResult::new()) } + + /// Strip the SEP-2322 `resultType: "complete"` discriminator so the result + /// keeps the wire shape that predates protocol version `2026-07-28`. + /// + /// The server handler calls this before responding to a peer that + /// negotiated an older protocol version, where the field did not exist and + /// strict peers may reject it. Only the `"complete"` value is stripped: + /// results whose discriminator carries meaning (`"input_required"`, + /// `"task"`) are already gated to `2026-07-28`+ sessions, and custom + /// extension values are preserved. + /// + /// # Examples + /// + /// ``` + /// use rmcp::model::{CallToolResult, ServerResult}; + /// + /// let mut result = ServerResult::CallToolResult(CallToolResult::success(vec![])); + /// result.strip_result_type_for_legacy_peer(); + /// + /// let json = serde_json::to_value(&result).unwrap(); + /// assert!(json.get("resultType").is_none()); + /// ``` + pub fn strip_result_type_for_legacy_peer(&mut self) { + let result_type = match self { + ServerResult::CompleteResult(r) => &mut r.result_type, + ServerResult::GetPromptResult(r) => &mut r.result_type, + ServerResult::ListPromptsResult(r) => &mut r.result_type, + ServerResult::ListResourcesResult(r) => &mut r.result_type, + ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type, + ServerResult::ReadResourceResult(r) => &mut r.result_type, + ServerResult::ListToolsResult(r) => &mut r.result_type, + ServerResult::CallToolResult(r) => &mut r.result_type, + _ => return, + }; + result_type.take_if(|result_type| result_type.is_complete()); + } } pub type ServerJsonRpcMessage = JsonRpcMessage; diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index b38db540c..595281eca 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -199,13 +199,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" @@ -326,13 +328,15 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1140,13 +1144,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1783,13 +1789,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1842,13 +1850,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1901,13 +1911,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1959,13 +1971,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "tools": { "type": "array", @@ -2581,13 +2595,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2948,7 +2964,7 @@ } }, "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", "type": "string" }, "Role": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index b38db540c..595281eca 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -199,13 +199,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "structuredContent": { "description": "An optional JSON object that represents the structured result of the tool call" @@ -326,13 +328,15 @@ "$ref": "#/definitions/CompletionInfo" }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1140,13 +1144,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] } }, "required": [ @@ -1783,13 +1789,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1842,13 +1850,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1901,13 +1911,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -1959,13 +1971,15 @@ ] }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "tools": { "type": "array", @@ -2581,13 +2595,15 @@ } }, "resultType": { - "description": "Result type discriminator. Absent values deserialize as `\"complete\"`.", - "allOf": [ + "description": "Result type discriminator (SEP-2322). Required by the [spec schema]\nfor servers implementing protocol version `2026-07-28`, but optional\nhere because this type also models results from older protocol\nversions, which do not carry the field: `None` means absent on the\nwire, and per the spec \"the client MUST treat the absent field as\n`\"complete\"`\". Constructors default to `Some(ResultType::COMPLETE)`;\nthe server handler clears the field when responding to peers that\nnegotiated an older version.\n\n[spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234", + "anyOf": [ { "$ref": "#/definitions/ResultType" + }, + { + "type": "null" } - ], - "default": "complete" + ] }, "ttlMs": { "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).\nRequired by spec version 2026-07-28, but optional here to maintain compatibility\nwith older spec versions.", @@ -2948,7 +2964,7 @@ } }, "ResultType": { - "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.", + "description": "Indicates the type of a result object, allowing the client to\ndetermine how to parse the response.\n\nThe spec defines this as an open string (`\"complete\" | \"input_required\" | string`),\nso unknown values are preserved rather than rejected. Servers implementing this\nprotocol version MUST include `resultType` in every result. For backward\ncompatibility, clients MUST treat an absent field as `\"complete\"`.\n\nOrdinary results model the field as `Option`: `None` means the\nfield is absent on the wire. Constructors default to `Some(COMPLETE)`, and\nthe server handler strips the `\"complete\"` discriminator before responding\nto peers that negotiated a protocol version older than `2026-07-28`, so\nlegacy sessions keep their historical wire shape (see\n[`ServerResult::strip_result_type_for_legacy_peer`]).", "type": "string" }, "Role": { diff --git a/crates/rmcp/tests/test_result_type_version.rs b/crates/rmcp/tests/test_result_type_version.rs new file mode 100644 index 000000000..849a6610e --- /dev/null +++ b/crates/rmcp/tests/test_result_type_version.rs @@ -0,0 +1,82 @@ +//! SEP-2322: the `resultType` discriminator follows the negotiated protocol version. +//! +//! Peers negotiating `2026-07-28` or newer receive `resultType: "complete"` on +//! ordinary results; older peers keep the legacy wire shape without the field. +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ClientInfo, ContentBlock, + ErrorData, ProtocolVersion, ResultType, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ToolServer; + +impl ServerHandler for ToolServer { + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn call_tool_result_type(client_version: ProtocolVersion) -> Option { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ToolServer.serve(server_transport).await?.waiting().await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let result = client + .call_tool(CallToolRequestParams::new("echo")) + .await + .expect("tool call should succeed"); + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + result.result_type +} + +#[tokio::test] +async fn legacy_version_omits_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2025_11_25).await, + None + ); +} + +#[tokio::test] +async fn sep_2322_version_gets_complete_result_type() { + assert_eq!( + call_tool_result_type(ProtocolVersion::V_2026_07_28).await, + Some(ResultType::COMPLETE), + ); +} diff --git a/crates/rmcp/tests/test_result_type_wire.rs b/crates/rmcp/tests/test_result_type_wire.rs index 9c340ff88..d5f201532 100644 --- a/crates/rmcp/tests/test_result_type_wire.rs +++ b/crates/rmcp/tests/test_result_type_wire.rs @@ -2,10 +2,16 @@ //! //! These pin the behavior that keeps older/strict peers working: //! - `EmptyResult` stays a bare `{}` (some peers strict-validate empty results -//! and reject extra keys), and -//! - ordinary results carry `resultType: "complete"`. +//! and reject extra keys), +//! - ordinary results carry `resultType: "complete"` by default, and +//! - [`ServerResult::strip_result_type_for_legacy_peer`] removes the +//! `"complete"` discriminator so legacy sessions keep their historical wire +//! shape (round-tripping a legacy result never adds the field). -use rmcp::model::{CallToolResult, ContentBlock, EmptyResult, ListToolsResult}; +use rmcp::model::{ + CallToolResult, CompleteResult, ContentBlock, EmptyResult, GetPromptResult, ListToolsResult, + ReadResourceResult, ResultType, ServerResult, +}; use serde_json::json; #[test] @@ -27,3 +33,94 @@ fn paginated_result_serializes_complete_result_type() { serde_json::to_value(ListToolsResult::default()).expect("serialize ListToolsResult"); assert_eq!(value["resultType"], "complete"); } + +// Guards the convention the server handler relies on: every constructor and +// `Default` impl produces `Some(COMPLETE)`, so 2026-07-28 sessions always +// include the spec-required field unless a handler clears it explicitly. +#[test] +fn constructors_and_defaults_produce_complete_result_type() { + let complete = Some(ResultType::COMPLETE); + assert_eq!(CallToolResult::default().result_type, complete); + assert_eq!(CallToolResult::success(vec![]).result_type, complete); + assert_eq!(CallToolResult::error(vec![]).result_type, complete); + assert_eq!(CallToolResult::structured(json!({})).result_type, complete); + assert_eq!( + CallToolResult::structured_error(json!({})).result_type, + complete + ); + assert_eq!(ListToolsResult::default().result_type, complete); + assert_eq!( + ListToolsResult::with_all_items(vec![]).result_type, + complete + ); + assert_eq!(ReadResourceResult::new(vec![]).result_type, complete); + assert_eq!(GetPromptResult::default().result_type, complete); + assert_eq!(GetPromptResult::new(vec![]).result_type, complete); + assert_eq!(CompleteResult::default().result_type, complete); +} + +#[test] +fn absent_result_type_deserializes_as_none() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy).expect("deserialize legacy CallToolResult"); + + assert_eq!(result.result_type, None); +} + +#[test] +fn legacy_call_tool_result_round_trips_without_result_type() { + let legacy = json!({ + "content": [], + "isError": false, + }); + + let result: CallToolResult = + serde_json::from_value(legacy.clone()).expect("deserialize legacy CallToolResult"); + let reserialized = serde_json::to_value(result).expect("serialize CallToolResult"); + + assert_eq!(reserialized, legacy); +} + +#[test] +fn strip_removes_complete_result_type() { + let mut result = + ServerResult::CallToolResult(CallToolResult::success(vec![ContentBlock::text("ok")])); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!( + value, + json!({ + "content": [{ "type": "text", "text": "ok" }], + "isError": false, + }) + ); +} + +#[test] +fn strip_removes_complete_result_type_from_paginated_result() { + let mut result = ServerResult::ListToolsResult(ListToolsResult::default()); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value, json!({ "tools": [] })); +} + +#[test] +fn strip_preserves_custom_result_type() { + let streaming: ResultType = + serde_json::from_value(json!("streaming")).expect("deserialize ResultType"); + let mut tool_result = CallToolResult::success(vec![ContentBlock::text("ok")]); + tool_result.result_type = Some(streaming); + + let mut result = ServerResult::CallToolResult(tool_result); + result.strip_result_type_for_legacy_peer(); + + let value = serde_json::to_value(result).expect("serialize ServerResult"); + assert_eq!(value["resultType"], "streaming"); +}