//! End-to-end tests for the MCP Tasks extension (SEP-2663, //! `io.modelcontextprotocol/tasks`). #![cfg(all(feature = "server", feature = "client", not(feature = "local")))] use rmcp::{ ErrorData as McpError, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::*, service::{RequestContext, RoleServer}, task_manager::{TaskExit, TaskManager, TaskOptions}, tool, tool_router, }; use serde_json::json; #[derive(Debug, serde::Deserialize, rmcp::schemars::JsonSchema)] struct SumArgs { a: i32, b: i32, } #[derive(Clone)] struct TaskServer { tool_router: ToolRouter, tasks: TaskManager, } #[tool_router] impl TaskServer { fn new() -> Self { Self { tool_router: Self::tool_router(), tasks: TaskManager::new(), } } #[tool(description = "Sum two numbers")] async fn sum( &self, Parameters(SumArgs { a, b }): Parameters, ) -> Result { Ok(CallToolResult::success(vec![ContentBlock::text( (a + b).to_string(), )])) } } impl ServerHandler for TaskServer { async fn call_tool( &self, request: CallToolRequestParams, context: RequestContext, ) -> Result { let client_supports_tasks = context .client_capabilities() .is_some_and(|caps| caps.supports_tasks()); if request.name == "sum" && client_supports_tasks { let args: SumArgs = serde_json::from_value(serde_json::Value::Object( request.arguments.clone().unwrap_or_default(), )) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; let task = self .tasks .spawn(TaskOptions::new().with_poll_interval_ms(10), move |ctx| { Box::pin(async move { tokio::select! { _ = ctx.cancelled() => { Err(TaskExit::Cancelled) } _ = tokio::time::sleep(std::time::Duration::from_millis(50)) => { Ok(CallToolResult::success(vec![ContentBlock::text( (args.a + args.b).to_string(), )])) } } }) }); return Ok(CallToolResponse::Task(CreateTaskResult::new(task))); } let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); self.tool_router.call(tcc).await } async fn get_task( &self, request: GetTaskParams, _context: RequestContext, ) -> Result { Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?)) } async fn update_task( &self, request: UpdateTaskParams, _context: RequestContext, ) -> Result<(), McpError> { self.tasks .update_task(&request.task_id, request.input_responses) } async fn cancel_task( &self, request: CancelTaskParams, _context: RequestContext, ) -> Result<(), McpError> { self.tasks.cancel_task(&request.task_id) } fn get_info(&self) -> ServerConfig { ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() .build(), ) } } fn tasks_client_info() -> ClientConfig { ClientConfig::new( ClientCapabilities::builder().enable_tasks().build(), Implementation::from_build_env(), ) } #[tokio::test] async fn task_lifecycle_create_poll_complete() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); let client = tasks_client_info().serve(client_transport).await.unwrap(); // Server materializes a task because we declared the extension. let response = client .call_tool_once( CallToolRequestParams::new("sum") .with_arguments(serde_json::from_value(json!({"a": 40, "b": 2})).unwrap()), ) .await .unwrap(); let create = match response { CallToolResponse::Task(create) => create, other => panic!("expected CreateTaskResult, got {other:?}"), }; assert_eq!(create.result_type, ResultType::TASK); let task_id = create.task.task_id.clone(); // Poll until terminal. let final_task = loop { tokio::time::sleep(std::time::Duration::from_millis( create.task.poll_interval_ms.unwrap_or(10), )) .await; let info = client .peer() .get_task(GetTaskParams::new(task_id.clone())) .await .unwrap(); if info.task.status().is_terminal() { break info.task; } }; match final_task.payload { TaskPayload::Completed { result } => { let result: CallToolResult = serde_json::from_value(serde_json::Value::Object(result)).unwrap(); let text = result.content[0].as_text().unwrap(); assert_eq!(text.text, "42"); } other => panic!("expected completed task, got {other:?}"), } client.cancel().await.unwrap(); server.abort(); } #[tokio::test] async fn task_cancel_acknowledged() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); let client = tasks_client_info().serve(client_transport).await.unwrap(); let response = client .call_tool_once( CallToolRequestParams::new("sum") .with_arguments(serde_json::from_value(json!({"a": 1, "b": 1})).unwrap()), ) .await .unwrap(); let create = match response { CallToolResponse::Task(create) => create, other => panic!("expected CreateTaskResult, got {other:?}"), }; client .peer() .cancel_task(CancelTaskParams::new(create.task.task_id.clone())) .await .unwrap(); // Cancellation is cooperative (SEP-2663): the ack is immediate, and the // operation settles the terminal status; poll until it does. let mut final_status = None; for _ in 0..100 { tokio::time::sleep(std::time::Duration::from_millis(10)).await; let info = client .peer() .get_task(GetTaskParams::new(create.task.task_id.clone())) .await .unwrap(); if info.task.status().is_terminal() { final_status = Some(info.task.status()); break; } } assert_eq!(final_status, Some(TaskStatus::Cancelled)); client.cancel().await.unwrap(); server.abort(); } #[tokio::test] async fn no_task_without_extension_capability() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); // Plain client: no tasks extension declared. let client = ().serve(client_transport).await.unwrap(); let result = client .call_tool( CallToolRequestParams::new("sum") .with_arguments(serde_json::from_value(json!({"a": 2, "b": 3})).unwrap()), ) .await .unwrap(); let text = result.content[0].as_text().unwrap(); assert_eq!(text.text, "5"); client.cancel().await.unwrap(); server.abort(); } /// A misbehaving handler that materializes a task without checking the /// client's capabilities. The SDK dispatch must catch this and reject with /// -32021 rather than sending a task handle the client cannot parse. #[derive(Clone)] struct AlwaysTaskServer { tasks: TaskManager, } impl ServerHandler for AlwaysTaskServer { async fn call_tool( &self, _request: CallToolRequestParams, _context: RequestContext, ) -> Result { let task = self.tasks.spawn(TaskOptions::default(), |_ctx| { Box::pin(async { Ok(CallToolResult::success(vec![ContentBlock::text("late")])) }) }); Ok(CallToolResponse::Task(CreateTaskResult::new(task))) } fn get_info(&self) -> ServerConfig { ServerConfig::new( ServerCapabilities::builder() .enable_tools() .enable_tasks() .build(), ) } } #[tokio::test] async fn dispatch_rejects_task_result_for_non_declaring_client() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = AlwaysTaskServer { tasks: TaskManager::new(), } .serve(server_transport) .await?; service.waiting().await?; anyhow::Ok(()) }); // Plain client: no tasks extension declared. The handler tries to return // a CreateTaskResult anyway; dispatch must reject with -32021. let client = ().serve(client_transport).await.unwrap(); let err = client .call_tool(CallToolRequestParams::new("anything")) .await .unwrap_err(); match err { rmcp::ServiceError::McpError(e) => { assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); } other => panic!("expected McpError, got {other:?}"), } client.cancel().await.unwrap(); server.abort(); } #[tokio::test] async fn tasks_methods_without_capability_return_missing_capability_error() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); // Plain client: no tasks extension declared. tasks/* must be rejected // with -32021 Missing Required Client Capability (SEP-2663), not -32601. let client = ().serve(client_transport).await.unwrap(); let err = client .peer() .get_task(GetTaskParams::new("whatever")) .await .unwrap_err(); match err { rmcp::ServiceError::McpError(e) => { assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); let data = e.data.expect("error data should be present"); assert!( data["requiredCapabilities"]["extensions"] .as_object() .is_some_and(|ext| ext.contains_key("io.modelcontextprotocol/tasks")), "error data should name the tasks extension: {data}" ); } other => panic!("expected McpError, got {other:?}"), } let err = client .peer() .cancel_task(CancelTaskParams::new("whatever")) .await .unwrap_err(); match err { rmcp::ServiceError::McpError(e) => { assert_eq!(e.code, ErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY); } other => panic!("expected McpError, got {other:?}"), } client.cancel().await.unwrap(); server.abort(); } #[tokio::test] async fn unknown_task_id_returns_invalid_params() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); let client = tasks_client_info().serve(client_transport).await.unwrap(); let err = client .peer() .get_task(GetTaskParams::new("no-such-task")) .await .unwrap_err(); match err { rmcp::ServiceError::McpError(e) => { // SEP-2663: unknown taskId is -32602 Invalid params. assert_eq!(e.code, ErrorCode::INVALID_PARAMS); } other => panic!("expected McpError, got {other:?}"), } client.cancel().await.unwrap(); server.abort(); } #[tokio::test] async fn legacy_task_param_is_ignored() { let (server_transport, client_transport) = tokio::io::duplex(4096); let server = tokio::spawn(async move { let service = TaskServer::new().serve(server_transport).await?; service.waiting().await?; anyhow::Ok(()) }); // Plain client sending a legacy 2025-11-25 `task: {...}` param: it must // be silently ignored and the call answered synchronously (SEP-2663). let client = ().serve(client_transport).await.unwrap(); let params: CallToolRequestParams = serde_json::from_value(json!({ "name": "sum", "arguments": {"a": 2, "b": 3}, "task": {"ttl": 60000} })) .expect("legacy task param must not break deserialization"); let result = client.call_tool(params).await.unwrap(); let text = result.content[0].as_text().unwrap(); assert_eq!(text.text, "5"); client.cancel().await.unwrap(); server.abort(); } #[test] fn task_status_notification_params_preserve_meta() { let raw = json!({ "_meta": { "traceId": "trace-1" }, "taskId": "task-1", "status": "working", "createdAt": "2026-06-24T00:00:00Z", "lastUpdatedAt": "2026-06-24T00:00:01Z", "ttlMs": null }); let params: TaskStatusNotificationParams = serde_json::from_value(raw).unwrap(); assert_eq!(params.task.task.task_id, "task-1"); assert_eq!(params.status(), TaskStatus::Working); assert_eq!(params.meta.as_ref().unwrap().0["traceId"], json!("trace-1")); let serialized = serde_json::to_value(¶ms).unwrap(); assert_eq!(serialized["_meta"]["traceId"], json!("trace-1")); assert_eq!(serialized["taskId"], json!("task-1")); assert_eq!(serialized["ttlMs"], serde_json::Value::Null); }