From 8c5f4916a7c1e90f8edc74102ee96093c6cd10a6 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:02:41 -0500 Subject: [PATCH] fix: align task response types with MCP spec --- crates/rmcp-macros/src/task_handler.rs | 39 ++-- crates/rmcp/src/handler/server.rs | 19 +- crates/rmcp/src/model.rs | 16 +- crates/rmcp/src/model/task.rs | 61 ++++-- .../server_json_rpc_message_schema.json | 174 +++++++++++++----- ...erver_json_rpc_message_schema_current.json | 174 +++++++++++++----- 6 files changed, 349 insertions(+), 134 deletions(-) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index 09d43f96..4ad02d6b 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -47,7 +47,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result syn::Result, - ) -> Result { + ) -> Result { use rmcp::task_manager::current_timestamp; let task_id = request.task_id.clone(); let mut processor = (#processor).lock().await; @@ -156,11 +156,11 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result(get_info_fn)?); @@ -191,7 +191,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result { + ) -> Result { use std::time::Duration; let task_id = request.task_id.clone(); @@ -207,11 +207,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result { let value = ::serde_json::to_value(call_tool).unwrap_or(::serde_json::Value::Null); - return Ok(rmcp::model::TaskResult { - content_type: "application/json".to_string(), - value, - summary: None, - }); + return Ok(rmcp::model::GetTaskPayloadResult(value)); } Err(err) => return Err(McpError::internal_error( format!("task failed: {}", err), @@ -251,12 +247,23 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result, - ) -> Result<(), McpError> { + ) -> Result { + use rmcp::task_manager::current_timestamp; let task_id = request.task_id; let mut processor = (#processor).lock().await; if processor.cancel_task(&task_id) { - return Ok(()); + let timestamp = current_timestamp(); + let task = rmcp::model::Task { + task_id, + status: rmcp::model::TaskStatus::Cancelled, + status_message: None, + created_at: timestamp.clone(), + last_updated_at: timestamp, + ttl: None, + poll_interval: None, + }; + return Ok(rmcp::model::CancelTaskResult { meta: None, task }); } // If already completed, signal it's not cancellable diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 86773d87..a7ae335b 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -116,15 +116,15 @@ impl Service for H { ClientRequest::GetTaskInfoRequest(request) => self .get_task_info(request.params, context) .await - .map(ServerResult::GetTaskInfoResult), + .map(ServerResult::GetTaskResult), ClientRequest::GetTaskResultRequest(request) => self .get_task_result(request.params, context) .await - .map(ServerResult::TaskResult), + .map(ServerResult::GetTaskPayloadResult), ClientRequest::CancelTaskRequest(request) => self .cancel_task(request.params, context) .await - .map(ServerResult::empty), + .map(ServerResult::CancelTaskResult), } } @@ -339,7 +339,8 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { + let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -347,7 +348,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -356,7 +357,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { let _ = (request, context); std::future::ready(Err(McpError::method_not_found::())) } @@ -543,7 +544,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskInfoParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).get_task_info(request, context) } @@ -551,7 +552,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: GetTaskResultParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).get_task_result(request, context) } @@ -559,7 +560,7 @@ macro_rules! impl_server_handler_for_wrapper { &self, request: CancelTaskParams, context: RequestContext, - ) -> impl Future> + Send + '_ { + ) -> impl Future> + Send + '_ { (**self).cancel_task(request, context) } } diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 00c51bcb..3832271a 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -2537,14 +2537,9 @@ impl RequestParamsMeta for CancelTaskParams { /// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). #[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] pub type CancelTaskParam = CancelTaskParams; -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct GetTaskInfoResult { - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, -} +/// Deprecated: Use [`GetTaskResult`] instead (spec alignment). +#[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] +pub type GetTaskInfoResult = GetTaskResult; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "camelCase")] @@ -2720,9 +2715,10 @@ ts_union!( | EmptyResult | CreateTaskResult | ListTasksResult - | GetTaskInfoResult - | TaskResult + | GetTaskResult + | CancelTaskResult | CustomResult + | GetTaskPayloadResult ; ); diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index 8cb0ee58..a18ed0c5 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use super::Meta; + /// Canonical task lifecycle status as defined by SEP-1686. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -19,21 +21,10 @@ pub enum TaskStatus { Cancelled, } -/// Final result for a succeeded task (returned from `tasks/result`). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct TaskResult { - /// MIME type or custom content-type identifier. - pub content_type: String, - /// The actual result payload, matching the underlying request's schema. - pub value: Value, - /// Optional short summary for UI surfaces. - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option, -} - /// Primary Task object that surfaces metadata during the task lifecycle. +/// +/// Per spec, `lastUpdatedAt` and `ttl` are required fields. +/// `ttl` is nullable (`null` means unlimited retention). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -48,10 +39,9 @@ pub struct Task { /// ISO-8601 creation timestamp. pub created_at: String, /// ISO-8601 timestamp for the most recent status change. - #[serde(skip_serializing_if = "Option::is_none")] - pub last_updated_at: Option, + pub last_updated_at: String, /// Retention window in milliseconds that the receiver agreed to honor. - #[serde(skip_serializing_if = "Option::is_none")] + /// `None` (serialized as `null`) means unlimited retention. pub ttl: Option, /// Suggested polling interval (milliseconds). #[serde(skip_serializing_if = "Option::is_none")] @@ -66,6 +56,43 @@ pub struct CreateTaskResult { pub task: Task, } +/// Response to a `tasks/get` request. +/// +/// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are +/// flattened at the top level, not nested under a `task` key. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct GetTaskResult { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: Task, +} + +/// Response to a `tasks/result` request. +/// +/// Per spec, the result structure matches the original request type +/// (e.g., `CallToolResult` for `tools/call`). This is represented as +/// an open object. The payload is the original request's result +/// serialized as a JSON value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct GetTaskPayloadResult(pub Value); + +/// Response to a `tasks/cancel` request. +/// +/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct CancelTaskResult { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(flatten)] + pub task: Task, +} + /// Paginated list of tasks #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] 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 f0b61735..2bb71dff 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 @@ -407,6 +407,70 @@ "content" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -937,21 +1001,72 @@ "messages" ] }, - "GetTaskInfoResult": { + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", "type": "object", "properties": { - "task": { - "anyOf": [ - { - "$ref": "#/definitions/Task" - }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/TaskStatus" } ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2701,13 +2816,16 @@ "$ref": "#/definitions/ListTasksResult" }, { - "$ref": "#/definitions/GetTaskInfoResult" + "$ref": "#/definitions/GetTaskResult" }, { - "$ref": "#/definitions/TaskResult" + "$ref": "#/definitions/CancelTaskResult" }, { "$ref": "#/definitions/CustomResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" } ] }, @@ -2813,7 +2931,7 @@ "const": "string" }, "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.", + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", "properties": { "createdAt": { @@ -2822,10 +2940,7 @@ }, "lastUpdatedAt": { "description": "ISO-8601 timestamp for the most recent status change.", - "type": [ - "string", - "null" - ] + "type": "string" }, "pollInterval": { "description": "Suggested polling interval (milliseconds).", @@ -2856,7 +2971,7 @@ "type": "string" }, "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.", + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", "type": [ "integer", "null" @@ -2868,7 +2983,8 @@ "required": [ "taskId", "status", - "createdAt" + "createdAt", + "lastUpdatedAt" ] }, "TaskRequestsCapability": { @@ -2907,30 +3023,6 @@ } } }, - "TaskResult": { - "description": "Final result for a succeeded task (returned from `tasks/result`).", - "type": "object", - "properties": { - "contentType": { - "description": "MIME type or custom content-type identifier.", - "type": "string" - }, - "summary": { - "description": "Optional short summary for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "value": { - "description": "The actual result payload, matching the underlying request's schema." - } - }, - "required": [ - "contentType", - "value" - ] - }, "TaskStatus": { "description": "Canonical task lifecycle status as defined by SEP-1686.", "oneOf": [ 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 f0b61735..2bb71dff 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 @@ -407,6 +407,70 @@ "content" ] }, + "CancelTaskResult": { + "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", + "type": "object", + "properties": { + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ + { + "$ref": "#/definitions/TaskStatus" + } + ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] + }, "CancelledNotificationMethod": { "type": "string", "format": "const", @@ -937,21 +1001,72 @@ "messages" ] }, - "GetTaskInfoResult": { + "GetTaskPayloadResult": { + "description": "Response to a `tasks/result` request.\n\nPer spec, the result structure matches the original request type\n(e.g., `CallToolResult` for `tools/call`). This is represented as\nan open object. The payload is the original request's result\nserialized as a JSON value." + }, + "GetTaskResult": { + "description": "Response to a `tasks/get` request.\n\nPer spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are\nflattened at the top level, not nested under a `task` key.", "type": "object", "properties": { - "task": { - "anyOf": [ - { - "$ref": "#/definitions/Task" - }, + "_meta": { + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "createdAt": { + "description": "ISO-8601 creation timestamp.", + "type": "string" + }, + "lastUpdatedAt": { + "description": "ISO-8601 timestamp for the most recent status change.", + "type": "string" + }, + "pollInterval": { + "description": "Suggested polling interval (milliseconds).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "status": { + "description": "Current lifecycle status (see [`TaskStatus`]).", + "allOf": [ { - "type": "null" + "$ref": "#/definitions/TaskStatus" } ] + }, + "statusMessage": { + "description": "Optional human-readable status message for UI surfaces.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Unique task identifier generated by the receiver.", + "type": "string" + }, + "ttl": { + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, - "additionalProperties": false + "required": [ + "taskId", + "status", + "createdAt", + "lastUpdatedAt" + ] }, "Icon": { "description": "A URL pointing to an icon resource or a base64-encoded data URI.\n\nClients that support rendering icons MUST support at least the following MIME types:\n- image/png - PNG images (safe, universal compatibility)\n- image/jpeg (and image/jpg) - JPEG images (safe, universal compatibility)\n\nClients that support rendering icons SHOULD also support:\n- image/svg+xml - SVG images (scalable but requires security precautions)\n- image/webp - WebP images (modern, efficient format)", @@ -2701,13 +2816,16 @@ "$ref": "#/definitions/ListTasksResult" }, { - "$ref": "#/definitions/GetTaskInfoResult" + "$ref": "#/definitions/GetTaskResult" }, { - "$ref": "#/definitions/TaskResult" + "$ref": "#/definitions/CancelTaskResult" }, { "$ref": "#/definitions/CustomResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" } ] }, @@ -2813,7 +2931,7 @@ "const": "string" }, "Task": { - "description": "Primary Task object that surfaces metadata during the task lifecycle.", + "description": "Primary Task object that surfaces metadata during the task lifecycle.\n\nPer spec, `lastUpdatedAt` and `ttl` are required fields.\n`ttl` is nullable (`null` means unlimited retention).", "type": "object", "properties": { "createdAt": { @@ -2822,10 +2940,7 @@ }, "lastUpdatedAt": { "description": "ISO-8601 timestamp for the most recent status change.", - "type": [ - "string", - "null" - ] + "type": "string" }, "pollInterval": { "description": "Suggested polling interval (milliseconds).", @@ -2856,7 +2971,7 @@ "type": "string" }, "ttl": { - "description": "Retention window in milliseconds that the receiver agreed to honor.", + "description": "Retention window in milliseconds that the receiver agreed to honor.\n`None` (serialized as `null`) means unlimited retention.", "type": [ "integer", "null" @@ -2868,7 +2983,8 @@ "required": [ "taskId", "status", - "createdAt" + "createdAt", + "lastUpdatedAt" ] }, "TaskRequestsCapability": { @@ -2907,30 +3023,6 @@ } } }, - "TaskResult": { - "description": "Final result for a succeeded task (returned from `tasks/result`).", - "type": "object", - "properties": { - "contentType": { - "description": "MIME type or custom content-type identifier.", - "type": "string" - }, - "summary": { - "description": "Optional short summary for UI surfaces.", - "type": [ - "string", - "null" - ] - }, - "value": { - "description": "The actual result payload, matching the underlying request's schema." - } - }, - "required": [ - "contentType", - "value" - ] - }, "TaskStatus": { "description": "Canonical task lifecycle status as defined by SEP-1686.", "oneOf": [