using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Tasks; /// /// Extension methods for to enable MCP Tasks support. /// public static class McpTasksBuilderExtensions { /// /// Enables MCP Tasks support backed by the specified task store. /// /// /// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before /// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool /// filters, run in the background before the tool. /// /// The server builder. /// The task store. /// The builder provided in . public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) => WithTasks(builder, store, static _ => { }); /// /// Enables MCP Tasks support backed by the specified task store. /// /// The server builder. /// The task store. /// A callback that configures per-call task execution behavior. /// The builder provided in . public static IMcpServerBuilder WithTasks( this IMcpServerBuilder builder, IMcpTaskStore store, Action configure) { #if NET ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(configure); #else if (builder is null) throw new ArgumentNullException(nameof(builder)); if (store is null) throw new ArgumentNullException(nameof(store)); if (configure is null) throw new ArgumentNullException(nameof(configure)); #endif McpTasksOptions taskOptions = new(); configure(taskOptions); #if NET ArgumentNullException.ThrowIfNull(taskOptions.ExecutionModeSelector); #else if (taskOptions.ExecutionModeSelector is null) throw new ArgumentNullException(nameof(taskOptions.ExecutionModeSelector)); #endif // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the // background task body has somewhere to report failures. It is optional: if no logging is // registered, the options fall back to NullLoggerFactory. builder.Services.AddSingleton>( sp => new McpTasksConfigureOptions( store, sp.GetRequiredService(), sp.GetService(), taskOptions)); return builder; } private sealed class McpTasksConfigureOptions( IMcpTaskStore store, IServiceScopeFactory serviceScopeFactory, ILoggerFactory? loggerFactory, McpTasksOptions taskOptions) : IConfigureOptions { private readonly IMcpTaskStore _store = store; private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); private readonly McpTasksOptions _taskOptions = taskOptions; private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); public void Configure(McpServerOptions options) { #if NET ArgumentNullException.ThrowIfNull(options); #else if (options is null) throw new ArgumentNullException(nameof(options)); #endif options.Capabilities ??= new ServerCapabilities(); options.Capabilities.Extensions ??= new Dictionary(); if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId)) { options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); } options.RequestHandlers ??= new List(); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, RoutingNameParameter = "taskId", Handler = HandleGetTask, }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, RoutingNameParameter = "taskId", Handler = HandleUpdateTask, }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, RoutingNameParameter = "taskId", Handler = HandleCancelTask, }); // Use a filter rather than a handler so it wraps around Core's tool dispatch. // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing // it to spawn background execution and return the task alternate immediately. options.Filters.Request.CallToolWithAlternateFilters.Insert( options.Filters.Request.CallToolWithAlternateFilters.Count, async (request, next, cancellationToken) => { var executionMode = _taskOptions.ExecutionModeSelector(request); if (executionMode is not McpTaskExecutionMode.Synchronous and not McpTaskExecutionMode.Optional and not McpTaskExecutionMode.Required) { throw new InvalidOperationException( $"{nameof(McpTasksOptions.ExecutionModeSelector)} returned an invalid {nameof(McpTaskExecutionMode)} value."); } if (executionMode == McpTaskExecutionMode.Synchronous) { return await next(request, cancellationToken).ConfigureAwait(false); } if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.JsonRpcRequest)) { if (executionMode == McpTaskExecutionMode.Required) { throw CreateMissingTasksCapabilityException(); } return await next(request, cancellationToken).ConfigureAwait(false); } return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false); }); } private async ValueTask> RunAsTaskAsync( McpRequestHandler> next, RequestContext request, CancellationToken cancellationToken) { var executionScope = _serviceScopeFactory.CreateAsyncScope(); var executionRequest = new RequestContext( request.Server, request.JsonRpcRequest, request.Params) { MatchedPrimitive = request.MatchedPrimitive, Services = executionScope.ServiceProvider, }; McpTaskInfo taskInfo; try { taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); } catch { await executionScope.DisposeAsync().ConfigureAwait(false); throw; } var taskId = taskInfo.TaskId; executionRequest.Server = request.Server.WithMcpTaskOutgoingRequestInterceptor(taskId, _store); var cts = new CancellationTokenSource(); _cancellationSources[taskId] = cts; // Capture the token before dispatching. Cancellation can remove and dispose the source // before the background delegate starts. var taskCancellationToken = cts.Token; _ = Task.Run( () => ExecuteTaskAsync(next, executionRequest, taskId, taskCancellationToken, executionScope), CancellationToken.None); return ResultOrAlternate.FromAlternate( ToCreateTaskResult(taskInfo), McpTasksJsonContext.Default.CreateTaskResult); } private async Task ExecuteTaskAsync( McpRequestHandler> next, RequestContext request, string taskId, CancellationToken taskCancellationToken, AsyncServiceScope executionScope) { try { try { await ExecuteToolPipelineAsync(next, request, taskId, taskCancellationToken).ConfigureAwait(false); } finally { await executionScope.DisposeAsync().ConfigureAwait(false); } } catch (Exception outer) { // Expected outcomes are recorded by ExecuteToolPipelineAsync. Reaching here means a // store operation, task scope, or service scope failed. Record it best-effort. _logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId); try { var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (Exception storeEx) { _logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId); } } finally { if (_cancellationSources.TryRemove(taskId, out var registeredCts)) { registeredCts.Dispose(); } } } private async Task ExecuteToolPipelineAsync( McpRequestHandler> next, RequestContext request, string taskId, CancellationToken taskCancellationToken) { try { var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); if (augmented.IsAlternate) { var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); return; } var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); } catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) { await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); } catch (InputRequiredException) { var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InvalidRequest, Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (McpProtocolException mcpEx) { // SEP-2663 ยง186: protocol exceptions store as failed with JSON-RPC error shape. var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } catch (Exception ex) { // Non-protocol exceptions are wrapped as CallToolResult { IsError = true }, // matching Core's BuildInitialCallToolFilter behavior. var errorResult = new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = ex is McpException ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}" : $"An error occurred invoking '{request.Params?.Name}'.", }], }; var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); } } private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); GateToTasksCapability(request); var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); if (info is null) { throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams); } return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult); } private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); GateToTasksCapability(request); var taskId = request.Params?["taskId"]?.GetValue() ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); // Deserialize inputResponses using Core's options which can access the internal // InputResponsesCore backing property on RequestParams. The extension's source-gen // context cannot see that internal member. var inputResponses = request.Params?["inputResponses"]?.Deserialize( McpJsonUtilities.DefaultOptions.GetTypeInfo>()) ?? new Dictionary(); await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult); } private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) { GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); GateToTasksCapability(request); var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts)) { cts.Cancel(); cts.Dispose(); } return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult); } private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method) { if (!IsJuly2026OrLaterProtocolRequest(request)) { throw new McpProtocolException( $"The method '{method}' requires a newer protocol revision that supports tasks " + $"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later); " + $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.", McpErrorCode.MethodNotFound); } } private static void GateToTasksCapability(JsonRpcRequest request) { if (!HasTaskExtensionOptIn(request)) { throw CreateMissingTasksCapabilityException(); } } private static MissingRequiredClientCapabilityException CreateMissingTasksCapabilityException() => new( new ClientCapabilities { Extensions = new Dictionary { [TasksProtocol.ExtensionId] = new JsonObject(), }, }, $"The request requires the '{TasksProtocol.ExtensionId}' client extension capability."); private static bool HasTaskExtensionOptIn(JsonRpcRequest request) => request.Context?.ClientCapabilities?.Extensions?.ContainsKey(TasksProtocol.ExtensionId) is true || request.Params?["_meta"]?[MetaKeys.ClientCapabilities]?["extensions"] is JsonObject extensions && extensions.ContainsKey(TasksProtocol.ExtensionId); private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() { TaskId = info.TaskId, Status = info.Status, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, }; private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch { McpTaskStatus.Working => new WorkingTaskResult { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", }, McpTaskStatus.Completed => new CompletedTaskResult { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, 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 { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, 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 { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", }, McpTaskStatus.InputRequired => new InputRequiredTaskResult { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, InputRequests = info.InputRequests is IDictionary dict ? dict : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(), ResultType = "complete", }, _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), }; } }