Implement SEP-2663 Tasks Extension (#1579)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Pranav Senthilnathan 2026-06-11 13:10:45 -07:00 • committed by GitHub
commit dbb7a20176
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 8190 additions and 11405 deletions

View file

@ -25,6 +25,7 @@
<!-- Product dependencies .NET Standard --> <!-- Product dependencies .NET Standard -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'"> <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageVersion Include="Microsoft.Bcl.Memory" Version="$(System10Version)" /> <PackageVersion Include="Microsoft.Bcl.Memory" Version="$(System10Version)" />
<PackageVersion Include="System.Collections.Immutable" Version="$(System10Version)" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(System10Version)" /> <PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(System10Version)" />
<PackageVersion Include="System.IO.Pipelines" Version="$(System10Version)" /> <PackageVersion Include="System.IO.Pipelines" Version="$(System10Version)" />
<PackageVersion Include="System.Text.Json" Version="$(System10Version)" /> <PackageVersion Include="System.Text.Json" Version="$(System10Version)" />

View file

@ -45,11 +45,11 @@
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" /> <Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
<Project Path="samples/EverythingServer/EverythingServer.csproj" /> <Project Path="samples/EverythingServer/EverythingServer.csproj" />
<Project Path="samples/InMemoryTransport/InMemoryTransport.csproj" /> <Project Path="samples/InMemoryTransport/InMemoryTransport.csproj" />
<Project Path="samples/LongRunningTasks/LongRunningTasks.csproj" />
<Project Path="samples/ProtectedMcpClient/ProtectedMcpClient.csproj" /> <Project Path="samples/ProtectedMcpClient/ProtectedMcpClient.csproj" />
<Project Path="samples/ProtectedMcpServer/ProtectedMcpServer.csproj" /> <Project Path="samples/ProtectedMcpServer/ProtectedMcpServer.csproj" />
<Project Path="samples/QuickstartClient/QuickstartClient.csproj" /> <Project Path="samples/QuickstartClient/QuickstartClient.csproj" />
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" /> <Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
<Project Path="samples/TasksExtension/TasksExtension.csproj" />
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" /> <Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
</Folder> </Folder>
<Folder Name="/Solution Items/"> <Folder Name="/Solution Items/">

View file

@ -568,7 +568,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ
## Tasks and session modes ## 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 <xref:ModelContextProtocol.IMcpTaskStore> 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 <xref:ModelContextProtocol.Server.IMcpTaskStore> configured (`McpServerOptions.TaskStore`), and behavior differs between session modes.
### Stateless mode ### Stateless mode
@ -578,9 +578,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses
### Stateful mode ### Stateful mode
In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in <xref:ModelContextProtocol.InMemoryMcpTaskStore> 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 <xref:ModelContextProtocol.Server.InMemoryMcpTaskStore> 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 <xref:ModelContextProtocol.IMcpTaskStore> backed by an external store. See [Fault-tolerant task implementations](xref:tasks#fault-tolerant-task-implementations) 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 <xref:ModelContextProtocol.Server.IMcpTaskStore> 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 ### Task cancellation vs request cancellation
@ -632,7 +632,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira
### With tasks (experimental) ### 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 <xref:ModelContextProtocol.IMcpTaskStore>, 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 <xref:ModelContextProtocol.Server.IMcpTaskStore>, 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**.
This means: This means:

View file

@ -1,604 +1,375 @@
--- ---
title: Tasks title: Tasks
author: eiriktsarpalis description: Run long-running tool invocations asynchronously with status polling and input requests.
description: MCP Tasks for Long-Running Operations
uid: tasks uid: tasks
--- ---
# MCP Tasks ## Tasks
<!-- mlc-disable-next-line --> Tasks let an MCP server run a request asynchronously and report its result to the client later. The
> [!WARNING] primary use case today is long-running tool invocations: the tool is offloaded to a background task,
> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. and the client polls for status, optionally exchanging additional input along the way.
The Model Context Protocol (MCP) supports [task-based execution] for long-running operations. Tasks enable a "call-now, fetch-later" pattern where clients can initiate operations that may take significant time to complete, then poll for status and retrieve results when ready. > **Status**: Experimental — diagnostic ID `MCPEXP001`. The implementation tracks
> [SEP-2663 (Tasks Extension)](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md).
> See [Experimental APIs](xref:experimental) for how to opt in.
[task-based execution]: https://modelcontextprotocol.io/seps/1686-tasks ### Overview
## 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 **may** respond
with a <xref:ModelContextProtocol.Protocol.CreateTaskResult> instead of the standard result
(e.g., <xref:ModelContextProtocol.Protocol.CallToolResult>). The client then polls `tasks/get`
until the task reaches a terminal state.
Tasks are useful when operations may take a long time to complete, such as: 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.
- Large dataset processing or analysis #### Task lifecycle
- Complex report generation
- Code migration or refactoring operations
- Machine learning inference or training
- Batch data transformations
Without tasks, clients must keep connections open for the entire duration of long-running operations. Tasks allow clients to: ```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)
```
1. Initiate an operation and receive a task ID immediately <xref:ModelContextProtocol.Protocol.McpTaskStatus> wire values are serialized in snake_case:
2. Disconnect and reconnect later `working`, `input_required`, `completed`, `cancelled`, `failed`.
3. Poll for status updates
4. Retrieve results when complete
5. Cancel operations if needed
## Task Lifecycle The discriminator field <xref:ModelContextProtocol.Protocol.Result.ResultType?displayProperty=nameWithType>
on the response payload is `"task"` for <xref:ModelContextProtocol.Protocol.CreateTaskResult>
and `"complete"` for ordinary results.
Tasks follow a defined lifecycle through these status values: ### Server configuration
| Status | Description | #### Using the task store
|--------|-------------|
| `working` | Task is actively being processed |
| `input_required` | Task is waiting for additional input (e.g., elicitation) |
| `completed` | Task finished successfully; results are available |
| `failed` | Task encountered an error |
| `cancelled` | Task was cancelled by the client |
Tasks begin in the `working` status and transition to one of the terminal states (`completed`, `failed`, or `cancelled`). Once in a terminal state, the status cannot change. The easiest way to enable tasks is to set an <xref:ModelContextProtocol.Server.IMcpTaskStore>
on <xref:ModelContextProtocol.Server.McpServerOptions.TaskStore?displayProperty=nameWithType>.
## Server Implementation The SDK ships <xref:ModelContextProtocol.Server.InMemoryMcpTaskStore> for development and tests:
### Configuring Task Support
To enable task support on a server, configure a task store when setting up the MCP server:
```csharp ```csharp
var builder = WebApplication.CreateBuilder(args); #pragma warning disable MCPEXP001
// Create a task store for managing task state
var taskStore = new InMemoryMcpTaskStore();
builder.Services.AddMcpServer(options => builder.Services.AddMcpServer(options =>
{ {
// Enable tasks by providing a task store options.TaskStore = new InMemoryMcpTaskStore();
options.TaskStore = taskStore;
}) })
.WithHttpTransport(o => o.Stateless = true)
.WithTools<MyTools>(); .WithTools<MyTools>();
``` ```
The <xref:ModelContextProtocol.InMemoryMcpTaskStore> is a reference implementation suitable for development and single-server deployments. For production multi-server scenarios, implement <xref:ModelContextProtocol.IMcpTaskStore> with a persistent backing store (database, Redis, etc.). When a `TaskStore` is configured the SDK automatically:
### Task Store Configuration - Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit
handlers in <xref:ModelContextProtocol.Server.McpServerOptions.Handlers> still take precedence
for any slot they fill.
- 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.
The `InMemoryMcpTaskStore` constructor accepts several optional parameters: For production scenarios that need durability, session isolation, multi-process routing, or
TTL-based cleanup, implement <xref:ModelContextProtocol.Server.IMcpTaskStore> 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
<xref:ModelContextProtocol.Server.McpRequestHandler`2> that receives an
<xref:ModelContextProtocol.Server.RequestContext`1> with typed parameters:
```csharp ```csharp
var taskStore = new InMemoryMcpTaskStore( options.Handlers.GetTaskHandler = (context, ct) =>
defaultTtl: TimeSpan.FromHours(1), // Default task retention time
maxTtl: TimeSpan.FromHours(24), // Maximum allowed TTL
pollInterval: TimeSpan.FromSeconds(1), // Suggested client poll interval
cleanupInterval: TimeSpan.FromMinutes(5), // Background cleanup frequency
pageSize: 100, // Tasks per page for listing
maxTasks: 1000, // Maximum total tasks allowed
maxTasksPerSession: 100 // Maximum tasks per session
);
```
### Tool Task Support
Tools automatically advertise task support when they return `Task`, `ValueTask`, `Task<T>`, or `ValueTask<T>`:
```csharp
[McpServerToolType]
public class MyTools
{ {
// This tool automatically supports task-augmented calls var taskId = context.Params!.TaskId;
// because it returns Task<string> (async method) // … look up state and return one of the GetTaskResult subtypes.
[McpServerTool, Description("Processes a large dataset")] return new ValueTask<GetTaskResult>(new WorkingTaskResult { TaskId = taskId, /* … */ });
public static async Task<string> ProcessDataset(
int recordCount,
CancellationToken cancellationToken)
{
// Long-running operation
await Task.Delay(5000, cancellationToken);
return $"Processed {recordCount} records";
}
// Synchronous tools don't support task augmentation by default
[McpServerTool, Description("Quick operation")]
public static string QuickOperation(string input) => $"Result: {input}";
}
```
You can explicitly control task support using <xref:ModelContextProtocol.Server.McpServerToolCreateOptions>:
```csharp
// In Program.cs or configuration
builder.Services.AddMcpServer()
.WithTools([
McpServerTool.Create(
(int count, CancellationToken ct) => ProcessAsync(count, ct),
new McpServerToolCreateOptions
{
Name = "requiredTaskTool",
Execution = new ToolExecution
{
// Require clients to use task augmentation
TaskSupport = ToolTaskSupport.Required
}
})
]);
```
Task support levels:
- `Forbidden` (default for sync methods): Tool cannot be called with task augmentation
- `Optional` (default for async methods): Tool can be called with or without task augmentation
- `Required`: Tool must be called with task augmentation
### Explicit Task Creation with `IMcpTaskStore`
For more control over task lifecycle, tools can directly interact with <xref:ModelContextProtocol.IMcpTaskStore> and return an `McpTask`. This approach allows you to:
- Create a task and return immediately while work continues in the background
- Control exactly when and how task status and results are updated
- Integrate with external systems for task execution
Here's a simple example using `Task.Run` to schedule background work:
```csharp
[McpServerToolType]
public class MyTools(IMcpTaskStore taskStore)
{
[McpServerTool]
[Description("Starts a background job and returns a task for polling.")]
public async Task<McpTask> StartBackgroundJob(
[Description("Number of items to process")] int itemCount,
RequestContext<CallToolRequestParams> context,
CancellationToken cancellationToken)
{
// Create a task in the store - this records the task metadata
var task = await taskStore.CreateTaskAsync(
new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(30) },
context.JsonRpcRequest.Id!,
context.JsonRpcRequest,
context.Server.SessionId,
cancellationToken);
// Schedule work to run in the background (fire-and-forget)
_ = Task.Run(async () =>
{
try
{
// Simulate long-running work
await Task.Delay(TimeSpan.FromSeconds(10));
var result = $"Processed {itemCount} items successfully";
// Store the completed result
await taskStore.StoreTaskResultAsync(
task.TaskId,
McpTaskStatus.Completed,
JsonSerializer.SerializeToElement(new CallToolResult
{
Content = [new TextContentBlock { Text = result }]
}),
context.Server.SessionId);
}
catch (Exception ex)
{
// Mark task as failed on error
await taskStore.StoreTaskResultAsync(
task.TaskId,
McpTaskStatus.Failed,
JsonSerializer.SerializeToElement(new CallToolResult
{
Content = [new TextContentBlock { Text = ex.Message }],
IsError = true
}),
context.Server.SessionId);
}
}, CancellationToken.None);
// Return immediately - client will poll for completion
return task;
}
}
```
When a tool returns `McpTask`, the SDK bypasses automatic task wrapping and returns the task directly to the client.
<!-- mlc-disable-next-line -->
> [!IMPORTANT]
> **No Fault Tolerance Guarantees**: Both `InMemoryMcpTaskStore` and the automatic task support for `Task`-returning tool methods do **not** provide fault tolerance. Task state and execution are bounded by the memory of the server process. If the server crashes or restarts:
> - All in-memory task metadata is lost
> - Any in-flight task execution is terminated
> - Clients will receive errors when polling for previously created tasks
>
> For fault-tolerant task execution, see the [Fault-Tolerant Task Implementations](#fault-tolerant-task-implementations) section.
### Task Status Notifications
When `SendTaskStatusNotifications` is enabled, the server automatically sends status updates to connected clients:
```csharp
builder.Services.AddMcpServer(options =>
{
options.TaskStore = taskStore;
options.SendTaskStatusNotifications = true; // Enable notifications
});
```
Clients receive `notifications/tasks/status` messages when task status changes.
## Client Implementation
### Calling Tools as Tasks
To execute a tool as a task, include the `Task` property in the request:
```csharp
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var client = await McpClient.CreateAsync(transport);
// Call tool with task augmentation
var result = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "processDataset",
Arguments = new Dictionary<string, JsonElement>
{
["recordCount"] = JsonSerializer.SerializeToElement(1000)
},
Task = new McpTaskMetadata
{
TimeToLive = TimeSpan.FromHours(2) // Request 2-hour retention
}
},
cancellationToken);
// Check if a task was created
if (result.Task != null)
{
Console.WriteLine($"Task created: {result.Task.TaskId}");
Console.WriteLine($"Status: {result.Task.Status}");
}
```
### Polling for Task Status
Use <xref:ModelContextProtocol.Client.McpClient.GetTaskAsync*> to check task status:
```csharp
var task = await client.GetTaskAsync(taskId, cancellationToken: cancellationToken);
Console.WriteLine($"Status: {task.Status}");
Console.WriteLine($"Last Updated: {task.LastUpdatedAt}");
if (task.StatusMessage != null)
{
Console.WriteLine($"Message: {task.StatusMessage}");
}
```
### Waiting for Completion
The SDK provides helper methods for polling until a task completes:
```csharp
// Poll until task reaches terminal state
var completedTask = await client.PollTaskUntilCompleteAsync(
taskId,
cancellationToken: cancellationToken);
if (completedTask.Status == McpTaskStatus.Completed)
{
// Get the result as raw JSON
var resultJson = await client.GetTaskResultAsync(
taskId,
cancellationToken: cancellationToken);
// Deserialize to the expected type
var result = resultJson.Deserialize<CallToolResult>(McpJsonUtilities.DefaultOptions);
foreach (var content in result?.Content ?? [])
{
if (content is TextContentBlock text)
{
Console.WriteLine(text.Text);
}
}
}
else if (completedTask.Status == McpTaskStatus.Failed)
{
Console.WriteLine($"Task failed: {completedTask.StatusMessage}");
}
```
### Listing Tasks
List all tasks for the current session:
```csharp
var tasks = await client.ListTasksAsync(cancellationToken: cancellationToken);
foreach (var task in tasks)
{
Console.WriteLine($"{task.TaskId}: {task.Status}");
}
```
### Cancelling Tasks
Cancel a running task:
```csharp
var cancelledTask = await client.CancelTaskAsync(
taskId,
cancellationToken: cancellationToken);
Console.WriteLine($"Task status: {cancelledTask.Status}"); // Cancelled
```
### Handling Status Notifications
Register a handler to receive real-time status updates:
```csharp
var options = new McpClientOptions
{
Handlers = new McpClientHandlers
{
TaskStatusHandler = (task, cancellationToken) =>
{
Console.WriteLine($"Task {task.TaskId} status changed to {task.Status}");
return ValueTask.CompletedTask;
}
}
}; };
var client = await McpClient.CreateAsync(transport, options); options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask<UpdateTaskResult> */;
options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask<CancelTaskResult> */;
``` ```
<!-- mlc-disable-next-line --> > **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting
> [!NOTE] > into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get`
> Clients should not rely on receiving status notifications. Notifications are optional and may not be sent in all scenarios. Always use polling as the primary mechanism for tracking task status. > handler is wired, the server throws `InvalidOperationException` at request time so misconfigured
> deployments fail loudly instead of shipping unpollable tasks.
## Implementing a Custom Task Store #### Returning a task from a tool handler
For production deployments, implement <xref:ModelContextProtocol.IMcpTaskStore> with a persistent backing store: <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
returns <xref:ModelContextProtocol.Protocol.ResultOrCreatedTask`1>, so each invocation can choose
between an immediate result and a background task:
```csharp ```csharp
public class DatabaseTaskStore : IMcpTaskStore options.Handlers.CallToolWithTaskHandler = async (context, ct) =>
{ {
private readonly IDbConnection _db; if (ShouldRunInline(context.Params!))
{
return new CallToolResult { Content = [/* … */] };
}
public DatabaseTaskStore(IDbConnection db) => _db = db; var taskId = await StartBackgroundWorkAsync(context.Params!, ct);
return new CreateTaskResult
public async Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskMetadata,
RequestId requestId,
JsonRpcRequest request,
string? sessionId,
CancellationToken cancellationToken)
{ {
var task = new McpTask TaskId = taskId,
{
TaskId = Guid.NewGuid().ToString(),
Status = McpTaskStatus.Working, Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow, LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = taskMetadata.TimeToLive ?? TimeSpan.FromHours(1) PollIntervalMs = 1000,
}; };
};
```
// Store in database > <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolHandler?displayProperty=nameWithType>
await _db.ExecuteAsync( > and <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
"INSERT INTO Tasks (TaskId, SessionId, Status, ...) VALUES (@TaskId, @SessionId, @Status, ...)", > are mutually exclusive. Setting one while the other is already non-null throws
new { task.TaskId, sessionId, task.Status, ... }); > `InvalidOperationException` at the property setter.
return task; #### Task scope for server-initiated requests
When you start background work from a custom <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
(rather than the SDK's auto-wrapping), use <xref:ModelContextProtocol.Server.McpServer.CreateMcpTaskScope*>
to route elicitation, sampling, and `roots/list` calls through the task store as input requests
instead of direct JSON-RPC messages:
```csharp
using (server.CreateMcpTaskScope(taskId, taskStore))
{
// ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as
// entries in the task's inputRequests, then await client responses via tasks/update.
var elicit = await server.ElicitAsync(elicitParams, ct);
} }
```
public async Task<McpTask?> GetTaskAsync( `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.
### Client usage
#### Automatic polling
<xref:ModelContextProtocol.Client.McpClient.CallToolAsync(ModelContextProtocol.Protocol.CallToolRequestParams,System.Threading.CancellationToken)>
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
var result = await client.CallToolAsync(
new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments },
cancellationToken);
```
#### Manual control
Use <xref:ModelContextProtocol.Client.McpClient.CallToolRawAsync*> to receive the raw
<xref:ModelContextProtocol.Protocol.ResultOrCreatedTask`1> without auto-polling, then drive the
lifecycle yourself using <xref:ModelContextProtocol.Client.McpClient.GetTaskAsync*>,
<xref:ModelContextProtocol.Client.McpClient.UpdateTaskAsync*>, and
<xref:ModelContextProtocol.Client.McpClient.CancelTaskAsync*>:
```csharp
var raw = await client.CallToolRawAsync(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
`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in
<xref:ModelContextProtocol.Protocol.McpTaskStatus.InputRequired> 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
<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
<xref:ModelContextProtocol.Client.McpClientOptions.MaxConsecutiveStuckPolls>. The effective
wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option
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.Protocol.McpTaskStatus.InputRequired> and returns the outstanding
requests in <xref:ModelContextProtocol.Protocol.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.Client.McpClient.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. `CallToolAsync` 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.Server.InMemoryMcpTaskStore> follows this rule.
### Implementing a custom task store
Implement <xref:ModelContextProtocol.Server.IMcpTaskStore> 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** —
<xref:ModelContextProtocol.Server.IMcpTaskStore.SetCompletedAsync*>,
<xref:ModelContextProtocol.Server.IMcpTaskStore.SetFailedAsync*>, and
<xref:ModelContextProtocol.Server.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.Server.IMcpTaskStore.ResolveInputRequestsAsync*>, raise
<xref:ModelContextProtocol.Server.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.Server.IMcpTaskStore.CreateTaskAsync*> must not return until the
task is durably persisted, so that a subsequent
<xref:ModelContextProtocol.Server.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, string taskId,
string? sessionId, IDictionary<string, InputResponse> inputResponses,
CancellationToken cancellationToken) CancellationToken cancellationToken = default)
{ {
// Retrieve from database with session isolation // 1. Atomically persist the resolved requests, ignoring keys that are no longer
return await _db.QuerySingleOrDefaultAsync<McpTask>( // outstanding or that target a terminal task.
"SELECT * FROM Tasks WHERE TaskId = @TaskId AND SessionId = @SessionId", await PersistResolvedResponsesAsync(taskId, inputResponses, cancellationToken);
new { taskId, sessionId });
// 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,
});
}
} }
// Implement other interface methods... // … other IMcpTaskStore members
} }
``` ```
### Task Store Best Practices ### Status semantics
1. **Session Isolation**: Always filter tasks by session ID to prevent cross-session access <xref:ModelContextProtocol.Protocol.McpTaskStatus.Completed> is the terminal status whenever the
2. **TTL Enforcement**: Implement background cleanup of expired tasks underlying request produced its standard result, *including a
3. **Thread Safety**: Ensure all operations are thread-safe for concurrent access <xref:ModelContextProtocol.Protocol.CallToolResult> with `IsError = true`*. Per SEP-2663,
4. **Atomic Updates**: Use database transactions for status transitions tool-level error results are not promoted to `Failed`.
5. **Optimistic Concurrency**: Prevent lost updates with version checking or row locks
## Error Handling <xref:ModelContextProtocol.Protocol.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.
Task operations may throw <xref:ModelContextProtocol.McpException> with these error codes: ### Cancellation semantics
| Error Code | Scenario | 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
| `InvalidParams` | Invalid or nonexistent task ID or invalid cursor | `Cancelled`. The notifications-cancelled mechanism (used for plain JSON-RPC requests) is not used
| `InvalidParams` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata | for task cancellation; clients must use `tasks/cancel`.
| `InternalError` | Task execution failure or result unavailable |
Example error handling: In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore`:
```csharp 1. The store's `SetCancelledAsync` transitions the task to `Cancelled` (a no-op if the task is
try already terminal).
{ 2. The associated `CancellationTokenSource` is signaled, propagating cancellation to the tool's
var task = await client.GetTaskAsync(taskId, cancellationToken: ct); `CancellationToken` so cooperative cleanup can run.
} 3. Whichever side (the cancel handler or the background runner's `finally` block) wins
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.InvalidParams) `TryRemove` on the cancellation source owns disposal, avoiding `ObjectDisposedException`.
{
Console.WriteLine($"Task not found: {taskId}");
}
```
## Complete Example ### Architecture notes
<!-- TODO: Remove mlc-disable block after merging to main --> #### Immutable store design
<!-- mlc-disable -->
See the [LongRunningTasks sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/LongRunningTasks) for a complete working example demonstrating:
<!-- mlc-enable -->
- Server setup with a file-based `IMcpTaskStore` for durability <xref:ModelContextProtocol.Server.InMemoryMcpTaskStore> uses immutable record snapshots with
- Explicit task creation via `IMcpTaskStore` in tools returning `McpTask` compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are
- Task polling and result retrieval across server restarts exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state.
- Cancellation support
## Fault-Tolerant Task Implementations #### Capability bypass inside a task scope
The default `InMemoryMcpTaskStore` and automatic task support for async tools are convenient for development, but they provide no durability or fault tolerance. When the server process terminates—whether due to a crash, deployment, or scaling event—all task state and in-flight computations are lost. 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 is
responsible for handling — or rejecting — the input requests surfaced through `tasks/get`.
### Why Fault Tolerance Requires External Systems ### Known limitations
True fault tolerance for long-running tasks requires two key capabilities that cannot be provided by an in-process solution: - **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on
polling exclusively.
1. **Durable Task State**: Task metadata (ID, status, results) must survive process termination. This requires an external persistent store such as a database, Redis, or distributed cache. - **Lazy task creation**: when a tool runs through `TaskStore`, the store's
<xref:ModelContextProtocol.Server.IMcpTaskStore.CreateTaskAsync*> is invoked eagerly before
2. **Resumable Compute**: The actual work being performed must be executed by an external system that can continue running independently of the MCP server process—such as a job queue (Azure Service Bus, RabbitMQ), workflow engine (Temporal, Azure Durable Functions), or batch processing system (Azure Batch, Kubernetes Jobs). the inner handler runs, so tools that complete inline still incur a store write. There is
currently no built-in deferral.
### Explicit Task Creation with `IMcpTaskStore` - **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
To implement fault-tolerant tasks, tools can directly interact with `IMcpTaskStore` and return an `McpTask` instead of relying on automatic task wrapping. This approach gives you full control over task lifecycle and enables integration with external compute fabrics: <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
if you need that pattern.
```csharp - **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the
[McpServerToolType] task channel when called from inside a task scope, but the client SDK does not currently
public class FaultTolerantTools(IMcpTaskStore taskStore, IJobQueue jobQueue) dispatch a handler for that method. Avoid calling `server.RequestRootsAsync` from within a
{ task scope until client-side support is added.
[McpServerTool] - **`ServerCapabilities.Extensions` round-trip**: the dictionary is typed as
[Description("Submits a long-running job with fault-tolerant execution.")] `IDictionary<string, object>` so its values cannot be deserialized by the source generator.
public async Task<McpTask> SubmitJob( The negotiated extension surfaces correctly at the wire level, but round-tripping arbitrary
[Description("The job parameters")] string jobInput, extension payloads in-process is not supported.
RequestContext<CallToolRequestParams> context,
CancellationToken cancellationToken)
{
// 1. Create a task in the durable store
var task = await taskStore.CreateTaskAsync(
new McpTaskMetadata { TimeToLive = TimeSpan.FromHours(24) },
context.JsonRpcRequest.Id!,
context.JsonRpcRequest,
context.Server.SessionId,
cancellationToken);
// 2. Submit work to an external compute fabric
// The job queue handles execution independently of this process
await jobQueue.EnqueueAsync(new JobMessage
{
TaskId = task.TaskId,
SessionId = context.Server.SessionId,
Input = jobInput
}, cancellationToken);
// 3. Return the task immediately - client will poll for completion
return task;
}
}
```
The external job processor updates the task store when work completes:
```csharp
// In a separate worker process or Azure Function
public class JobProcessor(IMcpTaskStore taskStore)
{
public async Task ProcessJobAsync(JobMessage job, CancellationToken cancellationToken)
{
try
{
// Perform the actual long-running work
var result = await DoExpensiveWorkAsync(job.Input, cancellationToken);
// Store the result in the durable task store
await taskStore.StoreTaskResultAsync(
job.TaskId,
McpTaskStatus.Completed,
JsonSerializer.SerializeToElement(new CallToolResult
{
Content = [new TextContentBlock { Text = result }]
}),
job.SessionId,
cancellationToken);
}
catch (Exception ex)
{
// Mark task as failed
await taskStore.StoreTaskResultAsync(
job.TaskId,
McpTaskStatus.Failed,
JsonSerializer.SerializeToElement(new CallToolResult
{
Content = [new TextContentBlock { Text = ex.Message }],
IsError = true
}),
job.SessionId,
cancellationToken);
}
}
}
```
### Simplified Example: File-Based Task Store
<!-- TODO: Remove mlc-disable block after merging to main -->
<!-- mlc-disable -->
The [LongRunningTasks sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/LongRunningTasks) demonstrates a simplified fault-tolerant approach using the file system. The `FileBasedMcpTaskStore` persists task state to disk, allowing tasks to survive server restarts:
<!-- mlc-enable -->
```csharp
// Use a file-based task store for durability
var taskStorePath = Path.Combine(Path.GetTempPath(), "mcp-tasks");
var taskStore = new FileBasedMcpTaskStore(taskStorePath);
builder.Services.AddMcpServer(options =>
{
options.TaskStore = taskStore;
})
.WithHttpTransport(o => o.Stateless = true)
.WithTools<TaskTools>();
```
The sample's tool returns an `McpTask` directly by calling `CreateTaskAsync`:
```csharp
[McpServerToolType]
public class TaskTools(IMcpTaskStore taskStore)
{
[McpServerTool]
[Description("Submits a job and returns a task that can be polled for completion.")]
public async Task<McpTask> SubmitJob(
[Description("A label for the job")] string jobName,
RequestContext<CallToolRequestParams> context,
CancellationToken cancellationToken)
{
return await taskStore.CreateTaskAsync(
new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) },
context.JsonRpcRequest.Id!,
context.JsonRpcRequest,
context.Server.SessionId,
cancellationToken);
}
}
```
While this file-based approach demonstrates the pattern, production systems should use proper distributed storage and compute infrastructure for true fault tolerance and scalability.
## See Also
- <xref:ModelContextProtocol.IMcpTaskStore>
- <xref:ModelContextProtocol.InMemoryMcpTaskStore>
- <xref:ModelContextProtocol.Protocol.McpTask>
- <xref:ModelContextProtocol.Protocol.McpTaskStatus>
- [MCP Tasks Specification](https://modelcontextprotocol.io/seps/1686-tasks)

View file

@ -26,8 +26,8 @@ Add the diagnostic ID to `<NoWarn>` in your project file:
Use `#pragma warning disable` around specific call sites: Use `#pragma warning disable` around specific call sites:
```csharp ```csharp
#pragma warning disable MCPEXP001 // The Tasks feature is experimental per the MCP specification and is subject to change. #pragma warning disable MCPEXP001 // The Extensions feature is part of a future MCP specification version that has not yet been ratified and is subject to change.
tool.Execution = new ToolExecution { ... }; capabilities.Extensions = new Dictionary<string, object> { ... };
#pragma warning restore MCPEXP001 #pragma warning restore MCPEXP001
``` ```
@ -67,4 +67,3 @@ By placing the SDK's resolver first, MCP types are serialized using the SDK's co
- [Versioning](versioning.md) - [Versioning](versioning.md)
- [List of diagnostics](list-of-diagnostics.md#experimental-apis) - [List of diagnostics](list-of-diagnostics.md#experimental-apis)
- [Tasks](concepts/tasks/tasks.md) (an experimental feature)

View file

@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T
| Diagnostic ID | Description | | Diagnostic ID | Description |
| :------------ | :---------- | | :------------ | :---------- |
| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [MCP Tasks specification](https://modelcontextprotocol.io/seps/1686-tasks)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). | | `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). |
| `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). | | `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). |
## Obsolete APIs ## Obsolete APIs

View file

@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp
### Next Spec Revision ### Next Spec Revision
The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol). The C# SDK already has experimental support for [Tasks](concepts/tasks/tasks.md) (experimental in the specification), which will be updated as the specification is revised. The next MCP specification revision is being developed in the [protocol repository](https://github.com/modelcontextprotocol/modelcontextprotocol).
### Feedback and End-to-End Scenarios ### Feedback and End-to-End Scenarios

View file

@ -1,393 +0,0 @@
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace LongRunningTasks;
/// <summary>
/// A minimal file-based implementation of <see cref="IMcpTaskStore"/> that demonstrates
/// durable, fault-tolerant task storage using simple time-based completion.
/// </summary>
/// <remarks>
/// <para>
/// This implementation stores task data to disk: task ID, creation timestamp, execution duration,
/// session ID, TTL, and optional result. Task completion is determined by:
/// <list type="bullet">
/// <item><description>Explicit completion or failure via <see cref="StoreTaskResultAsync"/></description></item>
/// <item><description>Explicit cancellation via <see cref="CancelTaskAsync"/></description></item>
/// <item><description>Time-based auto-completion when execution time has elapsed</description></item>
/// </list>
/// </para>
/// <para>
/// The file-based approach enables durability across process restarts - if the server
/// crashes and restarts, tasks can still be queried and will complete based on elapsed time.
/// </para>
/// </remarks>
public sealed partial class FileBasedMcpTaskStore : IMcpTaskStore
{
private readonly string _storePath;
private readonly TimeSpan _executionTime;
/// <summary>
/// Initializes a new instance of the <see cref="FileBasedMcpTaskStore"/> class.
/// </summary>
/// <param name="storePath">The directory path where task files will be stored.</param>
/// <param name="executionTime">
/// The fixed execution time for all tasks. Tasks are reported as completed once this
/// duration has elapsed since creation. Defaults to 5 seconds.
/// </param>
public FileBasedMcpTaskStore(string storePath, TimeSpan? executionTime = null)
{
_storePath = storePath ?? throw new ArgumentNullException(nameof(storePath));
_executionTime = executionTime ?? TimeSpan.FromSeconds(5);
Directory.CreateDirectory(_storePath);
}
/// <inheritdoc/>
public async Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskParams,
RequestId requestId,
JsonRpcRequest request,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var taskId = Guid.NewGuid().ToString("N");
var now = DateTimeOffset.UtcNow;
var entry = new TaskFileEntry
{
TaskId = taskId,
SessionId = sessionId,
Status = McpTaskStatus.Working,
CreatedAt = now,
ExecutionTime = _executionTime,
TimeToLive = taskParams.TimeToLive,
Result = JsonSerializer.SerializeToElement(request.Params, JsonContext.Default.JsonNode)
};
await WriteTaskEntryAsync(GetTaskFilePath(taskId), entry);
return ToMcpTask(entry);
}
/// <inheritdoc/>
public async Task<McpTask?> GetTaskAsync(
string taskId,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var entry = await ReadTaskEntryAsync(taskId);
if (entry is null)
{
return null;
}
// Session isolation
if (sessionId is not null && entry.SessionId != sessionId)
{
return null;
}
// Skip if TTL has expired
if (IsExpired(entry))
{
return null;
}
return ToMcpTask(entry);
}
/// <inheritdoc/>
public async Task<McpTask> StoreTaskResultAsync(
string taskId,
McpTaskStatus status,
JsonElement result,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
if (status is not (McpTaskStatus.Completed or McpTaskStatus.Failed))
{
throw new ArgumentException(
$"Status must be {nameof(McpTaskStatus.Completed)} or {nameof(McpTaskStatus.Failed)}.",
nameof(status));
}
var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry =>
{
var effectiveStatus = GetEffectiveStatus(entry);
if (IsTerminalStatus(effectiveStatus))
{
throw new InvalidOperationException(
$"Cannot store result for task in terminal state: {effectiveStatus}");
}
return entry with
{
Status = status,
Result = result
};
});
return ToMcpTask(updatedEntry);
}
/// <inheritdoc/>
public async Task<JsonElement> GetTaskResultAsync(
string taskId,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var entry = await ReadTaskEntryAsync(taskId)
?? throw new InvalidOperationException($"Task not found: {taskId}");
if (sessionId is not null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
var effectiveStatus = GetEffectiveStatus(entry);
if (!IsTerminalStatus(effectiveStatus))
{
throw new InvalidOperationException($"Task not yet completed: {taskId}");
}
// Return stored result
return entry.Result ?? default;
}
/// <inheritdoc/>
public async Task<McpTask> UpdateTaskStatusAsync(
string taskId,
McpTaskStatus status,
string? statusMessage,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry =>
entry with
{
Status = status,
StatusMessage = statusMessage
});
return ToMcpTask(updatedEntry);
}
/// <inheritdoc/>
public async Task<ListTasksResult> ListTasksAsync(
string? cursor = null,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var tasks = new List<McpTask>();
foreach (var file in Directory.EnumerateFiles(_storePath, "*.json"))
{
try
{
var entry = await ReadTaskEntryFromFileAsync(file);
if (entry is not null)
{
// Session isolation
if (sessionId is not null && entry.SessionId != sessionId)
{
continue;
}
// Skip expired tasks
if (IsExpired(entry))
{
continue;
}
tasks.Add(ToMcpTask(entry));
}
}
catch
{
// Skip corrupted or inaccessible files
}
}
tasks.Sort((a, b) => a.CreatedAt.CompareTo(b.CreatedAt));
return new ListTasksResult { Tasks = [.. tasks] };
}
/// <inheritdoc/>
public async Task<McpTask> CancelTaskAsync(
string taskId,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
var updatedEntry = await UpdateTaskEntryAsync(taskId, sessionId, entry =>
{
var effectiveStatus = GetEffectiveStatus(entry);
if (IsTerminalStatus(effectiveStatus))
{
// Already terminal, return unchanged
return entry;
}
return entry with { Status = McpTaskStatus.Cancelled };
});
return ToMcpTask(updatedEntry);
}
private string GetTaskFilePath(string taskId) => Path.Combine(_storePath, $"{taskId}.json");
/// <summary>
/// Reads, transforms, and writes a task entry while holding an exclusive file lock.
/// </summary>
/// <param name="taskId">The task ID to update.</param>
/// <param name="sessionId">Optional session ID for access control.</param>
/// <param name="updateFunc">A function that transforms the entry. May throw to abort the update.</param>
/// <returns>The updated task entry.</returns>
private async Task<TaskFileEntry> UpdateTaskEntryAsync(
string taskId,
string? sessionId,
Func<TaskFileEntry, TaskFileEntry> updateFunc)
{
var filePath = GetTaskFilePath(taskId);
// Acquire exclusive lock on the file for the entire read-modify-write cycle
using var stream = await AcquireFileStreamAsync(filePath, FileMode.Open, FileAccess.ReadWrite);
var entry = await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.TaskFileEntry)
?? throw new InvalidOperationException($"Task not found: {taskId}");
// Enforce session isolation
if (sessionId is not null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Apply the transformation (may throw to abort)
var updatedEntry = updateFunc(entry);
// Write back to the same stream
stream.SetLength(0);
stream.Position = 0;
await JsonSerializer.SerializeAsync(stream, updatedEntry, JsonContext.Default.TaskFileEntry);
return updatedEntry;
}
private async Task<TaskFileEntry?> ReadTaskEntryAsync(string taskId)
{
var filePath = GetTaskFilePath(taskId);
return File.Exists(filePath) ? await ReadTaskEntryFromFileAsync(filePath) : null;
}
private static async Task<TaskFileEntry?> ReadTaskEntryFromFileAsync(string filePath)
{
try
{
using var stream = await AcquireFileStreamAsync(filePath, FileMode.Open, FileAccess.Read);
return await JsonSerializer.DeserializeAsync(stream, JsonContext.Default.TaskFileEntry);
}
catch
{
return null;
}
}
private static async Task WriteTaskEntryAsync(string filePath, TaskFileEntry entry)
{
using var stream = await AcquireFileStreamAsync(filePath, FileMode.Create, FileAccess.Write);
await JsonSerializer.SerializeAsync(stream, entry, JsonContext.Default.TaskFileEntry);
}
private static async Task<FileStream> AcquireFileStreamAsync(string filePath, FileMode fileMode, FileAccess fileAccess)
{
const int MaxRetries = 10;
const int RetryDelayMs = 50;
for (int attempt = 0; ; attempt++)
{
try
{
return new FileStream(filePath, fileMode, fileAccess, FileShare.None);
}
catch (IOException) when (attempt < MaxRetries)
{
await Task.Delay(RetryDelayMs); // File is locked by another process, wait and retry
}
}
}
private McpTask ToMcpTask(TaskFileEntry entry)
{
var now = DateTimeOffset.UtcNow;
return new McpTask
{
TaskId = entry.TaskId,
Status = GetEffectiveStatus(entry),
StatusMessage = entry.StatusMessage,
CreatedAt = entry.CreatedAt,
LastUpdatedAt = now,
TimeToLive = entry.TimeToLive
};
}
private static McpTaskStatus GetEffectiveStatus(TaskFileEntry entry)
{
// If already in a terminal state, return it
if (IsTerminalStatus(entry.Status))
{
return entry.Status;
}
// Check if execution time has elapsed - auto-complete
if (DateTimeOffset.UtcNow - entry.CreatedAt >= entry.ExecutionTime)
{
return McpTaskStatus.Completed;
}
return entry.Status;
}
private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
private static bool IsExpired(TaskFileEntry entry) =>
entry.TimeToLive.HasValue && DateTimeOffset.UtcNow - entry.CreatedAt > entry.TimeToLive.Value;
/// <summary>
/// Represents the data stored for each task.
/// </summary>
private sealed record TaskFileEntry
{
/// <summary>The unique task identifier.</summary>
public required string TaskId { get; init; }
/// <summary>The session that created this task.</summary>
public string? SessionId { get; init; }
/// <summary>The current task status.</summary>
public required McpTaskStatus Status { get; init; }
/// <summary>Optional status message describing the current state.</summary>
public string? StatusMessage { get; init; }
/// <summary>When the task was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>How long until the task is considered complete (if not explicitly completed).</summary>
public required TimeSpan ExecutionTime { get; init; }
/// <summary>Time to live - task is filtered out after this duration from creation.</summary>
public TimeSpan? TimeToLive { get; init; }
/// <summary>The task result - initialized with request params, updated via StoreTaskResultAsync.</summary>
public JsonElement? Result { get; init; }
}
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(TaskFileEntry))]
[JsonSerializable(typeof(JsonNode))]
private sealed partial class JsonContext : JsonSerializerContext;
}

View file

@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
</ItemGroup>
</Project>

View file

@ -1,34 +0,0 @@
// This sample demonstrates using a custom IMcpTaskStore implementation for
// durable task storage. The FileBasedMcpTaskStore persists tasks to disk,
// allowing them to survive server restarts.
//
// To test:
// 1. Start the server and call the SubmitJob tool
// 2. Poll the returned task using tasks/get
// 3. Optionally restart the server - the task will still be queryable
using LongRunningTasks;
using LongRunningTasks.Tools;
var builder = WebApplication.CreateBuilder(args);
// Use a file-based task store for persistence across server restarts.
// Tasks survive server restarts and can be resumed or queried after a crash.
var taskStorePath = Path.Combine(Path.GetTempPath(), "mcp-tasks");
var taskStore = new FileBasedMcpTaskStore(taskStorePath);
builder.Services.AddMcpServer(options =>
{
options.TaskStore = taskStore;
options.ServerInfo = new()
{
Name = "LongRunningTasksServer",
Version = "1.0.0"
};
})
.WithHttpTransport(o => o.Stateless = true)
.WithTools<TaskTools>();
var app = builder.Build();
app.MapMcp();
app.Run();

View file

@ -1,12 +0,0 @@
{
"profiles": {
"LongRunningTasks": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60964;http://localhost:60965"
}
}
}

View file

@ -1,3 +0,0 @@
# Long-Running Tasks Sample
This sample demonstrates **explicit task handling** in MCP servers using the `IMcpTaskStore` interface directly. Unlike implicit task handling (where the server framework manages tasks automatically), this approach gives you full control over task lifecycle.

View file

@ -1,31 +0,0 @@
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace LongRunningTasks.Tools;
/// <summary>
/// Demonstrates creating and returning tasks via <see cref="IMcpTaskStore"/>.
/// </summary>
[McpServerToolType]
public class TaskTools(IMcpTaskStore taskStore)
{
/// <summary>
/// Submits a job to the task store and returns a task handle for polling.
/// </summary>
[McpServerTool]
[Description("Submits a job and returns a task that can be polled for completion.")]
public Task<McpTask> SubmitJob(
[Description("A label for the job")] string jobName,
RequestContext<CallToolRequestParams> context,
CancellationToken cancellationToken)
{
return taskStore.CreateTaskAsync(
new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) },
context.JsonRpcRequest.Id!,
context.JsonRpcRequest,
context.Server.SessionId,
cancellationToken);
}
}

View file

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "localhost;127.0.0.1;[::1]"
}

View file

@ -0,0 +1,109 @@
// Demonstrates the MCP tasks extension (SEP-2663):
//
// - A server is configured with InMemoryMcpTaskStore 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.
//
// 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.
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.IO.Pipelines;
using System.Text.Json;
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" })],
});
_ = 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();
Console.WriteLine("=== CallToolRawAsync (manual poll) ===");
var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" });
if (!raw.IsTask)
{
// Either the server doesn't advertise the tasks extension or it chose to run the call
// synchronously despite the client opt-in. Surface the inline result and stop.
Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}");
return;
}
CreateTaskResult created = raw.TaskCreated!;
Console.WriteLine($" task created: id={created.TaskId} status={created.Status} pollIntervalMs={created.PollIntervalMs}");
long pollIntervalMs = created.PollIntervalMs ?? 1000;
int pollCount = 0;
while (true)
{
await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs));
pollCount++;
var state = await client.GetTaskAsync(created.TaskId);
if (state.PollIntervalMs is { } updated)
{
pollIntervalMs = updated;
}
switch (state)
{
case CompletedTaskResult completed:
// The Result property carries the wrapped CallToolResult as a raw JsonElement.
var callToolResult = completed.Result.Deserialize<CallToolResult>()!;
Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}");
return;
case FailedTaskResult failed:
Console.WriteLine($" task failed: {failed.Error}");
return;
case CancelledTaskResult:
Console.WriteLine(" task was cancelled");
return;
case WorkingTaskResult:
Console.WriteLine($" poll {pollCount}: still working …");
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;
}
}
internal static class SlowTools
{
[Description("Runs a short simulated report and returns when it's done.")]
public static async Task<string> RunReport(CancellationToken cancellationToken)
{
// Real-world workloads would do meaningful work here; we just sleep so the polling
// path is observable in the console output.
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
return "report ready";
}
}

View file

@ -0,0 +1,50 @@
# Tasks Extension Sample
Demonstrates the MCP tasks extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)) end-to-end in a single process.
The server is configured with an in-memory `IMcpTaskStore`, which is sufficient to make any
`[McpServerTool]` method automatically run as a background task when the client opts into the
tasks extension on a per-request basis.
The client invokes the same `run-report` tool two ways:
1. **`CallToolAsync` (auto-poll)** — the SDK injects the opt-in marker into `_meta`, polls
`tasks/get` at the cadence the server suggests, dispatches any input requests through
registered handlers, and returns the final `CallToolResult` (or throws on a terminal
`Failed`/`Cancelled`).
2. **`CallToolRawAsync` (manual poll)** — the caller receives the raw
`ResultOrCreatedTask<CallToolResult>` and drives the lifecycle directly with
`GetTaskAsync`, `UpdateTaskAsync`, and `CancelTaskAsync`. Useful when you need to surface
progress to a UI or stream task status updates between polls.
Both ends of the conversation are connected in-process over an in-memory `Pipe`, so no separate
server process or HTTP transport is required.
## Run
```bash
dotnet run --project samples/TasksExtension/TasksExtension.csproj
```
Expected output:
```
=== CallToolAsync (auto-poll) ===
result: report ready
=== CallToolRawAsync (manual poll) ===
task created: id=… status=Working pollIntervalMs=250
poll 1: still working …
…
task completed after N poll(s): report ready
```
## Notes
- The `MCPEXP001` warning is suppressed because the tasks extension is still experimental. The
project's `<NoWarn>` already includes it; if you copy this pattern into your own project,
either suppress the diagnostic or wrap the experimental APIs in
`#pragma warning disable MCPEXP001`.
- For production deployments — especially stateless HTTP servers — implement
`IMcpTaskStore` against durable storage and register it as a singleton (see
[docs/concepts/tasks/tasks.md](../../docs/concepts/tasks/tasks.md) for the contract).

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol\ModelContextProtocol.csproj" />
</ItemGroup>
</Project>

View file

@ -10,7 +10,7 @@ namespace ModelContextProtocol;
/// <list type="bullet"> /// <list type="bullet">
/// <item><description> /// <item><description>
/// <c>MCPEXP001</c> covers APIs related to experimental features in the MCP specification itself, /// <c>MCPEXP001</c> covers APIs related to experimental features in the MCP specification itself,
/// such as Tasks and Extensions. These APIs may change as the specification evolves. /// such as Extensions. These APIs may change as the specification evolves.
/// </description></item> /// </description></item>
/// <item><description> /// <item><description>
/// <c>MCPEXP002</c> covers experimental SDK APIs that are unrelated to the MCP specification, /// <c>MCPEXP002</c> covers experimental SDK APIs that are unrelated to the MCP specification,
@ -35,30 +35,9 @@ namespace ModelContextProtocol;
/// </remarks> /// </remarks>
internal static class Experimentals internal static class Experimentals
{ {
/// <summary>
/// Diagnostic ID for the experimental MCP Tasks feature.
/// </summary>
public const string Tasks_DiagnosticId = "MCPEXP001";
/// <summary>
/// Message for the experimental MCP Tasks feature.
/// </summary>
public const string Tasks_Message = "The Tasks feature is experimental per the MCP specification and is subject to change.";
/// <summary>
/// URL for the experimental MCP Tasks feature.
/// </summary>
public const string Tasks_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
/// <summary> /// <summary>
/// Diagnostic ID for the experimental MCP Extensions feature. /// Diagnostic ID for the experimental MCP Extensions feature.
/// </summary> /// </summary>
/// <remarks>
/// This uses the same diagnostic ID as <see cref="Tasks_DiagnosticId"/> because both
/// Tasks and Extensions are covered by the same MCPEXP001 diagnostic for experimental
/// MCP features. Having separate constants improves code clarity while maintaining a
/// single diagnostic suppression point.
/// </remarks>
public const string Extensions_DiagnosticId = "MCPEXP001"; public const string Extensions_DiagnosticId = "MCPEXP001";
/// <summary> /// <summary>
@ -115,7 +94,7 @@ internal static class Experimentals
/// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature. /// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This uses the same diagnostic ID as <see cref="Tasks_DiagnosticId"/> because MRTR /// This uses the same diagnostic ID as <see cref="Extensions_DiagnosticId"/> because MRTR
/// is an experimental feature in the MCP specification (SEP-2322). /// is an experimental feature in the MCP specification (SEP-2322).
/// </remarks> /// </remarks>
public const string Mrtr_DiagnosticId = "MCPEXP001"; public const string Mrtr_DiagnosticId = "MCPEXP001";

View file

@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using System.Diagnostics; using System.Diagnostics;
@ -887,6 +887,13 @@ public abstract partial class McpClient : McpSession
/// <returns>The <see cref="CallToolResult"/> from the tool execution.</returns> /// <returns>The <see cref="CallToolResult"/> from the tool execution.</returns>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception> /// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception> /// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This overload supports the tasks extension transparently. If the server responds with a
/// task handle rather than an immediate result, this method polls <c>tasks/get</c> until the
/// task completes, dispatching any <see cref="McpTaskStatus.InputRequired"/> entries through
/// the client's registered sampling and elicitation handlers along the way. Use
/// <see cref="CallToolRawAsync"/> to disable automatic polling.
/// </remarks>
public ValueTask<CallToolResult> CallToolAsync( public ValueTask<CallToolResult> CallToolAsync(
string toolName, string toolName,
IReadOnlyDictionary<string, object?>? arguments = null, IReadOnlyDictionary<string, object?>? arguments = null,
@ -957,342 +964,216 @@ public abstract partial class McpClient : McpSession
/// <returns>The result of the request.</returns> /// <returns>The result of the request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception> /// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception> /// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<CallToolResult> CallToolAsync( /// <remarks>
/// This method automatically includes the <c>io.modelcontextprotocol/tasks</c> extension capability
/// in the request metadata. If the server returns a task handle instead of an immediate result,
/// this method transparently polls <c>tasks/get</c> until the task completes, fails, or is cancelled.
/// Use <see cref="CallToolRawAsync"/>
/// to receive the raw <see cref="ResultOrCreatedTask{TResult}"/> without automatic polling.
/// </remarks>
public async ValueTask<CallToolResult> CallToolAsync(
CallToolRequestParams requestParams, CallToolRequestParams requestParams,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Throw.IfNull(requestParams); Throw.IfNull(requestParams);
return SendRequestAsync( var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false);
RequestMethods.ToolsCall,
requestParams, if (!augmented.IsTask)
McpJsonUtilities.JsonContext.Default.CallToolRequestParams, {
McpJsonUtilities.JsonContext.Default.CallToolResult, return augmented.Result!;
cancellationToken: cancellationToken); }
return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false);
} }
/// <summary> /// <summary>
/// Invokes a tool on the server as a task for long-running operations. /// Polls a task until it reaches a terminal state and returns the final <see cref="CallToolResult"/>.
/// </summary> /// </summary>
/// <param name="toolName">The name of the tool to call on the server.</param> private async ValueTask<CallToolResult> PollTaskToCompletionAsync(
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param> CreateTaskResult taskCreated,
/// <param name="taskMetadata">Metadata for task augmentation, including optional TTL. If <see langword="null"/>, an empty metadata is used.</param>
/// <param name="progress">An optional progress reporter for server notifications.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// An <see cref="McpTask"/> representing the created task. Use <see cref="GetTaskAsync"/> to poll for status updates
/// and <see cref="GetTaskResultAsync"/> to retrieve the final result.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// <para>
/// Task-augmented tool calls allow long-running operations to be executed asynchronously. Instead of blocking
/// until the tool completes, the server immediately returns a task identifier that can be used to poll for
/// status updates and retrieve the final result.
/// </para>
/// <para>
/// The server must advertise task support via <c>capabilities.tasks.requests.tools.call</c> and the tool
/// must have <c>execution.taskSupport</c> set to <c>"optional"</c> or <c>"required"</c>.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public ValueTask<McpTask> CallToolAsTaskAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments = null,
McpTaskMetadata? taskMetadata = null,
IProgress<ProgressNotificationValue>? progress = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(toolName);
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
if (progress is null)
{
return SendTaskAugmentedCallToolRequestAsync(toolName, arguments, taskMetadata, options?.GetMetaForRequest(), serializerOptions, cancellationToken);
}
return SendTaskAugmentedCallToolRequestWithProgressAsync(toolName, arguments, taskMetadata, progress, options?.GetMetaForRequest(), serializerOptions, cancellationToken);
async ValueTask<McpTask> SendTaskAugmentedCallToolRequestAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments,
McpTaskMetadata? taskMetadata,
JsonObject? meta,
JsonSerializerOptions serializerOptions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await SendRequestAsync( // If the server claims InputRequired but never publishes new input requests after we have
RequestMethods.ToolsCall, // already responded to everything it asked for, treat that as a stuck task. The client
new CallToolRequestParams // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll
{ // loop when the server is misbehaving. The threshold is configurable via
Name = toolName, // McpClientOptions.MaxConsecutiveStuckPolls.
Arguments = ToArgumentsDictionary(arguments, serializerOptions), int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls;
Meta = meta,
Task = taskMetadata ?? new McpTaskMetadata(),
},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
return result.Task; string taskId = taskCreated.TaskId;
long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000;
HashSet<string>? 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;
} }
async ValueTask<McpTask> SendTaskAugmentedCallToolRequestWithProgressAsync( switch (taskResult)
string toolName,
IReadOnlyDictionary<string, object?>? arguments,
McpTaskMetadata? taskMetadata,
IProgress<ProgressNotificationValue> progress,
JsonObject? meta,
JsonSerializerOptions serializerOptions,
CancellationToken cancellationToken)
{ {
ProgressToken progressToken = new(Guid.NewGuid().ToString("N")); case CompletedTaskResult completed:
return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult)
?? throw new JsonException("Failed to deserialize CallToolResult from completed task.");
await using var _ = RegisterNotificationHandler(NotificationMethods.ProgressNotification, case FailedTaskResult failed:
(notification, cancellationToken) => 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<string, InputRequest>();
if (inputRequired.InputRequests is { } incomingRequests)
{ {
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn && foreach (var kvp in incomingRequests)
pn.ProgressToken == progressToken)
{ {
progress.Report(pn.Progress); if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key))
{
newRequests[kvp.Key] = kvp.Value;
}
}
} }
return default; if (newRequests.Count > 0)
}).ConfigureAwait(false);
JsonObject metaWithProgress = meta is not null ? (JsonObject)meta.DeepClone() : [];
metaWithProgress["progressToken"] = progressToken.ToString();
var result = await SendRequestAsync(
RequestMethods.ToolsCall,
new CallToolRequestParams
{ {
Name = toolName, consecutiveStuckPolls = 0;
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
Meta = metaWithProgress,
Task = taskMetadata ?? new McpTaskMetadata(),
},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
return result.Task; IDictionary<string, InputResponse> 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<string>(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}'.");
}
} }
} }
/// <summary> /// <summary>
/// Retrieves the current state of a specific task from the server. /// 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.
/// </summary> /// </summary>
/// <param name="taskId">The unique identifier of the task to retrieve.</param> /// <param name="requestParams">The request parameters to send. The tasks extension capability will be injected into the request metadata.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param> /// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The current state of the task.</returns> /// <returns>A <see cref="ResultOrCreatedTask{TResult}"/> that is either an immediate result or a task handle.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> GetTaskAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
var result = await SendRequestAsync(
RequestMethods.TasksGet,
new GetTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
McpJsonUtilities.JsonContext.Default.GetTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Convert GetTaskResult to McpTask
return new McpTask
{
TaskId = result.TaskId,
Status = result.Status,
StatusMessage = result.StatusMessage,
CreatedAt = result.CreatedAt,
LastUpdatedAt = result.LastUpdatedAt,
TimeToLive = result.TimeToLive,
PollInterval = result.PollInterval
};
}
/// <summary>
/// Retrieves the result of a completed task, blocking until the task reaches a terminal state.
/// </summary>
/// <param name="taskId">The unique identifier of the task whose result to retrieve.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The raw JSON result of the task.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// This method sends a tasks/result request to the server, which will block until the task completes if it hasn't already.
/// The server handles all polling logic internally.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public ValueTask<JsonElement> GetTaskResultAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
return SendRequestAsync(
RequestMethods.TasksResult,
new GetTaskPayloadRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement,
cancellationToken: cancellationToken);
}
/// <summary>
/// Retrieves a list of all tasks from the server.
/// </summary>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A list of all tasks.</returns>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<IList<McpTask>> ListTasksAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
ListTasksRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
List<McpTask> tasks = new();
do
{
var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false);
tasks.AddRange(taskResults.Tasks);
requestParams.Cursor = taskResults.NextCursor;
}
while (requestParams.Cursor is not null);
return tasks;
}
/// <summary>
/// Retrieves a list of tasks from the server.
/// </summary>
/// <param name="requestParams">The request parameters to send in the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result of the request as provided by the server.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception> /// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception> /// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks> /// <remarks>
/// The <see cref="ListTasksAsync(RequestOptions?, CancellationToken)"/> overload retrieves all tasks by automatically handling pagination. /// <para>
/// This overload works with the lower-level <see cref="ListTasksRequestParams"/> and <see cref="ListTasksResult"/>, returning the raw result from the server. /// Unlike <see cref="CallToolAsync(CallToolRequestParams, CancellationToken)"/>, this method does not
/// Any pagination needs to be managed by the caller. /// automatically poll for task completion. If the server returns a <see cref="CreateTaskResult"/>,
/// the caller must manage polling via <see cref="GetTaskAsync(string, CancellationToken)"/>.
/// </para>
/// </remarks> /// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public async ValueTask<ResultOrCreatedTask<CallToolResult>> CallToolRawAsync(
public ValueTask<ListTasksResult> ListTasksAsync( CallToolRequestParams requestParams,
ListTasksRequestParams requestParams,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Throw.IfNull(requestParams); Throw.IfNull(requestParams);
return SendRequestAsync( var paramsWithMeta = new CallToolRequestParams
RequestMethods.TasksList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
McpJsonUtilities.JsonContext.Default.ListTasksResult,
cancellationToken: cancellationToken);
}
/// <summary>
/// Cancels a running task on the server.
/// </summary>
/// <param name="taskId">The unique identifier of the task to cancel.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The updated state of the task after cancellation.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// Cancelling a task requests that the server stop execution. The server may not immediately cancel the task,
/// and may choose to allow the task to complete if it's close to finishing.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> CancelTaskAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{ {
Throw.IfNullOrWhiteSpace(taskId); Name = requestParams.Name,
Arguments = requestParams.Arguments,
var result = await SendRequestAsync( Meta = GetMetaWithTaskCapability(requestParams.Meta),
RequestMethods.TasksCancel,
new CancelMcpTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams,
McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
// Convert CancelMcpTaskResult to McpTask
return new McpTask
{
TaskId = result.TaskId,
Status = result.Status,
StatusMessage = result.StatusMessage,
CreatedAt = result.CreatedAt,
LastUpdatedAt = result.LastUpdatedAt,
TimeToLive = result.TimeToLive,
PollInterval = result.PollInterval
}; };
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<string>() == "task")
{
var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult)
?? throw new JsonException("Failed to deserialize CreateTaskResult from response.");
return new ResultOrCreatedTask<CallToolResult>(taskCreated);
} }
/// <summary> var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult)
/// Polls a task until it reaches a terminal status (completed, failed, or cancelled). ?? throw new JsonException("Failed to deserialize CallToolResult from response.");
/// </summary> return new ResultOrCreatedTask<CallToolResult>(callToolResult);
/// <param name="taskId">The unique identifier of the task to poll.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The task in its terminal state.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
/// <remarks>
/// <para>
/// This method repeatedly calls <see cref="GetTaskAsync"/> until the task reaches a terminal status.
/// It respects the <see cref="McpTask.PollInterval"/> returned by the server to determine how long
/// to wait between polling attempts.
/// </para>
/// <para>
/// For retrieving the actual result of a completed task, use <see cref="GetTaskResultAsync"/>.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> PollTaskUntilCompleteAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
McpTask task;
do
{
task = await GetTaskAsync(taskId, options, cancellationToken).ConfigureAwait(false);
// If task is in a terminal state, we're done
if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled)
{
break;
}
// Wait for the poll interval before checking again (default to 1 second)
var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1);
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
while (true);
return task;
} }
/// <summary> /// <summary>
@ -1347,6 +1228,106 @@ public abstract partial class McpClient : McpSession
cancellationToken: cancellationToken).AsTask(); cancellationToken: cancellationToken).AsTask();
} }
/// <summary>
/// Retrieves the current state of a task from the server.
/// </summary>
/// <param name="taskId">The stable identifier of the task to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="GetTaskResult"/> subtype representing the current task state.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<GetTaskResult> GetTaskAsync(
string taskId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(taskId);
return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken);
}
/// <summary>
/// Retrieves the current state of a task from the server.
/// </summary>
/// <param name="requestParams">The request parameters to send in the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="GetTaskResult"/> subtype representing the current task state.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<GetTaskResult> GetTaskAsync(
GetTaskRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
return SendRequestAsync(
RequestMethods.TasksGet,
requestParams,
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
McpJsonUtilities.JsonContext.Default.GetTaskResult,
cancellationToken: cancellationToken);
}
/// <summary>
/// Provides input responses to a task that is in the <see cref="McpTaskStatus.InputRequired"/> state.
/// </summary>
/// <param name="requestParams">The request parameters containing the task ID and input responses.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result acknowledging the update.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<UpdateTaskResult> UpdateTaskAsync(
UpdateTaskRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
return SendRequestAsync(
RequestMethods.TasksUpdate,
requestParams,
McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
McpJsonUtilities.JsonContext.Default.UpdateTaskResult,
cancellationToken: cancellationToken);
}
/// <summary>
/// Requests cancellation of an in-progress task on the server.
/// </summary>
/// <param name="taskId">The stable identifier of the task to cancel.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result acknowledging the cancellation request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<CancelTaskResult> CancelTaskAsync(
string taskId,
CancellationToken cancellationToken = default)
{
Throw.IfNull(taskId);
return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken);
}
/// <summary>
/// Requests cancellation of an in-progress task on the server.
/// </summary>
/// <param name="requestParams">The request parameters to send in the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result acknowledging the cancellation request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
public ValueTask<CancelTaskResult> CancelTaskAsync(
CancelTaskRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
return SendRequestAsync(
RequestMethods.TasksCancel,
requestParams,
McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
McpJsonUtilities.JsonContext.Default.CancelTaskResult,
cancellationToken: cancellationToken);
}
/// <summary>Converts a dictionary with <see cref="object"/> values to a dictionary with <see cref="JsonElement"/> values.</summary> /// <summary>Converts a dictionary with <see cref="object"/> values to a dictionary with <see cref="JsonElement"/> values.</summary>
private static Dictionary<string, JsonElement>? ToArgumentsDictionary( private static Dictionary<string, JsonElement>? ToArgumentsDictionary(
IReadOnlyDictionary<string, object?>? arguments, JsonSerializerOptions options) IReadOnlyDictionary<string, object?>? arguments, JsonSerializerOptions options)
@ -1365,4 +1346,33 @@ public abstract partial class McpClient : McpSession
return result; 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 = {}
// TODO: replace the literal with a shared NotificationMethods.ClientCapabilitiesMetaKey once
// the SEP-2575 plumbing lands and drop the local consts.
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
private const string ExtensionsKey = "extensions";
private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta)
{
JsonObject meta = existingMeta is not null
? (JsonObject)existingMeta.DeepClone()
: [];
if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot)
{
capsRoot = [];
meta[ClientCapabilitiesMetaKey] = capsRoot;
}
if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot)
{
extensionsRoot = [];
capsRoot[ExtensionsKey] = extensionsRoot;
}
extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject());
return meta;
}
} }

View file

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using ModelContextProtocol.Protocol; using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Client; namespace ModelContextProtocol.Client;
@ -71,6 +72,26 @@ public abstract partial class McpClient : McpSession
/// </remarks> /// </remarks>
public abstract Task<ClientCompletionDetails> Completion { get; } public abstract Task<ClientCompletionDetails> Completion { get; }
/// <summary>
/// Resolves input requests embedded in an <see cref="InputRequiredTaskResult"/> by dispatching
/// each request to the appropriate registered handler.
/// </summary>
/// <param name="inputRequests">
/// The input requests from the task, keyed by request identifier. Each value is an
/// <see cref="InputRequest"/> wrapping the server-to-client request payload.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A dictionary of responses keyed by the same identifiers as the input requests.</returns>
private protected abstract ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
IDictionary<string, InputRequest> inputRequests, CancellationToken cancellationToken);
/// <summary>
/// Gets the maximum number of consecutive stuck-in-<see cref="McpTaskStatus.InputRequired"/> polls
/// allowed by <see cref="PollTaskToCompletionAsync"/> before the client cancels and throws.
/// Sourced from <see cref="McpClientOptions.MaxConsecutiveStuckPolls"/>.
/// </summary>
private protected abstract int MaxConsecutiveStuckPolls { get; }
/// <summary> /// <summary>
/// Registers one or more tool definitions in the client's tool cache, enabling the transport /// Registers one or more tool definitions in the client's tool cache, enabling the transport
/// to send <c>Mcp-Param-*</c> headers for those tools without requiring a prior <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call. /// to send <c>Mcp-Param-*</c> headers for those tools without requiring a prior <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> call.

Some files were not shown because too many files have changed in this diff Show more