diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index abfb430cb..9020d2fbe 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index fff353307..b10d2fde4 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -13,6 +13,7 @@ // is self-contained — no separate server process or HTTP transport required. using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; @@ -21,22 +22,25 @@ Pipe clientToServerPipe = new(), serverToClientPipe = new(); +McpServerOptions serverOptions = new() +{ + ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], +}; + +// Calling WithTasks is all that's needed for [McpServerTool]-attributed tools to be +// automatically wrapped as background tasks when the client opts in. +serverOptions.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }); + await using McpServer server = McpServer.Create( new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be - // automatically wrapped as background tasks when the client opts in. - TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, - ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], - }); + serverOptions); _ = server.RunAsync(); await using McpClient client = await McpClient.CreateAsync( new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); -Console.WriteLine("=== CallToolAsync (auto-poll) ==="); -var auto = await client.CallToolAsync( +Console.WriteLine("=== CallToolAsTaskAsync (auto-poll) ==="); +var auto = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "run-report" }); Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); Console.WriteLine(); diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj index 2f66badd7..e0bdd3ff7 100644 --- a/samples/TasksExtension/TasksExtension.csproj +++ b/samples/TasksExtension/TasksExtension.csproj @@ -5,11 +5,12 @@ net8.0 enable enable - $(NoWarn);MCPEXP001 + $(NoWarn);MCPEXP001;MCPEXP002;MCPEXP004 + diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bfe81d19b..8ccae6dd8 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -911,13 +911,6 @@ public Task UnsubscribeFromResourceAsync( /// The from the tool execution. /// is . /// The request failed or the server returned an error response. - /// - /// This overload supports the tasks extension transparently. If the server responds with a - /// task handle rather than an immediate result, this method polls tasks/get until the - /// task completes, dispatching any entries through - /// the client's registered sampling and elicitation handlers along the way. Use - /// to disable automatic polling. - /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -988,219 +981,18 @@ async ValueTask SendRequestWithProgressAsync( /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// This method automatically includes the io.modelcontextprotocol/tasks extension capability - /// in the request metadata. If the server returns a task handle instead of an immediate result, - /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. - /// Use - /// to receive the raw without automatic polling. - /// - public async ValueTask CallToolAsync( - CallToolRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - - var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); - - if (!augmented.IsTask) - { - return augmented.Result!; - } - - return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); - } - - /// - /// Polls a task until it reaches a terminal state and returns the final . - /// - private async ValueTask PollTaskToCompletionAsync( - CreateTaskResult taskCreated, - CancellationToken cancellationToken) - { - // If the server claims InputRequired but never publishes new input requests after we have - // already responded to everything it asked for, treat that as a stuck task. The client - // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll - // loop when the server is misbehaving. The threshold is configurable via - // McpClientOptions.MaxConsecutiveStuckPolls. - int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; - - string taskId = taskCreated.TaskId; - long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; - HashSet? resolvedRequestKeys = null; - bool isFirstPoll = true; - int consecutiveStuckPolls = 0; - - while (true) - { - // Skip the delay before the first poll: many tasks complete almost immediately and we - // don't want to pay the poll interval as gratuitous latency. - if (!isFirstPoll) - { - await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); - } - isFirstPoll = false; - - var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Update poll interval if the server changed it. - if (taskResult.PollIntervalMs is { } newInterval) - { - pollIntervalMs = newInterval; - } - - switch (taskResult) - { - case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - - case FailedTaskResult failed: - throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - - case CancelledTaskResult: - throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); - - case InputRequiredTaskResult inputRequired: - // Dedup: only resolve input requests we haven't already responded to. - var newRequests = new Dictionary(); - if (inputRequired.InputRequests is { } incomingRequests) - { - foreach (var kvp in incomingRequests) - { - if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) - { - newRequests[kvp.Key] = kvp.Value; - } - } - } - - if (newRequests.Count > 0) - { - consecutiveStuckPolls = 0; - - IDictionary inputResponses; - try - { - inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). - // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired - // until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating the original exception. - } - - throw; - } - - await UpdateTaskAsync(new UpdateTaskRequestParams - { - TaskId = taskId, - InputResponses = inputResponses, - }, cancellationToken).ConfigureAwait(false); - - resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); - foreach (var key in inputResponses.Keys) - { - resolvedRequestKeys.Add(key); - } - } - else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) - { - // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating an exception. - } - - throw new McpException( - $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + - "without publishing new input requests after all previously requested inputs were resolved."); - } - - break; - - case WorkingTaskResult: - // Continue polling. - consecutiveStuckPolls = 0; - break; - - default: - throw new McpException( - $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); - } - } - } - - /// - /// Invokes a tool on the server with task extension support, returning the raw response - /// without automatic polling. The caller is responsible for handling task lifecycle. - /// - /// The request parameters to send. The tasks extension capability will be injected into the request metadata. - /// The to monitor for cancellation requests. The default is . - /// A that is either an immediate result or a task handle. - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Unlike , this method does not - /// automatically poll for task completion. If the server returns a , - /// the caller must manage polling via . - /// - /// - public async ValueTask> CallToolRawAsync( + public ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - var paramsWithMeta = new CallToolRequestParams - { - Name = requestParams.Name, - Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call - // (no task capability envelope) so the server returns a direct CallToolResult and never - // creates a task. - Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, - }; - - JsonRpcRequest jsonRpcRequest = new() - { - Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), - }; - - JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Discriminate based on resultType field. - if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() == "task") - { - var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) - ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); - return new ResultOrCreatedTask(taskCreated); - } - - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from response."); - return new ResultOrCreatedTask(callToolResult); + return SendRequestAsync( + RequestMethods.ToolsCall, + requestParams, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); } /// @@ -1258,109 +1050,6 @@ public Task SetLoggingLevelAsync( cancellationToken: cancellationToken).AsTask(); } - /// - /// Retrieves the current state of a task from the server. - /// - /// The stable identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - GetTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(GetTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksGet, - requestParams, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Provides input responses to a task that is in the state. - /// - /// The request parameters containing the task ID and input responses. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the update. - /// is . - /// The request failed or the server returned an error response. - public ValueTask UpdateTaskAsync( - UpdateTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksUpdate, - requestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The stable identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - CancelTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksCancel, - requestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult, - cancellationToken: cancellationToken); - } - /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( IReadOnlyDictionary? arguments, JsonSerializerOptions options) @@ -1379,44 +1068,4 @@ public ValueTask CancelTaskAsync( return result; } - - // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: - // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) - { - JsonObject meta = existingMeta is not null - ? (JsonObject)existingMeta.DeepClone() - : []; - - if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) - { - capsRoot = []; - meta[MetaKeys.ClientCapabilities] = capsRoot; - } - - if (capsRoot["extensions"] is not JsonObject extensionsRoot) - { - extensionsRoot = []; - capsRoot["extensions"] = extensionsRoot; - } - - extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); - return meta; - } - - /// - /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks - /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session - /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel - /// on an older session is a programming error rather than a recoverable protocol condition. - /// - private void ThrowIfTasksNotSupported(string operationName) - { - if (!IsJuly2026OrLaterProtocol()) - { - throw new InvalidOperationException( - $"'{operationName}' requires a newer protocol revision that supports tasks. " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); - } - } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index b238c59c3..13078e152 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -73,7 +73,7 @@ protected McpClient() public abstract Task Completion { get; } /// - /// Resolves input requests embedded in an by dispatching + /// Resolves input requests embedded in a task that requires input by dispatching /// each request to the appropriate registered handler. /// /// @@ -82,16 +82,10 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - private protected abstract ValueTask> ResolveInputRequestsAsync( + [System.Diagnostics.CodeAnalysis.Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); - /// - /// Gets the maximum number of consecutive stuck-in- polls - /// allowed by before the client cancels and throws. - /// Sourced from . - /// - private protected abstract int MaxConsecutiveStuckPolls { get; } - /// /// Registers one or more tool definitions in the client's tool cache, enabling the transport /// to send Mcp-Param-* headers for those tools without requiring a prior call. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index eb2fedc85..8b62b7b3d 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -41,7 +41,6 @@ internal sealed partial class McpClientImpl : McpClient /// Options for the client, defining protocol version and capabilities. /// The logger factory. internal McpClientImpl(ITransport transport, string endpointName, McpClientOptions? options, ILoggerFactory? loggerFactory) -#pragma warning restore MCPEXP002 { options ??= new(); @@ -178,10 +177,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not public override Task Completion => _sessionHandler.CompletionTask; /// - private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; - - /// - private protected override async ValueTask> ResolveInputRequestsAsync( + public override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..036fb2269 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -146,42 +146,4 @@ public McpClientHandlers Handlers field = value; } } - - /// - /// Gets or sets the maximum number of consecutive task polls during which a task may report - /// without publishing any new input requests, before - /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws - /// an . - /// - /// - /// The maximum number of consecutive stuck polls allowed. The default value is 60. - /// - /// - /// - /// This guard prevents an unbounded poll loop when the server keeps a task in - /// but never publishes new input requests after the - /// client has already responded to every previously surfaced request. It only affects the - /// long-poll path used by ; - /// it does not affect direct calls. - /// - /// - /// Callers should size this value with the configured server-side poll interval in mind: the - /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. - /// Setting this to a very small value can cause false positives for servers that are slow to - /// surface follow-up input requests; setting it too large can mask misbehaving servers. - /// - /// - /// The value is less than 1. - public int MaxConsecutiveStuckPolls - { - get; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); - } - field = value; - } - } = 60; } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index da1b898dd..74b45a0f1 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -120,12 +120,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(TaskStatusNotificationParams))] - [JsonSerializable(typeof(WorkingTaskNotificationParams))] - [JsonSerializable(typeof(CompletedTaskNotificationParams))] - [JsonSerializable(typeof(FailedTaskNotificationParams))] - [JsonSerializable(typeof(CancelledTaskNotificationParams))] - [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] @@ -174,19 +168,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(GetTaskRequestParams))] - [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(WorkingTaskResult))] - [JsonSerializable(typeof(CompletedTaskResult))] - [JsonSerializable(typeof(FailedTaskResult))] - [JsonSerializable(typeof(CancelledTaskResult))] - [JsonSerializable(typeof(InputRequiredTaskResult))] - [JsonSerializable(typeof(UpdateTaskRequestParams))] - [JsonSerializable(typeof(UpdateTaskResult))] - [JsonSerializable(typeof(CancelTaskRequestParams))] - [JsonSerializable(typeof(CancelTaskResult))] - [JsonSerializable(typeof(CreateTaskResult))] - // MCP Content [JsonSerializable(typeof(ContentBlock))] [JsonSerializable(typeof(TextContentBlock))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index e1bd0844b..63f1bb525 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -57,6 +57,16 @@ public abstract partial class McpSession : IAsyncDisposable internal bool IsJuly2026OrLaterProtocol() => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); + /// + /// Gets a value indicating whether the negotiated protocol version supports the draft protocol + /// features (the 2026-07-28 revision or later). + /// + /// + /// This is a public seam used by bolt-on extensions (such as the Tasks extension) to gate + /// functionality that requires the 2026-07-28 or later protocol revision. + /// + public bool IsDraftProtocol() => IsJuly2026OrLaterProtocol(); + /// /// Sends a JSON-RPC request to the connected session and waits for a response. /// diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 4d9139953..29db6f5ae 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -53,24 +53,4 @@ public static class MetaKeys /// belonging to different subscriptions on a shared channel (especially STDIO). /// public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; - - /// - /// The metadata key used to associate requests, responses, and notifications with a task. - /// - /// - /// - /// This constant defines the key "io.modelcontextprotocol/related-task" used in the - /// _meta field to associate messages with their originating task across the entire - /// request lifecycle. - /// - /// - /// For example, an elicitation that a task-augmented tool call depends on must share the - /// same related task ID with that tool call's task. - /// - /// - /// For tasks/get, tasks/list, and tasks/cancel operations, this - /// metadata should not be included as the taskId is already present in the message structure. - /// - /// - public const string RelatedTask = "io.modelcontextprotocol/related-task"; } diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 0bd3348f8..7911d9e33 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -144,16 +144,6 @@ public static class NotificationMethods /// public const string CancelledNotification = "notifications/cancelled"; - /// - /// The name of the notification sent by the server to push task status updates to subscribed clients. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Each notification carries a complete task state for the current status, identical to what - /// tasks/get would have returned at that moment. - /// - public const string TaskStatusNotification = "notifications/tasks/status"; - /// /// The name of the notification sent first on a /// response stream to indicate which notification types the server agreed to deliver. diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs index 54432a4c2..fcdfcd178 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class NotificationParams { - /// Prevent external derivations. - private protected NotificationParams() + /// Allow derivations only within the SDK and its extension packages. + protected NotificationParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..29d7d3764 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -125,33 +125,6 @@ public static class RequestMethods /// public const string Initialize = "initialize"; - /// - /// The name of the request method sent from the client to poll for task completion. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Clients poll for task status by sending this request with the task ID. - /// - public const string TasksGet = "tasks/get"; - - /// - /// The name of the request method sent from the client to provide input responses to a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Used when a task has input_required status and the client needs to fulfill outstanding requests. - /// - public const string TasksUpdate = "tasks/update"; - - /// - /// The name of the request method sent from the client to signal intent to cancel a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Cancellation is cooperative — the server decides whether and when to honor it. - /// - public const string TasksCancel = "tasks/cancel"; - /// /// The name of the request method sent from the client to discover the server's protocol versions, /// capabilities, and metadata. diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 67a8f00ba..4597857cf 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class RequestParams { - /// Prevent external derivations. - private protected RequestParams() + /// Allow derivations only within the SDK and its extension packages. + protected RequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..a7da7e5e6 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class Result { - /// Prevent external derivations. - private protected Result() + /// Allow derivations only within the SDK and its extension packages. + protected Result() { } @@ -30,8 +30,8 @@ private protected Result() /// When absent or set to "complete", the result is a normal completed response. /// When set to "input_required", the result is an indicating /// that additional input is needed before the request can be completed. - /// When set to "task", the result is a indicating that the server - /// has created a long-running task in lieu of returning the result directly. + /// When set to "task", the server has created a long-running task in lieu of returning the + /// result directly. /// /// /// Defaults to , which is equivalent to "complete". diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index f15ce316c..97e8b95df 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -45,49 +45,4 @@ public void Set( return JsonSerializer.SerializeToNode(result, responseTypeInfo); }; } - - /// - /// Registers a handler that may return either a standard result or a - /// for task-augmented execution. - /// - public void SetTaskAugmented( - string method, - Func>> handler, - JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) - where TResult : Result - { - Throw.IfNull(method); - Throw.IfNull(handler); - Throw.IfNull(requestTypeInfo); - Throw.IfNull(responseTypeInfo); - Throw.IfNull(taskResultTypeInfo); - - this[method] = async (request, cancellationToken) => - { - TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; - var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); - - if (augmented.IsTask) - { - // Guard against a misconfiguration where a handler opts into task-augmented - // execution but the server has no task lifecycle handlers wired up. Without - // tasks/get, a client that received a CreateTaskResult would have no way to - // poll the task to completion. Configure McpServerOptions.TaskStore or set - // the task handlers explicitly via McpServerOptions.Handlers. - if (!ContainsKey(RequestMethods.TasksGet)) - { - throw new InvalidOperationException( - $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " + - $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " + - "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server."); - } - - return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo); - } - - return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); - }; - } } diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..f6f85dcdc 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -51,8 +51,8 @@ public override Task SendRequestAsync(JsonRpcRequest request, C { // When an MRTR context is active, intercept server-to-client requests (sampling, elicitation, roots) // and route them through the MRTR mechanism instead of sending them over the wire. - // Task-augmented requests (SampleAsTaskAsync/ElicitAsTaskAsync) have a "task" property on their params - // and expect a CreateTaskResult response, so they must bypass MRTR and go over the wire. + // Requests carrying a "task" property on their params are part of a bolt-on task extension flow + // and expect a task-creation response, so they must bypass MRTR and go over the wire. if (ActiveMrtrContext is { } mrtrContext && !(request.Params is JsonObject paramsObj && paramsObj.ContainsKey("task"))) { diff --git a/src/ModelContextProtocol.Core/Server/IMcpServerRawHandlerRegistry.cs b/src/ModelContextProtocol.Core/Server/IMcpServerRawHandlerRegistry.cs new file mode 100644 index 000000000..5431ed686 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/IMcpServerRawHandlerRegistry.cs @@ -0,0 +1,61 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a low-level request handler that operates directly on the JSON-RPC request and produces +/// the serialized result node, bypassing the strongly-typed handler pipeline. +/// +/// The incoming JSON-RPC request. +/// The to monitor for cancellation requests. +/// The serialized result node, or . +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public delegate ValueTask McpRawRequestHandler(JsonRpcRequest request, CancellationToken cancellationToken); + +/// +/// Provides a low-level seam for registering and wrapping raw request handlers on an . +/// +/// +/// This is an SDK extensibility hook intended for bolt-on packages (such as the MCP Tasks extension) that +/// need to register new request methods or wrap existing handlers without taking a compile-time dependency +/// on the feature in the core SDK. Configure it via . +/// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public interface IMcpServerRawHandlerRegistry +{ + /// + /// Gets the these handlers are being configured for. + /// + McpServer Server { get; } + + /// + /// Determines whether a handler is currently registered for the specified method. + /// + /// The request method identifier (e.g., "tools/call"). + /// if a handler is registered; otherwise, . + bool ContainsHandler(string method); + + /// + /// Registers (or replaces) the raw handler for the specified method. + /// + /// The request method identifier (e.g., "tasks/get"). + /// The raw handler to register. + void SetHandler(string method, McpRawRequestHandler handler); + + /// + /// Wraps the existing raw handler for the specified method, if one is registered. + /// + /// The request method identifier (e.g., "tools/call"). + /// A function that receives the existing inner handler and returns a wrapping handler. + /// if a handler was present and wrapped; otherwise, . + bool TryWrapHandler(string method, Func wrap); + + /// + /// Determines whether the specified request was negotiated under the draft protocol revision. + /// + /// The JSON-RPC request to inspect. + /// if the request was negotiated under the draft revision; otherwise, . + bool IsDraftProtocolRequest(JsonRpcRequest request); +} diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index 9d9774e8b..891a86789 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -36,15 +36,9 @@ public IList> ListTool /// Gets or sets the filters for the call-tool handler pipeline. /// /// - /// /// These filters wrap handlers that are invoked when a client makes a call to a tool that isn't found in the collection. /// The filters can modify, log, or perform additional operations on requests and responses for /// requests. The handler should implement logic to execute the requested tool and return appropriate results. - /// - /// - /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. - /// /// public IList> CallToolFilters { @@ -56,31 +50,6 @@ public IList> CallToolFi } } - /// - /// Gets or sets the filters for the call-tool handler pipeline with task support. - /// - /// - /// - /// These filters wrap the task-augmented call-tool handler whose return type is - /// . Use these filters when the server's tool pipeline - /// supports returning either an immediate or a - /// for asynchronous execution. - /// - /// - /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. - /// - /// - public IList>> CallToolWithTaskFilters - { - get => field ??= []; - set - { - Throw.IfNull(value); - field = value; - } - } - /// /// Gets or sets the filters for the list-prompts handler pipeline. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 4f739c28a..0100a56ca 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -11,6 +11,8 @@ namespace ModelContextProtocol.Server; +#pragma warning disable MCPEXP002 // Outgoing-request interceptor seam is consumed internally by the server pipeline. + /// /// Represents an instance of a Model Context Protocol (MCP) server that connects to and communicates with an MCP client. /// @@ -63,8 +65,8 @@ public static McpServer Create( /// /// /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for the client response, - /// then returns to when the response is received. + /// status to input-required while waiting for the client response, + /// then returns to the working status when the response is received. /// /// [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] @@ -74,14 +76,12 @@ public ValueTask SampleAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect sampling through the task store. - // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is active (e.g., background task execution), redirect + // sampling through it. Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped + // here because the interceptor owns delivery through its own channel. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams, + return InterceptOutgoingRequestAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult, cancellationToken); @@ -282,14 +282,12 @@ public ValueTask RequestRootsAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect through the task store. - // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is active (e.g., background task execution), redirect + // through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because + // the interceptor owns delivery through its own channel. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams, + return InterceptOutgoingRequestAsync(interceptor, RequestMethods.RootsList, requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult, cancellationToken); @@ -324,8 +322,8 @@ public ValueTask RequestRootsAsync( /// /// /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for user input, - /// then returns to when the response is received. + /// status to input-required while waiting for user input, + /// then returns to the working status when the response is received. /// /// public async ValueTask ElicitAsync( @@ -334,17 +332,15 @@ public async ValueTask ElicitAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect elicitation through the task store. - // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because - // the client opted into the tasks extension when submitting the originating request, and - // input requests are delivered through the tasks/get response channel rather than as - // direct server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is active (e.g., background task execution), redirect + // elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are intentionally + // skipped here because the interceptor owns delivery through its own channel. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult, + var node = await interceptor(RequestMethods.ElicitationCreate, + JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams), cancellationToken).ConfigureAwait(false); + var taskResult = node is null ? null : JsonSerializer.Deserialize(node, McpJsonUtilities.JsonContext.Default.ElicitResult); return taskResult ?? new ElicitResult { Action = "cancel" }; } @@ -422,26 +418,6 @@ public async ValueTask> ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } - /// - /// Sends a task status notification to the connected client. - /// - /// The task status notification parameters to send. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous send operation. - /// is . - public Task SendTaskStatusNotificationAsync( - TaskStatusNotificationParams notificationParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(notificationParams); - - return SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams, - cancellationToken); - } - /// /// Builds a request schema for elicitation based on the public serializable properties of . /// @@ -592,84 +568,20 @@ private void ThrowIfRootsUnsupported() } /// - /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through - /// the task store as input requests for the duration of the scope. Use this when executing tool logic - /// in the background as a task, so that any server-to-client requests are surfaced to the client via - /// the task's state instead of direct JSON-RPC messages. + /// Sends a server-initiated request through the active + /// and deserializes the result. /// - /// The task ID in the store. - /// The task store to write input requests to. - /// An that restores the previous context when disposed. - public IDisposable CreateMcpTaskScope( - string taskId, - IMcpTaskStore store) - { - Throw.IfNull(taskId); - Throw.IfNull(store); - - var previous = McpTaskExecutionContext.Current.Value; - McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext - { - TaskId = taskId, - Store = store, - }; - return new McpTaskExecutionContext.Scope(previous); - } - - /// - /// Sends a server-initiated request through the task store as an input request, then awaits the response. - /// - /// - /// When executing inside a task scope, capability negotiation checks (such as - /// , , and - /// ) are intentionally skipped by the callers - /// of this helper. The task channel itself is the negotiated capability: the client opted - /// in to the tasks extension when it submitted the originating request, and is responsible - /// for handling or rejecting the input requests surfaced through tasks/get. - /// - private async ValueTask SendRequestViaTaskAsync( - McpTaskExecutionContext taskContext, + private async ValueTask InterceptOutgoingRequestAsync( + McpOutgoingRequestInterceptor interceptor, string method, TRequest request, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo, CancellationToken cancellationToken) { - var requestId = Guid.NewGuid().ToString("N"); - var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo); - - var inputRequest = new InputRequest - { - Method = method, - Params = paramsJson, - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskContext.TaskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } - } - - taskContext.Store.InputResponseReceived += handler; - try - { - await taskContext.Store.SetInputRequestsAsync( - taskContext.TaskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return response.Deserialize(responseTypeInfo)!; - } - finally - { - taskContext.Store.InputResponseReceived -= handler; - } + var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo); + var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(resultNode, responseTypeInfo)!; } private void ThrowIfElicitationUnsupported(ElicitRequestParams request) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 5f8ebf69a..b69240f04 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -1,13 +1,48 @@ using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Server; +/// +/// Intercepts a server-initiated outgoing request (sampling, elicitation, or roots) so that it can be +/// redirected through an alternate channel instead of being sent directly to the client. +/// +/// The request method identifier (e.g., "sampling/createMessage"). +/// The serialized request parameters. +/// The to monitor for cancellation requests. +/// The serialized result node for the request. +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public delegate ValueTask McpOutgoingRequestInterceptor(string method, JsonNode? @params, CancellationToken cancellationToken); + /// /// Represents an instance of a Model Context Protocol (MCP) server that connects to and communicates with an MCP client. /// public abstract partial class McpServer : McpSession { +#pragma warning disable MCPEXP002 + private static readonly AsyncLocal s_currentOutgoingRequestInterceptor = new(); +#pragma warning restore MCPEXP002 + + /// + /// Gets or sets the ambient interceptor that redirects server-initiated outgoing requests + /// (sampling, elicitation, roots) for the current asynchronous flow. + /// + /// + /// This is an SDK extensibility hook intended for bolt-on packages (such as the MCP Tasks extension) + /// that run tool logic in the background and need to surface server-to-client requests through an + /// alternate channel. When set, , + /// , and + /// route + /// through the interceptor instead of sending directly to the client. + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public static McpOutgoingRequestInterceptor? CurrentOutgoingRequestInterceptor + { + get => s_currentOutgoingRequestInterceptor.Value; + set => s_currentOutgoingRequestInterceptor.Value = value; + } + /// /// Initializes a new instance of the class. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 4f9509b9d..e07100e0e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -42,53 +42,8 @@ public sealed class McpServerHandlers /// /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. - /// Use instead if the tool may return a - /// for asynchronous execution. /// - /// is already set. - public McpRequestHandler? CallToolHandler - { - get; - set - { - if (value is not null && CallToolWithTaskHandler is not null) - { - throw new InvalidOperationException( - $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured."); - } - - field = value; - } - } - - /// - /// Gets or sets the handler for requests with task support. - /// - /// - /// - /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or a for - /// long-running asynchronous operations. - /// - /// - /// Cannot be set if is already set. - /// - /// - /// is already set. - public McpRequestHandler>? CallToolWithTaskHandler - { - get; - set - { - if (value is not null && CallToolHandler is not null) - { - throw new InvalidOperationException( - $"Cannot set {nameof(CallToolWithTaskHandler)} when {nameof(CallToolHandler)} is already set. Only one call tool handler may be configured."); - } - - field = value; - } - } + public McpRequestHandler? CallToolHandler { get; set; } /// /// Gets or sets the handler for requests. @@ -202,74 +157,6 @@ public McpRequestHandler? SetLoggingLevelHandler { get; set; } - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client polls for the current state of a task. - /// The handler should return the appropriate subtype - /// based on the task's current status (for example, , - /// , , - /// , or ). - /// - /// - /// Setting is the recommended way to wire all three - /// task lifecycle handlers (, , - /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a - /// but no is configured (either - /// directly or via a task store), the server throws - /// when processing the request so misconfigured deployments fail loudly instead of producing - /// unpollable tasks. - /// - /// - public McpRequestHandler? GetTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client provides input responses for a task - /// that is in the state. Responses keyed - /// by an identifier that does not currently correspond to an outstanding input request - /// (including responses for tasks in a terminal state) should be silently ignored per - /// SEP-2663. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store dispatches to - /// and raises - /// to wake any pending - /// - /// or - /// calls executing inside a task scope. - /// - /// - public McpRequestHandler? UpdateTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client requests cancellation of an in-progress task. - /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should - /// always acknowledge the request with , even if the task is - /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions - /// to is up to the implementation. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store calls - /// and signals the per-task - /// so the tool's - /// observes cancellation. - /// - /// - public McpRequestHandler? CancelTaskHandler { get; set; } - /// Gets or sets notification handlers to register with the server. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..161316f18 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -28,7 +28,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); @@ -64,7 +63,6 @@ internal sealed partial class McpServerImpl : McpServer /// Optional service provider to use for dependency injection /// The server was incorrectly configured. public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider) -#pragma warning restore MCPEXP002 { Throw.IfNull(transport); Throw.IfNull(options); @@ -95,7 +93,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); - ConfigureTasks(options); + ConfigureRawRequestHandlers(options); ConfigureMrtr(); // Register any notification handlers that were provided. @@ -312,13 +310,6 @@ public override async ValueTask DisposeAsync() _disposed = true; - 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 @@ -754,110 +745,56 @@ private void ConfigureCompletion(McpServerOptions options) return result; } - private void ConfigureTasks(McpServerOptions options) + private void ConfigureRawRequestHandlers(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) + if (options.RawRequestHandlerConfigurators is not { Count: > 0 } configurators) { - 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(); - 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(); - }; + return; } - if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null) + var registry = new RawHandlerRegistry(this); + foreach (var configurator in configurators) { - return; + configurator(registry); } + } - 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)); - - // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. - getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); - updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); - cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); - - // Advertise tasks extension in server capabilities. - ServerCapabilities.Extensions ??= new Dictionary(); - ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); + /// + /// Adapts the internal dictionary to the public + /// seam used by bolt-on extensions. + /// + private sealed class RawHandlerRegistry(McpServerImpl server) : IMcpServerRawHandlerRegistry + { + public McpServer Server => server; - SetHandler( - RequestMethods.TasksGet, - getTaskHandler, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult); + public bool ContainsHandler(string method) => server._requestHandlers.ContainsKey(method); - SetHandler( - RequestMethods.TasksUpdate, - updateTaskHandler, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult); + public void SetHandler(string method, McpRawRequestHandler handler) + { + Throw.IfNull(method); + Throw.IfNull(handler); - SetHandler( - RequestMethods.TasksCancel, - cancelTaskHandler, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult); - } + server._requestHandlers[method] = (request, cancellationToken) => handler(request, cancellationToken).AsTask(); + } - /// - /// Wraps a tasks/* request handler so it throws unless the - /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only - /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers. - /// - private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol( - McpRequestHandler inner, string method) - => (request, cancellationToken) => + public bool TryWrapHandler(string method, Func wrap) { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) + Throw.IfNull(method); + Throw.IfNull(wrap); + + if (!server._requestHandlers.TryGetValue(method, out var existing)) { - throw new McpProtocolException( - $"The method '{method}' requires a newer protocol revision that supports tasks; " + - $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", - McpErrorCode.MethodNotFound); + return false; } - return inner(request, cancellationToken); - }; + McpRawRequestHandler inner = (request, cancellationToken) => new ValueTask(existing(request, cancellationToken)); + var wrapped = wrap(inner); + server._requestHandlers[method] = (request, cancellationToken) => wrapped(request, cancellationToken).AsTask(); + return true; + } + + public bool IsDraftProtocolRequest(JsonRpcRequest request) => server.IsJuly2026OrLaterProtocolRequest(request); + } private void ConfigureExperimentalAndExtensions(McpServerOptions options) { @@ -1134,11 +1071,10 @@ private void ConfigureTools(McpServerOptions options) { 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 && callToolWithTaskHandler is null && tools is null && + if (listToolsHandler is null && callToolHandler is null && tools is null && toolsCapability is null) { return; @@ -1150,18 +1086,6 @@ private void ConfigureTools(McpServerOptions options) var listChanged = toolsCapability?.ListChanged; 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) @@ -1202,159 +1126,26 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // 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) - { - var originalHandler = callToolWithTaskHandler; - callToolWithTaskHandler = (request, cancellationToken) => - { - if (request.MatchedPrimitive is McpServerTool tool) - { - return InvokeToolAsTask(tool, request, cancellationToken); - } - - 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) => - { - if (request.MatchedPrimitive is McpServerTool tool) - { - return tool.InvokeAsync(request, cancellationToken); - } - - return originalHandler(request, cancellationToken); - }; - } - - callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); - - // Convert to task-based. - var finalCallToolHandler = callToolHandler; - callToolWithTaskHandler = async (request, cancellationToken) => - await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); - } + // Build the call-tool handler: dispatch to DI tools, then apply filters. + callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); - // 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) + // Augment with DI tools. + if (tools is not null) { - var innerTaskHandler = callToolWithTaskHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + var originalHandler = callToolHandler; + callToolHandler = (request, cancellationToken) => { - // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not - // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the - // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise - // run the inner handler and return the direct result (best-effort downgrade, which also - // defends against a non-conformant legacy client that forges the opt-in envelope). - if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + if (request.MatchedPrimitive is McpServerTool tool) { - 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 () => - { - using (CreateMcpTaskScope(taskId, taskStore)) - { - 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 tool.InvokeAsync(request, cancellationToken); } - return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false); + return originalHandler(request, cancellationToken); }; } + callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); + ServerCapabilities.Tools.ListChanged = listChanged; SetHandler( @@ -1363,99 +1154,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetTaskAugmentedHandler( + SetHandler( RequestMethods.ToolsCall, - callToolWithTaskHandler, + callToolHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult, - McpJsonUtilities.JsonContext.Default.CreateTaskResult); - } - - 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, - ResultType = "task", - }; - - 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, - // 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 dict - ? dict - : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(), - ResultType = "complete", - }, - _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), - }; - - private static async ValueTask> InvokeToolAsTask( - McpServerTool tool, - RequestContext request, - CancellationToken cancellationToken) - { - return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + McpJsonUtilities.JsonContext.Default.CallToolResult); } private McpRequestFilter BuildInitialCallToolFilter( @@ -1501,53 +1204,6 @@ private McpRequestFilter BuildInitialCall } }; - private McpRequestFilter> BuildInitialTaskToolFilter( - McpServerPrimitiveCollection? 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. @@ -1670,20 +1326,6 @@ private void SetHandler( requestTypeInfo, responseTypeInfo); } - private void SetTaskAugmentedHandler( - string method, - McpRequestHandler> handler, - JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) - where TResult : Result - { - _requestHandlers.SetTaskAugmented(method, - (request, jsonRpcRequest, cancellationToken) => - InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), - requestTypeInfo, responseTypeInfo, taskResultTypeInfo); - } - private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, IList> filters, @@ -1704,15 +1346,6 @@ private static McpRequestHandler BuildFilterPipeline - meta is not null && - meta[MetaKeys.ClientCapabilities] is JsonObject caps && - caps["extensions"] is JsonObject exts && - exts.ContainsKey(McpExtensions.Tasks); - private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..157d9cbf9 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -191,17 +191,14 @@ public McpServerFilters Filters public int MaxSamplingOutputTokens { get; set; } = 1000; /// - /// Gets or sets the task store for managing asynchronous task executions. + /// Gets or sets a list of configurators that register or wrap low-level raw request handlers + /// after the strongly-typed handlers have been configured. /// /// - /// - /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension - /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store. - /// Tool executions from clients that signal task support will be wrapped in tasks via the store. - /// - /// - /// If explicit task handlers are also set on , the explicit handlers take precedence. - /// + /// This is an SDK extensibility hook intended for bolt-on packages (such as the MCP Tasks extension) + /// that need to register new request methods or wrap existing handlers. Each configurator receives an + /// for the server being constructed. /// - public IMcpTaskStore? TaskStore { get; set; } + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IList>? RawRequestHandlerConfigurators { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs deleted file mode 100644 index 749f36517..000000000 --- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ModelContextProtocol.Server; - -/// -/// Provides ambient context when a tool is executing as a background task. -/// When established, calls to , -/// , -/// and -/// are redirected through the task store as input requests rather than sent directly to the client. -/// -internal sealed class McpTaskExecutionContext -{ - internal static readonly AsyncLocal Current = new(); - - public required string TaskId { get; init; } - public required IMcpTaskStore Store { get; init; } - - internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable - { - public void Dispose() => Current.Value = previous; - } -} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpClientTasksExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpClientTasksExtensions.cs new file mode 100644 index 000000000..4729a5c8a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpClientTasksExtensions.cs @@ -0,0 +1,410 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides extension methods that add MCP Tasks extension (SEP-2663) support to an MCP client. +/// +/// +/// These methods let a client request task-augmented tool execution and drive the task lifecycle +/// (tasks/get, tasks/update, tasks/cancel). The Tasks extension requires the +/// 2026-07-28 or later protocol revision. +/// +public static class McpClientTasksExtensions +{ + /// The default number of consecutive stuck polls tolerated before a task is abandoned. + public const int DefaultMaxConsecutiveStuckPolls = 60; + + /// + /// Invokes a tool on the server with task extension support, transparently polling to completion. + /// + /// The client to invoke the tool on. + /// The request parameters to send in the request. + /// + /// The maximum number of consecutive polls in which the task remains in + /// without publishing new input requests before the task is abandoned. Must be positive. + /// + /// The to monitor for cancellation requests. The default is . + /// The result of the tool invocation. + /// or is . + /// The request failed or the server returned an error response. + /// + /// This method includes the io.modelcontextprotocol/tasks extension capability in the request + /// metadata. If the server returns a task handle, this method transparently polls tasks/get until + /// the task completes, fails, or is cancelled. Use to disable polling. + /// + public static async ValueTask CallToolAsTaskAsync( + this McpClient client, + CallToolRequestParams requestParams, + int maxConsecutiveStuckPolls = DefaultMaxConsecutiveStuckPolls, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + if (maxConsecutiveStuckPolls <= 0) throw new ArgumentOutOfRangeException(nameof(maxConsecutiveStuckPolls)); + + var augmented = await client.CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); + + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await client.PollTaskToCompletionAsync(augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false); + } + + /// + /// Invokes a tool on the server with task extension support, returning the raw response without polling. + /// + /// The client to invoke the tool on. + /// The request parameters to send. The tasks extension capability is injected into the request metadata. + /// The to monitor for cancellation requests. The default is . + /// A that is either an immediate result or a task handle. + /// or is . + /// The request failed or the server returned an error response. + public static async ValueTask> CallToolRawAsync( + this McpClient client, + CallToolRequestParams requestParams, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + // The SEP-2663 Tasks extension is draft-only. On a legacy session, send a plain tools/call + // (no task capability envelope) so the server returns a direct CallToolResult. + Meta = client.IsDraftProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + }; + + JsonRpcRequest jsonRpcRequest = new() + { + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, TasksJsonContext.Default.CallToolRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + // Discriminate based on resultType field. + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() == "task") + { + var taskCreated = resultObj.Deserialize(TasksJsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, TasksJsonContext.Default.CallToolResult) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); + } + + /// + /// Polls a task until it reaches a terminal state and returns the final . + /// + /// The client polling the task. + /// The task handle returned by the server. + /// + /// The maximum number of consecutive polls in which the task remains in + /// without publishing new input requests before the task is abandoned. Must be positive. + /// + /// The to monitor for cancellation requests. The default is . + /// The final result of the completed task. + /// or is . + /// The task failed or remained stuck. + /// The task was cancelled by the server. + public static async ValueTask PollTaskToCompletionAsync( + this McpClient client, + CreateTaskResult taskCreated, + int maxConsecutiveStuckPolls = DefaultMaxConsecutiveStuckPolls, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskCreated is null) throw new ArgumentNullException(nameof(taskCreated)); + if (maxConsecutiveStuckPolls <= 0) throw new ArgumentOutOfRangeException(nameof(maxConsecutiveStuckPolls)); + + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) + { + // Skip the delay before the first poll: many tasks complete almost immediately. + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + isFirstPoll = false; + + var taskResult = await client.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } + + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, TasksJsonContext.Default.CallToolResult) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); + + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); + + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } + } + + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // The input handler failed. Best-effort cancel of the server-side task so it + // doesn't stay stuck in InputRequired until TTL expires. + try + { + await client.CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Swallow secondary failures; we're already propagating the original exception. + } + + throw; + } + + await client.UpdateTaskAsync(new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + try + { + await client.CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Swallow secondary failures; we're already propagating an exception. + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } + + break; + + case WorkingTaskResult: + consecutiveStuckPolls = 0; + break; + + default: + throw new McpException( + $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } + } + } + + /// + /// Retrieves the current state of a task from the server. + /// + /// The client. + /// The stable identifier of the task to retrieve. + /// The to monitor for cancellation requests. The default is . + /// A subtype representing the current task state. + public static ValueTask GetTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + + return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Retrieves the current state of a task from the server. + /// + /// The client. + /// The request parameters to send in the request. + /// The to monitor for cancellation requests. The default is . + /// A subtype representing the current task state. + public static async ValueTask GetTaskAsync( + this McpClient client, + GetTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = TaskMethods.Get, + Params = JsonSerializer.SerializeToNode(requestParams, TasksJsonContext.Default.GetTaskRequestParams), + }, + cancellationToken).ConfigureAwait(false); + + return JsonSerializer.Deserialize(response.Result, TasksJsonContext.Default.GetTaskResult) + ?? throw new JsonException("Failed to deserialize GetTaskResult from response."); + } + + /// + /// Provides input responses to a task that is in the state. + /// + /// The client. + /// The request parameters containing the task ID and input responses. + /// The to monitor for cancellation requests. The default is . + /// The result acknowledging the update. + public static async ValueTask UpdateTaskAsync( + this McpClient client, + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync)); + + // RequestParams serializes inputResponses via an internal backing property that this assembly's + // source-generated context cannot access, so attach the inputResponses node explicitly. + var paramsNode = JsonSerializer.SerializeToNode(requestParams, TasksJsonContext.Default.UpdateTaskRequestParams); + if (requestParams.InputResponses is { Count: > 0 } responses && paramsNode is JsonObject paramsObject) + { + paramsObject["inputResponses"] = JsonSerializer.SerializeToNode(responses, TasksJsonContext.Default.IDictionaryStringInputResponse); + } + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = TaskMethods.Update, + Params = paramsNode, + }, + cancellationToken).ConfigureAwait(false); + + return JsonSerializer.Deserialize(response.Result, TasksJsonContext.Default.UpdateTaskResult) + ?? throw new JsonException("Failed to deserialize UpdateTaskResult from response."); + } + + /// + /// Requests cancellation of an in-progress task on the server. + /// + /// The client. + /// The stable identifier of the task to cancel. + /// The to monitor for cancellation requests. The default is . + /// The result acknowledging the cancellation request. + public static ValueTask CancelTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + + return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Requests cancellation of an in-progress task on the server. + /// + /// The client. + /// The request parameters to send in the request. + /// The to monitor for cancellation requests. The default is . + /// The result acknowledging the cancellation request. + public static async ValueTask CancelTaskAsync( + this McpClient client, + CancelTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); + ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = TaskMethods.Cancel, + Params = JsonSerializer.SerializeToNode(requestParams, TasksJsonContext.Default.CancelTaskRequestParams), + }, + cancellationToken).ConfigureAwait(false); + + return JsonSerializer.Deserialize(response.Result, TasksJsonContext.Default.CancelTaskResult) + ?? throw new JsonException("Failed to deserialize CancelTaskResult from response."); + } + + // Per SEP-2663 51, the per-request opt-in uses the SEP-2575 capabilities envelope: + // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) + { + capsRoot = []; + meta[MetaKeys.ClientCapabilities] = capsRoot; + } + + if (capsRoot["extensions"] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot["extensions"] = extensionsRoot; + } + + extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); + return meta; + } + + private static void ThrowIfTasksNotSupported(McpClient client, string operationName) + { + if (!client.IsDraftProtocol()) + { + throw new InvalidOperationException( + $"'{operationName}' requires a newer protocol revision that supports tasks. " + + $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonUtilities.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonUtilities.cs new file mode 100644 index 000000000..ac711cff9 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonUtilities.cs @@ -0,0 +1,20 @@ +using System.Text.Json; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides serialization helpers for the SEP-2663 Tasks extension protocol types. +/// +public static class McpTasksJsonUtilities +{ + /// + /// Gets the used to serialize and deserialize the + /// Tasks extension protocol types (for example + /// and ). + /// + /// + /// These options are backed by a source-generated , + /// so they are safe to use when reflection-based serialization is disabled (for example under Native AOT). + /// + public static JsonSerializerOptions DefaultOptions { get; } = TasksJsonContext.Default.Options; +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj new file mode 100644 index 000000000..29ad0e6a7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -0,0 +1,54 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Tasks + MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002;MCPEXP003;MCPEXP004 + + false + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 3fea8cc6b..f7d294039 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; @@ -143,7 +144,7 @@ internal sealed class Converter : JsonConverter resultType = reader.GetString(); break; case "_meta": - meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + meta = JsonSerializer.Deserialize(ref reader, TasksJsonContext.Default.JsonObject); break; case "result": result = JsonElement.ParseValue(ref reader); @@ -169,7 +170,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, TasksJsonContext.Default.InputRequest) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -265,7 +266,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria if (value.Meta is not null) { writer.WritePropertyName("_meta"); - JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo()); + JsonSerializer.Serialize(writer, value.Meta, TasksJsonContext.Default.JsonObject); } writer.WriteString("taskId", value.TaskId); @@ -315,7 +316,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, TasksJsonContext.Default.InputRequest); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpExtensions.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/McpExtensions.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpExtensions.cs diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs similarity index 94% rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs index 87b857470..4d703c930 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs @@ -31,8 +31,7 @@ public class ResultOrCreatedTask where TResult : Result /// The standard result returned by the server. public ResultOrCreatedTask(TResult result) { - Throw.IfNull(result); - _result = result; + _result = result ?? throw new ArgumentNullException(nameof(result)); } /// @@ -41,8 +40,7 @@ public ResultOrCreatedTask(TResult result) /// The task creation result returned by the server. public ResultOrCreatedTask(CreateTaskResult taskCreated) { - Throw.IfNull(taskCreated); - _taskCreated = taskCreated; + _taskCreated = taskCreated ?? throw new ArgumentNullException(nameof(taskCreated)); } /// diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 4e859a22c..13f96e058 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; @@ -145,7 +146,7 @@ internal sealed class Converter : JsonConverter pollIntervalMs = reader.GetInt64(); break; case "_meta": - meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo()); + meta = JsonSerializer.Deserialize(ref reader, TasksJsonContext.Default.JsonObject); break; case "result": result = JsonElement.ParseValue(ref reader); @@ -171,7 +172,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, TasksJsonContext.Default.InputRequest) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -261,7 +262,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v if (value.Meta is not null) { writer.WritePropertyName("_meta"); - JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo()); + JsonSerializer.Serialize(writer, value.Meta, TasksJsonContext.Default.JsonObject); } writer.WriteString("taskId", value.TaskId); @@ -311,7 +312,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, TasksJsonContext.Default.InputRequest); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs similarity index 100% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs similarity index 100% rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs similarity index 100% rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs similarity index 100% rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpServerTasksExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpServerTasksExtensions.cs new file mode 100644 index 000000000..84151afb6 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpServerTasksExtensions.cs @@ -0,0 +1,386 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides extension methods that add MCP Tasks extension (SEP-2663) support to an MCP server. +/// +/// +/// The Tasks extension lets a server offload long-running tool executions to the background and lets a +/// client poll for completion via tasks/get, supply input via tasks/update, and cancel via +/// tasks/cancel. State is held in an . +/// +public static class McpServerTasksExtensions +{ + /// + /// Enables the MCP Tasks extension on the supplied using the given task store. + /// + /// The server options to configure. + /// The task store that backs task state. + /// The instance, for chaining. + /// or is . + public static McpServerOptions WithTasks(this McpServerOptions options, IMcpTaskStore store) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (store is null) throw new ArgumentNullException(nameof(store)); + + // Advertise the tasks extension in server capabilities. + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); + + options.RawRequestHandlerConfigurators ??= new List>(); + options.RawRequestHandlerConfigurators.Add(registry => ConfigureTaskHandlers(registry, store)); + + return options; + } + + /// + /// Enables the MCP Tasks extension on the server built by using the given task store. + /// + /// The server builder. + /// The task store that backs task state. + /// The instance, for chaining. + /// or is . + public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (store is null) throw new ArgumentNullException(nameof(store)); + + builder.Services.AddSingleton>( + new TasksPostConfigureOptions(store)); + return builder; + } + + /// + /// Sends a task status notification (notifications/tasks/status) to the connected client. + /// + /// The server sending the notification. + /// The task status notification parameters to send. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous send operation. + /// or is . + public static Task SendTaskStatusNotificationAsync( + this McpServer server, + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { + if (server is null) throw new ArgumentNullException(nameof(server)); + if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams)); + + var paramsNode = JsonSerializer.SerializeToNode(notificationParams, TasksJsonContext.Default.TaskStatusNotificationParams); + return server.SendNotificationAsync( + TaskMethods.StatusNotification, + paramsNode, + McpJsonUtilities.DefaultOptions, + cancellationToken); + } + + private sealed class TasksPostConfigureOptions(IMcpTaskStore store) : IPostConfigureOptions + { + public void PostConfigure(string? name, McpServerOptions options) => options.WithTasks(store); + } + + private static void ConfigureTaskHandlers(IMcpServerRawHandlerRegistry registry, IMcpTaskStore store) + { + // Per-task cancellation sources shared between the tools/call task wrapper and the tasks/cancel handler. + var cancellationSources = new ConcurrentDictionary(); + + registry.SetHandler(TaskMethods.Get, async (request, cancellationToken) => + { + EnsureDraft(registry, request, TaskMethods.Get); + + var requestParams = Deserialize(request.Params, TasksJsonContext.Default.GetTaskRequestParams); + 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), TasksJsonContext.Default.GetTaskResult); + }); + + registry.SetHandler(TaskMethods.Update, async (request, cancellationToken) => + { + EnsureDraft(registry, request, TaskMethods.Update); + + var requestParams = Deserialize(request.Params, TasksJsonContext.Default.UpdateTaskRequestParams); + + // RequestParams deserializes inputResponses via an internal backing property that this assembly's + // source-generated context cannot access, so read the inputResponses node explicitly. + IDictionary inputResponses = new Dictionary(); + if (request.Params is JsonObject paramsObject && + paramsObject.TryGetPropertyValue("inputResponses", out var inputResponsesNode) && + inputResponsesNode is not null) + { + inputResponses = JsonSerializer.Deserialize(inputResponsesNode, TasksJsonContext.Default.IDictionaryStringInputResponse) + ?? inputResponses; + } + + await store.ResolveInputRequestsAsync(requestParams.TaskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToNode(new UpdateTaskResult(), TasksJsonContext.Default.UpdateTaskResult); + }); + + registry.SetHandler(TaskMethods.Cancel, async (request, cancellationToken) => + { + EnsureDraft(registry, request, TaskMethods.Cancel); + + var requestParams = Deserialize(request.Params, TasksJsonContext.Default.CancelTaskRequestParams); + + // 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 terminal tasks. + await store.SetCancelledAsync(requestParams.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. + if (cancellationSources.TryRemove(requestParams.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return JsonSerializer.SerializeToNode(new CancelTaskResult(), TasksJsonContext.Default.CancelTaskResult); + }); + + // Wrap tools/call so that when the client opts in (and the session negotiated draft), the tool + // execution is offloaded to the background via the store. + registry.TryWrapHandler(RequestMethods.ToolsCall, inner => async (request, cancellationToken) => + { + var callToolParams = request.Params is null + ? null + : JsonSerializer.Deserialize(request.Params, TasksJsonContext.Default.CallToolRequestParams); + + // The SEP-2663 Tasks extension is draft-only. Only materialize a task when the request was + // negotiated under the draft revision AND the client opted in; otherwise run the inner handler + // and return the direct result. + if (registry.IsDraftProtocolRequest(request) && HasTaskExtensionOptIn(callToolParams?.Meta)) + { + var taskInfo = await store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; + + var cts = new CancellationTokenSource(); + cancellationSources[taskId] = cts; + + // Capture the token synchronously before Task.Run dispatches the work. The cancel handler + // may race with the background runner: whichever side wins TryRemove owns disposal. + var taskCancellationToken = cts.Token; + + _ = Task.Run(async () => + { + var previousInterceptor = McpServer.CurrentOutgoingRequestInterceptor; + McpServer.CurrentOutgoingRequestInterceptor = BuildTaskInterceptor(store, taskId); + try + { + var resultNode = await inner(request, taskCancellationToken).ConfigureAwait(false); + var resultElement = ToElement(resultNode); + await store.SetCompletedAsync(taskId, resultElement).ConfigureAwait(false); + } + catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) + { + await store.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. + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " + + "drive the input-request loop manually from within the task body.", + }; + await store.SetFailedAsync(taskId, ToElement(error, TasksJsonContext.Default.JsonRpcErrorDetail)).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. For any other exception type, redact the message and use InternalError. + 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." }; + await store.SetFailedAsync(taskId, ToElement(error, TasksJsonContext.Default.JsonRpcErrorDetail)).ConfigureAwait(false); + } + finally + { + McpServer.CurrentOutgoingRequestInterceptor = previousInterceptor; + + // Only the side that wins TryRemove disposes cts, preventing a double-dispose race + // with the default tasks/cancel handler. + if (cancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } + }, CancellationToken.None); + + return JsonSerializer.SerializeToNode(ToCreateTaskResult(taskInfo), TasksJsonContext.Default.CreateTaskResult); + } + + return await inner(request, cancellationToken).ConfigureAwait(false); + }); + } + + private static McpOutgoingRequestInterceptor BuildTaskInterceptor(IMcpTaskStore store, string taskId) => + async (method, paramsNode, cancellationToken) => + { + var requestId = Guid.NewGuid().ToString("N"); + JsonElement? paramsElement = paramsNode is null + ? null + : JsonSerializer.SerializeToElement(paramsNode, TasksJsonContext.Default.JsonNode); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsElement, + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void Handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += Handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + +#if NET + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); +#else + InputResponse response; + using (cancellationToken.Register(static s => ((TaskCompletionSource)s!).TrySetCanceled(), tcs)) + { + response = await tcs.Task.ConfigureAwait(false); + } +#endif + + return response.RawValue.ValueKind == JsonValueKind.Undefined + ? null + : JsonNode.Parse(response.RawValue.GetRawText()); + } + finally + { + store.InputResponseReceived -= Handler; + } + }; + + private static bool HasTaskExtensionOptIn(JsonObject? meta) => + meta is not null && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && + exts.ContainsKey(McpExtensions.Tasks); + + private static void EnsureDraft(IMcpServerRawHandlerRegistry registry, JsonRpcRequest request, string method) + { + // The tasks/* methods do not exist before the draft revision (SEP-2663). Reject them with + // MethodNotFound when the request was negotiated under a legacy protocol version. + if (!registry.IsDraftProtocolRequest(request)) + { + throw new McpProtocolException( + $"The method '{method}' requires the draft protocol revision.", + McpErrorCode.MethodNotFound); + } + } + + private static T Deserialize(JsonNode? node, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) => + (node is null ? default : JsonSerializer.Deserialize(node, typeInfo)) + ?? throw new McpProtocolException("Invalid or missing request parameters.", McpErrorCode.InvalidParams); + + private static JsonElement ToElement(JsonNode? node) => + node is null + ? default + : JsonSerializer.SerializeToElement(node, TasksJsonContext.Default.JsonNode); + + private static JsonElement ToElement(T value, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) => + JsonSerializer.SerializeToElement(value, typeInfo); + + internal 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, + ResultType = "task", + }; + + internal 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}"), + }; +} diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs similarity index 100% rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/TasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/TasksJsonContext.cs new file mode 100644 index 000000000..b9ca27f5e --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/TasksJsonContext.cs @@ -0,0 +1,44 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides source-generated JSON serialization metadata for MCP Tasks extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CreateTaskResult))] +[JsonSerializable(typeof(GetTaskRequestParams))] +[JsonSerializable(typeof(GetTaskResult))] +[JsonSerializable(typeof(WorkingTaskResult))] +[JsonSerializable(typeof(CompletedTaskResult))] +[JsonSerializable(typeof(FailedTaskResult))] +[JsonSerializable(typeof(CancelledTaskResult))] +[JsonSerializable(typeof(InputRequiredTaskResult))] +[JsonSerializable(typeof(UpdateTaskRequestParams))] +[JsonSerializable(typeof(UpdateTaskResult))] +[JsonSerializable(typeof(CancelTaskRequestParams))] +[JsonSerializable(typeof(CancelTaskResult))] +[JsonSerializable(typeof(TaskStatusNotificationParams))] +[JsonSerializable(typeof(WorkingTaskNotificationParams))] +[JsonSerializable(typeof(CompletedTaskNotificationParams))] +[JsonSerializable(typeof(FailedTaskNotificationParams))] +[JsonSerializable(typeof(CancelledTaskNotificationParams))] +[JsonSerializable(typeof(InputRequiredTaskNotificationParams))] +[JsonSerializable(typeof(CallToolRequestParams))] +[JsonSerializable(typeof(CallToolResult))] +[JsonSerializable(typeof(InputRequest))] +[JsonSerializable(typeof(InputResponse))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(JsonObject))] +[JsonSerializable(typeof(JsonNode))] +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonRpcErrorDetail))] +internal sealed partial class TasksJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/TaskMethods.cs b/src/ModelContextProtocol.Extensions.Tasks/TaskMethods.cs new file mode 100644 index 000000000..3c3f47774 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/TaskMethods.cs @@ -0,0 +1,28 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides constants for the JSON-RPC methods defined by the MCP Tasks extension (SEP-2663). +/// +public static class TaskMethods +{ + /// The tasks/get request method, used to retrieve the current state of a task. + public const string Get = "tasks/get"; + + /// The tasks/update request method, used to provide input responses to a task. + public const string Update = "tasks/update"; + + /// The tasks/cancel request method, used to request cancellation of a task. + public const string Cancel = "tasks/cancel"; + + /// The notifications/tasks/status notification method, used to push task status updates. + public const string StatusNotification = "notifications/tasks/status"; +} + +/// +/// Provides constants for the _meta keys defined by the MCP Tasks extension (SEP-2663). +/// +public static class TaskMetaKeys +{ + /// The _meta key carrying the related task identifier. + public const string RelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index b4a79a31e..fa7670f03 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -5,6 +5,11 @@ True $(NoWarn);MCPEXP001 + + $(NoWarn);MCPEXP002 + + $(NoWarn);MCPEXP004 $(NoWarn);MCP9004