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/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md
index 6750443df..41d357144 100644
--- a/docs/concepts/stateless/stateless.md
+++ b/docs/concepts/stateless/stateless.md
@@ -590,7 +590,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ
## Tasks and session modes
-[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes.
+[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (enabled via `WithTasks`), and behavior differs between session modes.
### Stateless mode
@@ -600,9 +600,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses
### Stateful mode
-In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another.
+In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation: `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another.
-Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance.
+Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance.
### Task cancellation vs request cancellation
@@ -654,7 +654,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira
### With tasks (experimental)
-[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**.
+[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately, so the POST response completes **before the handler starts its real work**.
This means:
diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md
index 16e3a6dc2..5342946bc 100644
--- a/docs/concepts/tasks/tasks.md
+++ b/docs/concepts/tasks/tasks.md
@@ -18,7 +18,7 @@ and the client polls for status, optionally exchanging additional input along th
A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks`
extension key in the request's `_meta`. When that opt-in is present, the server **may** respond
-with a instead of the standard result
+with a instead of the standard result
(e.g., ). The client then polls `tasks/get`
until the task reaches a terminal state.
@@ -39,36 +39,35 @@ the extension opt-in. The SDK enforces this on the server side.
└──→ Failed (terminal — JSON-RPC errors only)
```
- wire values are serialized in snake_case:
+ wire values are serialized in snake_case:
`working`, `input_required`, `completed`, `cancelled`, `failed`.
The discriminator field
-on the response payload is `"task"` for
+on the response payload is `"task"` for
and `"complete"` for ordinary results.
### Server configuration
#### Using the task store
-The easiest way to enable tasks is to set an
-on .
-The SDK ships for development and tests:
+Tasks support lives in the `ModelContextProtocol.Extensions.Tasks` package. The easiest way to
+enable tasks is to call
+on the server builder, passing an .
+The SDK ships for development and tests:
```csharp
#pragma warning disable MCPEXP001
-builder.Services.AddMcpServer(options =>
-{
- options.TaskStore = new InMemoryMcpTaskStore();
-})
-.WithTools();
+using ModelContextProtocol.Extensions.Tasks;
+
+builder.Services.AddMcpServer()
+ .WithTools()
+ .WithTasks(new InMemoryMcpTaskStore());
```
-When a `TaskStore` is configured the SDK automatically:
+When tasks are enabled with `WithTasks` the SDK automatically:
-- Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit
- handlers in still take precedence
- for any slot they fill.
+- Wires the `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store.
- Advertises the `io.modelcontextprotocol/tasks` extension in
.
- Wraps each `[McpServerTool]` invocation so that, when the client opts in to the extension,
@@ -81,40 +80,21 @@ When a `TaskStore` is configured the SDK automatically:
`tasks/cancel`, so cancellation propagates cooperatively.
For production scenarios that need durability, session isolation, multi-process routing, or
-TTL-based cleanup, implement yourself
+TTL-based cleanup, implement yourself
(see [Implementing a custom task store](#implementing-a-custom-task-store) below).
-#### Custom task handlers
-
-For full control without a store, set the handlers directly. Each handler is an
- that receives an
- with typed parameters:
-
-```csharp
-options.Handlers.GetTaskHandler = (context, ct) =>
-{
- var taskId = context.Params!.TaskId;
- // … look up state and return one of the GetTaskResult subtypes.
- return new ValueTask(new WorkingTaskResult { TaskId = taskId, /* … */ });
-};
-
-options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask */;
-options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask */;
-```
-
-> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting
-> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get`
-> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured
-> deployments fail loudly instead of shipping unpollable tasks.
-
#### Returning a task from a tool handler
-
-returns , so each invocation can choose
-between an immediate result and a background task:
+For full control without the store's auto-wrapping, set
+.
+It returns a , so each invocation can choose
+between an immediate result and an alternate result such as a
+:
```csharp
-options.Handlers.CallToolWithTaskHandler = async (context, ct) =>
+using ModelContextProtocol.Extensions.Tasks;
+
+options.Handlers.CallToolWithAlternateHandler = async (context, ct) =>
{
if (ShouldRunInline(context.Params!))
{
@@ -122,7 +102,7 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) =>
}
var taskId = await StartBackgroundWorkAsync(context.Params!, ct);
- return new CreateTaskResult
+ var created = new CreateTaskResult
{
TaskId = taskId,
Status = McpTaskStatus.Working,
@@ -130,22 +110,32 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) =>
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 1000,
};
+
+ return new ResultOrAlternate(created, McpTasksJsonContext.Default.CreateTaskResult);
};
```
+> This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the
+> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also
+> returns an alternate. Use one mechanism or the other. When you return a task this way you are also
+> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides
+> automatically.
+
>
-> and
+> and
> are mutually exclusive. Setting one while the other is already non-null throws
> `InvalidOperationException` at the property setter.
#### Task scope for server-initiated requests
-When you start background work from a custom
-(rather than the SDK's auto-wrapping), use
+When you start background work from a custom
+(rather than the SDK's auto-wrapping), use
to route elicitation, sampling, and `roots/list` calls through the task store as input requests
instead of direct JSON-RPC messages:
```csharp
+using ModelContextProtocol.Extensions.Tasks;
+
using (server.CreateMcpTaskScope(taskId, taskStore))
{
// ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as
@@ -156,13 +146,13 @@ using (server.CreateMcpTaskScope(taskId, taskStore))
`CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on
`Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via
-`McpServerOptions.TaskStore`, so this API is only needed for custom handlers.
+`WithTasks`, so this API is only needed for custom handlers.
### Client usage
#### Automatic polling
-
+
handles the full task lifecycle automatically:
- Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`.
@@ -176,21 +166,25 @@ handles the full task lifecycle automatically:
or throws on `Failed`/`Cancelled`.
```csharp
-var result = await client.CallToolAsync(
+using ModelContextProtocol.Extensions.Tasks;
+
+var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments },
- cancellationToken);
+ cancellationToken: cancellationToken);
```
#### Manual control
-Use to receive the raw
- without auto-polling, then drive the
-lifecycle yourself using ,
-, and
-:
+Use to receive the raw
+ without auto-polling, then drive the
+lifecycle yourself using ,
+, and
+:
```csharp
-var raw = await client.CallToolRawAsync(requestParams, cancellationToken);
+using ModelContextProtocol.Extensions.Tasks;
+
+var raw = await client.CallToolAsTaskAsync(requestParams, cancellationToken);
if (raw.IsTask)
{
var taskId = raw.TaskCreated!.TaskId;
@@ -206,17 +200,17 @@ if (raw.IsTask)
#### Stuck-task detector
-`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in
- across many consecutive polls
+`CallToolWithPollingAsync` includes a safety net for misbehaving servers: if the task stays in
+ across many consecutive polls
without exposing any new input request keys (i.e. every previously requested input has already
been resolved by the client and yet the server keeps returning `InputRequired`), the client
gives up, issues a best-effort `tasks/cancel`, and throws
. This guards against a server that never transitions
out of `InputRequired` and prevents an unbounded poll loop.
-The threshold defaults to `60` consecutive stuck polls and is configurable via
-. The effective
-wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option
+The threshold defaults to `60` consecutive stuck polls and is configurable via the
+`maxConsecutiveStuckPolls` parameter on `CallToolWithPollingAsync`. The effective
+wall-clock timeout is roughly `maxConsecutiveStuckPolls * pollIntervalMs`, so tune the value
with the server-side poll cadence in mind. Setting it too low risks false positives for servers
that are slow to surface follow-up input requests; setting it too high can mask misbehaving
servers.
@@ -224,11 +218,11 @@ servers.
### Input requests (multi-round-trip)
When a task needs additional input from the client, the server transitions it to
- and returns the outstanding
-requests in . Each
+ and returns the outstanding
+requests in . Each
entry is an arbitrary key paired with a `{ method, params }` envelope representing an
equivalent standalone server-to-client request. The client provides answers via
-, keyed by the same identifiers.
+, keyed by the same identifiers.
Supported input request methods:
@@ -241,34 +235,34 @@ Per SEP-2663:
- Each input request key **must** be unique over the lifetime of the task.
- Clients **should** deduplicate keys across polls so a request is only presented to the user
- or model once. `CallToolAsync` does this automatically.
+ or model once. `CallToolWithPollingAsync` does this automatically.
- Servers **should** ignore `inputResponses` entries whose key does not currently correspond to
an outstanding request, including responses for terminal-state tasks.
- follows this rule.
+ follows this rule.
### Implementing a custom task store
-Implement for production scenarios. Key
+Implement for production scenarios. Key
requirements drawn from the SEP and the SDK contract:
1. **Thread safety** — every method may be called concurrently.
2. **Idempotent terminal transitions** —
- ,
- , and
- must be no-ops on a task
+ ,
+ , and
+ must be no-ops on a task
that is already in a terminal state so a late cancellation cannot overwrite a result.
3. **`InputResponseReceived` event** — after persisting an input response inside
- , raise
-
+ , raise
+
for each resolved entry. This is the only mechanism that wakes a pending
`server.ElicitAsync`/`server.SampleAsync` call waiting inside a task scope. In distributed
deployments where a different server instance receives the `tasks/update`, the event must
be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom
transport).
4. **Strong-consistency on `CreateTaskAsync`** —
- must not return until the
+ must not return until the
task is durably persisted, so that a subsequent
- with the returned task ID
+ with the returned task ID
resolves immediately — even from a different process or node. Stores backed by
eventually-consistent storage must wait for the write to become visible (quorum
acknowledgement, write-through, etc.) before returning. Required by SEP-2663 §306.
@@ -310,12 +304,12 @@ public sealed class MyTaskStore : IMcpTaskStore
### Status semantics
- is the terminal status whenever the
+ is the terminal status whenever the
underlying request produced its standard result, *including a
with `IsError = true`*. Per SEP-2663,
tool-level error results are not promoted to `Failed`.
- is reserved for JSON-RPC protocol-level
+ is reserved for JSON-RPC protocol-level
errors during execution — for example, a malformed request, or an unhandled exception in a custom
handler that the SDK converts to a JSON-RPC error. Use
for
@@ -341,7 +335,7 @@ In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore`
#### Immutable store design
- uses immutable record snapshots with
+ uses immutable record snapshots with
compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are
exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state.
@@ -357,13 +351,13 @@ responsible for handling — or rejecting — the input requests surfaced throug
- **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on
polling exclusively.
-- **Lazy task creation**: when a tool runs through `TaskStore`, the store's
- is invoked eagerly before
+- **Lazy task creation**: when a tool runs through the task store, the store's
+ is invoked eagerly before
the inner handler runs, so tools that complete inline still incur a store write. There is
currently no built-in deferral.
- **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing
synchronously and then transition its remaining work to a background task. Use a custom
-
+
if you need that pattern.
- **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the
task channel when called from inside a task scope, but the client SDK does not currently
diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs
index fff353307..50ddf0d03 100644
--- a/samples/TasksExtension/Program.cs
+++ b/samples/TasksExtension/Program.cs
@@ -1,18 +1,17 @@
// Demonstrates the MCP tasks extension (SEP-2663):
//
-// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation
+// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation
// becomes a background task when the client opts in via the per-request _meta marker.
-// - The client invokes the same tool two ways:
-// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final
-// CallToolResult, just like a synchronous call.
-// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls,
-// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream
-// status updates rather than block on a single await.
+// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync.
//
// Both server and client are wired together in-process over an in-memory pipe so the sample
// is self-contained — no separate server process or HTTP transport required.
+#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004
+
+using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
@@ -21,28 +20,25 @@
Pipe clientToServerPipe = new(), serverToClientPipe = new();
-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" })],
- });
+var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 };
+
+var services = new ServiceCollection();
+services.AddMcpServer()
+ .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })])
+ .WithTasks(store);
+services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()));
+
+await using var serviceProvider = services.BuildServiceProvider();
+var server = serviceProvider.GetRequiredService();
_ = 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(
- new CallToolRequestParams { Name = "run-report" });
-Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}");
-Console.WriteLine();
+ new StreamClientTransport(
+ serverInput: clientToServerPipe.Writer.AsStream(),
+ serverOutput: serverToClientPipe.Reader.AsStream()));
-Console.WriteLine("=== CallToolRawAsync (manual poll) ===");
-var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" });
+Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ===");
+var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" });
if (!raw.IsTask)
{
// Either the server doesn't advertise the tasks extension or it chose to run the call
@@ -88,9 +84,6 @@
continue;
case InputRequiredTaskResult inputRequired:
- // The auto-poll path (CallToolAsync above) routes these through the registered
- // ElicitationHandler/SamplingHandler automatically. The manual path needs to call
- // UpdateTaskAsync with responses for each outstanding key.
Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))");
continue;
}
diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj
index 2f66badd7..fe01a8440 100644
--- a/samples/TasksExtension/TasksExtension.csproj
+++ b/samples/TasksExtension/TasksExtension.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index 9ecfe1c12..a96ba5ae0 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -944,25 +944,17 @@ public Task UnsubscribeFromResourceAsync(
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken).AsTask();
}
-
///
/// Invokes a tool on the server.
///
- /// The name of the tool to call on the server.
+ /// The name of the tool to invoke.
/// An optional dictionary of arguments to pass to the tool.
- /// An optional progress reporter for server notifications.
+ /// An optional progress handler for tracking operation progress.
/// Optional request options including metadata, serialization settings, and progress tracking.
/// The to monitor for cancellation requests. The default is .
- /// The from the tool execution.
+ /// The result of the tool invocation.
/// 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,
@@ -1033,221 +1025,19 @@ 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);
}
-
///
/// Sets the logging level for the server to control which log messages are sent to the client.
///
@@ -1302,111 +1092,6 @@ public Task SetLoggingLevelAsync(
McpJsonUtilities.JsonContext.Default.EmptyResult,
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)
{
@@ -1425,43 +1110,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 da85e9792..d683172da 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 by dispatching
/// each request to the appropriate registered handler.
///
///
@@ -82,16 +82,9 @@ 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(
+ 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; }
-
///
/// Inspects a received cacheable result (tools/list, prompts/list, resources/list,
/// resources/templates/list, or resources/read) so derived clients can emit diagnostics.
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index 39f6579e3..882f97bb2 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -179,10 +179,9 @@ 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..510934cd8 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
@@ -147,41 +147,4 @@ public McpClientHandlers Handlers
}
}
- ///
- /// 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/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
index e686b454f..b2421e854 100644
--- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
+++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml
@@ -57,6 +57,13 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.CreateTaskResult
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability
@@ -71,6 +78,20 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskRequestParams
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskResult
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ListMcpTasksCapability
@@ -113,6 +134,13 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.McpTaskStatus
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams
@@ -211,6 +239,13 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.CreateTaskResult
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability
@@ -225,6 +260,20 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskRequestParams
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskResult
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ListMcpTasksCapability
@@ -267,6 +316,13 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.McpTaskStatus
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams
@@ -365,6 +421,13 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.CreateTaskResult
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability
@@ -379,6 +442,20 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskRequestParams
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskResult
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ListMcpTasksCapability
@@ -421,6 +498,13 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.McpTaskStatus
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams
@@ -519,6 +603,13 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.CreateTaskResult
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability
@@ -533,6 +624,20 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskRequestParams
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0001
+ T:ModelContextProtocol.Protocol.GetTaskResult
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.ListMcpTasksCapability
@@ -575,6 +680,13 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0001
+ T:ModelContextProtocol.Protocol.McpTaskStatus
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0001
T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams
@@ -624,6 +736,27 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksGet
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
F:ModelContextProtocol.Protocol.RequestMethods.TasksList
@@ -785,20 +918,6 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask)
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task
@@ -813,34 +932,6 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.#ctor
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan})
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus)
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks
@@ -960,6 +1051,13 @@
lib/net10.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore)
+ lib/net10.0/ModelContextProtocol.Core.dll
+ lib/net10.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport
@@ -995,6 +1093,27 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksGet
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
F:ModelContextProtocol.Protocol.RequestMethods.TasksList
@@ -1156,20 +1275,6 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask)
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task
@@ -1184,34 +1289,6 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.#ctor
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan})
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus)
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks
@@ -1331,6 +1408,13 @@
lib/net8.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore)
+ lib/net8.0/ModelContextProtocol.Core.dll
+ lib/net8.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport
@@ -1366,6 +1450,27 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksGet
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
F:ModelContextProtocol.Protocol.RequestMethods.TasksList
@@ -1527,20 +1632,6 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask)
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task
@@ -1555,34 +1646,6 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.#ctor
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan})
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus)
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks
@@ -1702,6 +1765,13 @@
lib/net9.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore)
+ lib/net9.0/ModelContextProtocol.Core.dll
+ lib/net9.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport
@@ -1737,6 +1807,27 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
+
+ CP0002
+ F:ModelContextProtocol.Protocol.RequestMethods.TasksGet
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
F:ModelContextProtocol.Protocol.RequestMethods.TasksList
@@ -1898,20 +1989,6 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask)
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task
@@ -1926,34 +2003,6 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.#ctor
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan})
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0002
- M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus)
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
CP0002
M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks
@@ -2073,6 +2122,13 @@
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
+
+ CP0002
+ M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore)
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ lib/netstandard2.0/ModelContextProtocol.Core.dll
+ true
+
CP0002
M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport
@@ -2102,57 +2158,29 @@
true
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled
+ CP0005
+ M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken)
lib/net10.0/ModelContextProtocol.Core.dll
lib/net10.0/ModelContextProtocol.Core.dll
true
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Failed
- lib/net10.0/ModelContextProtocol.Core.dll
- lib/net10.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled
- lib/net8.0/ModelContextProtocol.Core.dll
- lib/net8.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Failed
+ CP0005
+ M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken)
lib/net8.0/ModelContextProtocol.Core.dll
lib/net8.0/ModelContextProtocol.Core.dll
true
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled
- lib/net9.0/ModelContextProtocol.Core.dll
- lib/net9.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Failed
+ CP0005
+ M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken)
lib/net9.0/ModelContextProtocol.Core.dll
lib/net9.0/ModelContextProtocol.Core.dll
true
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- lib/netstandard2.0/ModelContextProtocol.Core.dll
- true
-
-
- CP0011
- F:ModelContextProtocol.Protocol.McpTaskStatus.Failed
+ CP0005
+ M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken)
lib/netstandard2.0/ModelContextProtocol.Core.dll
lib/netstandard2.0/ModelContextProtocol.Core.dll
true
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index da1b898dd..b193bb8de 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -54,7 +54,13 @@ private static JsonSerializerOptions CreateDefaultOptions()
return options;
}
- internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) =>
+ ///
+ /// Gets the resolved for from the specified options.
+ ///
+ /// The type whose serialization metadata should be resolved.
+ /// The serializer options providing the type-info resolver chain.
+ /// The resolved .
+ public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) =>
(JsonTypeInfo)options.GetTypeInfo(typeof(T));
internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8);
@@ -120,12 +126,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 +174,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/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs
deleted file mode 100644
index a41e4a576..000000000
--- a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ModelContextProtocol.Protocol;
-
-///
-/// Provides constants for well-known MCP extension identifiers.
-///
-public static class McpExtensions
-{
- ///
- /// The extension identifier for the MCP Tasks extension.
- ///
- ///
- /// When included in client per-request capabilities, indicates the client can handle
- /// in lieu of a standard result.
- /// See the SEP-2663
- /// specification for details.
- ///
- public const string Tasks = "io.modelcontextprotocol/tasks";
-}
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..b13a746cd 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()
+ /// Initializes the base notification parameter type.
+ 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 37872ec5b..04c3d82ef 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()
+ /// Initializes the base request parameter type.
+ protected RequestParams()
{
}
diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs
index 15eb6fa46..e64be9728 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()
+ /// Initializes the base result type.
+ protected Result()
{
}
@@ -28,10 +28,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.
+ /// Other values discriminate alternate result subtypes so callers can choose the appropriate
+ /// concrete payload to deserialize.
///
///
/// Defaults to , which is equivalent to "complete".
diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs
new file mode 100644
index 000000000..e2e1f112e
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Protocol;
+
+///
+/// Represents the result of a request that may return either the standard result or an alternate
+/// subtype for scenarios like asynchronous task execution.
+///
+/// The standard result type for the request (e.g., ).
+///
+///
+/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate
+/// that the server returned an alternate result instead of the normal one. The alternate carries its
+/// own so the transport layer can serialize it without compile-time knowledge
+/// of the concrete type.
+///
+///
+/// Use to determine which variant was returned, then access either
+/// for the immediate result or for the alternate.
+///
+///
+public class ResultOrAlternate where TResult : Result
+{
+ private readonly TResult? _result;
+ private readonly Result? _alternate;
+ private readonly JsonTypeInfo? _alternateTypeInfo;
+
+ ///
+ /// Initializes a new instance of with an immediate result.
+ ///
+ /// The standard result returned by the server.
+ public ResultOrAlternate(TResult result)
+ {
+ Throw.IfNull(result);
+ _result = result;
+ }
+
+ ///
+ /// Initializes a new instance of with an alternate result.
+ ///
+ /// The alternate result.
+ /// The used to serialize the alternate result.
+ public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo)
+ {
+ Throw.IfNull(alternate);
+ Throw.IfNull(alternateTypeInfo);
+ _alternate = alternate;
+ _alternateTypeInfo = alternateTypeInfo;
+ }
+
+ ///
+ /// Gets a value indicating whether the server returned an alternate result instead of the standard result.
+ ///
+ public bool IsAlternate => _alternate is not null;
+
+ ///
+ /// Gets the immediate result, or if the server returned an alternate.
+ ///
+ public TResult? Result => _result;
+
+ ///
+ /// Gets the alternate result, or if the server returned the standard result.
+ ///
+ public Result? Alternate => _alternate;
+
+ ///
+ /// Gets the for serializing the alternate result, or
+ /// if the server returned the standard result.
+ ///
+ public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo;
+
+ ///
+ /// Implicitly converts a to a
+ /// wrapping the immediate result.
+ ///
+ /// The result to wrap.
+ public static implicit operator ResultOrAlternate(TResult result) => new(result);
+}
diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs
index f15ce316c..ff167cc7d 100644
--- a/src/ModelContextProtocol.Core/RequestHandlers.cs
+++ b/src/ModelContextProtocol.Core/RequestHandlers.cs
@@ -47,44 +47,29 @@ public void Set(
}
///
- /// Registers a handler that may return either a standard result or a
- /// for task-augmented execution.
+ /// Registers a handler that may return either a standard result or an alternate
+ /// subtype for scenarios like task-augmented execution.
///
- public void SetTaskAugmented(
+ public void SetWithAlternate(
string method,
- Func>> handler,
+ Func>> handler,
JsonTypeInfo requestTypeInfo,
- JsonTypeInfo responseTypeInfo,
- JsonTypeInfo taskResultTypeInfo)
+ JsonTypeInfo responseTypeInfo)
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)
+ if (augmented.IsAlternate)
{
- // 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.Alternate!, augmented.AlternateTypeInfo!);
}
return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo);
diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
index 9d9774e8b..a82baf598 100644
--- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
+++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
@@ -42,8 +42,10 @@ public IList> ListTool
/// 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.
+ /// Cannot be used together with . If both are non-empty at configuration time,
+ /// an will be thrown. This can happen indirectly when combining features that
+ /// register different tool-call filter styles, such as authorization filters (which use this collection) and the
+ /// tasks extension (which uses ).
///
///
public IList> CallToolFilters
@@ -57,21 +59,23 @@ public IList> CallToolFi
}
///
- /// Gets or sets the filters for the call-tool handler pipeline with task support.
+ /// Gets or sets the filters for the call-tool handler pipeline with alternate result 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.
+ /// These filters wrap the alternate-result call-tool handler whose return type is
+ /// . Use these filters when the server's tool pipeline
+ /// supports returning either an immediate or an alternate
+ /// subtype for asynchronous execution.
///
///
/// Cannot be used together with . If both are non-empty at configuration time,
- /// an will be thrown.
+ /// an will be thrown. This can happen indirectly when combining features that
+ /// register different tool-call filter styles, such as the tasks extension (which uses this collection) and
+ /// authorization filters (which use ).
///
///
- public IList>> CallToolWithTaskFilters
+ public IList>> CallToolWithAlternateFilters
{
get => field ??= [];
set
diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
index 4f739c28a..6e3c94f6a 100644
--- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
@@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession
private static Dictionary>? s_elicitAllowedProperties = null;
+ ///
+ /// Ambient interceptor that, when installed, redirects server-initiated requests
+ /// (,
+ /// , and
+ /// ) away from the
+ /// transport. The interceptor receives the request method and pre-serialized parameters and
+ /// returns the serialized result. Used by extensions (such as the Tasks extension) to surface
+ /// these requests through an alternate channel during background execution.
+ ///
+ private static readonly AsyncLocal>?> s_outgoingRequestInterceptor = new();
+
+ ///
+ /// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any.
+ ///
+ internal static Func>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value;
+
+ ///
+ /// Installs an interceptor that redirects server-initiated requests for the duration of the
+ /// returned scope on the current asynchronous execution context.
+ ///
+ ///
+ /// The interceptor invoked for each outgoing request. It receives the request method, the
+ /// pre-serialized request parameters (or ), and a cancellation token, and
+ /// returns the serialized result (or to indicate no result).
+ ///
+ /// An that restores the previous interceptor when disposed.
+ /// is .
+ ///
+ /// While an interceptor is installed, the redirected methods skip their client-capability checks,
+ /// because the alternate channel is responsible for delivering the request to the client.
+ ///
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ public IDisposable InterceptOutgoingRequests(Func> interceptor)
+ {
+ Throw.IfNull(interceptor);
+
+ var previous = s_outgoingRequestInterceptor.Value;
+ s_outgoingRequestInterceptor.Value = interceptor;
+ return new OutgoingRequestInterceptorScope(previous);
+ }
+
+ private sealed class OutgoingRequestInterceptorScope(Func>? previous) : IDisposable
+ {
+ public void Dispose() => s_outgoingRequestInterceptor.Value = previous;
+ }
+
///
/// Creates a new instance of an .
///
@@ -61,11 +107,6 @@ public static McpServer Create(
/// , which is always open for the duration of
/// the request, rather than relying on the optional standalone GET SSE stream.
///
- ///
- /// 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.
- ///
///
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
public ValueTask SampleAsync(
@@ -74,14 +115,13 @@ 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 installed (e.g., during background task execution),
+ // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are
+ // intentionally skipped because the interceptor's alternate channel is responsible for
+ // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
{
- return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams,
+ return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
cancellationToken);
@@ -282,14 +322,13 @@ 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 installed (e.g., during background task execution),
+ // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped
+ // because the interceptor's alternate channel is responsible for delivering the request to
+ // the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
{
- return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams,
+ return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams,
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
McpJsonUtilities.JsonContext.Default.ListRootsResult,
cancellationToken);
@@ -322,11 +361,6 @@ public ValueTask RequestRootsAsync(
/// , which is always open for the duration of
/// the request, rather than relying on the optional standalone GET SSE stream.
///
- ///
- /// 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.
- ///
///
public async ValueTask ElicitAsync(
ElicitRequestParams requestParams,
@@ -334,18 +368,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)
- {
- var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams,
- McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
- McpJsonUtilities.JsonContext.Default.ElicitResult,
- cancellationToken).ConfigureAwait(false);
- return taskResult ?? new ElicitResult { Action = "cancel" };
+ // If an outgoing-request interceptor is installed (e.g., during background task execution),
+ // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are
+ // intentionally skipped because the interceptor's alternate channel is responsible for
+ // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
+ {
+ var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams);
+ var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false);
+ return resultNode?.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult) ?? new ElicitResult { Action = "cancel" };
}
ThrowIfElicitationUnsupported(requestParams);
@@ -422,26 +453,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 +603,37 @@ 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.
- ///
- /// 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.
+ /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response.
///
///
- /// When executing inside a task scope, capability negotiation checks (such as
+ /// When an interceptor is installed, 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.
+ /// of this helper. The interceptor's alternate channel is the negotiated capability and is
+ /// responsible for delivering the request to the client or rejecting it.
///
- private async ValueTask SendRequestViaTaskAsync(
- McpTaskExecutionContext taskContext,
+ private async ValueTask SendRequestViaInterceptorAsync(
+ Func> 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
+ var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo);
+ var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false);
+ if (resultNode is null)
{
- 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);
- }
+ // A null result cannot be deserialized into a concrete TResponse (e.g. SampleAsync's
+ // CreateMessageResult or RequestRootsAsync's ListRootsResult). Returning default! here
+ // would hand callers a null response typed as non-null, deferring the failure to a
+ // confusing NullReferenceException at the use site. Fail fast with a clear message.
+ // Callers that can tolerate no result (such as ElicitAsync) do not route through this
+ // helper and handle null themselves.
+ throw new McpException($"The outgoing-request interceptor returned no result for the '{method}' request.");
}
- 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;
- }
+ return resultNode.Deserialize(responseTypeInfo)!;
}
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
index 4f9509b9d..37d2a3d73 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
@@ -42,19 +42,19 @@ 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.
+ /// Use instead if the tool may return an alternate result
+ /// for the caller to handle.
///
- /// is already set.
+ /// is already set.
public McpRequestHandler? CallToolHandler
{
get;
set
{
- if (value is not null && CallToolWithTaskHandler is not null)
+ if (value is not null && CallToolWithAlternateHandler is not null)
{
throw new InvalidOperationException(
- $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured.");
+ $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithAlternateHandler)} is already set. Only one call tool handler may be configured.");
}
field = value;
@@ -62,20 +62,19 @@ public McpRequestHandler? CallToolHandler
}
///
- /// Gets or sets the handler for requests with task support.
+ /// Gets or sets the handler for requests with alternate result 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.
+ /// a for immediate results or an alternate subtype.
///
///
/// Cannot be set if is already set.
///
///
/// is already set.
- public McpRequestHandler>? CallToolWithTaskHandler
+ public McpRequestHandler>? CallToolWithAlternateHandler
{
get;
set
@@ -83,7 +82,7 @@ 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..8ab83ca29 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();
@@ -95,8 +94,8 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
ConfigureCompletion(options);
ConfigureSubscriptions(options);
ConfigureExperimentalAndExtensions(options);
- ConfigureTasks(options);
ConfigureMrtr();
+ ConfigureCustomRequestHandlers(options);
// Register any notification handlers that were provided.
if (options.Handlers.NotificationHandlers is { } notificationHandlers)
@@ -312,13 +311,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
@@ -753,116 +745,47 @@ private void ConfigureCompletion(McpServerOptions options)
return result;
}
-
- private void ConfigureTasks(McpServerOptions options)
+ private void ConfigureExperimentalAndExtensions(McpServerOptions options)
{
- var getTaskHandler = options.Handlers.GetTaskHandler;
- var updateTaskHandler = options.Handlers.UpdateTaskHandler;
- var cancelTaskHandler = options.Handlers.CancelTaskHandler;
- var taskStore = options.TaskStore;
-
- // If a task store is provided, wire up handlers from it for any that aren't explicitly set.
- if (taskStore is not null)
- {
- getTaskHandler ??= async (request, cancellationToken) =>
- {
- var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
- return info is null
- ? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams)
- : ToGetTaskResult(info);
- };
-
- updateTaskHandler ??= async (request, cancellationToken) =>
- {
- var inputResponses = request.Params!.InputResponses ?? new Dictionary();
- 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();
- };
- }
+ ServerCapabilities.Experimental = options.Capabilities?.Experimental;
+ ServerCapabilities.Extensions = options.Capabilities?.Extensions;
+ }
- if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null)
+ private void ConfigureCustomRequestHandlers(McpServerOptions options)
+ {
+#pragma warning disable MCPEXP002
+ if (options.RequestHandlers is not { Count: > 0 } customHandlers)
{
return;
}
- getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
- updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
- cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
-
- // 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();
-
- SetHandler(
- RequestMethods.TasksGet,
- getTaskHandler,
- McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.GetTaskResult);
-
- SetHandler(
- RequestMethods.TasksUpdate,
- updateTaskHandler,
- McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.UpdateTaskResult);
-
- SetHandler(
- RequestMethods.TasksCancel,
- cancelTaskHandler,
- McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.CancelTaskResult);
- }
-
- ///
- /// 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) =>
+ foreach (var entry in customHandlers)
{
- if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
+ if (string.IsNullOrEmpty(entry.Method))
{
- throw new McpProtocolException(
- $"The method '{method}' requires a newer protocol revision that supports tasks; " +
- $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
- McpErrorCode.MethodNotFound);
+ throw new InvalidOperationException(
+ $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}.");
}
- return inner(request, cancellationToken);
- };
+ // Custom handlers are registered after all built-in handlers, so a method already present
+ // belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler.
+ // Silently overwriting it would bypass the built-in handler's filters and protocol gating,
+ // so reject the collision instead.
+ if (_requestHandlers.ContainsKey(entry.Method))
+ {
+ throw new InvalidOperationException(
+ $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} " +
+ $"uses the method '{entry.Method}', which is already handled by the server. Custom handlers cannot replace built-in methods or other custom handlers.");
+ }
- private void ConfigureExperimentalAndExtensions(McpServerOptions options)
+ SetRawHandler(entry.Method, entry.Handler);
+ }
+#pragma warning restore MCPEXP002
+ }
+
+ private void SetRawHandler(string method, Func> handler)
{
- ServerCapabilities.Experimental = options.Capabilities?.Experimental;
- ServerCapabilities.Extensions = options.Capabilities?.Extensions;
+ _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask();
}
private void ConfigureResources(McpServerOptions options)
@@ -1134,11 +1057,11 @@ private void ConfigureTools(McpServerOptions options)
{
var listToolsHandler = options.Handlers.ListToolsHandler;
var callToolHandler = options.Handlers.CallToolHandler;
- var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler;
+ var callToolWithAlternateHandler = options.Handlers.CallToolWithAlternateHandler;
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 && callToolWithAlternateHandler is null && tools is null &&
toolsCapability is null)
{
return;
@@ -1150,17 +1073,22 @@ private void ConfigureTools(McpServerOptions options)
var listChanged = toolsCapability?.ListChanged;
var callToolFilters = options.Filters.Request.CallToolFilters;
- var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters;
+ var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters;
- // 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;
+ // Validate: cannot mix non-alternate filters/handler with alternate filters/handler.
+ bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0;
+ bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0;
- if (hasNonTaskPath && hasTaskPath)
+ if (hasNonAlternatePath && hasAlternatePath)
{
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.");
+ $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
+ $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " +
+ $"These two styles cannot currently be composed on the same server. " +
+ $"This most commonly happens when combining features that register different tool-call filter styles, " +
+ $"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " +
+ $"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " +
+ $"Configure only one style, or avoid combining features that require different styles.");
}
// Handle tools provided via DI by augmenting the list handler.
@@ -1202,32 +1130,32 @@ 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)
+ // Build the unified alternate-result handler from one of the two paths.
+ if (hasAlternatePath)
{
- // Case 2: task filter + task handler
- callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
+ // Case 2: alternate filter + alternate handler
+ callToolWithAlternateHandler ??= (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) =>
+ var originalHandler = callToolWithAlternateHandler;
+ callToolWithAlternateHandler = (request, cancellationToken) =>
{
if (request.MatchedPrimitive is McpServerTool tool)
{
- return InvokeToolAsTask(tool, request, cancellationToken);
+ return InvokeToolWithAlternate(tool, request, cancellationToken);
}
return originalHandler(request, cancellationToken);
};
}
- callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools));
+ callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools));
}
else
{
- // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based
+ // Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools.
@@ -1247,114 +1175,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
- // Convert to task-based.
+ // Convert to alternate-based.
var finalCallToolHandler = callToolHandler;
- callToolWithTaskHandler = async (request, cancellationToken) =>
+ callToolWithAlternateHandler = async (request, cancellationToken) =>
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
}
-
- // If a task store is configured, wrap so that when the client signals task support
- // the tool execution is offloaded to the background via the store.
- if (options.TaskStore is { } taskStore)
- {
- var innerTaskHandler = callToolWithTaskHandler;
- callToolWithTaskHandler = async (request, cancellationToken) =>
- {
- // 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))
- {
- 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 await innerTaskHandler(request, cancellationToken).ConfigureAwait(false);
- };
- }
-
ServerCapabilities.Tools.ListChanged = listChanged;
SetHandler(
@@ -1363,94 +1188,13 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
McpJsonUtilities.JsonContext.Default.ListToolsResult);
- SetTaskAugmentedHandler(
+ SetWithAlternateHandler(
RequestMethods.ToolsCall,
- callToolWithTaskHandler,
+ callToolWithAlternateHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
- McpJsonUtilities.JsonContext.Default.CallToolResult,
- McpJsonUtilities.JsonContext.Default.CreateTaskResult);
+ McpJsonUtilities.JsonContext.Default.CallToolResult);
}
-
- 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(
+ private static async ValueTask> InvokeToolWithAlternate(
McpServerTool tool,
RequestContext request,
CancellationToken cancellationToken)
@@ -1501,7 +1245,7 @@ private McpRequestFilter BuildInitialCall
}
};
- private McpRequestFilter> BuildInitialTaskToolFilter(
+ private McpRequestFilter> BuildInitialAlternateToolFilter(
McpServerPrimitiveCollection? tools) => handler =>
async (request, cancellationToken) =>
{
@@ -1514,7 +1258,7 @@ private McpRequestFilter(
requestTypeInfo, responseTypeInfo);
}
- private void SetTaskAugmentedHandler(
+ private void SetWithAlternateHandler(
string method,
- McpRequestHandler> handler,
+ McpRequestHandler> handler,
JsonTypeInfo requestTypeInfo,
- JsonTypeInfo responseTypeInfo,
- JsonTypeInfo taskResultTypeInfo)
+ JsonTypeInfo responseTypeInfo)
where TResult : Result
{
- _requestHandlers.SetTaskAugmented(method,
+ _requestHandlers.SetWithAlternate(method,
(request, jsonRpcRequest, cancellationToken) =>
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
- requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
+ requestTypeInfo, responseTypeInfo);
}
private static McpRequestHandler BuildFilterPipeline(
@@ -1704,15 +1447,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)
@@ -1787,12 +1521,10 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
///
internal bool HasStatefulTransport() =>
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
-
///
/// Returns when the given request was negotiated under the 2026-07-28 or later protocol
/// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works
/// for requests over stateless HTTP) and falling back to the session-negotiated version.
- /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
///
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
index eb99913d5..ad3758151 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
-#pragma warning disable MCPEXP001
+#pragma warning disable MCPEXP001, MCPEXP002
namespace ModelContextProtocol.Server;
@@ -191,17 +191,18 @@ public McpServerFilters Filters
public int MaxSamplingOutputTokens { get; set; } = 1000;
///
- /// Gets or sets the task store for managing asynchronous task executions.
+ /// Gets or sets custom request handlers to register with the server.
///
///
///
- /// 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.
+ /// Each registers a raw JSON-RPC method handler that
+ /// bypasses the typed handler infrastructure. This enables extensions to register handlers
+ /// for methods not known to Core at compile time.
///
///
- /// If explicit task handlers are also set on , the explicit handlers take precedence.
+ /// Handlers registered here take precedence over built-in handlers for the same method.
///
///
- public IMcpTaskStore? TaskStore { get; set; }
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ public IList? RequestHandlers { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
new file mode 100644
index 000000000..be845f599
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
@@ -0,0 +1,35 @@
+using ModelContextProtocol.Protocol;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents a custom request handler that can be registered with the MCP server to handle
+/// arbitrary JSON-RPC methods.
+///
+///
+///
+/// Custom request handlers are registered via and
+/// are invoked when a JSON-RPC request with the matching is received.
+/// The handler receives the raw and returns a serialized
+/// response, giving extensions full control over request/response serialization.
+///
+///
+[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+public sealed class McpServerRequestHandler
+{
+ ///
+ /// Gets the JSON-RPC method name this handler responds to.
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// Gets the handler function that processes incoming requests for the specified method.
+ ///
+ ///
+ /// The handler receives the full and a ,
+ /// and returns a serialized response (or for void methods).
+ ///
+ public required Func> Handler { get; init; }
+}
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/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs
new file mode 100644
index 000000000..43dd0c2ee
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs
@@ -0,0 +1,370 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Extension methods for task-aware client operations.
+///
+public static class McpTasksClientExtensions
+{
+ ///
+ /// Calls a tool and returns either an immediate result or a created task.
+ ///
+ public static async ValueTask> CallToolAsTaskAsync(
+ this McpClient client,
+ CallToolRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ var paramsWithMeta = new CallToolRequestParams
+ {
+ Name = requestParams.Name,
+ Arguments = requestParams.Arguments,
+ Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
+ };
+
+ JsonRpcRequest jsonRpcRequest = new()
+ {
+ Method = RequestMethods.ToolsCall,
+ Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()),
+ };
+
+ JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
+
+ if (response.Result is JsonObject resultObj &&
+ resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
+ string.Equals(resultTypeNode?.GetValue(), "task", StringComparison.Ordinal))
+ {
+ var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult)
+ ?? throw new JsonException("Failed to deserialize CreateTaskResult from response.");
+ return new ResultOrCreatedTask(taskCreated);
+ }
+
+ var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo())
+ ?? throw new JsonException("Failed to deserialize CallToolResult from response.");
+ return new ResultOrCreatedTask(callToolResult);
+ }
+
+ ///
+ /// Calls a tool and, if needed, polls the created task to completion.
+ ///
+ public static async ValueTask CallToolWithPollingAsync(
+ this McpClient client,
+ CallToolRequestParams requestParams,
+ int maxConsecutiveStuckPolls = 60,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false);
+ if (!augmented.IsTask)
+ {
+ return augmented.Result!;
+ }
+
+ return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Retrieves a task by ID.
+ ///
+ public static ValueTask GetTaskAsync(
+ this McpClient client,
+ string taskId,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(taskId);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (taskId is null) throw new ArgumentNullException(nameof(taskId));
+#endif
+
+ return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken);
+ }
+
+ ///
+ /// Retrieves a task using explicit request parameters.
+ ///
+ public static ValueTask GetTaskAsync(
+ this McpClient client,
+ GetTaskRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ ThrowIfTasksNotSupported(client, nameof(GetTaskAsync));
+ return client.SendRequestAsync(
+ TasksProtocol.MethodTasksGet,
+ requestParams,
+ McpTasksJsonContext.Default.Options,
+ cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Updates a task with input responses.
+ ///
+ public static async ValueTask UpdateTaskAsync(
+ this McpClient client,
+ UpdateTaskRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync));
+
+ // Manually construct the JSON params because InputResponses is backed by an internal
+ // property in Core that the extension's source-gen context cannot access for serialization.
+ JsonObject paramsObj = new()
+ {
+ ["taskId"] = requestParams.TaskId,
+ };
+
+ if (requestParams.InputResponses is { Count: > 0 } inputResponses)
+ {
+ paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
+ inputResponses,
+ McpJsonUtilities.DefaultOptions.GetTypeInfo>());
+ }
+
+ JsonRpcRequest jsonRpcRequest = new()
+ {
+ Method = TasksProtocol.MethodTasksUpdate,
+ Params = paramsObj,
+ };
+
+ JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
+ return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult)
+ ?? new UpdateTaskResult();
+ }
+
+ ///
+ /// Requests task cancellation by ID.
+ ///
+ public static ValueTask CancelTaskAsync(
+ this McpClient client,
+ string taskId,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(taskId);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (taskId is null) throw new ArgumentNullException(nameof(taskId));
+#endif
+
+ return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken);
+ }
+
+ ///
+ /// Requests task cancellation using explicit request parameters.
+ ///
+ public static ValueTask CancelTaskAsync(
+ this McpClient client,
+ CancelTaskRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync));
+ return client.SendRequestAsync(
+ TasksProtocol.MethodTasksCancel,
+ requestParams,
+ McpTasksJsonContext.Default.Options,
+ cancellationToken: cancellationToken);
+ }
+
+ private static async ValueTask PollTaskToCompletionAsync(
+ McpClient client,
+ CreateTaskResult taskCreated,
+ int maxConsecutiveStuckPolls,
+ CancellationToken cancellationToken)
+ {
+ string taskId = taskCreated.TaskId;
+ long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000;
+ HashSet? resolvedRequestKeys = null;
+ bool isFirstPoll = true;
+ int consecutiveStuckPolls = 0;
+
+ while (true)
+ {
+ if (!isFirstPoll)
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false);
+ }
+
+ isFirstPoll = false;
+
+ var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false);
+ if (taskResult.PollIntervalMs is { } newInterval)
+ {
+ pollIntervalMs = newInterval;
+ }
+
+ switch (taskResult)
+ {
+ case CompletedTaskResult completed:
+ return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo())
+ ?? 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
+ {
+ try
+ {
+ await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ }
+
+ throw;
+ }
+
+ await UpdateTaskAsync(client, 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 CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch
+ {
+ }
+
+ 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}'.");
+ }
+ }
+ }
+
+ 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;
+ }
+
+ if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId))
+ {
+ extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject();
+ }
+
+ return meta;
+ }
+
+ private static bool IsJuly2026OrLaterProtocol(McpClient client) =>
+ McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion);
+
+ private static void ThrowIfTasksNotSupported(McpClient client, string operationName)
+ {
+ if (!IsJuly2026OrLaterProtocol(client))
+ {
+ throw new InvalidOperationException(
+ $"'{operationName}' requires a newer protocol revision that supports tasks " +
+ $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later). " +
+ $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'.");
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs
new file mode 100644
index 000000000..13c92ee14
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs
@@ -0,0 +1,32 @@
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+
+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))]
+public sealed partial class McpTasksJsonContext : JsonSerializerContext
+{
+}
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..e2b52f106
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj
@@ -0,0 +1,51 @@
+
+
+
+ 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
+
+ false
+
+
+
+ true
+
+
+
+
+ $(NoWarn);CS0436
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
similarity index 92%
rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
index ee458d064..f4d6ff69f 100644
--- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task.
diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
similarity index 90%
rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
index 4d066862b..c9d92a06e 100644
--- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/cancel request. This is an empty acknowledgement.
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
similarity index 96%
rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
index 6d9bac23c..9e262e5c0 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
@@ -1,8 +1,9 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result returned by a server when it creates a task in lieu of a standard result.
diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
similarity index 90%
rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
index 52b82d902..a865bf690 100644
--- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/get request to poll for task completion.
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..4d083d8a1 100644
--- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs
@@ -1,9 +1,9 @@
-using System.Diagnostics.CodeAnalysis;
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/get request, containing the full task state.
@@ -169,7 +169,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, McpJsonUtilities.DefaultOptions.GetTypeInfo())
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
inputRequests[requestKey] = inputRequest;
}
@@ -315,7 +315,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, McpJsonUtilities.DefaultOptions.GetTypeInfo());
}
}
writer.WriteEndObject();
diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
similarity index 94%
rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
index 3b705a947..90ee12d27 100644
--- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the status of an MCP task.
diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
similarity index 91%
rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
index 87b857470..1e9100c67 100644
--- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
@@ -1,4 +1,5 @@
-namespace ModelContextProtocol.Protocol;
+using ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a request that supports task-augmented execution, which may be either
@@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result
/// The standard result returned by the server.
public ResultOrCreatedTask(TResult result)
{
- Throw.IfNull(result);
+ if (result is null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
_result = result;
}
@@ -41,7 +45,10 @@ public ResultOrCreatedTask(TResult result)
/// The task creation result returned by the server.
public ResultOrCreatedTask(CreateTaskResult taskCreated)
{
- Throw.IfNull(taskCreated);
+ if (taskCreated is null)
+ {
+ throw new ArgumentNullException(nameof(taskCreated));
+ }
_taskCreated = 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..020896a1f 100644
--- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs
@@ -1,9 +1,9 @@
-using System.Diagnostics.CodeAnalysis;
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a notifications/tasks notification sent by the server
@@ -171,7 +171,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, McpJsonUtilities.DefaultOptions.GetTypeInfo())
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
inputRequests[requestKey] = inputRequest;
}
@@ -311,7 +311,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, McpJsonUtilities.DefaultOptions.GetTypeInfo());
}
}
writer.WriteEndObject();
@@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification
[JsonPropertyName("inputRequests")]
public IDictionary? InputRequests { get; set; }
}
-
diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
similarity index 93%
rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
index 07b45de15..aeeb2c79a 100644
--- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/update request to provide input responses
diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
similarity index 88%
rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
index 531039c23..b9f59f395 100644
--- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/update request. This is an empty acknowledgement.
diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
similarity index 99%
rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
index 337cb1946..6851f21ac 100644
--- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides an interface for storing and managing the lifecycle of MCP tasks.
diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
similarity index 73%
rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
index 762598226..d1014c110 100644
--- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
@@ -3,7 +3,7 @@
using System.Collections.Immutable;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides an in-memory implementation of for development and testing scenarios.
@@ -15,13 +15,21 @@ namespace ModelContextProtocol.Server;
/// Tasks are not persisted across process restarts.
///
///
-/// For production scenarios requiring durability, session isolation, or TTL-based cleanup,
-/// implement a custom .
+/// Tasks created with a are discarded once their time-to-live
+/// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic
+/// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a
+/// time-to-live are retained until the process exits.
+///
+///
+/// For production scenarios requiring durability, session isolation, or more advanced retention
+/// policies, implement a custom .
///
///
public class InMemoryMcpTaskStore : IMcpTaskStore
{
private readonly ConcurrentDictionary _tasks = new();
+ private static readonly long s_sweepIntervalTicks = TimeSpan.FromSeconds(30).Ticks;
+ private long _lastSweepTicks = DateTimeOffset.UtcNow.UtcTicks;
///
/// Gets or sets the default poll interval in milliseconds for new tasks.
@@ -32,13 +40,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore
///
/// Gets or sets the default time-to-live for new tasks, or for unlimited.
///
+ ///
+ /// When set to a positive value, tasks are discarded once this duration elapses from their
+ /// creation. A or non-positive value keeps tasks until the process exits.
+ ///
public TimeSpan? DefaultTimeToLive { get; set; }
///
public Task CreateTaskAsync(CancellationToken cancellationToken = default)
{
- var taskId = Guid.NewGuid().ToString("N");
var now = DateTimeOffset.UtcNow;
+ SweepExpired(now);
+
+ var taskId = Guid.NewGuid().ToString("N");
var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs);
_tasks[taskId] = info;
@@ -49,8 +63,19 @@ public Task CreateTaskAsync(CancellationToken cancellationToken = d
///
public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
{
- _tasks.TryGetValue(taskId, out var info);
- return Task.FromResult(info);
+ var now = DateTimeOffset.UtcNow;
+ if (_tasks.TryGetValue(taskId, out var info))
+ {
+ if (IsExpired(info, now))
+ {
+ _tasks.TryRemove(taskId, out _);
+ return Task.FromResult(null);
+ }
+
+ return Task.FromResult(info);
+ }
+
+ return Task.FromResult(null);
}
///
@@ -198,6 +223,35 @@ public Task SetInputRequestsAsync(
private static bool IsTerminal(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
+ private static bool IsExpired(McpTaskInfo info, DateTimeOffset now) =>
+ info.TimeToLive is { } ttl && ttl > TimeSpan.Zero && now - info.CreatedAt >= ttl;
+
+ private void SweepExpired(DateTimeOffset now)
+ {
+ long last = Interlocked.Read(ref _lastSweepTicks);
+ if (now.UtcTicks - last < s_sweepIntervalTicks)
+ {
+ return;
+ }
+
+ // Ensure only one caller runs the sweep per interval; concurrent callers skip it.
+ if (Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) != last)
+ {
+ return;
+ }
+
+ foreach (var kvp in _tasks)
+ {
+ if (IsExpired(kvp.Value, now))
+ {
+ // TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive
+ // are immutable after creation, so an entry judged expired here stays expired.
+ // Removing by key is therefore safe even if the value was concurrently updated.
+ _tasks.TryRemove(kvp.Key, out _);
+ }
+ }
+ }
+
private void Update(string taskId, Func transform)
{
SpinWait spin = default;
diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
similarity index 92%
rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
index 14447bd6f..c1cedf6d1 100644
--- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
@@ -1,6 +1,6 @@
using ModelContextProtocol.Protocol;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides data for the event.
diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
similarity index 94%
rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
index d275fb3a6..a9a86ed54 100644
--- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the state of a task in an .
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
new file mode 100644
index 000000000..599b426d8
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
@@ -0,0 +1,335 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Collections.Concurrent;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Extension methods for to enable MCP Tasks support.
+///
+public static class McpTasksBuilderExtensions
+{
+ ///
+ /// Enables MCP Tasks support backed by the specified task store.
+ ///
+ /// The server builder.
+ /// The task store.
+ /// The builder provided in .
+ public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(store);
+#else
+ if (builder is null) throw new ArgumentNullException(nameof(builder));
+ if (store is null) throw new ArgumentNullException(nameof(store));
+#endif
+
+ // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the
+ // background task body has somewhere to report failures. It is optional: if no logging is
+ // registered, the options fall back to NullLoggerFactory.
+ builder.Services.AddSingleton>(
+ sp => new McpTasksPostConfigureOptions(store, sp.GetService()));
+ return builder;
+ }
+
+ private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions
+ {
+ private readonly IMcpTaskStore _store = store;
+ private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger();
+ private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal);
+
+ public void PostConfigure(string? name, McpServerOptions options)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(options);
+#else
+ if (options is null) throw new ArgumentNullException(nameof(options));
+#endif
+
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId))
+ {
+ options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
+ }
+
+ options.RequestHandlers ??= new List();
+ options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask });
+ options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask });
+ options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask });
+
+ // Use a filter rather than a handler so it wraps around Core's tool dispatch.
+ // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing
+ // it to spawn background execution and return the task alternate immediately.
+ options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) =>
+ {
+ if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta))
+ {
+ var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
+ var taskId = taskInfo.TaskId;
+ var cts = new CancellationTokenSource();
+ _cancellationSources[taskId] = cts;
+ var taskCancellationToken = cts.Token;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store))
+ {
+ try
+ {
+ var augmented = await next(request, taskCancellationToken).ConfigureAwait(false);
+
+ if (augmented.IsAlternate)
+ {
+ var error = new JsonRpcErrorDetail
+ {
+ Code = (int)McpErrorCode.InternalError,
+ Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.",
+ };
+ var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
+ return;
+ }
+
+ var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
+ {
+ await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (InputRequiredException)
+ {
+ var error = new JsonRpcErrorDetail
+ {
+ Code = (int)McpErrorCode.InvalidRequest,
+ Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.",
+ };
+ var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
+ }
+ catch (McpProtocolException mcpEx)
+ {
+ // SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape.
+ var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message };
+ var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Non-protocol exceptions are wrapped as CallToolResult { IsError = true },
+ // matching Core's BuildInitialAlternateToolFilter behavior.
+ var errorResult = new CallToolResult
+ {
+ IsError = true,
+ Content = [new TextContentBlock
+ {
+ Text = ex is McpException
+ ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}"
+ : $"An error occurred invoking '{request.Params?.Name}'.",
+ }],
+ };
+ var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (_cancellationSources.TryRemove(taskId, out var registeredCts))
+ {
+ registeredCts.Dispose();
+ }
+ }
+ }
+ }
+ catch (Exception outer)
+ {
+ // The inner handlers above record every expected outcome. Reaching here means a
+ // store operation inside one of those handlers (or the task scope) threw, most
+ // likely from a custom IMcpTaskStore. Record the failure best-effort and never let
+ // it surface as an unobserved task exception.
+ _logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId);
+
+ try
+ {
+ var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message };
+ var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
+ }
+ catch (Exception storeEx)
+ {
+ _logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId);
+ }
+
+ if (_cancellationSources.TryRemove(taskId, out var leftoverCts))
+ {
+ leftoverCts.Dispose();
+ }
+ }
+ }, CancellationToken.None);
+
+ return new ResultOrAlternate(
+ ToCreateTaskResult(taskInfo),
+ McpTasksJsonContext.Default.CreateTaskResult);
+ }
+
+ return await next(request, cancellationToken).ConfigureAwait(false);
+ });
+ }
+
+ private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet);
+
+ var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams)
+ ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
+
+ var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false);
+ if (info is null)
+ {
+ throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams);
+ }
+
+ return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult);
+ }
+
+ private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate);
+
+ var taskId = request.Params?["taskId"]?.GetValue()
+ ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams);
+
+ // Deserialize inputResponses using Core's options which can access the internal
+ // InputResponsesCore backing property on RequestParams. The extension's source-gen
+ // context cannot see that internal member.
+ var inputResponses = request.Params?["inputResponses"]?.Deserialize(
+ McpJsonUtilities.DefaultOptions.GetTypeInfo>())
+ ?? new Dictionary();
+
+ await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false);
+
+ return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult);
+ }
+
+ private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel);
+
+ var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams)
+ ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams);
+
+ await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false);
+
+ if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts))
+ {
+ cts.Cancel();
+ cts.Dispose();
+ }
+
+ return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult);
+ }
+
+ private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method)
+ {
+ if (!IsJuly2026OrLaterProtocolRequest(request))
+ {
+ throw new McpProtocolException(
+ $"The method '{method}' requires a newer protocol revision that supports tasks " +
+ $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later); " +
+ $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.",
+ McpErrorCode.MethodNotFound);
+ }
+ }
+
+ private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
+ meta is not null &&
+ meta[MetaKeys.ClientCapabilities] is JsonObject caps &&
+ caps["extensions"] is JsonObject exts &&
+ exts.ContainsKey(TasksProtocol.ExtensionId);
+
+ private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
+ McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion);
+
+ private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new()
+ {
+ TaskId = info.TaskId,
+ Status = info.Status,
+ CreatedAt = info.CreatedAt,
+ LastUpdatedAt = info.LastUpdatedAt,
+ TimeToLive = info.TimeToLive,
+ PollIntervalMs = info.PollIntervalMs,
+ StatusMessage = info.StatusMessage,
+ 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,
+ 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.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs
new file mode 100644
index 000000000..31445fd1f
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs
@@ -0,0 +1,109 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Extension methods for task-aware server operations.
+///
+public static class McpTasksServerExtensions
+{
+ ///
+ /// Sends a task status notification to the connected client.
+ ///
+ /// The server sending the notification.
+ /// The notification payload.
+ /// The cancellation token.
+ /// A task representing the send operation.
+ public static Task SendTaskStatusNotificationAsync(
+ this McpServer server,
+ TaskStatusNotificationParams notificationParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(notificationParams);
+#else
+ if (server is null) throw new ArgumentNullException(nameof(server));
+ if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams));
+#endif
+
+ return server.SendNotificationAsync(
+ TasksProtocol.NotificationTaskStatus,
+ notificationParams,
+ McpTasksJsonContext.Default.Options,
+ cancellationToken);
+ }
+
+ ///
+ /// Creates a scope that routes server-initiated requests through the specified task store.
+ ///
+ /// The server whose outgoing requests should be redirected.
+ /// The related task identifier.
+ /// The task store used to surface input requests.
+ /// An that restores the previous outgoing-request behavior.
+ public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(taskId);
+ ArgumentNullException.ThrowIfNull(store);
+#else
+ if (server is null) throw new ArgumentNullException(nameof(server));
+ if (taskId is null) throw new ArgumentNullException(nameof(taskId));
+ if (store is null) throw new ArgumentNullException(nameof(store));
+#endif
+
+ return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) =>
+ {
+ var requestId = Guid.NewGuid().ToString("N");
+
+ var inputRequest = new InputRequest
+ {
+ Method = method,
+ Params = paramsNode is null
+ ? default
+ : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()),
+ };
+
+ 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
+ using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
+ {
+ var response = await tcs.Task.ConfigureAwait(false);
+ return JsonNode.Parse(response.RawValue.GetRawText());
+ }
+#endif
+
+#if NET
+ return JsonNode.Parse(response.RawValue.GetRawText());
+#endif
+ }
+ finally
+ {
+ store.InputResponseReceived -= handler;
+ }
+ });
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
new file mode 100644
index 000000000..44172666a
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
@@ -0,0 +1,37 @@
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Provides constants for the MCP Tasks extension (SEP-2663).
+///
+public static class TasksProtocol
+{
+ ///
+ /// The extension identifier for the MCP Tasks extension.
+ ///
+ public const string ExtensionId = "io.modelcontextprotocol/tasks";
+
+ ///
+ /// The name of the request method sent from the client to poll for task completion.
+ ///
+ public const string MethodTasksGet = "tasks/get";
+
+ ///
+ /// The name of the request method sent from the client to provide input responses to a task.
+ ///
+ public const string MethodTasksUpdate = "tasks/update";
+
+ ///
+ /// The name of the request method sent from the client to signal intent to cancel a task.
+ ///
+ public const string MethodTasksCancel = "tasks/cancel";
+
+ ///
+ /// The name of the notification sent by the server when a task's status changes.
+ ///
+ public const string NotificationTaskStatus = "notifications/tasks/status";
+
+ ///
+ /// The metadata key used to associate requests, responses, and notifications with a task.
+ ///
+ public const string MetaRelatedTask = "io.modelcontextprotocol/related-task";
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
index 879173819..af1334fab 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client;
///
/// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync,
-/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync.
+/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync.
///
public class McpClientTaskMethodsTests : ClientServerTestBase
{
@@ -25,15 +26,12 @@ public McpClientTaskMethodsTests(ITestOutputHelper outputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
-
- mcpServerBuilder.WithTools([McpServerTool.Create(
+ })
+ .WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
@@ -60,7 +58,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -97,12 +95,12 @@ await Assert.ThrowsAsync(async () =>
}
[Fact]
- public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask()
+ public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -122,12 +120,12 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
+ var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "hello"),
- }, ct);
+ }, cancellationToken: ct);
Assert.NotNull(result);
Assert.NotEmpty(result.Content);
@@ -141,7 +139,7 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -172,7 +170,7 @@ public async Task CancelTaskAsync_NullTaskId_Throws()
await using var client = await CreateMcpClientForServer();
await Assert.ThrowsAsync(async () =>
- await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken));
+ await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
@@ -194,7 +192,7 @@ public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -235,7 +233,7 @@ public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds()
for (int i = 0; i < 5; i++)
{
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 4b782cd64..c9fdff85f 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -83,6 +83,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
index 86acc57f6..da4afb62f 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -27,8 +28,8 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie
Meta = new JsonObject { ["key"] = "value" }
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.NotNull(deserialized);
Assert.Equal("task-123", deserialized.TaskId);
@@ -57,7 +58,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames()
ResultType = "task",
};
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
// Must use camelCase wire names
Assert.Contains("\"ttlMs\":", json);
@@ -82,7 +83,7 @@ public static void CreateTaskResult_ResultType_SerializesAsTask()
ResultType = "task",
};
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
var node = JsonNode.Parse(json)!;
Assert.Equal("task", (string)node["resultType"]!);
@@ -104,8 +105,8 @@ public static void GetTaskResult_Working_RoundTrip()
PollIntervalMs = 2000,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var working = Assert.IsType(deserialized);
Assert.Equal("w1", working.TaskId);
@@ -126,8 +127,8 @@ public static void GetTaskResult_Completed_RoundTrip_IncludesResult()
Result = resultPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var completed = Assert.IsType(deserialized);
Assert.Equal("c1", completed.TaskId);
@@ -147,8 +148,8 @@ public static void GetTaskResult_Failed_RoundTrip_IncludesError()
Error = errorPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var failed = Assert.IsType(deserialized);
Assert.Equal("f1", failed.TaskId);
@@ -167,8 +168,8 @@ public static void GetTaskResult_Cancelled_RoundTrip()
StatusMessage = "User cancelled",
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var cancelled = Assert.IsType(deserialized);
Assert.Equal("x1", cancelled.TaskId);
@@ -195,8 +196,8 @@ public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests()
InputRequests = inputRequests,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var inputRequired = Assert.IsType(deserialized);
Assert.Equal("i1", inputRequired.TaskId);
@@ -228,7 +229,7 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus()
_ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""",
};
- var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.NotNull(result);
Assert.IsType(expectedType, result);
}
@@ -238,42 +239,42 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus()
public static void GetTaskResult_MissingTaskId_ThrowsJsonException()
{
var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_MissingStatus_ThrowsJsonException()
{
var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_UnknownStatus_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize