18 KiB
| title | description | uid |
|---|---|---|
| Tasks | Run long-running tool invocations asynchronously with status polling and input requests. | tasks |
Tasks
Tasks let an MCP server run a request asynchronously and report its result to the client later. The primary use case today is long-running tool invocations: the tool is offloaded to a background task, and the client polls for status, optionally exchanging additional input along the way.
Status: Experimental — diagnostic ID
MCPEXP001. The implementation tracks SEP-2663 (Tasks Extension). See Experimental APIs for how to opt in.
Overview
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 might respond
with a xref:ModelContextProtocol.Extensions.Tasks.CreateTaskResult instead of the standard result
(for example, xref:ModelContextProtocol.Protocol.CallToolResult). The client then polls tasks/get
until the task reaches a terminal state.
Per the SEP, the server must not return CreateTaskResult for a request that did not include
the extension opt-in. The SDK enforces this on the server side.
Task lifecycle
┌─────────────────────────┐
▼ │
(start) → Working ──→ InputRequired │
│ │ │
│ └──────────────┘ (client responds via tasks/update)
│
├──→ Completed (terminal — includes tool results with isError: true)
├──→ Cancelled (terminal)
└──→ Failed (terminal — JSON-RPC errors only)
xref:ModelContextProtocol.Extensions.Tasks.McpTaskStatus wire values are serialized in snake_case:
working, input_required, completed, cancelled, failed.
The discriminator field xref:ModelContextProtocol.Protocol.Result.ResultType?displayProperty=nameWithType
on the response payload is "task" for xref:ModelContextProtocol.Extensions.Tasks.CreateTaskResult
and "complete" for ordinary results.
Server configuration
Using the task store
Tasks support lives in the ModelContextProtocol.Extensions.Tasks package. The easiest way to
enable tasks is to call xref:ModelContextProtocol.Extensions.Tasks.McpTasksBuilderExtensions.WithTasks*
on the server builder, passing an xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.
The SDK ships xref:ModelContextProtocol.Extensions.Tasks.InMemoryMcpTaskStore for development and tests:
#pragma warning disable MCPEXP001
using ModelContextProtocol.Extensions.Tasks;
builder.Services.AddMcpServer()
.WithTools<MyTools>()
.WithTasks(new InMemoryMcpTaskStore());
When tasks are enabled with WithTasks the SDK automatically:
- Wires the
tasks/get,tasks/update, andtasks/cancelhandlers from the store. - Advertises the
io.modelcontextprotocol/tasksextension in xref:ModelContextProtocol.Protocol.ServerCapabilities.Extensions?displayProperty=nameWithType. - Wraps each
[McpServerTool]invocation so that, when the client opts in to the extension, the tool is offloaded to a background task tracked by the store. - Establishes a task scope so that xref:ModelContextProtocol.Server.McpServer.ElicitAsync*,
xref:ModelContextProtocol.Server.McpServer.SampleAsync*, and
xref:ModelContextProtocol.Server.McpServer.RequestRootsAsync* called from inside the tool
surface as entries in the task's
inputRequestsinstead of as direct JSON-RPC requests. - Plumbs a
CancellationTokenthrough to the tool that fires when the client invokestasks/cancel, so cancellation propagates cooperatively.
Alternate-result tools/call filters run in registration order, with the Tasks filter creating a
task at its position in that order. Filters before Tasks run before task creation. Filters after
Tasks run in the background before the ordinary filter pipeline. ASP.NET Core tool authorization
uses an alternate-result filter registered before Tasks, so an unauthorized call does not create
a task.
Ordinary tools/call filters still run exactly once for task-backed calls. They execute in the
background after the task record is created and before the tool body, so validation and telemetry
continue to apply. Each background invocation gets an independent DI scope that remains alive
until the tool pipeline completes.
For production scenarios that need durability, session isolation, multi-process routing, or TTL-based cleanup, implement xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore yourself (see the Implementing a custom task store section).
Returning a task from a tool handler
For full control without the store's auto-wrapping, set xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithAlternateHandler?displayProperty=nameWithType. It returns a xref:ModelContextProtocol.Protocol.ResultOrAlternate`1, so each invocation can choose between an immediate result and an alternate result such as a xref:ModelContextProtocol.Extensions.Tasks.CreateTaskResult:
using ModelContextProtocol.Extensions.Tasks;
options.Handlers.CallToolWithAlternateHandler = async (context, ct) =>
{
if (ShouldRunInline(context.Params!))
{
return new CallToolResult { Content = [/* … */] };
}
var taskId = await StartBackgroundWorkAsync(context.Params!, ct);
var created = new CreateTaskResult
{
TaskId = taskId,
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
PollIntervalMs = 1000,
};
return new ResultOrAlternate<CallToolResult>(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 throwsInvalidOperationExceptionif the alternate handler also returns an alternate. Use one mechanism or the other. When you return a task this way, you're also responsible for servingtasks/get,tasks/update, andtasks/cancel, which the store provides automatically.
xref:ModelContextProtocol.Server.McpServerHandlers.CallToolHandler?displayProperty=nameWithType and xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithAlternateHandler?displayProperty=nameWithType are mutually exclusive. Setting one while the other is already non-null throws
InvalidOperationExceptionat the property setter.
Client usage
Automatic polling
xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CallToolWithPollingAsync* handles the full task lifecycle automatically:
- Injects the
io.modelcontextprotocol/tasksextension capability into the request's_meta. - Polls
tasks/getat the cadence the server suggests viapollIntervalMs. - Dispatches input requests through the client's registered handlers (xref:ModelContextProtocol.Client.McpClientHandlers.SamplingHandler and xref:ModelContextProtocol.Client.McpClientHandlers.ElicitationHandler).
- Deduplicates already-resolved input request keys across polls so each request is handled at most once.
- Returns the final xref:ModelContextProtocol.Protocol.CallToolResult when the task completes,
or throws xref:ModelContextProtocol.McpException on
Failed/Cancelled.
using ModelContextProtocol.Extensions.Tasks;
var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments },
cancellationToken: cancellationToken);
Manual control
Use xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CallToolAsTaskAsync* to receive the raw xref:ModelContextProtocol.Extensions.Tasks.ResultOrCreatedTask`1 without auto-polling, then drive the lifecycle yourself using xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.GetTaskAsync*, xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.UpdateTaskAsync*, and xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CancelTaskAsync*:
using ModelContextProtocol.Extensions.Tasks;
var raw = await client.CallToolAsTaskAsync(requestParams, cancellationToken);
if (raw.IsTask)
{
var taskId = raw.TaskCreated!.TaskId;
while (true)
{
await Task.Delay(TimeSpan.FromMilliseconds(raw.TaskCreated.PollIntervalMs ?? 1000), cancellationToken);
var state = await client.GetTaskAsync(taskId, cancellationToken);
// Handle InputRequiredTaskResult by calling UpdateTaskAsync,
// CompletedTaskResult by deserializing its Result property, etc.
}
}
Stuck-task detector
CallToolWithPollingAsync includes a safety net for misbehaving servers: if the task stays in
xref:ModelContextProtocol.Extensions.Tasks.McpTaskStatus.InputRequired across many consecutive polls
without exposing any new input request keys (that is, 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
xref:ModelContextProtocol.McpException. 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
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.
Input requests (multi-round-trip)
When a task needs additional input from the client, the server transitions it to
xref:ModelContextProtocol.Extensions.Tasks.McpTaskStatus.InputRequired and returns the outstanding
requests in xref:ModelContextProtocol.Extensions.Tasks.InputRequiredTaskResult.InputRequests. 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
xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.UpdateTaskAsync*, keyed by the same identifiers.
Supported input request methods:
| Method | Dispatched to the client handler |
|---|---|
elicitation/create |
xref:ModelContextProtocol.Client.McpClientHandlers.ElicitationHandler |
sampling/createMessage |
xref:ModelContextProtocol.Client.McpClientHandlers.SamplingHandler |
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.
CallToolWithPollingAsyncdoes this automatically. - Servers should ignore
inputResponsesentries whose key does not currently correspond to an outstanding request, including responses for terminal-state tasks. xref:ModelContextProtocol.Extensions.Tasks.InMemoryMcpTaskStore follows this rule.
Implementing a custom task store
Implement xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore for production scenarios. Key requirements drawn from the SEP and the SDK contract:
- Thread safety — every method can be called concurrently.
- Idempotent terminal transitions — xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.SetCompletedAsync*, xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.SetFailedAsync*, and xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.SetCancelledAsync* must be no-ops on a task that is already in a terminal state so a late cancellation cannot overwrite a result.
InputResponseReceivedevent — after persisting an input response inside xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.ResolveInputRequestsAsync*, raise xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.InputResponseReceived?displayProperty=nameWithType for each resolved entry. This is the only mechanism that wakes a pendingserver.ElicitAsync/server.SampleAsynccall waiting inside a task scope. In distributed deployments where a different server instance receives thetasks/update, the event must be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom transport).- Strong-consistency on
CreateTaskAsync— xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.CreateTaskAsync* must not return until the task is durably persisted, so that a subsequent xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.GetTaskAsync* 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. - Singleton under stateless HTTP — when the server runs in stateless mode (each request
spins up a fresh server instance), the same
IMcpTaskStoreinstance must be shared across requests — either by registering it as a singleton in DI, or by backing it with external storage that every instance can reach. Otherwisetasks/getpolls from subsequent requests will see an empty in-memory store and never find the task.
public sealed class MyTaskStore : IMcpTaskStore
{
public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
public async Task ResolveInputRequestsAsync(
string taskId,
IDictionary<string, InputResponse> inputResponses,
CancellationToken cancellationToken = default)
{
// 1. Atomically persist the resolved requests, ignoring keys that are no longer
// outstanding or that target a terminal task.
await PersistResolvedResponsesAsync(taskId, inputResponses, cancellationToken);
// 2. Then notify subscribers so any awaiting server.ElicitAsync/SampleAsync resumes.
foreach (var kvp in inputResponses)
{
InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs
{
TaskId = taskId,
RequestId = kvp.Key,
Response = kvp.Value,
});
}
}
// … other IMcpTaskStore members
}
Status semantics
xref:ModelContextProtocol.Extensions.Tasks.McpTaskStatus.Completed is the terminal status whenever the
underlying request produced its standard result, including a
xref:ModelContextProtocol.Protocol.CallToolResult with IsError = true. Per SEP-2663,
tool-level error results are not promoted to Failed.
xref:ModelContextProtocol.Extensions.Tasks.McpTaskStatus.Failed 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 xref:ModelContextProtocol.Protocol.CallToolResult.IsError?displayProperty=nameWithType for domain-level errors the model should see.
Cancellation semantics
Per SEP-2663, tasks/cancel is eventually consistent and cooperative: the server acknowledges
the request immediately, but is not required to actually stop the work or to transition to
Cancelled. The notifications-cancelled mechanism (used for plain JSON-RPC requests) is not used
for task cancellation; clients must use tasks/cancel.
In the built-in SDK pipeline, when a task is wrapped by a configured TaskStore:
- The store's
SetCancelledAsynctransitions the task toCancelled(a no-op if the task is already terminal). - The associated
CancellationTokenSourceis signaled, propagating cancellation to the tool'sCancellationTokenso cooperative cleanup can run. - Whichever side (the cancel handler or the background runner's
finallyblock) winsTryRemoveon the cancellation source owns disposal, avoidingObjectDisposedException.
Architecture notes
Immutable store design
xref:ModelContextProtocol.Extensions.Tasks.InMemoryMcpTaskStore uses immutable record snapshots with
compare-and-swap updates for lock-free thread safety. InputRequests and InputResponses are
exposed as ImmutableDictionary<,> so observers can't mutate internal state.
Capability bypass inside a task scope
When server.ElicitAsync/server.SampleAsync/server.RequestRootsAsync execute inside a task
scope, the SDK intentionally skips the normal client-capability negotiation checks
(ThrowIfElicitationUnsupported, etc.). The tasks extension itself is the negotiated capability:
the client opted in by including the extension marker in the originating request, so it's
responsible for handling — or rejecting — the input requests surfaced through tasks/get.
Known limitations
- Server-push task status notifications (SEP-2575): not yet implemented. Clients rely on polling exclusively.
- Lazy task creation: when a tool runs through the task store, the store's xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore.CreateTaskAsync* 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 xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithAlternateHandler?displayProperty=nameWithType if you need that pattern. roots/listas an input request: the server SDK routesRequestRootsAsyncthrough the task channel when called from inside a task scope, but the client SDK does not currently dispatch a handler for that method. Avoid callingserver.RequestRootsAsyncfrom within a task scope until client-side support is added.ServerCapabilities.Extensionsround-trip: the dictionary is typed asIDictionary<string, object>so its values cannot be deserialized by the source generator. The negotiated extension surfaces correctly at the wire level, but round-tripping arbitrary extension payloads in-process is not supported.