Skip to content

Commit f5d2b12

Browse files
committed
fix!: omit resultType for legacy protocol sessions
1 parent 7698802 commit f5d2b12

9 files changed

Lines changed: 412 additions & 109 deletions

File tree

crates/rmcp-macros/src/prompt_handler.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result<Toke
6161
) -> Result<rmcp::model::ListPromptsResult, rmcp::ErrorData> {
6262
let prompts = #router_expr.list_all();
6363
Ok(rmcp::model::ListPromptsResult {
64-
result_type: Default::default(),
64+
result_type: Some(rmcp::model::ResultType::COMPLETE),
6565
prompts,
6666
meta: #meta,
6767
next_cursor: None,

crates/rmcp-macros/src/tool_handler.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
6969
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
7070
) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> {
7171
Ok(rmcp::model::ListToolsResult{
72-
result_type: Default::default(),
72+
result_type: Some(rmcp::model::ResultType::COMPLETE),
7373
tools: #router.list_all(),
7474
meta: #result_meta,
7575
next_cursor: None,

crates/rmcp/src/handler/server.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ impl<H: ServerHandler> Service<RoleServer> for H {
5555
) -> Result<<RoleServer as ServiceRole>::Resp, McpError> {
5656
// `context` is moved into the dispatch below, so read the negotiated version first.
5757
let protocol_version = context.protocol_version();
58-
let mrtr_supported = protocol_version
58+
// SEP-2322 (`resultType` discriminator, MRTR) exists from 2026-07-28.
59+
let sep_2322_supported = protocol_version
5960
.as_ref()
6061
.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
6162
let requested_version = context.meta.protocol_version();
@@ -240,13 +241,18 @@ impl<H: ServerHandler> Service<RoleServer> for H {
240241
.map(ServerResult::task_ack)
241242
}
242243
};
243-
let result = result.and_then(|result| {
244-
if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported {
244+
let result = result.and_then(|mut result| {
245+
if matches!(result, ServerResult::InputRequiredResult(_)) && !sep_2322_supported {
245246
Err(McpError::invalid_request(
246247
"InputRequiredResult requires negotiated protocol version 2026-07-28 or newer",
247248
None,
248249
))
249250
} else {
251+
// Peers on protocol versions older than 2026-07-28 keep the
252+
// legacy wire shape without `resultType: "complete"`.
253+
if !sep_2322_supported {
254+
result.strip_result_type_for_legacy_peer();
255+
}
250256
Ok(result)
251257
}
252258
});

crates/rmcp/src/handler/server/prompt.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,7 @@ impl IntoGetPromptResult for InputRequiredResult {
108108

109109
impl IntoGetPromptResult for Vec<PromptMessage> {
110110
fn into_get_prompt_result(self) -> Result<GetPromptResponse, crate::ErrorData> {
111-
Ok(GetPromptResult {
112-
result_type: Default::default(),
113-
description: None,
114-
messages: self,
115-
meta: None,
116-
}
117-
.into())
111+
Ok(GetPromptResult::new(self).into())
118112
}
119113
}
120114

crates/rmcp/src/model.rs

Lines changed: 146 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,13 @@ impl From<EmptyResult> for () {
776776
/// so unknown values are preserved rather than rejected. Servers implementing this
777777
/// protocol version MUST include `resultType` in every result. For backward
778778
/// compatibility, clients MUST treat an absent field as `"complete"`.
779+
///
780+
/// Ordinary results model the field as `Option<ResultType>`: `None` means the
781+
/// field is absent on the wire. Constructors default to `Some(COMPLETE)`, and
782+
/// the server handler strips the `"complete"` discriminator before responding
783+
/// to peers that negotiated a protocol version older than `2026-07-28`, so
784+
/// legacy sessions keep their historical wire shape (see
785+
/// [`ServerResult::strip_result_type_for_legacy_peer`]).
779786
#[derive(Debug, Clone, PartialEq, Eq)]
780787
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
781788
pub struct ResultType(Cow<'static, str>);
@@ -1473,14 +1480,23 @@ macro_rules! paginated_result {
14731480
($t:ident {
14741481
$i_item: ident: $t_item: ty
14751482
}) => {
1476-
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
1483+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
14771484
#[serde(rename_all = "camelCase")]
14781485
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
14791486
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
14801487
pub struct $t {
1481-
/// Result type discriminator. Absent values deserialize as `"complete"`.
1482-
#[serde(default)]
1483-
pub result_type: ResultType,
1488+
/// Result type discriminator (SEP-2322). Required by the [spec schema]
1489+
/// for servers implementing protocol version `2026-07-28`, but optional
1490+
/// here because this type also models results from older protocol
1491+
/// versions, which do not carry the field: `None` means absent on the
1492+
/// wire, and per the spec "the client MUST treat the absent field as
1493+
/// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`;
1494+
/// the server handler clears the field when responding to peers that
1495+
/// negotiated an older version.
1496+
///
1497+
/// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234
1498+
#[serde(default, skip_serializing_if = "Option::is_none")]
1499+
pub result_type: Option<ResultType>,
14841500
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
14851501
pub meta: Option<MetaObject>,
14861502
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1502,10 +1518,16 @@ macro_rules! paginated_result {
15021518
pub $i_item: $t_item,
15031519
}
15041520

1521+
impl Default for $t {
1522+
fn default() -> Self {
1523+
Self::with_all_items(Default::default())
1524+
}
1525+
}
1526+
15051527
impl $t {
15061528
pub fn with_all_items(items: $t_item) -> Self {
15071529
Self {
1508-
result_type: ResultType::default(),
1530+
result_type: Some(ResultType::COMPLETE),
15091531
meta: None,
15101532
next_cursor: None,
15111533
ttl_ms: None,
@@ -1621,9 +1643,18 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams;
16211643
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
16221644
#[non_exhaustive]
16231645
pub struct ReadResourceResult {
1624-
/// Result type discriminator. Absent values deserialize as `"complete"`.
1625-
#[serde(default)]
1626-
pub result_type: ResultType,
1646+
/// Result type discriminator (SEP-2322). Required by the [spec schema]
1647+
/// for servers implementing protocol version `2026-07-28`, but optional
1648+
/// here because this type also models results from older protocol
1649+
/// versions, which do not carry the field: `None` means absent on the
1650+
/// wire, and per the spec "the client MUST treat the absent field as
1651+
/// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`;
1652+
/// the server handler clears the field when responding to peers that
1653+
/// negotiated an older version.
1654+
///
1655+
/// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234
1656+
#[serde(default, skip_serializing_if = "Option::is_none")]
1657+
pub result_type: Option<ResultType>,
16271658
/// Time, in milliseconds, that this result may be treated as fresh (SEP-2549).
16281659
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
16291660
/// with older spec versions.
@@ -1648,7 +1679,7 @@ impl ReadResourceResult {
16481679
/// Create a new ReadResourceResult with the given contents.
16491680
pub fn new(contents: Vec<ResourceContents>) -> Self {
16501681
Self {
1651-
result_type: ResultType::default(),
1682+
result_type: Some(ResultType::COMPLETE),
16521683
ttl_ms: None,
16531684
cache_scope: None,
16541685
contents,
@@ -3208,24 +3239,39 @@ impl CompletionInfo {
32083239
}
32093240
}
32103241

3211-
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
3242+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
32123243
#[serde(rename_all = "camelCase")]
32133244
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
32143245
#[non_exhaustive]
32153246
pub struct CompleteResult {
3216-
/// Result type discriminator. Absent values deserialize as `"complete"`.
3217-
#[serde(default)]
3218-
pub result_type: ResultType,
3247+
/// Result type discriminator (SEP-2322). Required by the [spec schema]
3248+
/// for servers implementing protocol version `2026-07-28`, but optional
3249+
/// here because this type also models results from older protocol
3250+
/// versions, which do not carry the field: `None` means absent on the
3251+
/// wire, and per the spec "the client MUST treat the absent field as
3252+
/// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`;
3253+
/// the server handler clears the field when responding to peers that
3254+
/// negotiated an older version.
3255+
///
3256+
/// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234
3257+
#[serde(default, skip_serializing_if = "Option::is_none")]
3258+
pub result_type: Option<ResultType>,
32193259
pub completion: CompletionInfo,
32203260
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
32213261
pub meta: Option<MetaObject>,
32223262
}
32233263

3264+
impl Default for CompleteResult {
3265+
fn default() -> Self {
3266+
Self::new(CompletionInfo::default())
3267+
}
3268+
}
3269+
32243270
impl CompleteResult {
32253271
/// Create a new CompleteResult with the given completion info.
32263272
pub fn new(completion: CompletionInfo) -> Self {
32273273
Self {
3228-
result_type: ResultType::default(),
3274+
result_type: Some(ResultType::COMPLETE),
32293275
completion,
32303276
meta: None,
32313277
}
@@ -3674,14 +3720,23 @@ pub type CreateElicitationRequest = ElicitRequest;
36743720
///
36753721
/// Contains the content returned by the tool execution and an optional
36763722
/// flag indicating whether the operation resulted in an error.
3677-
#[derive(Default, Debug, Serialize, Clone, PartialEq)]
3723+
#[derive(Debug, Serialize, Clone, PartialEq)]
36783724
#[serde(rename_all = "camelCase")]
36793725
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
36803726
#[non_exhaustive]
36813727
pub struct CallToolResult {
3682-
/// Result type discriminator. Absent values deserialize as `"complete"`.
3683-
#[serde(default)]
3684-
pub result_type: ResultType,
3728+
/// Result type discriminator (SEP-2322). Required by the [spec schema]
3729+
/// for servers implementing protocol version `2026-07-28`, but optional
3730+
/// here because this type also models results from older protocol
3731+
/// versions, which do not carry the field: `None` means absent on the
3732+
/// wire, and per the spec "the client MUST treat the absent field as
3733+
/// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`;
3734+
/// the server handler clears the field when responding to peers that
3735+
/// negotiated an older version.
3736+
///
3737+
/// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234
3738+
#[serde(default, skip_serializing_if = "Option::is_none")]
3739+
pub result_type: Option<ResultType>,
36853740
/// The content returned by the tool (text, images, etc.)
36863741
#[serde(default)]
36873742
pub content: Vec<ContentBlock>,
@@ -3710,7 +3765,7 @@ impl<'de> Deserialize<'de> for CallToolResult {
37103765
#[serde(rename_all = "camelCase")]
37113766
struct Helper {
37123767
#[serde(default)]
3713-
result_type: ResultType,
3768+
result_type: Option<ResultType>,
37143769
content: Option<Vec<ContentBlock>>,
37153770
structured_content: Option<Value>,
37163771
is_error: Option<bool>,
@@ -3741,11 +3796,23 @@ impl<'de> Deserialize<'de> for CallToolResult {
37413796
}
37423797
}
37433798

3799+
impl Default for CallToolResult {
3800+
fn default() -> Self {
3801+
CallToolResult {
3802+
result_type: Some(ResultType::COMPLETE),
3803+
content: Vec::new(),
3804+
structured_content: None,
3805+
is_error: None,
3806+
meta: None,
3807+
}
3808+
}
3809+
}
3810+
37443811
impl CallToolResult {
37453812
/// Create a successful tool result with unstructured content
37463813
pub fn success(content: Vec<ContentBlock>) -> Self {
37473814
CallToolResult {
3748-
result_type: ResultType::default(),
3815+
result_type: Some(ResultType::COMPLETE),
37493816
content,
37503817
structured_content: None,
37513818
is_error: Some(false),
@@ -3803,7 +3870,7 @@ impl CallToolResult {
38033870
/// ```
38043871
pub fn error(content: Vec<ContentBlock>) -> Self {
38053872
CallToolResult {
3806-
result_type: ResultType::default(),
3873+
result_type: Some(ResultType::COMPLETE),
38073874
content,
38083875
structured_content: None,
38093876
is_error: Some(true),
@@ -3826,7 +3893,7 @@ impl CallToolResult {
38263893
/// ```
38273894
pub fn structured(value: Value) -> Self {
38283895
CallToolResult {
3829-
result_type: ResultType::default(),
3896+
result_type: Some(ResultType::COMPLETE),
38303897
content: vec![ContentBlock::text(value.to_string())],
38313898
structured_content: Some(value),
38323899
is_error: Some(false),
@@ -3853,7 +3920,7 @@ impl CallToolResult {
38533920
/// ```
38543921
pub fn structured_error(value: Value) -> Self {
38553922
CallToolResult {
3856-
result_type: ResultType::default(),
3923+
result_type: Some(ResultType::COMPLETE),
38573924
content: vec![ContentBlock::text(value.to_string())],
38583925
structured_content: Some(value),
38593926
is_error: Some(true),
@@ -4041,26 +4108,41 @@ impl CreateMessageResult {
40414108
}
40424109
}
40434110

4044-
#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
4111+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
40454112
#[serde(rename_all = "camelCase")]
40464113
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40474114
#[non_exhaustive]
40484115
pub struct GetPromptResult {
4049-
/// Result type discriminator. Absent values deserialize as `"complete"`.
4050-
#[serde(default)]
4051-
pub result_type: ResultType,
4116+
/// Result type discriminator (SEP-2322). Required by the [spec schema]
4117+
/// for servers implementing protocol version `2026-07-28`, but optional
4118+
/// here because this type also models results from older protocol
4119+
/// versions, which do not carry the field: `None` means absent on the
4120+
/// wire, and per the spec "the client MUST treat the absent field as
4121+
/// `"complete"`". Constructors default to `Some(ResultType::COMPLETE)`;
4122+
/// the server handler clears the field when responding to peers that
4123+
/// negotiated an older version.
4124+
///
4125+
/// [spec schema]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/5bed7b30527019e34ccb0eb474636651424501f6/schema/draft/schema.ts#L225-L234
4126+
#[serde(default, skip_serializing_if = "Option::is_none")]
4127+
pub result_type: Option<ResultType>,
40524128
#[serde(skip_serializing_if = "Option::is_none")]
40534129
pub description: Option<String>,
40544130
pub messages: Vec<PromptMessage>,
40554131
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
40564132
pub meta: Option<MetaObject>,
40574133
}
40584134

4135+
impl Default for GetPromptResult {
4136+
fn default() -> Self {
4137+
Self::new(Vec::new())
4138+
}
4139+
}
4140+
40594141
impl GetPromptResult {
40604142
/// Create a new GetPromptResult with required fields.
40614143
pub fn new(messages: Vec<PromptMessage>) -> Self {
40624144
Self {
4063-
result_type: ResultType::default(),
4145+
result_type: Some(ResultType::COMPLETE),
40644146
description: None,
40654147
messages,
40664148
meta: None,
@@ -4424,6 +4506,42 @@ impl ServerResult {
44244506
pub fn task_ack(_: ()) -> ServerResult {
44254507
ServerResult::TaskAckResult(TaskAckResult::new())
44264508
}
4509+
4510+
/// Strip the SEP-2322 `resultType: "complete"` discriminator so the result
4511+
/// keeps the wire shape that predates protocol version `2026-07-28`.
4512+
///
4513+
/// The server handler calls this before responding to a peer that
4514+
/// negotiated an older protocol version, where the field did not exist and
4515+
/// strict peers may reject it. Only the `"complete"` value is stripped:
4516+
/// results whose discriminator carries meaning (`"input_required"`,
4517+
/// `"task"`) are already gated to `2026-07-28`+ sessions, and custom
4518+
/// extension values are preserved.
4519+
///
4520+
/// # Examples
4521+
///
4522+
/// ```
4523+
/// use rmcp::model::{CallToolResult, ServerResult};
4524+
///
4525+
/// let mut result = ServerResult::CallToolResult(CallToolResult::success(vec![]));
4526+
/// result.strip_result_type_for_legacy_peer();
4527+
///
4528+
/// let json = serde_json::to_value(&result).unwrap();
4529+
/// assert!(json.get("resultType").is_none());
4530+
/// ```
4531+
pub fn strip_result_type_for_legacy_peer(&mut self) {
4532+
let result_type = match self {
4533+
ServerResult::CompleteResult(r) => &mut r.result_type,
4534+
ServerResult::GetPromptResult(r) => &mut r.result_type,
4535+
ServerResult::ListPromptsResult(r) => &mut r.result_type,
4536+
ServerResult::ListResourcesResult(r) => &mut r.result_type,
4537+
ServerResult::ListResourceTemplatesResult(r) => &mut r.result_type,
4538+
ServerResult::ReadResourceResult(r) => &mut r.result_type,
4539+
ServerResult::ListToolsResult(r) => &mut r.result_type,
4540+
ServerResult::CallToolResult(r) => &mut r.result_type,
4541+
_ => return,
4542+
};
4543+
result_type.take_if(|result_type| result_type.is_complete());
4544+
}
44274545
}
44284546

44294547
pub type ServerJsonRpcMessage = JsonRpcMessage<ServerRequest, ServerResult, ServerNotification>;

0 commit comments

Comments
 (0)