Implement SEP-2663 Tasks Extension (#1579)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ed07fdb6f4
commit
dbb7a20176
119 changed files with 8190 additions and 11405 deletions
|
|
@ -25,6 +25,7 @@
|
|||
<!-- Product dependencies .NET Standard -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="$(System10Version)" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="$(System10Version)" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="$(System10Version)" />
|
||||
<PackageVersion Include="System.IO.Pipelines" Version="$(System10Version)" />
|
||||
<PackageVersion Include="System.Text.Json" Version="$(System10Version)" />
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@
|
|||
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
|
||||
<Project Path="samples/EverythingServer/EverythingServer.csproj" />
|
||||
<Project Path="samples/InMemoryTransport/InMemoryTransport.csproj" />
|
||||
<Project Path="samples/LongRunningTasks/LongRunningTasks.csproj" />
|
||||
<Project Path="samples/ProtectedMcpClient/ProtectedMcpClient.csproj" />
|
||||
<Project Path="samples/ProtectedMcpServer/ProtectedMcpServer.csproj" />
|
||||
<Project Path="samples/QuickstartClient/QuickstartClient.csproj" />
|
||||
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
|
||||
<Project Path="samples/TasksExtension/TasksExtension.csproj" />
|
||||
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
|
|
|
|||
|
|
@ -568,7 +568,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ
|
|||
|
||||
## Tasks and session modes
|
||||
|
||||
[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an <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
|
||||
|
||||
|
|
@ -578,9 +578,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses
|
|||
|
||||
### Stateful mode
|
||||
|
||||
In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in <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
|
||||
|
||||
|
|
@ -632,7 +632,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira
|
|||
|
||||
### With tasks (experimental)
|
||||
|
||||
[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the <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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,604 +1,375 @@
|
|||
---
|
||||
title: Tasks
|
||||
author: eiriktsarpalis
|
||||
description: MCP Tasks for Long-Running Operations
|
||||
description: Run long-running tool invocations asynchronously with status polling and input requests.
|
||||
uid: tasks
|
||||
---
|
||||
|
||||
# MCP Tasks
|
||||
## Tasks
|
||||
|
||||
<!-- mlc-disable-next-line -->
|
||||
> [!WARNING]
|
||||
> 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.
|
||||
Tasks let an MCP server run a request asynchronously and report its result to the client later. The
|
||||
primary use case today is long-running tool invocations: the tool is offloaded to a background task,
|
||||
and the client polls for status, optionally exchanging additional input along the way.
|
||||
|
||||
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
|
||||
- Complex report generation
|
||||
- Code migration or refactoring operations
|
||||
- Machine learning inference or training
|
||||
- Batch data transformations
|
||||
#### Task lifecycle
|
||||
|
||||
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
|
||||
2. Disconnect and reconnect later
|
||||
3. Poll for status updates
|
||||
4. Retrieve results when complete
|
||||
5. Cancel operations if needed
|
||||
<xref:ModelContextProtocol.Protocol.McpTaskStatus> wire values are serialized in snake_case:
|
||||
`working`, `input_required`, `completed`, `cancelled`, `failed`.
|
||||
|
||||
## 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 |
|
||||
|--------|-------------|
|
||||
| `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 |
|
||||
#### Using the task store
|
||||
|
||||
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.
|
||||
|
||||
## Server Implementation
|
||||
|
||||
### Configuring Task Support
|
||||
|
||||
To enable task support on a server, configure a task store when setting up the MCP server:
|
||||
The easiest way to enable tasks is to set an <xref:ModelContextProtocol.Server.IMcpTaskStore>
|
||||
on <xref:ModelContextProtocol.Server.McpServerOptions.TaskStore?displayProperty=nameWithType>.
|
||||
The SDK ships <xref:ModelContextProtocol.Server.InMemoryMcpTaskStore> for development and tests:
|
||||
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Create a task store for managing task state
|
||||
var taskStore = new InMemoryMcpTaskStore();
|
||||
#pragma warning disable MCPEXP001
|
||||
|
||||
builder.Services.AddMcpServer(options =>
|
||||
{
|
||||
// Enable tasks by providing a task store
|
||||
options.TaskStore = taskStore;
|
||||
options.TaskStore = new InMemoryMcpTaskStore();
|
||||
})
|
||||
.WithHttpTransport(o => o.Stateless = true)
|
||||
.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
|
||||
var taskStore = new InMemoryMcpTaskStore(
|
||||
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
|
||||
options.Handlers.GetTaskHandler = (context, ct) =>
|
||||
{
|
||||
// This tool automatically supports task-augmented calls
|
||||
// because it returns Task<string> (async method)
|
||||
[McpServerTool, Description("Processes a large dataset")]
|
||||
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 taskId = context.Params!.TaskId;
|
||||
// … look up state and return one of the GetTaskResult subtypes.
|
||||
return new ValueTask<GetTaskResult>(new WorkingTaskResult { TaskId = taskId, /* … */ });
|
||||
};
|
||||
|
||||
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 -->
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting
|
||||
> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get`
|
||||
> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured
|
||||
> deployments fail loudly instead of shipping unpollable tasks.
|
||||
|
||||
## 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
|
||||
public class DatabaseTaskStore : IMcpTaskStore
|
||||
options.Handlers.CallToolWithTaskHandler = async (context, ct) =>
|
||||
{
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public DatabaseTaskStore(IDbConnection db) => _db = db;
|
||||
|
||||
public async Task<McpTask> CreateTaskAsync(
|
||||
McpTaskMetadata taskMetadata,
|
||||
RequestId requestId,
|
||||
JsonRpcRequest request,
|
||||
string? sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
if (ShouldRunInline(context.Params!))
|
||||
{
|
||||
var task = new McpTask
|
||||
return new CallToolResult { Content = [/* … */] };
|
||||
}
|
||||
|
||||
var taskId = await StartBackgroundWorkAsync(context.Params!, ct);
|
||||
return new CreateTaskResult
|
||||
{
|
||||
TaskId = Guid.NewGuid().ToString(),
|
||||
TaskId = taskId,
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
TimeToLive = taskMetadata.TimeToLive ?? TimeSpan.FromHours(1)
|
||||
PollIntervalMs = 1000,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
// Store in database
|
||||
await _db.ExecuteAsync(
|
||||
"INSERT INTO Tasks (TaskId, SessionId, Status, ...) VALUES (@TaskId, @SessionId, @Status, ...)",
|
||||
new { task.TaskId, sessionId, task.Status, ... });
|
||||
> <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolHandler?displayProperty=nameWithType>
|
||||
> and <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
|
||||
> are mutually exclusive. Setting one while the other is already non-null throws
|
||||
> `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);
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
public async Task<McpTask?> GetTaskAsync(
|
||||
#### 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? sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Retrieve from database with session isolation
|
||||
return await _db.QuerySingleOrDefaultAsync<McpTask>(
|
||||
"SELECT * FROM Tasks WHERE TaskId = @TaskId AND SessionId = @SessionId",
|
||||
new { taskId, sessionId });
|
||||
// 1. Atomically persist the resolved requests, ignoring keys that are no longer
|
||||
// outstanding or that target a terminal task.
|
||||
await PersistResolvedResponsesAsync(taskId, inputResponses, cancellationToken);
|
||||
|
||||
// 2. Then notify subscribers so any awaiting server.ElicitAsync/SampleAsync resumes.
|
||||
foreach (var kvp in inputResponses)
|
||||
{
|
||||
InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs
|
||||
{
|
||||
TaskId = taskId,
|
||||
RequestId = kvp.Key,
|
||||
Response = kvp.Value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
2. **TTL Enforcement**: Implement background cleanup of expired tasks
|
||||
3. **Thread Safety**: Ensure all operations are thread-safe for concurrent access
|
||||
4. **Atomic Updates**: Use database transactions for status transitions
|
||||
5. **Optimistic Concurrency**: Prevent lost updates with version checking or row locks
|
||||
<xref:ModelContextProtocol.Protocol.McpTaskStatus.Completed> is the terminal status whenever the
|
||||
underlying request produced its standard result, *including a
|
||||
<xref:ModelContextProtocol.Protocol.CallToolResult> with `IsError = true`*. Per SEP-2663,
|
||||
tool-level error results are not promoted to `Failed`.
|
||||
|
||||
## 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 |
|
||||
|------------|----------|
|
||||
| `InvalidParams` | Invalid or nonexistent task ID or invalid cursor |
|
||||
| `InvalidParams` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata |
|
||||
| `InternalError` | Task execution failure or result unavailable |
|
||||
Per SEP-2663, `tasks/cancel` is **eventually consistent and cooperative**: the server acknowledges
|
||||
the request immediately, but is not required to actually stop the work or to transition to
|
||||
`Cancelled`. The notifications-cancelled mechanism (used for plain JSON-RPC requests) is not used
|
||||
for task cancellation; clients must use `tasks/cancel`.
|
||||
|
||||
Example error handling:
|
||||
In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore`:
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var task = await client.GetTaskAsync(taskId, cancellationToken: ct);
|
||||
}
|
||||
catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.InvalidParams)
|
||||
{
|
||||
Console.WriteLine($"Task not found: {taskId}");
|
||||
}
|
||||
```
|
||||
1. The store's `SetCancelledAsync` transitions the task to `Cancelled` (a no-op if the task is
|
||||
already terminal).
|
||||
2. The associated `CancellationTokenSource` is signaled, propagating cancellation to the tool's
|
||||
`CancellationToken` so cooperative cleanup can run.
|
||||
3. Whichever side (the cancel handler or the background runner's `finally` block) wins
|
||||
`TryRemove` on the cancellation source owns disposal, avoiding `ObjectDisposedException`.
|
||||
|
||||
## Complete Example
|
||||
### Architecture notes
|
||||
|
||||
<!-- TODO: Remove mlc-disable block after merging to main -->
|
||||
<!-- mlc-disable -->
|
||||
See the [LongRunningTasks sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/LongRunningTasks) for a complete working example demonstrating:
|
||||
<!-- mlc-enable -->
|
||||
#### Immutable store design
|
||||
|
||||
- Server setup with a file-based `IMcpTaskStore` for durability
|
||||
- Explicit task creation via `IMcpTaskStore` in tools returning `McpTask`
|
||||
- Task polling and result retrieval across server restarts
|
||||
- Cancellation support
|
||||
<xref:ModelContextProtocol.Server.InMemoryMcpTaskStore> uses immutable record snapshots with
|
||||
compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are
|
||||
exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state.
|
||||
|
||||
## 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:
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
### Explicit Task Creation with `IMcpTaskStore`
|
||||
|
||||
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:
|
||||
|
||||
```csharp
|
||||
[McpServerToolType]
|
||||
public class FaultTolerantTools(IMcpTaskStore taskStore, IJobQueue jobQueue)
|
||||
{
|
||||
[McpServerTool]
|
||||
[Description("Submits a long-running job with fault-tolerant execution.")]
|
||||
public async Task<McpTask> SubmitJob(
|
||||
[Description("The job parameters")] string jobInput,
|
||||
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)
|
||||
- **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on
|
||||
polling exclusively.
|
||||
- **Lazy task creation**: when a tool runs through `TaskStore`, the store's
|
||||
<xref:ModelContextProtocol.Server.IMcpTaskStore.CreateTaskAsync*> is invoked eagerly before
|
||||
the inner handler runs, so tools that complete inline still incur a store write. There is
|
||||
currently no built-in deferral.
|
||||
- **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing
|
||||
synchronously and then transition its remaining work to a background task. Use a custom
|
||||
<xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithTaskHandler?displayProperty=nameWithType>
|
||||
if you need that pattern.
|
||||
- **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the
|
||||
task channel when called from inside a task scope, but the client SDK does not currently
|
||||
dispatch a handler for that method. Avoid calling `server.RequestRootsAsync` from within a
|
||||
task scope until client-side support is added.
|
||||
- **`ServerCapabilities.Extensions` round-trip**: the dictionary is typed as
|
||||
`IDictionary<string, object>` so its values cannot be deserialized by the source generator.
|
||||
The negotiated extension surfaces correctly at the wire level, but round-tripping arbitrary
|
||||
extension payloads in-process is not supported.
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ Add the diagnostic ID to `<NoWarn>` in your project file:
|
|||
Use `#pragma warning disable` around specific call sites:
|
||||
|
||||
```csharp
|
||||
#pragma warning disable MCPEXP001 // The Tasks feature is experimental per the MCP specification and is subject to change.
|
||||
tool.Execution = new ToolExecution { ... };
|
||||
#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.
|
||||
capabilities.Extensions = new Dictionary<string, object> { ... };
|
||||
#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)
|
||||
- [List of diagnostics](list-of-diagnostics.md#experimental-apis)
|
||||
- [Tasks](concepts/tasks/tasks.md) (an experimental feature)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T
|
|||
|
||||
| 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). |
|
||||
|
||||
## Obsolete APIs
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ The C# SDK tracks implementation of MCP spec components using the [modelcontextp
|
|||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"profiles": {
|
||||
"LongRunningTasks": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:60964;http://localhost:60965"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]"
|
||||
}
|
||||
109
samples/TasksExtension/Program.cs
Normal file
109
samples/TasksExtension/Program.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
50
samples/TasksExtension/README.md
Normal file
50
samples/TasksExtension/README.md
Normal 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).
|
||||
15
samples/TasksExtension/TasksExtension.csproj
Normal file
15
samples/TasksExtension/TasksExtension.csproj
Normal 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>
|
||||
|
|
@ -10,7 +10,7 @@ namespace ModelContextProtocol;
|
|||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <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>
|
||||
/// <item><description>
|
||||
/// <c>MCPEXP002</c> covers experimental SDK APIs that are unrelated to the MCP specification,
|
||||
|
|
@ -35,30 +35,9 @@ namespace ModelContextProtocol;
|
|||
/// </remarks>
|
||||
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>
|
||||
/// Diagnostic ID for the experimental MCP Extensions feature.
|
||||
/// </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";
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -115,7 +94,7 @@ internal static class Experimentals
|
|||
/// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature.
|
||||
/// </summary>
|
||||
/// <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).
|
||||
/// </remarks>
|
||||
public const string Mrtr_DiagnosticId = "MCPEXP001";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics;
|
||||
|
|
@ -887,6 +887,13 @@ public abstract partial class McpClient : McpSession
|
|||
/// <returns>The <see cref="CallToolResult"/> from the tool execution.</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>
|
||||
/// 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(
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? arguments = null,
|
||||
|
|
@ -957,342 +964,216 @@ public abstract partial class McpClient : McpSession
|
|||
/// <returns>The result of the 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<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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.ToolsCall,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CallToolResult,
|
||||
cancellationToken: cancellationToken);
|
||||
var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!augmented.IsTask)
|
||||
{
|
||||
return augmented.Result!;
|
||||
}
|
||||
|
||||
return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="toolName">The name of the tool to call on the server.</param>
|
||||
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
|
||||
/// <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,
|
||||
private async ValueTask<CallToolResult> PollTaskToCompletionAsync(
|
||||
CreateTaskResult taskCreated,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.ToolsCall,
|
||||
new CallToolRequestParams
|
||||
{
|
||||
Name = toolName,
|
||||
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
|
||||
Meta = meta,
|
||||
Task = taskMetadata ?? new McpTaskMetadata(),
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// If the server claims InputRequired but never publishes new input requests after we have
|
||||
// already responded to everything it asked for, treat that as a stuck task. The client
|
||||
// can still cancel earlier via cancellationToken; this guard prevents an unbounded poll
|
||||
// loop when the server is misbehaving. The threshold is configurable via
|
||||
// McpClientOptions.MaxConsecutiveStuckPolls.
|
||||
int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls;
|
||||
|
||||
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(
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? arguments,
|
||||
McpTaskMetadata? taskMetadata,
|
||||
IProgress<ProgressNotificationValue> progress,
|
||||
JsonObject? meta,
|
||||
JsonSerializerOptions serializerOptions,
|
||||
CancellationToken cancellationToken)
|
||||
switch (taskResult)
|
||||
{
|
||||
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,
|
||||
(notification, cancellationToken) =>
|
||||
case FailedTaskResult failed:
|
||||
throw new McpException($"Task '{taskId}' failed: {failed.Error}");
|
||||
|
||||
case CancelledTaskResult:
|
||||
throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server.");
|
||||
|
||||
case InputRequiredTaskResult inputRequired:
|
||||
// Dedup: only resolve input requests we haven't already responded to.
|
||||
var newRequests = new Dictionary<string, InputRequest>();
|
||||
if (inputRequired.InputRequests is { } incomingRequests)
|
||||
{
|
||||
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn &&
|
||||
pn.ProgressToken == progressToken)
|
||||
foreach (var kvp in incomingRequests)
|
||||
{
|
||||
progress.Report(pn.Progress);
|
||||
if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key))
|
||||
{
|
||||
newRequests[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
JsonObject metaWithProgress = meta is not null ? (JsonObject)meta.DeepClone() : [];
|
||||
metaWithProgress["progressToken"] = progressToken.ToString();
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.ToolsCall,
|
||||
new CallToolRequestParams
|
||||
if (newRequests.Count > 0)
|
||||
{
|
||||
Name = toolName,
|
||||
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
|
||||
Meta = metaWithProgress,
|
||||
Task = taskMetadata ?? new McpTaskMetadata(),
|
||||
},
|
||||
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
consecutiveStuckPolls = 0;
|
||||
|
||||
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>
|
||||
/// 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>
|
||||
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
|
||||
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
|
||||
/// <param name="requestParams">The request parameters to send. The tasks extension capability will be injected into the request metadata.</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>
|
||||
/// <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>
|
||||
/// <returns>A <see cref="ResultOrCreatedTask{TResult}"/> that is either an immediate result or a task handle.</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>
|
||||
/// <remarks>
|
||||
/// The <see cref="ListTasksAsync(RequestOptions?, CancellationToken)"/> overload retrieves all tasks by automatically handling pagination.
|
||||
/// This overload works with the lower-level <see cref="ListTasksRequestParams"/> and <see cref="ListTasksResult"/>, returning the raw result from the server.
|
||||
/// Any pagination needs to be managed by the caller.
|
||||
/// <para>
|
||||
/// Unlike <see cref="CallToolAsync(CallToolRequestParams, CancellationToken)"/>, this method does not
|
||||
/// 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>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public ValueTask<ListTasksResult> ListTasksAsync(
|
||||
ListTasksRequestParams requestParams,
|
||||
public async ValueTask<ResultOrCreatedTask<CallToolResult>> CallToolRawAsync(
|
||||
CallToolRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
|
||||
return SendRequestAsync(
|
||||
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)
|
||||
var paramsWithMeta = new CallToolRequestParams
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
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
|
||||
Name = requestParams.Name,
|
||||
Arguments = requestParams.Arguments,
|
||||
Meta = GetMetaWithTaskCapability(requestParams.Meta),
|
||||
};
|
||||
|
||||
JsonRpcRequest jsonRpcRequest = new()
|
||||
{
|
||||
Method = RequestMethods.ToolsCall,
|
||||
Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams),
|
||||
};
|
||||
|
||||
JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Discriminate based on resultType field.
|
||||
if (response.Result is JsonObject resultObj &&
|
||||
resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
|
||||
resultTypeNode?.GetValue<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>
|
||||
/// Polls a task until it reaches a terminal status (completed, failed, or cancelled).
|
||||
/// </summary>
|
||||
/// <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;
|
||||
var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult)
|
||||
?? throw new JsonException("Failed to deserialize CallToolResult from response.");
|
||||
return new ResultOrCreatedTask<CallToolResult>(callToolResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1347,6 +1228,106 @@ public abstract partial class McpClient : McpSession
|
|||
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>
|
||||
private static Dictionary<string, JsonElement>? ToArgumentsDictionary(
|
||||
IReadOnlyDictionary<string, object?>? arguments, JsonSerializerOptions options)
|
||||
|
|
@ -1365,4 +1346,33 @@ public abstract partial class McpClient : McpSession
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Client;
|
||||
|
|
@ -71,6 +72,26 @@ public abstract partial class McpClient : McpSession
|
|||
/// </remarks>
|
||||
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>
|
||||
/// 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.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue