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