Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(task): add task support (SEP-1686) by jokemanfire · Pull Request #536 · modelcontextprotocol/rust-sdk · GitHub
Skip to content

feat(task): add task support (SEP-1686) - #536

Merged
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task
Dec 22, 2025
Merged

feat(task): add task support (SEP-1686)#536
alexhancock merged 1 commit into
modelcontextprotocol:mainfrom
jokemanfire:task

Conversation

@jokemanfire

Copy link
Copy Markdown
Member

Support task manager to control the task , it will be very useful for longtime and concurrence env .
#528

Motivation and Context

How Has This Been Tested?

Breaking Changes

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@jokemanfire
jokemanfire marked this pull request as draft November 11, 2025 03:10
@github-actionsgithub-actionsBot added T-core Core library changes T-model Model/data structure changes labels Nov 11, 2025
@github-actionsgithub-actionsBot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-handler Handler implementation changes labels Nov 14, 2025
@github-actionsgithub-actionsBot added the T-examples Example code changes label Dec 5, 2025
@github-actionsgithub-actionsBot added T-macros Macro changes T-transport Transport layer changes labels Dec 12, 2025
@jokemanfire
jokemanfire marked this pull request as ready for review December 12, 2025 07:30
CopilotAI review requested due to automatic review settings December 12, 2025 07:30
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

The basic function have done, I will add some example, have a look ,if free @4t145@alexhancock

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements task support (SEP-1686) to enable asynchronous execution of long-running operations in the MCP protocol. The implementation adds a task manager for coordinating operations, new model types for task status and results, procedural macros for automatic task handler generation, and protocol extensions for task-related requests (list, get, cancel).

Key Changes

  • Added OperationProcessor to manage async task execution with timeout and cancellation support
  • Introduced task-related models (Task, TaskStatus, TaskResult, CreateTaskResult) and request/response types
  • Created #[task_handler] macro to auto-generate task management methods for server handlers
  • Extended CallToolRequestParam with optional task field to trigger async execution

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 22 comments.

Show a summary per file
FileDescription
crates/rmcp/src/task_manager.rsCore task processor implementation managing running tasks, timeouts, and result collection
crates/rmcp/src/model/task.rsTask lifecycle models including status enum and result types per SEP-1686
crates/rmcp/src/model.rsAdded task-related request/response types (GetTaskInfo, ListTasks, GetTaskResult, CancelTask)
crates/rmcp/src/model/capabilities.rsAdded TasksCapability for capability negotiation of task support
crates/rmcp/src/model/meta.rsExtended variant list with task request types
crates/rmcp/src/handler/server.rsAdded task request routing and enqueue_task branching logic
crates/rmcp/src/handler/server/tool.rsExtended ToolCallContext with task metadata field
crates/rmcp/src/error.rsAdded TaskError variant to error enum
crates/rmcp-macros/src/task_handler.rsProcedural macro generating task handler methods (enqueue, list, get_info, get_result, cancel)
crates/rmcp-macros/src/lib.rsExported task_handler attribute macro
crates/rmcp/src/lib.rsExposed task_manager module
crates/rmcp/Cargo.tomlAdded test configuration for task tests
crates/rmcp/tests/test_task.rsUnit tests for operation processor basics
crates/rmcp/src/transport/streamable_http_client.rsAdded clippy allow for large enum variant
examples/servers/src/common/counter.rsIntegration test demonstrating task enqueueing with long_task tool
examples//src/.rsUpdated all CallToolRequestParam usage to include task: None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to 38
struct ToolCallOperationResult {
id: String,
result: Result<CallToolResult, McpError>,
}

impl OperationResultTransport for ToolCallOperationResult {
fn operation_id(&self) -> &String {
&self.id
}

fn as_any(&self) -> &dyn Any {
self
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ToolCallOperationResult struct (lines 24-37) is defined but never used in this file. The task_handler macro generates code that uses ToolCallTaskResult from the task_manager module instead. This is dead code and should be removed to avoid confusion.

Suggested change
structToolCallOperationResult{
id:String,
result:Result<CallToolResult,McpError>,
}
implOperationResultTransportforToolCallOperationResult{
fn operation_id(&self) -> &String{
&self.id
}
fn as_any(&self) -> &dynAny{
self
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +47
#[error("Task error: {0}")]
TaskError(String),

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TaskError variant only contains a String message, which loses the source error information. This makes debugging difficult when tasks fail due to underlying errors (like I/O errors, network errors, etc.). Consider changing this to store a boxed error like other variants, or adding a separate variant for errors with sources: TaskError { message: String, source: Option<Box<dyn std::error::Error + Send + Sync>> }.

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +76
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)
} else {
self.call_tool(request.params, context)
.await
.map(ServerResult::CallToolResult)
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler accepts task requests without checking if task support was negotiated in capabilities. When a client sends a CallToolRequest with the task field populated, the server will attempt to enqueue it regardless of whether task capabilities were advertised during initialization. This violates the MCP capability negotiation contract. The handler should verify that task support is enabled in capabilities before processing task-augmented requests, or return a method_not_found/not_supported error.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +232
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check_timeouts method is defined but never called anywhere in the codebase. Tasks can timeout via tokio::time::timeout in spawn_async_task, but this method provides an additional timeout checking mechanism that remains unused. Either this method should be called periodically (e.g., in a background task or when collecting results), or it should be removed to avoid confusion and dead code.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
}

Copilot uses AI. Check for mistakes.
/// Currently running tasks keyed by id
running_tasks: HashMap<String, RunningTask>,
/// Completed results waiting to be collected
completed_results: Vec<TaskResult>,

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed_results Vec grows unbounded as tasks complete. Without any cleanup mechanism or TTL enforcement on completed results, this will cause a memory leak in long-running servers that process many tasks. Consider implementing automatic cleanup of old completed results based on TTL, or providing a method to periodically purge old results.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +180
if running.into_iter().any(|id| id == task_id) {
let timestamp = current_timestamp();
let task = rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
};
return Ok(rmcp::model::GetTaskInfoResult { task: Some(task) });

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated created_at and last_updated_at timestamps use current_timestamp() which calls chrono::Utc::now() at the time of query, not when the task was actually created. This means the timestamps don't reflect the true task creation or update times, but rather when the status was queried. These timestamps should be stored in the RunningTask structure and retrieved from there for accuracy.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +70
self.enqueue_task(request.params, context.clone())
.await
.map(ServerResult::CreateTaskResult)

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task routing logic clones the RequestContext (line 68) when enqueueing a task, but this clone contains a CancellationToken which doesn't behave as expected when cloned. Cloning a CancellationToken creates a new token that shares the same cancellation state, so cancelling the original context will affect the cloned one. However, the task spawned with the cloned context might outlive the original request, leading to unexpected cancellation behavior. Consider creating a new CancellationToken for the task or document this behavior clearly.

Copilot uses AI. Check for mistakes.
Comment on lines +208 to +230
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pub fn check_timeouts(&mut self) {
let now = std::time::Instant::now();
let mut timed_out_tasks = Vec::new();

for (task_id, task) in &self.running_tasks {
if let Some(timeout_duration) = task.timeout {
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}

for task_id in timed_out_tasks {
if let Some(task) = self.running_tasks.remove(&task_id) {
let timeout_result = TaskResult {
descriptor: task.descriptor,
result: Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task timeout mechanism has a race condition. A task is aborted in check_timeouts (line 216), but spawn_async_task also applies timeout via tokio::time::timeout (lines 170-173). If check_timeouts is ever called, it could abort a task that's also being timed out by tokio, leading to two timeout results being generated. The timeout responsibility should be handled in one place, not both.

Suggested change
/// Check for tasks that have exceeded their timeout and handle them appropriately.
pubfn check_timeouts(&mutself){
let now = std::time::Instant::now();
letmut timed_out_tasks = Vec::new();
for(task_id, task)in&self.running_tasks{
ifletSome(timeout_duration) = task.timeout{
if now.duration_since(task.started_at).as_secs() > timeout_duration {
task.task_handle.abort();
timed_out_tasks.push(task_id.clone());
}
}
}
for task_id in timed_out_tasks {
ifletSome(task) = self.running_tasks.remove(&task_id){
let timeout_result = TaskResult{
descriptor: task.descriptor,
result:Err(Error::TaskError("Operation timed out".to_string())),
};
self.completed_results.push(timeout_result);
}
}
/// Check for tasks that have exceeded their timeout.
/// (No-op: timeout is now handled exclusively in spawn_async_task via tokio::time::timeout.)
pubfn check_timeouts(&mutself){
// Timeout handling is now managed by tokio::time::timeout in spawn_async_task.
// This method is retained for API compatibility but does nothing.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62
async fn list_tasks(
&self,
_request: Option<rmcp::model::PaginatedRequestParam>,
_: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListTasksResult, McpError> {
let running_ids = (#processor).lock().await.list_running();
let total = running_ids.len() as u64;
let tasks = running_ids
.into_iter()
.map(|task_id| {
let timestamp = rmcp::task_manager::current_timestamp();
rmcp::model::Task {
task_id,
status: rmcp::model::TaskStatus::Working,
status_message: None,
created_at: timestamp.clone(),
last_updated_at: Some(timestamp),
ttl: None,
poll_interval: None,
}
})
.collect::<Vec<_>>();

Ok(rmcp::model::ListTasksResult {
tasks,
next_cursor: None,
total: Some(total),
})
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated list_tasks method assumes all running tasks have status Working, but it doesn't check completed results that might not have been collected yet. This means tasks that have just completed but haven't been polled yet won't appear in the list, which could confuse clients. The method should call collect_completed_results first and include recently completed tasks in the listing.

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +243
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::TaskResult, McpError> {
use std::time::Duration;
let task_id = request.task_id.clone();

loop {
// Scope the lock so we can await outside if needed
{
let mut processor = (#processor).lock().await;
processor.collect_completed_results();

if let Some(task_result) = processor.take_completed_result(&task_id) {
match task_result.result {
Ok(boxed) => {
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
match &tool.result {
Ok(call_tool) => {
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,
});
}
Err(err) => return Err(McpError::internal_error(
format!("task failed: {}", err),
None,
)),
}
} else {
return Err(McpError::internal_error("unsupported task result transport", None));
}
}
Err(err) => return Err(McpError::internal_error(
format!("task execution error: {}", err),
None,
)),
}
}

// Not completed yet: if not running, return not found
let running = processor.list_running();
if !running.iter().any(|id| id == &task_id) {
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
}
}

tokio::time::sleep(Duration::from_millis(100)).await;
}
}

CopilotAIDec 12, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_task_result method implementation lacks test coverage. While there's a basic integration test that verifies task enqueueing and listing, there's no test that validates the actual result retrieval mechanism via GetTaskResultRequest. This is a critical path that involves complex polling logic and should be tested to ensure it correctly waits for and returns task results.

Copilot uses AI. Check for mistakes.
@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire it LGTM and I'd love to get this in so we can test Tasks in real world clients.

Can you look into the failing checks?

alexhancock
alexhancock previously approved these changes Dec 19, 2025
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
@jokemanfire

Copy link
Copy Markdown
MemberAuthor

@alexhancock Try it in real world, perhaps some implementations differ from the SEP description and will be further modified later.

@alexhancock

Copy link
Copy Markdown
Contributor

@jokemanfire Yes, will do. I think given the state of this feature of MCP is "experimental" we have plenty of room to test out. But this implementation LGTM - merging

@alexhancock
alexhancock self-requested a review December 22, 2025 14:00
@alexhancock
alexhancock merged commit 621c9f6 into modelcontextprotocol:mainDec 22, 2025
11 checks passed
@alexhancockalexhancock mentioned this pull request Jan 14, 2026
takumi-earth pushed a commit to earthlings-dev/rmcp that referenced this pull request Jan 27, 2026
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
TheUnderdev added a commit to TheUnderdev/rust-sdk that referenced this pull request Jun 23, 2026
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: modelcontextprotocol#528, modelcontextprotocol#536 (which added the server-side half of SEP-1686).
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-configConfiguration file changesT-coreCore library changesT-dependenciesDependencies related changesT-documentationDocumentation improvementsT-examplesExample code changesT-handlerHandler implementation changesT-macrosMacro changesT-modelModel/data structure changesT-testTesting related changesT-transportTransport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jokemanfire@alexhancock