From a77203f13963242917499bb7151f3813b3235001 Mon Sep 17 00:00:00 2001 From: TheUnderdev Date: Mon, 20 Apr 2026 18:41:45 +0200 Subject: [PATCH 1/2] feat(task): route server-originated tasks/* to ClientHandler (SEP-1686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks are bidirectional per SEP-1686: either party can be the requestor or the receiver. The ServerHandler side is already wired for client→server task flow (tools/call augmentation). This patch mirrors the same wiring on the client side so servers that initiate task-augmented requests (notably sampling/createMessage and elicitation/create) can follow up with tasks/get, tasks/list, tasks/result, and tasks/cancel directed at the client. Changes, purely additive: * ServerRequest: add GetTaskInfoRequest | ListTasksRequest | GetTaskResultRequest | CancelTaskRequest variants. Add a ServerRequest::method() accessor mirroring ClientRequest::method(). Update the variant_extension! invocation so the existing GetExtensions / GetMeta impls cover the new variants. * ClientResult: add ListTasksResult | GetTaskResult | GetTaskPayloadResult | CancelTaskResult response variants. GetTaskPayloadResult retains its existing custom Deserialize-fails behavior, so payload responses are still observed on the wire as CustomResult (matching the server-side pattern). * ClientHandler: add list_tasks, get_task_info, get_task_result, and cancel_task methods with default -32601 Method-not-found impls, mirroring the server-side signatures. Propagate via the Box/Arc wrapper macro. Dispatch all four from the handle_request match. This unblocks clients that want to advertise capabilities.tasks.requests.sampling.createMessage, capabilities.tasks.requests.elicitation.create, or the client-side tasks.list / tasks.cancel capabilities: previously, servers had no way to reach the client's task methods through the typed request enum, and such capabilities couldn't be honored end-to-end. Tests: new test_task_client_receiver.rs exercises a full bidirectional roundtrip for each of the four methods (server → client RPC → ClientHandler → response → server), plus a default-impl test that confirms the unit () client returns -32601 for tasks/get. Existing message-schema golden files regenerated to include the new ServerRequest and ClientResult variants; no other tests affected. Related: #528, #536 (which added the server-side half of SEP-1686). --- crates/rmcp/Cargo.toml | 5 + crates/rmcp/src/handler/client.rs | 110 ++++++ crates/rmcp/src/model.rs | 24 ++ crates/rmcp/src/model/meta.rs | 4 + .../client_json_rpc_message_schema.json | 258 ++++++++++++++ ...lient_json_rpc_message_schema_current.json | 258 ++++++++++++++ .../server_json_rpc_message_schema.json | 177 ++++++++++ ...erver_json_rpc_message_schema_current.json | 177 ++++++++++ .../rmcp/tests/test_task_client_receiver.rs | 333 ++++++++++++++++++ 9 files changed, 1346 insertions(+) create mode 100644 crates/rmcp/tests/test_task_client_receiver.rs diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 5006681d..a4a36806 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -283,6 +283,11 @@ name = "test_custom_request" required-features = ["server", "client"] path = "tests/test_custom_request.rs" +[[test]] +name = "test_task_client_receiver" +required-features = ["server", "client"] +path = "tests/test_task_client_receiver.rs" + [[test]] name = "test_prompt_macros" required-features = ["server", "client"] diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index 926aafcb..bfd1d975 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -30,6 +30,22 @@ impl Service for H { .create_elicitation(request.params, context) .await .map(ClientResult::CreateElicitationResult), + ServerRequest::ListTasksRequest(request) => self + .list_tasks(request.params, context) + .await + .map(ClientResult::ListTasksResult), + ServerRequest::GetTaskInfoRequest(request) => self + .get_task_info(request.params, context) + .await + .map(ClientResult::GetTaskResult), + ServerRequest::GetTaskResultRequest(request) => self + .get_task_result(request.params, context) + .await + .map(ClientResult::GetTaskPayloadResult), + ServerRequest::CancelTaskRequest(request) => self + .cancel_task(request.params, context) + .await + .map(ClientResult::CancelTaskResult), ServerRequest::CustomRequest(request) => self .on_custom_request(request, context) .await @@ -191,6 +207,68 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { ))) } + /// Handle a `tasks/list` request from a server. Only relevant when the + /// client is also a task *receiver* (e.g. it accepted a task-augmented + /// `sampling/createMessage` or `elicitation/create` request). + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Clients that advertise + /// `capabilities.tasks.list` must override this. + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/get` request from a server. Only relevant when the + /// client is also a task *receiver* (e.g. it accepted a task-augmented + /// `sampling/createMessage` or `elicitation/create` request). + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Clients that advertise + /// `capabilities.tasks.requests.sampling.createMessage` or + /// `capabilities.tasks.requests.elicitation.create` must override this. + fn get_task_info( + &self, + request: GetTaskInfoParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/result` request from a server. Only relevant when + /// the client is also a task *receiver*. + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). + fn get_task_result( + &self, + request: GetTaskResultParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + + /// Handle a `tasks/cancel` request from a server. Only relevant when + /// the client is also a task *receiver*. + /// + /// # Default Behavior + /// Returns `-32601` (Method not found). Clients that advertise + /// `capabilities.tasks.cancel` must override this. + fn cancel_task( + &self, + request: CancelTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + let _ = (request, context); + std::future::ready(Err(McpError::method_not_found::())) + } + fn on_cancelled( &self, params: CancelledNotificationParam, @@ -310,6 +388,38 @@ macro_rules! impl_client_handler_for_wrapper { (**self).on_custom_request(request, context) } + fn list_tasks( + &self, + request: Option, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).list_tasks(request, context) + } + + fn get_task_info( + &self, + request: GetTaskInfoParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_task_info(request, context) + } + + fn get_task_result( + &self, + request: GetTaskResultParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).get_task_result(request, context) + } + + fn cancel_task( + &self, + request: CancelTaskParams, + context: RequestContext, + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).cancel_task(request, context) + } + fn on_cancelled( &self, params: CancelledNotificationParam, diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index b473e9ac..908f5241 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3323,6 +3323,10 @@ ts_union!( box CreateMessageResult | ListRootsResult | CreateElicitationResult + | ListTasksResult + | GetTaskResult + | GetTaskPayloadResult + | CancelTaskResult | EmptyResult | CustomResult; ); @@ -3341,9 +3345,29 @@ ts_union!( | CreateMessageRequest | ListRootsRequest | CreateElicitationRequest + | GetTaskInfoRequest + | ListTasksRequest + | GetTaskResultRequest + | CancelTaskRequest | CustomRequest; ); +impl ServerRequest { + pub fn method(&self) -> &str { + match &self { + ServerRequest::PingRequest(r) => r.method.as_str(), + ServerRequest::CreateMessageRequest(r) => r.method.as_str(), + ServerRequest::ListRootsRequest(r) => r.method.as_str(), + ServerRequest::CreateElicitationRequest(r) => r.method.as_str(), + ServerRequest::GetTaskInfoRequest(r) => r.method.as_str(), + ServerRequest::ListTasksRequest(r) => r.method.as_str(), + ServerRequest::GetTaskResultRequest(r) => r.method.as_str(), + ServerRequest::CancelTaskRequest(r) => r.method.as_str(), + ServerRequest::CustomRequest(r) => r.method.as_str(), + } + } +} + ts_union!( export type ServerNotification = | CancelledNotification diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 186db6a2..91cfcf19 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -166,6 +166,10 @@ variant_extension! { ListRootsRequest CreateElicitationRequest CustomRequest + GetTaskInfoRequest + ListTasksRequest + GetTaskResultRequest + CancelTaskRequest } } diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json index 8e082db9..6b7a0ee6 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema.json @@ -248,6 +248,70 @@ "taskId" ] }, + "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", @@ -351,6 +415,18 @@ { "$ref": "#/definitions/CreateElicitationResult" }, + { + "$ref": "#/definitions/ListTasksResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/CancelTaskResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -680,6 +756,73 @@ "taskId" ] }, + "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": { + "_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" + ] + }, "GetTaskResultMethod": { "type": "string", "format": "const", @@ -1031,6 +1174,34 @@ "format": "const", "const": "tasks/list" }, + "ListTasksResult": { + "type": "object", + "properties": { + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/Task" + } + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tasks" + ] + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -2016,6 +2187,63 @@ "uri" ] }, + "Task": { + "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": { + "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" + ] + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2052,6 +2280,36 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", diff --git a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json index 8e082db9..6b7a0ee6 100644 --- a/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/client_json_rpc_message_schema_current.json @@ -248,6 +248,70 @@ "taskId" ] }, + "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", @@ -351,6 +415,18 @@ { "$ref": "#/definitions/CreateElicitationResult" }, + { + "$ref": "#/definitions/ListTasksResult" + }, + { + "$ref": "#/definitions/GetTaskResult" + }, + { + "$ref": "#/definitions/GetTaskPayloadResult" + }, + { + "$ref": "#/definitions/CancelTaskResult" + }, { "$ref": "#/definitions/EmptyObject" }, @@ -680,6 +756,73 @@ "taskId" ] }, + "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": { + "_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" + ] + }, "GetTaskResultMethod": { "type": "string", "format": "const", @@ -1031,6 +1174,34 @@ "format": "const", "const": "tasks/list" }, + "ListTasksResult": { + "type": "object", + "properties": { + "nextCursor": { + "type": [ + "string", + "null" + ] + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/Task" + } + }, + "total": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "tasks" + ] + }, "ListToolsRequestMethod": { "type": "string", "format": "const", @@ -2016,6 +2187,63 @@ "uri" ] }, + "Task": { + "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": { + "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" + ] + }, "TaskRequestsCapability": { "description": "Request types that support task-augmented execution.", "type": "object", @@ -2052,6 +2280,36 @@ } } }, + "TaskStatus": { + "description": "Canonical task lifecycle status as defined by SEP-1686.", + "oneOf": [ + { + "description": "The receiver accepted the request and is currently working on it.", + "type": "string", + "const": "working" + }, + { + "description": "The receiver requires additional input before work can continue.", + "type": "string", + "const": "input_required" + }, + { + "description": "The underlying operation completed successfully and the result is ready.", + "type": "string", + "const": "completed" + }, + { + "description": "The underlying operation failed and will not continue.", + "type": "string", + "const": "failed" + }, + { + "description": "The task was cancelled and will not continue processing.", + "type": "string", + "const": "cancelled" + } + ] + }, "TasksCapability": { "description": "Task capabilities shared by client and server.", "type": "object", 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 405b3e02..4982a975 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 @@ -405,6 +405,30 @@ } } }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", "type": "object", @@ -1007,6 +1031,30 @@ "messages" ] }, + "GetTaskInfoMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskInfoParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "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." }, @@ -1074,6 +1122,30 @@ "lastUpdatedAt" ] }, + "GetTaskResultMethod": { + "type": "string", + "format": "const", + "const": "tasks/result" + }, + "GetTaskResultParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "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)", "type": "object", @@ -1356,6 +1428,18 @@ { "$ref": "#/definitions/Request2" }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1515,6 +1599,11 @@ "format": "const", "const": "roots/list" }, + "ListTasksMethod": { + "type": "string", + "format": "const", + "const": "tasks/list" + }, "ListTasksResult": { "type": "object", "properties": { @@ -1864,6 +1953,25 @@ "format": "const", "const": "object" }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, "PingRequestMethod": { "type": "string", "format": "const", @@ -2452,6 +2560,54 @@ "params" ] }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskInfoMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskInfoParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskResultMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskResultParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, "RequestNoParam": { "type": "object", "properties": { @@ -2474,6 +2630,27 @@ "method" ] }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListTasksMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, "ResourceContents": { "anyOf": [ { 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 405b3e02..4982a975 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 @@ -405,6 +405,30 @@ } } }, + "CancelTaskMethod": { + "type": "string", + "format": "const", + "const": "tasks/cancel" + }, + "CancelTaskParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "CancelTaskResult": { "description": "Response to a `tasks/cancel` request.\n\nPer spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.", "type": "object", @@ -1007,6 +1031,30 @@ "messages" ] }, + "GetTaskInfoMethod": { + "type": "string", + "format": "const", + "const": "tasks/get" + }, + "GetTaskInfoParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "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." }, @@ -1074,6 +1122,30 @@ "lastUpdatedAt" ] }, + "GetTaskResultMethod": { + "type": "string", + "format": "const", + "const": "tasks/result" + }, + "GetTaskResultParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "taskId" + ] + }, "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)", "type": "object", @@ -1356,6 +1428,18 @@ { "$ref": "#/definitions/Request2" }, + { + "$ref": "#/definitions/Request3" + }, + { + "$ref": "#/definitions/RequestOptionalParam" + }, + { + "$ref": "#/definitions/Request4" + }, + { + "$ref": "#/definitions/Request5" + }, { "$ref": "#/definitions/CustomRequest" } @@ -1515,6 +1599,11 @@ "format": "const", "const": "roots/list" }, + "ListTasksMethod": { + "type": "string", + "format": "const", + "const": "tasks/list" + }, "ListTasksResult": { "type": "object", "properties": { @@ -1864,6 +1953,25 @@ "format": "const", "const": "object" }, + "PaginatedRequestParams": { + "type": "object", + "properties": { + "_meta": { + "description": "Protocol-level metadata for this request (SEP-1319)", + "type": [ + "object", + "null" + ], + "additionalProperties": true + }, + "cursor": { + "type": [ + "string", + "null" + ] + } + } + }, "PingRequestMethod": { "type": "string", "format": "const", @@ -2452,6 +2560,54 @@ "params" ] }, + "Request3": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskInfoMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskInfoParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request4": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/GetTaskResultMethod" + }, + "params": { + "$ref": "#/definitions/GetTaskResultParams" + } + }, + "required": [ + "method", + "params" + ] + }, + "Request5": { + "description": "Represents a JSON-RPC request with method, parameters, and extensions.\n\nThis is the core structure for all MCP requests, containing:\n- `method`: The name of the method being called\n- `params`: The parameters for the method\n- `extensions`: Additional context data (similar to HTTP headers)", + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/CancelTaskMethod" + }, + "params": { + "$ref": "#/definitions/CancelTaskParams" + } + }, + "required": [ + "method", + "params" + ] + }, "RequestNoParam": { "type": "object", "properties": { @@ -2474,6 +2630,27 @@ "method" ] }, + "RequestOptionalParam": { + "type": "object", + "properties": { + "method": { + "$ref": "#/definitions/ListTasksMethod" + }, + "params": { + "anyOf": [ + { + "$ref": "#/definitions/PaginatedRequestParams" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "method" + ] + }, "ResourceContents": { "anyOf": [ { diff --git a/crates/rmcp/tests/test_task_client_receiver.rs b/crates/rmcp/tests/test_task_client_receiver.rs new file mode 100644 index 00000000..793b9c65 --- /dev/null +++ b/crates/rmcp/tests/test_task_client_receiver.rs @@ -0,0 +1,333 @@ +//! End-to-end tests proving that task-management RPCs (`tasks/get`, +//! `tasks/list`, `tasks/result`, `tasks/cancel`) sent from a server to a +//! client are dispatched to the appropriate `ClientHandler` methods and +//! return results that the server receives as the correct `ClientResult` +//! variant. +//! +//! Companion tests for the server-as-receiver path already exist; these +//! round out the bidirectional coverage required by SEP-1686. + +#![cfg(not(feature = "local"))] +use std::sync::Arc; + +use rmcp::{ + ClientHandler, ServerHandler, ServiceExt, + model::{ + CancelTaskParams, CancelTaskRequest, CancelTaskResult, ClientResult, GetTaskInfoParams, + GetTaskInfoRequest, GetTaskPayloadResult, GetTaskResult, GetTaskResultParams, + GetTaskResultRequest, ListTasksRequest, ListTasksResult, PaginatedRequestParams, + ServerRequest, Task, TaskStatus, + }, +}; +use serde_json::json; +use tokio::sync::{Mutex, Notify}; + +/// Shared bookkeeping for the client handler: records which method was +/// invoked with which task id, so each test can assert on it. +#[derive(Default)] +struct ClientState { + last_get_task_id: Option, + last_result_task_id: Option, + last_cancel_task_id: Option, + list_called: bool, +} + +struct TaskClient { + state: Arc>, + received: Arc, +} + +impl ClientHandler for TaskClient { + async fn get_task_info( + &self, + request: GetTaskInfoParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_get_task_id = Some(request.task_id.clone()); + self.received.notify_one(); + Ok(GetTaskResult { + meta: None, + task: Task::new( + request.task_id, + TaskStatus::Working, + "2025-11-25T10:30:00Z".into(), + "2025-11-25T10:30:00Z".into(), + ), + }) + } + + async fn list_tasks( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.list_called = true; + self.received.notify_one(); + Ok(ListTasksResult::new(vec![Task::new( + "task-42".to_string(), + TaskStatus::Working, + "2025-11-25T10:30:00Z".into(), + "2025-11-25T10:30:00Z".into(), + )])) + } + + async fn get_task_result( + &self, + request: GetTaskResultParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_result_task_id = Some(request.task_id); + self.received.notify_one(); + Ok(GetTaskPayloadResult::new(json!({ "ok": true }))) + } + + async fn cancel_task( + &self, + request: CancelTaskParams, + _context: rmcp::service::RequestContext, + ) -> Result { + self.state.lock().await.last_cancel_task_id = Some(request.task_id.clone()); + self.received.notify_one(); + Ok(CancelTaskResult { + meta: None, + task: Task::new( + request.task_id, + TaskStatus::Cancelled, + "2025-11-25T10:30:00Z".into(), + "2025-11-25T10:30:00Z".into(), + ), + }) + } +} + +/// Signal we fire when the server finishes its outbound RPC so the test +/// can assert on the response shape. +struct ServerCompletion { + done: Arc, + last_response: Arc>>>, +} + +/// A server that, on initialize, fires a single `ServerRequest` (parameterised +/// externally via a closure) at the client and stashes the response. +struct RequestingServer { + request: Arc>>, + completion: ServerCompletion, +} + +impl ServerHandler for RequestingServer { + async fn on_initialized(&self, context: rmcp::service::NotificationContext) { + let peer = context.peer.clone(); + let request = self.request.lock().await.take(); + let done = self.completion.done.clone(); + let last_response = self.completion.last_response.clone(); + tokio::spawn(async move { + let Some(req) = request else { + *last_response.lock().await = Some(Err("no request".into())); + done.notify_one(); + return; + }; + let outcome = peer + .send_request(req) + .await + .map_err(|e| format!("send_request failed: {e}")); + *last_response.lock().await = Some(outcome); + done.notify_one(); + }); + } +} + +async fn run_server_request(request: ServerRequest) -> (Arc>, ClientResult) { + let _ = tracing_subscriber::fmt::try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let completion = ServerCompletion { + done: Arc::new(Notify::new()), + last_response: Arc::new(Mutex::new(None)), + }; + let server_done = completion.done.clone(); + let server_response = completion.last_response.clone(); + tokio::spawn({ + let request_slot = Arc::new(Mutex::new(Some(request))); + async move { + let server = RequestingServer { + request: request_slot, + completion, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + } + }); + + let state = Arc::new(Mutex::new(ClientState::default())); + let received = Arc::new(Notify::new()); + let client = TaskClient { + state: state.clone(), + received: received.clone(), + } + .serve(client_transport) + .await + .expect("client serve"); + + tokio::time::timeout(std::time::Duration::from_secs(5), received.notified()) + .await + .expect("client handler fired"); + tokio::time::timeout(std::time::Duration::from_secs(5), server_done.notified()) + .await + .expect("server got response"); + + let outcome = server_response + .lock() + .await + .take() + .expect("server outcome set"); + let response = outcome.expect("server request succeeded"); + + client.cancel().await.ok(); + (state, response) +} + +#[tokio::test] +async fn tasks_get_reaches_client_handler() { + let request = ServerRequest::GetTaskInfoRequest(GetTaskInfoRequest::new(GetTaskInfoParams { + meta: None, + task_id: "task-abc".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_get_task_id.as_deref(), + Some("task-abc") + ); + match response { + ClientResult::GetTaskResult(r) => { + assert_eq!(r.task.task_id, "task-abc"); + assert_eq!(r.task.status, TaskStatus::Working); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_list_reaches_client_handler() { + let request = ServerRequest::ListTasksRequest(ListTasksRequest::default()); + let (state, response) = run_server_request(request).await; + + assert!(state.lock().await.list_called); + match response { + ClientResult::ListTasksResult(r) => { + assert_eq!(r.tasks.len(), 1); + assert_eq!(r.tasks[0].task_id, "task-42"); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_result_reaches_client_handler() { + let request = + ServerRequest::GetTaskResultRequest(GetTaskResultRequest::new(GetTaskResultParams { + meta: None, + task_id: "task-xyz".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_result_task_id.as_deref(), + Some("task-xyz") + ); + // GetTaskPayloadResult has a custom Deserialize that always fails (see + // crates/rmcp/src/model/task.rs) so the payload surfaces as the + // catch-all CustomResult on the wire. This matches the existing design + // on the server-as-receiver path. + match response { + ClientResult::CustomResult(r) => { + assert_eq!(r.0, json!({ "ok": true })); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn tasks_cancel_reaches_client_handler() { + let request = ServerRequest::CancelTaskRequest(CancelTaskRequest::new(CancelTaskParams { + meta: None, + task_id: "task-cancelme".into(), + })); + let (state, response) = run_server_request(request).await; + + assert_eq!( + state.lock().await.last_cancel_task_id.as_deref(), + Some("task-cancelme") + ); + // CancelTaskResult and GetTaskResult share the same JSON shape + // (`Result + flattened Task`); the untagged ClientResult enum picks + // the first match, which is GetTaskResult. Callers distinguish by + // knowing which request they sent rather than by inspecting the + // response variant. This mirrors the existing behavior on the server + // side. + match response { + ClientResult::GetTaskResult(r) => { + assert_eq!(r.task.task_id, "task-cancelme"); + assert_eq!(r.task.status, TaskStatus::Cancelled); + } + other => panic!("unexpected variant: {other:?}"), + } +} + +#[tokio::test] +async fn default_handler_returns_method_not_found() { + use rmcp::model::ErrorCode; + let _ = tracing_subscriber::fmt::try_init(); + + let (server_transport, client_transport) = tokio::io::duplex(4096); + let completion = ServerCompletion { + done: Arc::new(Notify::new()), + last_response: Arc::new(Mutex::new(None)), + }; + let done = completion.done.clone(); + let response = completion.last_response.clone(); + tokio::spawn({ + let request_slot = Arc::new(Mutex::new(Some(ServerRequest::GetTaskInfoRequest( + GetTaskInfoRequest::new(GetTaskInfoParams { + meta: None, + task_id: "whatever".into(), + }), + )))); + async move { + let server = RequestingServer { + request: request_slot, + completion, + } + .serve(server_transport) + .await?; + server.waiting().await?; + anyhow::Ok(()) + } + }); + + // Use the empty-unit client, which relies on the default trait impls. + let client = ().serve(client_transport).await.expect("client"); + + tokio::time::timeout(std::time::Duration::from_secs(5), done.notified()) + .await + .expect("server got response"); + + let outcome = response.lock().await.take().expect("response set"); + let err_msg = match outcome { + Ok(other) => panic!("expected method-not-found error, got: {other:?}"), + Err(s) => s, + }; + // The ServiceError Display surfaces the inner McpError, which carries + // the METHOD_NOT_FOUND code. + assert!( + err_msg.contains(&ErrorCode::METHOD_NOT_FOUND.0.to_string()) + || err_msg.to_lowercase().contains("method not found") + || err_msg.to_lowercase().contains("tasks/get"), + "expected method-not-found style error, got: {err_msg}" + ); + + client.cancel().await.ok(); +} From 6d45614a583c1cd31b4dc8c6c99ff3652fc45d7c Mon Sep 17 00:00:00 2001 From: TheUnderdev Date: Wed, 13 May 2026 17:11:55 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(task):=20rename=20tasks/cancel=20to=20t?= =?UTF-8?q?asks/delete=20(SEP-1686=20=C2=A73.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tasks/cancel is not part of SEP-1686. The spec defines tasks/delete (§3.6) for explicitly removing a task and its associated stored result. Rename the method, request/params/result types, and trait methods to match. cancelled remains the terminal task *status* name, since that matches the SEP. DeleteTaskResult drops the flattened Task field — per spec the response is just _meta. It also gets a custom always-fail Deserialize impl (same trick used for GetTaskPayloadResult) so its on-wire shape does not shadow EmptyResult / CustomResult in the untagged ClientResult / ServerResult enums. TaskManager gains delete_task(&mut self, &str) -> bool which aborts a running task AND removes any stored completed result — the real delete semantics. The existing cancel_task is left in place as a public API. The #[task_handler] macro now generates delete_task (calling processor.delete_task) instead of cancel_task, and no longer rejects deletion of already-completed tasks. Addresses review feedback on #816. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/rmcp-macros/src/task_handler.rs | 30 +++-------- crates/rmcp/src/handler/client.rs | 30 +++++------ crates/rmcp/src/handler/server.rs | 22 ++++---- crates/rmcp/src/model.rs | 24 ++++----- crates/rmcp/src/model/meta.rs | 4 +- crates/rmcp/src/model/task.rs | 29 ++++++++--- crates/rmcp/src/task_manager.rs | 20 ++++++++ .../rmcp/tests/test_task_client_receiver.rs | 51 ++++++++----------- 8 files changed, 109 insertions(+), 101 deletions(-) diff --git a/crates/rmcp-macros/src/task_handler.rs b/crates/rmcp-macros/src/task_handler.rs index ba8df4b9..a2077e63 100644 --- a/crates/rmcp-macros/src/task_handler.rs +++ b/crates/rmcp-macros/src/task_handler.rs @@ -223,38 +223,24 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result(get_result_fn)?); } - if !has_method("cancel_task", &item_impl) { - let cancel_fn = quote! { - async fn cancel_task( + if !has_method("delete_task", &item_impl) { + let delete_fn = quote! { + async fn delete_task( &self, - request: rmcp::model::CancelTaskParams, + request: rmcp::model::DeleteTaskParams, _context: rmcp::service::RequestContext, - ) -> Result { - use rmcp::task_manager::current_timestamp; + ) -> Result { let task_id = request.task_id; let mut processor = (#processor).lock().await; - if processor.cancel_task(&task_id) { - let timestamp = current_timestamp(); - let task = rmcp::model::Task::new( - task_id, - rmcp::model::TaskStatus::Cancelled, - timestamp.clone(), - timestamp, - ); - return Ok(rmcp::model::CancelTaskResult { meta: None, task }); - } - - // If already completed, signal it's not cancellable - let exists_completed = processor.peek_completed().iter().any(|r| r.descriptor.operation_id == task_id); - if exists_completed { - return Err(McpError::invalid_request(format!("task already completed: {}", task_id), None)); + if processor.delete_task(&task_id) { + return Ok(rmcp::model::DeleteTaskResult { meta: None }); } Err(McpError::resource_not_found(format!("task not found: {}", task_id), None)) } }; - item_impl.items.push(syn::parse2::(cancel_fn)?); + item_impl.items.push(syn::parse2::(delete_fn)?); } // Auto-generate get_info() if not already provided and no sibling tool/prompt handler diff --git a/crates/rmcp/src/handler/client.rs b/crates/rmcp/src/handler/client.rs index bfd1d975..b6296ee4 100644 --- a/crates/rmcp/src/handler/client.rs +++ b/crates/rmcp/src/handler/client.rs @@ -42,10 +42,10 @@ impl Service for H { .get_task_result(request.params, context) .await .map(ClientResult::GetTaskPayloadResult), - ServerRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) + ServerRequest::DeleteTaskRequest(request) => self + .delete_task(request.params, context) .await - .map(ClientResult::CancelTaskResult), + .map(ClientResult::DeleteTaskResult), ServerRequest::CustomRequest(request) => self .on_custom_request(request, context) .await @@ -254,19 +254,19 @@ pub trait ClientHandler: Sized + Send + Sync + 'static { std::future::ready(Err(McpError::method_not_found::())) } - /// Handle a `tasks/cancel` request from a server. Only relevant when - /// the client is also a task *receiver*. + /// Handle a `tasks/delete` request from a server (SEP-1686 §3.6). + /// Only relevant when the client is also a task *receiver*. /// /// # Default Behavior - /// Returns `-32601` (Method not found). Clients that advertise - /// `capabilities.tasks.cancel` must override this. - fn cancel_task( + /// Returns `-32601` (Method not found). Task receivers must override this + /// to delete the task and any associated stored result. + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } fn on_cancelled( @@ -412,12 +412,12 @@ macro_rules! impl_client_handler_for_wrapper { (**self).get_task_result(request, context) } - fn cancel_task( + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).cancel_task(request, context) + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).delete_task(request, context) } fn on_cancelled( diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 8673a8bf..8e838e68 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -123,10 +123,10 @@ impl Service for H { .get_task_result(request.params, context) .await .map(ServerResult::GetTaskPayloadResult), - ClientRequest::CancelTaskRequest(request) => self - .cancel_task(request.params, context) + ClientRequest::DeleteTaskRequest(request) => self + .delete_task(request.params, context) .await - .map(ServerResult::CancelTaskResult), + .map(ServerResult::DeleteTaskResult), } } @@ -359,13 +359,13 @@ macro_rules! server_handler_methods { std::future::ready(Err(McpError::method_not_found::())) } - fn cancel_task( + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { + ) -> impl Future> + MaybeSendFuture + '_ { let _ = (request, context); - std::future::ready(Err(McpError::method_not_found::())) + std::future::ready(Err(McpError::method_not_found::())) } }; } @@ -575,12 +575,12 @@ macro_rules! impl_server_handler_for_wrapper { (**self).get_task_result(request, context) } - fn cancel_task( + fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, context: RequestContext, - ) -> impl Future> + MaybeSendFuture + '_ { - (**self).cancel_task(request, context) + ) -> impl Future> + MaybeSendFuture + '_ { + (**self).delete_task(request, context) } } }; diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 908f5241..b62508ba 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -3155,21 +3155,21 @@ impl RequestParamsMeta for GetTaskResultParams { #[deprecated(since = "0.13.0", note = "Use GetTaskResultParams instead")] pub type GetTaskResultParam = GetTaskResultParams; -const_string!(CancelTaskMethod = "tasks/cancel"); -pub type CancelTaskRequest = Request; +const_string!(DeleteTaskMethod = "tasks/delete"); +pub type DeleteTaskRequest = Request; #[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 CancelTaskParams { +pub struct DeleteTaskParams { /// Protocol-level metadata for this request (SEP-1319) #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, pub task_id: String, } -impl RequestParamsMeta for CancelTaskParams { +impl RequestParamsMeta for DeleteTaskParams { fn meta(&self) -> Option<&Meta> { self.meta.as_ref() } @@ -3177,10 +3177,6 @@ impl RequestParamsMeta for CancelTaskParams { &mut self.meta } } - -/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance). -#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")] -pub type CancelTaskParam = CancelTaskParams; /// Deprecated: Use [`GetTaskResult`] instead (spec alignment). #[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")] pub type GetTaskInfoResult = GetTaskResult; @@ -3280,7 +3276,7 @@ ts_union!( | GetTaskInfoRequest | ListTasksRequest | GetTaskResultRequest - | CancelTaskRequest + | DeleteTaskRequest | CustomRequest; ); @@ -3303,7 +3299,7 @@ impl ClientRequest { ClientRequest::GetTaskInfoRequest(r) => r.method.as_str(), ClientRequest::ListTasksRequest(r) => r.method.as_str(), ClientRequest::GetTaskResultRequest(r) => r.method.as_str(), - ClientRequest::CancelTaskRequest(r) => r.method.as_str(), + ClientRequest::DeleteTaskRequest(r) => r.method.as_str(), ClientRequest::CustomRequest(r) => r.method.as_str(), } } @@ -3326,7 +3322,7 @@ ts_union!( | ListTasksResult | GetTaskResult | GetTaskPayloadResult - | CancelTaskResult + | DeleteTaskResult | EmptyResult | CustomResult; ); @@ -3348,7 +3344,7 @@ ts_union!( | GetTaskInfoRequest | ListTasksRequest | GetTaskResultRequest - | CancelTaskRequest + | DeleteTaskRequest | CustomRequest; ); @@ -3362,7 +3358,7 @@ impl ServerRequest { ServerRequest::GetTaskInfoRequest(r) => r.method.as_str(), ServerRequest::ListTasksRequest(r) => r.method.as_str(), ServerRequest::GetTaskResultRequest(r) => r.method.as_str(), - ServerRequest::CancelTaskRequest(r) => r.method.as_str(), + ServerRequest::DeleteTaskRequest(r) => r.method.as_str(), ServerRequest::CustomRequest(r) => r.method.as_str(), } } @@ -3395,7 +3391,7 @@ ts_union!( | CreateTaskResult | ListTasksResult | GetTaskResult - | CancelTaskResult + | DeleteTaskResult | CallToolResult | GetTaskPayloadResult | EmptyResult diff --git a/crates/rmcp/src/model/meta.rs b/crates/rmcp/src/model/meta.rs index 91cfcf19..2e01de3f 100644 --- a/crates/rmcp/src/model/meta.rs +++ b/crates/rmcp/src/model/meta.rs @@ -155,7 +155,7 @@ variant_extension! { GetTaskInfoRequest ListTasksRequest GetTaskResultRequest - CancelTaskRequest + DeleteTaskRequest } } @@ -169,7 +169,7 @@ variant_extension! { GetTaskInfoRequest ListTasksRequest GetTaskResultRequest - CancelTaskRequest + DeleteTaskRequest } } diff --git a/crates/rmcp/src/model/task.rs b/crates/rmcp/src/model/task.rs index dbdc3406..50aba27a 100644 --- a/crates/rmcp/src/model/task.rs +++ b/crates/rmcp/src/model/task.rs @@ -156,18 +156,35 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult { } } -/// Response to a `tasks/cancel` request. +/// Response to a `tasks/delete` request. /// -/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// Per SEP-1686 §3.6, the result carries only protocol-level metadata. +#[derive(Debug, Clone, PartialEq, Serialize, Default)] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] -pub struct CancelTaskResult { +pub struct DeleteTaskResult { #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - #[serde(flatten)] - pub task: Task, +} + +// Custom Deserialize that always fails, so that `DeleteTaskResult` is skipped +// during `#[serde(untagged)]` enum deserialization (e.g. `ClientResult` / +// `ServerResult`). The on-wire shape (`{ "_meta"?: ... }`) is +// indistinguishable from many other results — `EmptyResult` / `CustomResult` +// act as the catch-all. `DeleteTaskResult` should be constructed +// programmatically (e.g. via `::default()`). +impl<'de> serde::Deserialize<'de> for DeleteTaskResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + serde::de::IgnoredAny::deserialize(deserializer)?; + Err(serde::de::Error::custom( + "DeleteTaskResult cannot be deserialized directly; \ + use EmptyResult / CustomResult as the catch-all", + )) + } } /// Paginated list of tasks diff --git a/crates/rmcp/src/task_manager.rs b/crates/rmcp/src/task_manager.rs index 32bcf8f0..e709fd96 100644 --- a/crates/rmcp/src/task_manager.rs +++ b/crates/rmcp/src/task_manager.rs @@ -286,6 +286,26 @@ impl OperationProcessor { false } + /// Delete a task and its associated stored result (SEP-1686 §3.6). + /// + /// Aborts the task if it is still running and removes any stored + /// completed result. Returns `true` if anything was deleted. + pub fn delete_task(&mut self, task_id: &str) -> bool { + self.collect_completed_results(); + let mut deleted = false; + if let Some(task) = self.running_tasks.remove(task_id) { + task.task_handle.abort(); + deleted = true; + } + let before = self.completed_results.len(); + self.completed_results + .retain(|r| r.descriptor.operation_id != task_id); + if self.completed_results.len() != before { + deleted = true; + } + deleted + } + /// Retrieve a completed task result if available. pub fn take_completed_result(&mut self, task_id: &str) -> Option { self.collect_completed_results(); diff --git a/crates/rmcp/tests/test_task_client_receiver.rs b/crates/rmcp/tests/test_task_client_receiver.rs index 793b9c65..0e3a7a18 100644 --- a/crates/rmcp/tests/test_task_client_receiver.rs +++ b/crates/rmcp/tests/test_task_client_receiver.rs @@ -1,5 +1,5 @@ //! End-to-end tests proving that task-management RPCs (`tasks/get`, -//! `tasks/list`, `tasks/result`, `tasks/cancel`) sent from a server to a +//! `tasks/list`, `tasks/result`, `tasks/delete`) sent from a server to a //! client are dispatched to the appropriate `ClientHandler` methods and //! return results that the server receives as the correct `ClientResult` //! variant. @@ -13,7 +13,7 @@ use std::sync::Arc; use rmcp::{ ClientHandler, ServerHandler, ServiceExt, model::{ - CancelTaskParams, CancelTaskRequest, CancelTaskResult, ClientResult, GetTaskInfoParams, + ClientResult, DeleteTaskParams, DeleteTaskRequest, DeleteTaskResult, GetTaskInfoParams, GetTaskInfoRequest, GetTaskPayloadResult, GetTaskResult, GetTaskResultParams, GetTaskResultRequest, ListTasksRequest, ListTasksResult, PaginatedRequestParams, ServerRequest, Task, TaskStatus, @@ -28,7 +28,7 @@ use tokio::sync::{Mutex, Notify}; struct ClientState { last_get_task_id: Option, last_result_task_id: Option, - last_cancel_task_id: Option, + last_delete_task_id: Option, list_called: bool, } @@ -81,22 +81,14 @@ impl ClientHandler for TaskClient { Ok(GetTaskPayloadResult::new(json!({ "ok": true }))) } - async fn cancel_task( + async fn delete_task( &self, - request: CancelTaskParams, + request: DeleteTaskParams, _context: rmcp::service::RequestContext, - ) -> Result { - self.state.lock().await.last_cancel_task_id = Some(request.task_id.clone()); + ) -> Result { + self.state.lock().await.last_delete_task_id = Some(request.task_id); self.received.notify_one(); - Ok(CancelTaskResult { - meta: None, - task: Task::new( - request.task_id, - TaskStatus::Cancelled, - "2025-11-25T10:30:00Z".into(), - "2025-11-25T10:30:00Z".into(), - ), - }) + Ok(DeleteTaskResult::default()) } } @@ -251,28 +243,25 @@ async fn tasks_result_reaches_client_handler() { } #[tokio::test] -async fn tasks_cancel_reaches_client_handler() { - let request = ServerRequest::CancelTaskRequest(CancelTaskRequest::new(CancelTaskParams { +async fn tasks_delete_reaches_client_handler() { + let request = ServerRequest::DeleteTaskRequest(DeleteTaskRequest::new(DeleteTaskParams { meta: None, - task_id: "task-cancelme".into(), + task_id: "task-deleteme".into(), })); let (state, response) = run_server_request(request).await; assert_eq!( - state.lock().await.last_cancel_task_id.as_deref(), - Some("task-cancelme") + state.lock().await.last_delete_task_id.as_deref(), + Some("task-deleteme") ); - // CancelTaskResult and GetTaskResult share the same JSON shape - // (`Result + flattened Task`); the untagged ClientResult enum picks - // the first match, which is GetTaskResult. Callers distinguish by - // knowing which request they sent rather than by inspecting the - // response variant. This mirrors the existing behavior on the server - // side. + // `DeleteTaskResult` has a custom Deserialize that always fails (see + // crates/rmcp/src/model/task.rs) so its on-wire shape (`{_meta?}`) + // surfaces as `EmptyResult` — the first matching variant in the + // untagged `ClientResult` enum. Callers distinguish by knowing which + // request they sent rather than by inspecting the response variant. + // This mirrors the design used for `GetTaskPayloadResult`. match response { - ClientResult::GetTaskResult(r) => { - assert_eq!(r.task.task_id, "task-cancelme"); - assert_eq!(r.task.status, TaskStatus::Cancelled); - } + ClientResult::EmptyResult(_) => {} other => panic!("unexpected variant: {other:?}"), } }