Implement SEP-2663 Tasks Extension (#1579)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Pranav Senthilnathan 2026-06-11 13:10:45 -07:00 • committed by GitHub
commit dbb7a20176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -11,7 +11,7 @@ using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Server;
/// <inheritdoc />
#pragma warning disable MCPEXP002
#pragma warning disable MCPEXP001, MCPEXP002
internal sealed partial class McpServerImpl : McpServer
{
internal static Implementation DefaultImplementation { get; } = new()
@ -28,7 +28,7 @@ internal sealed partial class McpServerImpl : McpServer
private readonly RequestHandlers _requestHandlers;
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private readonly ConcurrentDictionary<string, CancellationTokenSource> _taskCancellationSources = new();
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
@ -77,12 +77,6 @@ internal sealed partial class McpServerImpl : McpServer
_servicesScopePerRequest = options.ScopeRequests;
_logger = loggerFactory?.CreateLogger<McpServer>() ?? NullLogger<McpServer>.Instance;
// Only allocate the cancellation token provider if a task store is configured
if (options.TaskStore is not null)
{
_taskCancellationTokenProvider = new McpTaskCancellationTokenProvider();
}
_clientInfo = options.KnownClientInfo;
_clientCapabilities = options.KnownClientCapabilities;
UpdateEndpointNameWithClientInfo();
@ -96,10 +90,10 @@ internal sealed partial class McpServerImpl : McpServer
ConfigureTools(options);
ConfigurePrompts(options);
ConfigureResources(options);
ConfigureTasks(options);
ConfigureLogging(options);
ConfigureCompletion(options);
ConfigureExperimentalAndExtensions(options);
ConfigureTasks(options);
ConfigureMrtr();
// Register any notification handlers that were provided.
@ -220,11 +214,17 @@ internal sealed partial class McpServerImpl : McpServer
_disposed = true;
// Dispose the session handler first - cancels message processing and waits for all
foreach (var kvp in _taskCancellationSources)
{
kvp.Value.Cancel();
kvp.Value.Dispose();
}
_taskCancellationSources.Clear();
// Dispose the session handler - cancels message processing and waits for all
// in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete.
// After this returns, no new requests can be processed and no new MRTR continuations
// can be created, so _mrtrContinuations is effectively frozen.
_taskCancellationTokenProvider?.Dispose();
_disposables.ForEach(d => d());
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
@ -431,6 +431,84 @@ internal sealed partial class McpServerImpl : McpServer
return result;
}
private void ConfigureTasks(McpServerOptions options)
{
var getTaskHandler = options.Handlers.GetTaskHandler;
var updateTaskHandler = options.Handlers.UpdateTaskHandler;
var cancelTaskHandler = options.Handlers.CancelTaskHandler;
var taskStore = options.TaskStore;
// If a task store is provided, wire up handlers from it for any that aren't explicitly set.
if (taskStore is not null)
{
getTaskHandler ??= async (request, cancellationToken) =>
{
var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
return info is null
? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams)
: ToGetTaskResult(info);
};
updateTaskHandler ??= async (request, cancellationToken) =>
{
var inputResponses = request.Params!.InputResponses ?? new Dictionary<string, InputResponse>();
await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false);
return new UpdateTaskResult();
};
cancelTaskHandler ??= async (request, cancellationToken) =>
{
// Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether
// the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown
// or already-terminal tasks; we still surface a success response to the client.
await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
// Signal the task's CancellationTokenSource if one exists. Whichever side
// (this handler or the background runner's finally block) wins TryRemove owns disposal,
// which prevents the runner from observing ObjectDisposedException through cts.Token.
if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts))
{
cts.Cancel();
cts.Dispose();
}
return new CancelTaskResult();
};
}
if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null)
{
return;
}
getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
// Advertise tasks extension in server capabilities.
ServerCapabilities.Extensions ??= new Dictionary<string, object>();
ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject();
SetHandler(
RequestMethods.TasksGet,
getTaskHandler,
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
McpJsonUtilities.JsonContext.Default.GetTaskResult);
SetHandler(
RequestMethods.TasksUpdate,
updateTaskHandler,
McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
McpJsonUtilities.JsonContext.Default.UpdateTaskResult);
SetHandler(
RequestMethods.TasksCancel,
cancelTaskHandler,
McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
McpJsonUtilities.JsonContext.Default.CancelTaskResult);
}
private void ConfigureExperimentalAndExtensions(McpServerOptions options)
{
ServerCapabilities.Experimental = options.Capabilities?.Experimental;
@ -706,10 +784,11 @@ internal sealed partial class McpServerImpl : McpServer
{
var listToolsHandler = options.Handlers.ListToolsHandler;
var callToolHandler = options.Handlers.CallToolHandler;
var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler;
var tools = options.ToolCollection;
var toolsCapability = options.Capabilities?.Tools;
if (listToolsHandler is null && callToolHandler is null && tools is null &&
if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null &&
toolsCapability is null)
{
return;
@ -718,10 +797,23 @@ internal sealed partial class McpServerImpl : McpServer
ServerCapabilities.Tools = new();
listToolsHandler ??= (static async (_, __) => new ListToolsResult());
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
var listChanged = toolsCapability?.ListChanged;
// Handle tools provided via DI by augmenting the handlers to incorporate them.
var callToolFilters = options.Filters.Request.CallToolFilters;
var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters;
// Validate: cannot mix non-task filters/handler with task filters/handler.
bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0;
bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0;
if (hasNonTaskPath && hasTaskPath)
{
throw new InvalidOperationException(
$"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
$"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other.");
}
// Handle tools provided via DI by augmenting the list handler.
if (tools is not null)
{
var originalListToolsHandler = listToolsHandler;
@ -742,103 +834,158 @@ internal sealed partial class McpServerImpl : McpServer
return result;
};
var originalCallToolHandler = callToolHandler;
var taskStore = options.TaskStore;
var sendNotifications = options.SendTaskStatusNotifications;
callToolHandler = async (request, cancellationToken) =>
{
if (request.MatchedPrimitive is McpServerTool tool)
{
var taskSupport = tool.ProtocolTool.Execution?.TaskSupport ?? ToolTaskSupport.Forbidden;
// Check if this is a task-augmented request
if (request.Params?.Task is { } taskMetadata)
{
// Validate tool-level task support
if (taskSupport is ToolTaskSupport.Forbidden)
{
throw new McpProtocolException(
$"Tool '{tool.ProtocolTool.Name}' does not support task-augmented execution.",
McpErrorCode.InvalidParams);
}
// Task augmentation requested with immediate creation
return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false);
}
// Validate that required task support is satisfied
if (taskSupport is ToolTaskSupport.Required)
{
throw new McpProtocolException(
$"Tool '{tool.ProtocolTool.Name}' requires task-augmented execution. " +
"Include a 'task' parameter with the request.",
McpErrorCode.InvalidParams);
}
// Normal synchronous execution
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
}
return await originalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
};
listChanged = true;
}
listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);
callToolHandler = BuildFilterPipeline(callToolHandler, options.Filters.Request.CallToolFilters, handler =>
async (request, cancellationToken) =>
// Build the unified task-augmented handler from one of the two paths.
if (hasTaskPath)
{
// Case 2: task filter + task handler
callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools.
if (tools is not null)
{
// Initial handler that sets MatchedPrimitive
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
var originalHandler = callToolWithTaskHandler;
callToolWithTaskHandler = (request, cancellationToken) =>
{
request.MatchedPrimitive = tool;
}
try
{
var result = await handler(request, cancellationToken).ConfigureAwait(false);
// Don't log here for task-augmented calls; logging happens asynchronously
// in ExecuteToolAsTaskAsync when the tool actually completes.
if (result.Task is null)
if (request.MatchedPrimitive is McpServerTool tool)
{
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
return InvokeToolAsTask(tool, request, cancellationToken);
}
return result;
}
catch (Exception e)
return originalHandler(request, cancellationToken);
};
}
callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools));
}
else
{
// Case 1: non-task filter + non-task handler → apply filters, then convert to task-based
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools.
if (tools is not null)
{
var originalHandler = callToolHandler;
callToolHandler = (request, cancellationToken) =>
{
// Skip logging for OperationCanceledException when the cancellation token
// is signaled - tool handler cancellation is an expected lifecycle event
// (client request cancellation, session shutdown, MRTR teardown), not a
// tool error.
// Skip logging for InputRequiredException - it's normal MRTR control flow,
// not an error (tools throw it to signal an InputRequiredResult).
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
if (request.MatchedPrimitive is McpServerTool tool)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
return tool.InvokeAsync(request, cancellationToken);
}
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{
throw;
}
return originalHandler(request, cancellationToken);
};
}
return new()
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
// Convert to task-based.
var finalCallToolHandler = callToolHandler;
callToolWithTaskHandler = async (request, cancellationToken) =>
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
}
// If a task store is configured, wrap so that when the client signals task support
// the tool execution is offloaded to the background via the store.
if (options.TaskStore is { } taskStore)
{
var innerTaskHandler = callToolWithTaskHandler;
callToolWithTaskHandler = async (request, cancellationToken) =>
{
if (HasTaskExtensionOptIn(request.Params?.Meta))
{
var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
var taskId = taskInfo.TaskId;
var cts = new CancellationTokenSource();
_taskCancellationSources[taskId] = cts;
// Capture the token synchronously before Task.Run dispatches the work.
// The cancel handler may race with the background runner: whichever side wins
// the TryRemove call owns disposal. If we accessed cts.Token from inside the
// lambda after the handler had already disposed cts, we'd hit ObjectDisposedException.
var taskCancellationToken = cts.Token;
_ = Task.Run(async () =>
{
IsError = true,
Content = [new TextContentBlock
using (CreateMcpTaskScope(taskId, taskStore))
{
Text = e is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
try
{
var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false);
if (augmented.IsTask)
{
// The handler created its own task externally, but the client already holds
// the store's taskId from the synchronous return below — we can't redirect.
// Fail the store's task so the client sees a clear error instead of polling forever.
var error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InternalError,
Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.",
};
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
return;
}
var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult);
await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
}
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
{
await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
}
catch (InputRequiredException)
{
// MRTR (input requests) cannot be composed with the task-store wrapper for
// [McpServerTool] methods today: the task ID was already returned synchronously,
// so we have no way to surface InputRequiredResult to the client retroactively.
// Fail the task with a clear, actionable error instead of leaking the raw
// InputRequiredException through the generic catch below.
var error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InvalidRequest,
Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " +
$"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.",
};
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (Exception ex)
{
// SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}.
// McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to
// propagate (Message + ErrorCode). For any other exception type, redact the message
// and use InternalError (mirrors the redaction in BuildInitialCallToolFilter).
var error = ex is McpProtocolException mcpEx
? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }
: new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
finally
{
// Only the side that wins TryRemove disposes cts. This prevents a
// double-dispose race with the default tasks/cancel handler.
if (_taskCancellationSources.TryRemove(taskId, out var registeredCts))
{
registeredCts.Dispose();
}
}
}
}, CancellationToken.None);
return ToCreateTaskResult(taskInfo);
}
});
return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false);
};
}
ServerCapabilities.Tools.ListChanged = listChanged;
@ -848,145 +995,191 @@ internal sealed partial class McpServerImpl : McpServer
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
McpJsonUtilities.JsonContext.Default.ListToolsResult);
SetHandler(
SetTaskAugmentedHandler(
RequestMethods.ToolsCall,
callToolHandler,
callToolWithTaskHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CallToolResult);
McpJsonUtilities.JsonContext.Default.CallToolResult,
McpJsonUtilities.JsonContext.Default.CreateTaskResult);
}
private void ConfigureTasks(McpServerOptions options)
private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new()
{
var taskStore = options.TaskStore;
TaskId = info.TaskId,
Status = info.Status,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
ResultType = "task",
};
// If no task store is configured, tasks are not supported
if (taskStore is null)
private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch
{
McpTaskStatus.Working => new WorkingTaskResult
{
return;
}
// Advertise task support in server capabilities
ServerCapabilities.Tasks = new McpTasksCapability
TaskId = info.TaskId,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
ResultType = "complete",
},
McpTaskStatus.Completed => new CompletedTaskResult
{
List = new ListMcpTasksCapability(),
Cancel = new CancelMcpTasksCapability(),
Requests = new RequestMcpTasksCapability
{
Tools = new ToolsMcpTasksCapability
{
Call = new CallToolMcpTasksCapability()
}
}
};
// tasks/get handler - Retrieve task status
McpRequestHandler<GetTaskRequestParams, McpTask> getTaskHandler = async (request, cancellationToken) =>
TaskId = info.TaskId,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."),
ResultType = "complete",
},
McpTaskStatus.Failed => new FailedTaskResult
{
if (request.Params?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
var task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
return task;
};
// tasks/result handler - Retrieve task result (blocking until terminal status)
McpRequestHandler<GetTaskPayloadRequestParams, JsonElement> getTaskResultHandler = (request, cancellationToken) =>
TaskId = info.TaskId,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."),
ResultType = "complete",
},
McpTaskStatus.Cancelled => new CancelledTaskResult
{
return new ValueTask<JsonElement>(GetTaskResultAsync(request, cancellationToken));
async Task<JsonElement> GetTaskResultAsync(RequestContext<GetTaskPayloadRequestParams> request, CancellationToken cancellationToken)
{
if (request.Params?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
// Poll until task reaches terminal status
while (true)
{
McpTask? task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
// If terminal, break and retrieve result
if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled)
{
break;
}
// Poll according to task's pollInterval (default 1 second)
var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1);
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
// Retrieve the stored result - already stored as JsonElement
return await taskStore.GetTaskResultAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
}
};
// tasks/list handler - List tasks with pagination
McpRequestHandler<ListTasksRequestParams, ListTasksResult> listTasksHandler = async (request, cancellationToken) =>
TaskId = info.TaskId,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
ResultType = "complete",
},
McpTaskStatus.InputRequired => new InputRequiredTaskResult
{
var cursor = request.Params?.Cursor;
return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false);
};
TaskId = info.TaskId,
CreatedAt = info.CreatedAt,
LastUpdatedAt = info.LastUpdatedAt,
TtlMs = info.TtlMs,
PollIntervalMs = info.PollIntervalMs,
StatusMessage = info.StatusMessage,
// McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store
// implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire
// DTO uses IDictionary like every other Protocol type. Most concrete stores back
// their dictionaries with a type that implements both interfaces (Dictionary,
// ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we
// only allocate a copy as a fallback.
InputRequests = info.InputRequests is IDictionary<string, InputRequest> dict
? dict
: info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
?? new Dictionary<string, InputRequest>(),
ResultType = "complete",
},
_ => throw new InvalidOperationException($"Unknown task status: {info.Status}"),
};
// tasks/cancel handler - Cancel a task
McpRequestHandler<CancelMcpTaskRequestParams, McpTask> cancelTaskHandler = async (request, cancellationToken) =>
{
if (request.Params?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
// Signal cancellation if task is still running
_taskCancellationTokenProvider!.Cancel(taskId);
// Delegate to task store - it handles idempotent cancellation
var task = await taskStore.CancelTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
return task;
};
// Register handlers
SetHandler(
RequestMethods.TasksGet,
getTaskHandler,
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
McpJsonUtilities.JsonContext.Default.McpTask);
SetHandler(
RequestMethods.TasksResult,
getTaskResultHandler,
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement);
SetHandler(
RequestMethods.TasksList,
listTasksHandler,
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
McpJsonUtilities.JsonContext.Default.ListTasksResult);
SetHandler(
RequestMethods.TasksCancel,
cancelTaskHandler,
McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams,
McpJsonUtilities.JsonContext.Default.McpTask);
private static async ValueTask<ResultOrCreatedTask<CallToolResult>> InvokeToolAsTask(
McpServerTool tool,
RequestContext<CallToolRequestParams> request,
CancellationToken cancellationToken)
{
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
}
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
async (request, cancellationToken) =>
{
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
try
{
var result = await handler(request, cancellationToken).ConfigureAwait(false);
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
return result;
}
catch (Exception e)
{
// Skip logging for InputRequiredException - it's normal MRTR control flow,
// not an error (tools throw it to signal an InputRequiredResult).
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
}
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{
throw;
}
return new()
{
IsError = true,
Content = [new TextContentBlock
{
Text = e is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
}
};
private McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>> BuildInitialTaskToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
async (request, cancellationToken) =>
{
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
try
{
var result = await handler(request, cancellationToken).ConfigureAwait(false);
if (!result.IsTask)
{
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.Result!.IsError is true);
}
return result;
}
catch (Exception e)
{
// Skip logging for InputRequiredException - it's normal MRTR control flow,
// not an error (tools throw it to signal an InputRequiredResult).
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
}
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{
throw;
}
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock
{
Text = e is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
}
};
private void ConfigureLogging(McpServerOptions options)
{
// We don't require that the handler be provided, as we always store the provided log level to the server.
@ -1088,6 +1281,20 @@ internal sealed partial class McpServerImpl : McpServer
requestTypeInfo, responseTypeInfo);
}
private void SetTaskAugmentedHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, ResultOrCreatedTask<TResult>> handler,
JsonTypeInfo<TParams> requestTypeInfo,
JsonTypeInfo<TResult> responseTypeInfo,
JsonTypeInfo<CreateTaskResult> taskResultTypeInfo)
where TResult : Result
{
_requestHandlers.SetTaskAugmented(method,
(request, jsonRpcRequest, cancellationToken) =>
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
}
private static McpRequestHandler<TParams, TResult> BuildFilterPipeline<TParams, TResult>(
McpRequestHandler<TParams, TResult> baseHandler,
IList<McpRequestFilter<TParams, TResult>> filters,
@ -1108,6 +1315,20 @@ internal sealed partial class McpServerImpl : McpServer
return current;
}
// Per SEP-2663 §51, the client opts in to the tasks extension on a per-request basis
// via the SEP-2575 capabilities envelope:
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
// TODO: swap the literals for a shared NotificationMethods.ClientCapabilitiesMetaKey once
// the SEP-2575 plumbing lands.
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
private const string ExtensionsKey = "extensions";
private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
meta is not null &&
meta[ClientCapabilitiesMetaKey] is JsonObject caps &&
caps[ExtensionsKey] is JsonObject exts &&
exts.ContainsKey(McpExtensions.Tasks);
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters)
{
if (filters.Count == 0)
@ -1617,160 +1838,4 @@ internal sealed partial class McpServerImpl : McpServer
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
private partial void MrtrHandlerError(Exception exception);
/// <summary>
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
/// </summary>
private async ValueTask<CallToolResult> ExecuteToolAsTaskAsync(
McpServerTool tool,
RequestContext<CallToolRequestParams> request,
McpTaskMetadata taskMetadata,
IMcpTaskStore? taskStore,
bool sendNotifications,
CancellationToken cancellationToken)
{
if (taskStore is null)
{
throw new McpProtocolException(
"Task-augmented requests are not supported. No task store configured.",
McpErrorCode.InvalidRequest);
}
// Create the task in the task store
var mcpTask = await taskStore.CreateTaskAsync(
taskMetadata,
request.JsonRpcRequest.Id,
request.JsonRpcRequest,
SessionId,
cancellationToken).ConfigureAwait(false);
// Register the task for TTL-based cancellation
var taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive);
// Execute the tool asynchronously in the background
_ = Task.Run(async () =>
{
// When per-request service scoping is enabled, InvokeHandlerAsync creates a new
// IServiceScope and disposes it once the handler returns. Since ExecuteToolAsTaskAsync
// returns immediately (before the tool runs), the scope is disposed before the tool
// gets a chance to resolve any DI services. Create a fresh scope here, tied to this
// background task's lifetime, so the tool's DI resolution uses a live provider.
var taskScope = _servicesScopePerRequest
? Services?.GetService<IServiceScopeFactory>()?.CreateAsyncScope()
: null;
if (taskScope is not null)
{
request.Services = taskScope.Value.ServiceProvider;
}
// Set up the task execution context for automatic input_required status tracking
TaskExecutionContext.Current = new TaskExecutionContext
{
TaskId = mcpTask.TaskId,
SessionId = SessionId,
TaskStore = taskStore,
SendNotifications = sendNotifications,
NotifyTaskStatusFunc = NotifyTaskStatusAsync
};
try
{
// Update task status to working
var workingTask = await taskStore.UpdateTaskStatusAsync(
mcpTask.TaskId,
McpTaskStatus.Working,
null, // statusMessage
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Send notification if enabled
if (sendNotifications)
{
_ = NotifyTaskStatusAsync(workingTask, CancellationToken.None);
}
// Invoke the tool with task-specific cancellation token
var result = await tool.InvokeAsync(request, taskCancellationToken).ConfigureAwait(false);
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
// Determine final status based on whether there was an error
var finalStatus = result.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed;
// Store the result (serialize to JsonElement)
var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult);
var finalTask = await taskStore.StoreTaskResultAsync(
mcpTask.TaskId,
finalStatus,
resultElement,
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Send final notification if enabled
if (sendNotifications)
{
_ = NotifyTaskStatusAsync(finalTask, CancellationToken.None);
}
}
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
{
// Task was cancelled via TTL expiration or explicit cancellation.
// For TTL expiration, the task is deleted so no status update needed.
// For explicit cancellation, the cancel handler already updates the status.
}
catch (Exception ex)
{
// Log the error
ToolCallError(request.Params?.Name ?? string.Empty, ex);
// Store error result
var errorResult = new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = $"Task execution failed: {ex.Message}" }],
};
try
{
var errorResultElement = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.JsonContext.Default.CallToolResult);
var failedTask = await taskStore.StoreTaskResultAsync(
mcpTask.TaskId,
McpTaskStatus.Failed,
errorResultElement,
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Send failure notification if enabled
if (sendNotifications)
{
_ = NotifyTaskStatusAsync(failedTask, CancellationToken.None);
}
}
catch
{
// If we can't store the error result, there's not much we can do
// The task will remain in "working" status, which will eventually be cleaned up
}
}
finally
{
// Clean up task execution context
TaskExecutionContext.Current = null;
// Clean up task cancellation tracking
_taskCancellationTokenProvider!.Complete(mcpTask.TaskId);
// Dispose the per-task service scope (if one was created)
if (taskScope is not null)
{
await taskScope.Value.DisposeAsync().ConfigureAwait(false);
}
}
}, CancellationToken.None);
// Return the task result immediately
return new CallToolResult
{
Task = mcpTask
};
}
}