csharp-sdk/docs/concepts/tasks/tasks.md
Jeff Handley 314bdb2f49
Refine 2.0.0 concept documentation (#1742)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 782b5bad-4046-49ec-93a6-72768b5b7521
Copilot-Session: 4b938335-418c-4ee7-8287-6c70d9148b89
2026-07-27 20:15:16 -07:00

368 lines
19 KiB
Markdown

---
title: Tasks
description: Run long-running tool invocations asynchronously with status polling and input requests.
uid: 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.
Tasks are provided by the `ModelContextProtocol.Extensions.Tasks` package and require MCP protocol
version `2026-07-28` or later. The implementation follows
[SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md).
### 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
```text
┌─────────────────────────┐
▼ │
(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
The easiest way to enable the package's task store integration 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:
```csharp
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`, and `tasks/cancel` handlers from the store.
- Advertises the `io.modelcontextprotocol/tasks` extension 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 `inputRequests` instead of as direct JSON-RPC requests.
- Plumbs a `CancellationToken` through to the tool that fires when the client invokes
`tasks/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](#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>:
```csharp
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 throws `InvalidOperationException` if 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 serving `tasks/get`, `tasks/update`, and `tasks/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
> `InvalidOperationException` at the property setter.
### Client usage
#### Automatic polling
<xref:ModelContextProtocol.Extensions.Tasks.McpTasksClientExtensions.CallToolWithPollingAsync*>
handles the full task lifecycle automatically:
- Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`.
- Polls `tasks/get` at the cadence the server suggests via `pollIntervalMs`.
- 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`.
```csharp
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*>:
```csharp
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. `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.
<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:
1. **Thread safety** — every method can be called concurrently.
2. **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.
3. **`InputResponseReceived` event** — 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 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`** —
<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.
5. **Singleton under stateless HTTP** — when the server runs in stateless mode (each request
spins up a fresh server instance), the same `IMcpTaskStore` instance 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. Otherwise `tasks/get` polls from subsequent requests
will see an empty in-memory store and never find the task.
```csharp
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`:
1. The store's `SetCancelledAsync` transitions the task to `Cancelled` (a no-op if the task is
already terminal).
2. The associated `CancellationTokenSource` is signaled, propagating cancellation to the tool's
`CancellationToken` so cooperative cleanup can run.
3. Whichever side (the cancel handler or the background runner's `finally` block) wins
`TryRemove` on the cancellation source owns disposal, avoiding `ObjectDisposedException`.
### 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`.
#### Compatibility with v1 experimental Tasks
The Tasks extension in v2.0.0 replaces the experimental Tasks implementation shipped in v1.3.0 and
v1.4.x. The implementations are not compatible at either the API or protocol level. A v2 Tasks
client or server must use a connection negotiated to `2026-07-28` or later; it cannot fall back to
the down-level implementation.
On a connection negotiated to the down-level `2025-11-25` protocol:
- A v2 client calling a v1 server receives an ordinary tool result. The v2 client does not opt in
to the down-level Tasks protocol, and `GetTaskAsync` rejects use before a `2026-07-28`
connection is negotiated.
- A v1 client calling a v2 server likewise receives an ordinary tool result. The v2 server does
not create Tasks on a down-level connection, and its `tasks/get` endpoint rejects the legacy
request with a method-not-found error.
Upgrade both peers to the v2 Tasks extension before using Tasks. The extension provides no
compatibility bridge for the previous experimental API.
### 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/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
dispatch a handler for that method. Avoid calling `server.RequestRootsAsync` from within a
task scope until client-side support is added.
- **`ServerCapabilities.Extensions` round-trip**: the dictionary is typed as
`IDictionary<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.