Implement MCP task support (SEP-1686) (#1170)

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Eirik Tsarpalis 2026-01-28 05:21:02 +02:00 • committed by GitHub
commit 10e4f5b20f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 11580 additions and 46 deletions

View file

@ -44,6 +44,7 @@
<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" />

View file

@ -0,0 +1,599 @@
---
title: Tasks
author: eiriktsarpalis
description: MCP Tasks for Long-Running Operations
uid: tasks
---
# MCP Tasks
> [!WARNING]
> Tasks are an **experimental feature** in the MCP specification (version 2025-11-25). The API may change in future releases.
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.
[task-based execution]: https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks
## Overview
Tasks are useful when operations may take a long time to complete, such as:
- Large dataset processing or analysis
- Complex report generation
- Code migration or refactoring operations
- Machine learning inference or training
- Batch data transformations
Without tasks, clients must keep connections open for the entire duration of long-running operations. Tasks allow clients to:
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
## Task Lifecycle
Tasks follow a defined lifecycle through these status values:
| 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 |
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:
```csharp
var builder = WebApplication.CreateBuilder(args);
// Create a task store for managing task state
var taskStore = new InMemoryMcpTaskStore();
builder.Services.AddMcpServer(options =>
{
// Enable tasks by providing a task store
options.TaskStore = taskStore;
})
.WithHttpTransport()
.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.).
### Task Store Configuration
The `InMemoryMcpTaskStore` constructor accepts several optional 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
);
```
### Tool Task Support
Tools automatically advertise task support when they return `Task`, `ValueTask`, `Task<T>`, or `ValueTask<T>`:
```csharp
[McpServerToolType]
public class MyTools
{
// This tool automatically supports task-augmented calls
// 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.
> [!IMPORTANT]
> **No Fault Tolerance Guarantees**: Both `InMemoryMcpTaskStore` and the automatic task support for `Task`-returning tool methods do **not** provide fault tolerance. Task state and execution are bounded by the memory of the server process. If the server crashes or restarts:
> - All in-memory task metadata is lost
> - Any in-flight task execution is terminated
> - Clients will receive errors when polling for previously created tasks
>
> For fault-tolerant task execution, see the [Fault-Tolerant Task Implementations](#fault-tolerant-task-implementations) section.
### Task Status Notifications
When `SendTaskStatusNotifications` is enabled, the server automatically sends status updates to connected clients:
```csharp
builder.Services.AddMcpServer(options =>
{
options.TaskStore = taskStore;
options.SendTaskStatusNotifications = true; // Enable notifications
});
```
Clients receive `notifications/tasks/status` messages when task status changes.
## Client Implementation
### Calling Tools as Tasks
To execute a tool as a task, include the `Task` property in the request:
```csharp
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
var client = await McpClient.CreateAsync(transport);
// Call tool with task augmentation
var result = await client.CallToolAsync(
new CallToolRequestParams
{
Name = "processDataset",
Arguments = new Dictionary<string, JsonElement>
{
["recordCount"] = JsonSerializer.SerializeToElement(1000)
},
Task = new McpTaskMetadata
{
TimeToLive = TimeSpan.FromHours(2) // Request 2-hour retention
}
},
cancellationToken);
// Check if a task was created
if (result.Task != null)
{
Console.WriteLine($"Task created: {result.Task.TaskId}");
Console.WriteLine($"Status: {result.Task.Status}");
}
```
### Polling for Task Status
Use <xref:ModelContextProtocol.Client.McpClient.GetTaskAsync*> to check task status:
```csharp
var task = await client.GetTaskAsync(taskId, cancellationToken: cancellationToken);
Console.WriteLine($"Status: {task.Status}");
Console.WriteLine($"Last Updated: {task.LastUpdatedAt}");
if (task.StatusMessage != null)
{
Console.WriteLine($"Message: {task.StatusMessage}");
}
```
### Waiting for Completion
The SDK provides helper methods for polling until a task completes:
```csharp
// Poll until task reaches terminal state
var completedTask = await client.PollTaskUntilCompleteAsync(
taskId,
cancellationToken: cancellationToken);
if (completedTask.Status == McpTaskStatus.Completed)
{
// Get the result as raw JSON
var resultJson = await client.GetTaskResultAsync(
taskId,
cancellationToken: cancellationToken);
// Deserialize to the expected type
var result = resultJson.Deserialize<CallToolResult>(McpJsonUtilities.DefaultOptions);
foreach (var content in result?.Content ?? [])
{
if (content is TextContentBlock text)
{
Console.WriteLine(text.Text);
}
}
}
else if (completedTask.Status == McpTaskStatus.Failed)
{
Console.WriteLine($"Task failed: {completedTask.StatusMessage}");
}
```
### Listing Tasks
List all tasks for the current session:
```csharp
var tasks = await client.ListTasksAsync(cancellationToken: cancellationToken);
foreach (var task in tasks)
{
Console.WriteLine($"{task.TaskId}: {task.Status}");
}
```
### Cancelling Tasks
Cancel a running task:
```csharp
var cancelledTask = await client.CancelTaskAsync(
taskId,
cancellationToken: cancellationToken);
Console.WriteLine($"Task status: {cancelledTask.Status}"); // Cancelled
```
### Handling Status Notifications
Register a handler to receive real-time status updates:
```csharp
var options = new McpClientOptions
{
Handlers = new McpClientHandlers
{
TaskStatusHandler = (task, cancellationToken) =>
{
Console.WriteLine($"Task {task.TaskId} status changed to {task.Status}");
return ValueTask.CompletedTask;
}
}
};
var client = await McpClient.CreateAsync(transport, options);
```
> [!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.
## Implementing a Custom Task Store
For production deployments, implement <xref:ModelContextProtocol.IMcpTaskStore> with a persistent backing store:
```csharp
public class DatabaseTaskStore : IMcpTaskStore
{
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)
{
var task = new McpTask
{
TaskId = Guid.NewGuid().ToString(),
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
TimeToLive = taskMetadata.TimeToLive ?? TimeSpan.FromHours(1)
};
// Store in database
await _db.ExecuteAsync(
"INSERT INTO Tasks (TaskId, SessionId, Status, ...) VALUES (@TaskId, @SessionId, @Status, ...)",
new { task.TaskId, sessionId, task.Status, ... });
return task;
}
public async Task<McpTask?> GetTaskAsync(
string taskId,
string? sessionId,
CancellationToken cancellationToken)
{
// Retrieve from database with session isolation
return await _db.QuerySingleOrDefaultAsync<McpTask>(
"SELECT * FROM Tasks WHERE TaskId = @TaskId AND SessionId = @SessionId",
new { taskId, sessionId });
}
// Implement other interface methods...
}
```
### Task Store Best Practices
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
## Error Handling
Task operations may throw <xref:ModelContextProtocol.McpException> with these error codes:
| Error Code | Scenario |
|------------|----------|
| `InvalidParams` | Invalid or nonexistent task ID |
| `InvalidRequest` | Tool with `taskSupport: forbidden` called with task metadata, or tool with `taskSupport: required` called without task metadata |
| `InternalError` | Task execution failure or result unavailable |
Example error handling:
```csharp
try
{
var task = await client.GetTaskAsync(taskId, cancellationToken: ct);
}
catch (McpException ex) when (ex.ErrorCode == McpErrorCode.InvalidParams)
{
Console.WriteLine($"Task not found: {taskId}");
}
```
## Complete Example
<!-- 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 -->
- 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
## Fault-Tolerant Task Implementations
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.
### Why Fault Tolerance Requires External Systems
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()
.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/specification/draft/basic/utilities/tasks)

View file

@ -5,6 +5,8 @@ items:
items:
- name: Progress
uid: progress
- name: Tasks
uid: tasks
- name: Client Features
items:
- name: Elicitation

View file

@ -0,0 +1,26 @@
# List of Diagnostics Produced by MCP C# SDK
This document provides information about each of the diagnostics produced by the MCP C# SDK analyzers and source generators.
## Analyzer Diagnostics
Analyzer diagnostic IDs are in the format `MCP###`.
| Diagnostic ID | Description |
| :------------ | :---------- |
| `MCP001` | Invalid XML documentation for MCP method |
| `MCP002` | MCP method must be partial to generate [Description] attributes |
## Experimental APIs
Experimental diagnostic IDs are in the format `MCPEXP###`.
As new functionality is introduced to this SDK, new in-development APIs are marked as being experimental. Experimental APIs offer no compatibility guarantees and can change without notice. They are usually published in order to gather feedback before finalizing a design.
You may use experimental APIs in your application, but we advise against using these APIs in production scenarios as they may not be fully tested nor fully reliable. Additionally, we strongly recommend that library authors do not publish versions of their libraries that depend on experimental APIs as this will quite possibly lead to future breaking changes and diamond problems.
If you use experimental APIs, you will get one of the diagnostics shown below. The diagnostic is there to let you know you're using such an API so that you can avoid accidentally depending on experimental features. You may suppress these diagnostics if desired.
| Diagnostic ID | Description |
| :------------ | :---------- |
| `MCPEXP001` | MCP task-related APIs are experimental. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results. See [MCP Tasks specification](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) for details. |

View file

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

View file

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

View file

@ -0,0 +1,34 @@
// 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()
.WithTools<TaskTools>();
var app = builder.Build();
app.MapMcp();
app.Run();

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ namespace ModelContextProtocol;
/// when available. If there is a SEP associated with the experimental API, the Url should
/// point to the SEP issue.
/// <para>
/// Experimental diagnostic IDs are in the format MCP5###.
/// Experimental diagnostic IDs are in the format MCPEXP###.
/// </para>
/// <para>
/// Diagnostic IDs cannot be reused when experimental API are removed or promoted to stable.
@ -21,7 +21,18 @@ namespace ModelContextProtocol;
/// </remarks>
internal static class Experimentals
{
// public const string Tasks_DiagnosticId = "MCP5001";
// public const string Tasks_Message = "The Tasks feature is experimental within specification version 2025-11-25 and is subject to change. See SEP-1686 for more information.";
// public const string Tasks_Url = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686";
/// <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";
}

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -912,6 +913,325 @@ public abstract partial class McpClient : McpSession
cancellationToken: cancellationToken);
}
/// <summary>
/// Invokes a tool on the server as a task for long-running operations.
/// </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,
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);
return result.Task;
}
async ValueTask<McpTask> SendTaskAugmentedCallToolRequestWithProgressAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments,
McpTaskMetadata? taskMetadata,
IProgress<ProgressNotificationValue> progress,
JsonObject? meta,
JsonSerializerOptions serializerOptions,
CancellationToken cancellationToken)
{
ProgressToken progressToken = new(Guid.NewGuid().ToString("N"));
await using var _ = RegisterNotificationHandler(NotificationMethods.ProgressNotification,
(notification, cancellationToken) =>
{
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn &&
pn.ProgressToken == progressToken)
{
progress.Report(pn.Progress);
}
return default;
}).ConfigureAwait(false);
JsonObject metaWithProgress = meta is not null ? new(meta) : [];
metaWithProgress["progressToken"] = progressToken.ToString();
var result = await SendRequestAsync(
RequestMethods.ToolsCall,
new CallToolRequestParams
{
Name = toolName,
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
Meta = metaWithProgress,
Task = taskMetadata ?? new McpTaskMetadata(),
},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
return result.Task;
}
}
/// <summary>
/// Retrieves the current state of a specific task from the server.
/// </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="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>
[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>
/// <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>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<IList<McpTask>> ListTasksAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
ListTasksRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
List<McpTask> tasks = new();
do
{
var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false);
tasks.AddRange(taskResults.Tasks);
requestParams.Cursor = taskResults.NextCursor;
}
while (requestParams.Cursor is not null);
return tasks;
}
/// <summary>
/// Retrieves a list of tasks from the server.
/// </summary>
/// <param name="requestParams">The request parameters to send in the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The result of the request as provided by the server.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <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.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public ValueTask<ListTasksResult> ListTasksAsync(
ListTasksRequestParams 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>
/// <remarks>
/// Cancelling a task requests that the server stop execution. The server may not immediately cancel the task,
/// and may choose to allow the task to complete if it's close to finishing.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> CancelTaskAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
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
};
}
/// <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;
}
/// <summary>
/// Sets the logging level for the server to control which log messages are sent to the client.
/// </summary>

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
namespace ModelContextProtocol.Client;
@ -85,4 +86,25 @@ public class McpClientHandlers
/// </para>
/// </remarks>
public Func<CreateMessageRequestParams?, IProgress<ProgressNotificationValue>, CancellationToken, ValueTask<CreateMessageResult>>? SamplingHandler { get; set; }
/// <summary>
/// Gets or sets the handler for processing <see cref="NotificationMethods.TaskStatusNotification"/> notifications.
/// </summary>
/// <remarks>
/// <para>
/// This handler is called when the server sends a task status notification to inform the client
/// about changes to a task's state. These notifications are optional and clients MUST NOT rely
/// on receiving them.
/// </para>
/// <para>
/// The handler receives the updated <see cref="McpTask"/> object containing the current task state,
/// including its status, status message, and timestamps.
/// </para>
/// <para>
/// This handler is typically used to update UI or trigger actions based on task progress
/// without requiring explicit polling.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public Func<McpTask, CancellationToken, ValueTask>? TaskStatusHandler { get; set; }
}

View file

@ -20,6 +20,7 @@ internal sealed partial class McpClientImpl : McpClient
private readonly McpClientOptions _options;
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private CancellationTokenSource? _connectCts;
@ -46,6 +47,12 @@ internal sealed partial class McpClientImpl : McpClient
_options = options;
_logger = loggerFactory?.CreateLogger<McpClient>() ?? NullLogger<McpClient>.Instance;
// Only allocate the cancellation token provider if a task store is configured
if (options.TaskStore is not null)
{
_taskCancellationTokenProvider = new();
}
var notificationHandlers = new NotificationHandlers();
var requestHandlers = new RequestHandlers();
@ -62,13 +69,79 @@ internal sealed partial class McpClientImpl : McpClient
var samplingHandler = handlers.SamplingHandler;
var rootsHandler = handlers.RootsHandler;
var elicitationHandler = handlers.ElicitationHandler;
var taskStatusHandler = handlers.TaskStatusHandler;
var taskStore = options.TaskStore;
if (notificationHandlersFromOptions is not null)
{
notificationHandlers.RegisterRange(notificationHandlersFromOptions);
}
if (taskStatusHandler is not null)
{
notificationHandlers.Register(
NotificationMethods.TaskStatusNotification,
(notification, cancellationToken) =>
{
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams) is { } notificationParams)
{
var task = new McpTask
{
TaskId = notificationParams.TaskId,
Status = notificationParams.Status,
StatusMessage = notificationParams.StatusMessage,
CreatedAt = notificationParams.CreatedAt,
LastUpdatedAt = notificationParams.LastUpdatedAt,
TimeToLive = notificationParams.TimeToLive,
PollInterval = notificationParams.PollInterval
};
return taskStatusHandler(task, cancellationToken);
}
return default;
});
}
if (samplingHandler is not null)
{
// If task store is configured, wrap the handler to support task-augmented requests
if (taskStore is not null)
{
requestHandlers.Set(
RequestMethods.SamplingCreateMessage,
async (request, jsonRpcRequest, cancellationToken) =>
{
// Check if this is a task-augmented request
if (request?.Task is { } taskMetadata)
{
// Create task in store and return immediately
return await ExecuteAsTaskAsync(
taskStore,
taskMetadata,
jsonRpcRequest,
async ct =>
{
var result = await samplingHandler(
request,
request.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
ct).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CreateMessageResult);
},
options.SendTaskStatusNotifications,
cancellationToken).ConfigureAwait(false);
}
// Normal synchronous execution - serialize result to JsonElement
var samplingResult = await samplingHandler(
request,
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(samplingResult, McpJsonUtilities.JsonContext.Default.CreateMessageResult);
},
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement); // Return JsonElement to support both CreateMessageResult and CreateTaskResult
}
else
{
requestHandlers.Set(
RequestMethods.SamplingCreateMessage,
@ -78,6 +151,7 @@ internal sealed partial class McpClientImpl : McpClient
cancellationToken),
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
}
_options.Capabilities ??= new();
_options.Capabilities.Sampling ??= new();
@ -96,12 +170,46 @@ internal sealed partial class McpClientImpl : McpClient
}
if (elicitationHandler is not null)
{
// If task store is configured, wrap the handler to support task-augmented requests
if (taskStore is not null)
{
requestHandlers.Set(
RequestMethods.ElicitationCreate,
async (request, jsonRpcRequest, cancellationToken) =>
{
// Check if this is a task-augmented request
if (request?.Task is { } taskMetadata)
{
// Create task in store and return immediately
return await ExecuteAsTaskAsync(
taskStore,
taskMetadata,
jsonRpcRequest,
async ct =>
{
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
options.SendTaskStatusNotifications,
cancellationToken).ConfigureAwait(false);
}
// Normal synchronous execution - serialize result to JsonElement
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement); // Return JsonElement to support both ElicitResult and CreateTaskResult
}
else
{
requestHandlers.Set(
RequestMethods.ElicitationCreate,
(request, _, cancellationToken) => elicitationHandler(request, cancellationToken),
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult);
}
_options.Capabilities ??= new();
_options.Capabilities.Elicitation ??= new();
@ -112,6 +220,276 @@ internal sealed partial class McpClientImpl : McpClient
_options.Capabilities.Elicitation.Form = new();
}
}
// Register task handlers if a task store is configured
if (taskStore is not null)
{
RegisterTaskHandlers(requestHandlers, taskStore);
}
}
/// <summary>
/// Executes an operation as a task, creating the task immediately and running the operation asynchronously.
/// </summary>
private async ValueTask<JsonElement> ExecuteAsTaskAsync(
IMcpTaskStore taskStore,
McpTaskMetadata taskMetadata,
JsonRpcRequest jsonRpcRequest,
Func<CancellationToken, Task<JsonElement>> operation,
bool sendNotifications,
CancellationToken cancellationToken)
{
// Create the task in the store
var mcpTask = await taskStore.CreateTaskAsync(
taskMetadata,
jsonRpcRequest.Id,
jsonRpcRequest,
SessionId,
cancellationToken).ConfigureAwait(false);
// Register the task for TTL-based cancellation
var taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive);
// Execute the operation asynchronously in the background
_ = Task.Run(async () =>
{
try
{
// Send notification if enabled
if (sendNotifications)
{
var workingTask = await taskStore.GetTaskAsync(mcpTask.TaskId, SessionId, CancellationToken.None).ConfigureAwait(false);
if (workingTask is not null)
{
_ = NotifyTaskStatusAsync(workingTask, CancellationToken.None);
}
}
// Execute the operation with task-specific cancellation token
var result = await operation(taskCancellationToken).ConfigureAwait(false);
// Store the result
var completedTask = await taskStore.StoreTaskResultAsync(
mcpTask.TaskId,
McpTaskStatus.Completed,
result,
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Send final notification if enabled
if (sendNotifications)
{
_ = NotifyTaskStatusAsync(completedTask, CancellationToken.None);
}
}
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
{
// Task was cancelled via TTL expiration or explicit cancellation.
// For TTL expiration, the task is deleted so no status update needed.
// For explicit cancellation, the cancel handler already updates the status.
}
catch (Exception ex)
{
// Store error result using a simple string message
try
{
var errorElement = JsonSerializer.SerializeToElement(ex.Message, McpJsonUtilities.JsonContext.Default.String);
await taskStore.StoreTaskResultAsync(
mcpTask.TaskId,
McpTaskStatus.Failed,
errorElement,
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Update task with error message
var failedTask = await taskStore.UpdateTaskStatusAsync(
mcpTask.TaskId,
McpTaskStatus.Failed,
ex.Message,
SessionId,
CancellationToken.None).ConfigureAwait(false);
// Send failure notification if enabled
if (sendNotifications)
{
_ = NotifyTaskStatusAsync(failedTask, CancellationToken.None);
}
}
catch
{
// If we can't store the error result, there's not much we can do
}
}
finally
{
// Clean up task cancellation tracking
_taskCancellationTokenProvider!.Complete(mcpTask.TaskId);
}
}, CancellationToken.None);
// Return the task result immediately
var createTaskResult = new CreateTaskResult { Task = mcpTask };
return JsonSerializer.SerializeToElement(createTaskResult, McpJsonUtilities.JsonContext.Default.CreateTaskResult);
}
/// <summary>
/// Sends a task status notification to the connected server.
/// </summary>
private Task NotifyTaskStatusAsync(McpTask task, CancellationToken cancellationToken)
{
var notificationParams = new McpTaskStatusNotificationParams
{
TaskId = task.TaskId,
Status = task.Status,
StatusMessage = task.StatusMessage,
CreatedAt = task.CreatedAt,
LastUpdatedAt = task.LastUpdatedAt,
TimeToLive = task.TimeToLive,
PollInterval = task.PollInterval
};
return this.SendNotificationAsync(
NotificationMethods.TaskStatusNotification,
notificationParams,
McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams,
cancellationToken);
}
/// <summary>
/// Registers handlers for task-related requests from the server.
/// </summary>
private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore taskStore)
{
// tasks/get handler - Retrieve task status
requestHandlers.Set(
RequestMethods.TasksGet,
async (request, _, cancellationToken) =>
{
if (request?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
var task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
return new GetTaskResult
{
TaskId = task.TaskId,
Status = task.Status,
StatusMessage = task.StatusMessage,
CreatedAt = task.CreatedAt,
LastUpdatedAt = task.LastUpdatedAt,
TimeToLive = task.TimeToLive,
PollInterval = task.PollInterval
};
},
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
McpJsonUtilities.JsonContext.Default.GetTaskResult);
// tasks/result handler - Retrieve task result (blocking until terminal status)
requestHandlers.Set(
RequestMethods.TasksResult,
async (request, _, cancellationToken) =>
{
if (request?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
// Poll until task reaches terminal status
while (true)
{
McpTask? task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
// If terminal, break and retrieve result
if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled)
{
break;
}
// Poll according to task's pollInterval (default 1 second)
var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1);
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
// Retrieve the stored result
return await taskStore.GetTaskResultAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
},
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement);
// tasks/list handler - List tasks with pagination
requestHandlers.Set(
RequestMethods.TasksList,
async (request, _, cancellationToken) =>
{
var cursor = request?.Cursor;
return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false);
},
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
McpJsonUtilities.JsonContext.Default.ListTasksResult);
// tasks/cancel handler - Cancel a task
requestHandlers.Set(
RequestMethods.TasksCancel,
async (request, _, cancellationToken) =>
{
if (request?.TaskId is not { } taskId)
{
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
}
// Signal cancellation if task is still running
_taskCancellationTokenProvider!.Cancel(taskId);
var task = await taskStore.CancelTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
if (task is null)
{
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
}
return new CancelMcpTaskResult
{
TaskId = task.TaskId,
Status = task.Status,
StatusMessage = task.StatusMessage,
CreatedAt = task.CreatedAt,
LastUpdatedAt = task.LastUpdatedAt,
TimeToLive = task.TimeToLive,
PollInterval = task.PollInterval
};
},
McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams,
McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult);
// Advertise task capabilities
_options.Capabilities ??= new();
var tasksCapability = _options.Capabilities.Tasks ??= new McpTasksCapability();
tasksCapability.List ??= new ListMcpTasksCapability();
tasksCapability.Cancel ??= new CancelMcpTasksCapability();
var requestsCapability = tasksCapability.Requests ??= new RequestMcpTasksCapability();
// Only advertise sampling tasks if sampling handler is present
if (_options.Handlers.SamplingHandler is not null)
{
var samplingCapability = requestsCapability.Sampling ??= new SamplingMcpTasksCapability();
samplingCapability.CreateMessage ??= new CreateMessageMcpTasksCapability();
}
// Only advertise elicitation tasks if elicitation handler is present
if (_options.Handlers.ElicitationHandler is not null)
{
var elicitationCapability = requestsCapability.Elicitation ??= new ElicitationMcpTasksCapability();
elicitationCapability.Create ??= new CreateElicitationMcpTasksCapability();
}
}
/// <inheritdoc/>
@ -263,6 +641,7 @@ internal sealed partial class McpClientImpl : McpClient
_disposed = true;
_taskCancellationTokenProvider?.Dispose();
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
await _transport.DisposeAsync().ConfigureAwait(false);
}

View file

@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
namespace ModelContextProtocol.Client;
@ -78,4 +79,36 @@ public sealed class McpClientOptions
field = value;
}
}
/// <summary>
/// Gets or sets the task store for managing client-side tasks.
/// </summary>
/// <remarks>
/// <para>
/// When a task store is configured, the client will support task-augmented requests from the server.
/// This allows the server to request sampling or elicitation as tasks, which the client executes
/// asynchronously and allows the server to poll for status and results.
/// </para>
/// <para>
/// If not set, task-augmented requests will not be supported, and the client will not advertise
/// task capabilities to the server.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public IMcpTaskStore? TaskStore { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the client should send task status notifications to the server.
/// </summary>
/// <value>
/// <see langword="true"/> to send task status notifications; <see langword="false"/> otherwise.
/// The default is <see langword="true"/>.
/// </value>
/// <remarks>
/// When enabled and a <see cref="TaskStore"/> is configured, the client will send optional
/// <c>notifications/tasks/status</c> notifications to inform the server of task state changes.
/// Servers MUST NOT rely on receiving these notifications and should continue polling via <c>tasks/get</c>.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public bool SendTaskStatusNotifications { get; set; } = true;
}

View file

@ -173,7 +173,7 @@ public sealed class McpClientTool : AIFunction
/// The base <see cref="AIFunction.InvokeAsync"/> method is overridden to invoke this <see cref="CallAsync"/> method.
/// The only difference in behavior is that <see cref="AIFunction.InvokeAsync"/> serializes the resulting <see cref="CallToolResult"/>"/>
/// such that the <see cref="object"/> returned is a <see cref="JsonElement"/> containing the serialized <see cref="CallToolResult"/>.
/// This <see cref="CallToolResult"/> method is intended to be called directly by user code, whereas the base <see cref="AIFunction.InvokeAsync"/>
/// This <see cref="CallAsync"/> method is intended to be called directly by user code, whereas the base <see cref="AIFunction.InvokeAsync"/>
/// is intended to be used polymorphically via the base class, typically as part of an <see cref="IChatClient"/> operation.
/// </remarks>
/// <exception cref="McpException">The server could not find the requested tool, or the server encountered an error while processing the request.</exception>

View file

@ -108,10 +108,12 @@ public static partial class McpJsonUtilities
[JsonSerializable(typeof(ResourceUpdatedNotificationParams))]
[JsonSerializable(typeof(RootsListChangedNotificationParams))]
[JsonSerializable(typeof(ToolListChangedNotificationParams))]
[JsonSerializable(typeof(McpTaskStatusNotificationParams))]
// MCP Request Params / Results
[JsonSerializable(typeof(CallToolRequestParams))]
[JsonSerializable(typeof(CallToolResult))]
[JsonSerializable(typeof(CreateTaskResult))]
[JsonSerializable(typeof(CompleteRequestParams))]
[JsonSerializable(typeof(CompleteResult))]
[JsonSerializable(typeof(CreateMessageRequestParams))]
@ -142,6 +144,22 @@ public static partial class McpJsonUtilities
[JsonSerializable(typeof(SubscribeRequestParams))]
[JsonSerializable(typeof(UnsubscribeRequestParams))]
// MCP Task Request Params / Results
[JsonSerializable(typeof(McpTask))]
[JsonSerializable(typeof(McpTaskStatus))]
[JsonSerializable(typeof(McpTaskMetadata))]
[JsonSerializable(typeof(GetTaskRequestParams))]
[JsonSerializable(typeof(GetTaskResult))]
[JsonSerializable(typeof(GetTaskPayloadRequestParams))]
[JsonSerializable(typeof(ListTasksRequestParams))]
[JsonSerializable(typeof(ListTasksResult))]
[JsonSerializable(typeof(CancelMcpTaskRequestParams))]
[JsonSerializable(typeof(CancelMcpTaskResult))]
[JsonSerializable(typeof(McpTasksCapability))]
[JsonSerializable(typeof(RequestMcpTasksCapability))]
[JsonSerializable(typeof(ToolExecution))]
[JsonSerializable(typeof(ToolTaskSupport))]
// MCP Content
[JsonSerializable(typeof(ContentBlock))]
[JsonSerializable(typeof(TextContentBlock))]
@ -161,6 +179,7 @@ public static partial class McpJsonUtilities
// Other MCP Types
[JsonSerializable(typeof(IReadOnlyDictionary<string, object>))]
[JsonSerializable(typeof(ProgressToken))]
[JsonSerializable(typeof(JsonElement))]
[JsonSerializable(typeof(ProtectedResourceMetadata))]
[JsonSerializable(typeof(AuthorizationServerMetadata))]

View file

@ -0,0 +1,127 @@
using System.Collections.Concurrent;
namespace ModelContextProtocol;
/// <summary>
/// Provides cancellation tokens for running MCP tasks, enabling TTL-based
/// automatic cancellation and explicit task cancellation.
/// </summary>
/// <remarks>
/// <para>
/// This class provides lifecycle management for <see cref="CancellationTokenSource"/> instances
/// associated with running tasks. Each task gets its own CTS that can be:
/// </para>
/// <list type="bullet">
/// <item><description>Automatically cancelled when the task's TTL expires</description></item>
/// <item><description>Explicitly cancelled via the <see cref="Cancel"/> method</description></item>
/// <item><description>Cleaned up when the task completes via <see cref="Complete"/></description></item>
/// </list>
/// <para>
/// Both <c>McpClient</c> and <c>McpServer</c> use this class to manage task cancellation
/// independently of request cancellation tokens.
/// </para>
/// </remarks>
internal sealed class McpTaskCancellationTokenProvider : IDisposable
{
private readonly ConcurrentDictionary<string, CancellationTokenSource> _runningTasks = new();
private bool _disposed;
/// <summary>
/// Registers a new task and returns a cancellation token for use during execution.
/// </summary>
/// <param name="taskId">The unique identifier of the task.</param>
/// <param name="timeToLive">
/// Optional TTL duration. If specified, the returned token will be automatically
/// cancelled when the TTL expires.
/// </param>
/// <returns>
/// A <see cref="CancellationToken"/> that will be cancelled when the TTL expires,
/// when <see cref="Cancel"/> is called, or when this provider is disposed.
/// </returns>
/// <exception cref="ObjectDisposedException">The provider has been disposed.</exception>
/// <exception cref="InvalidOperationException">A task with the same ID is already registered.</exception>
public CancellationToken RequestToken(string taskId, TimeSpan? timeToLive)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(McpTaskCancellationTokenProvider));
}
Throw.IfNullOrWhiteSpace(taskId);
CancellationTokenSource cts = new();
if (timeToLive is { } ttl)
{
cts.CancelAfter(ttl);
}
if (!_runningTasks.TryAdd(taskId, cts))
{
cts.Dispose();
throw new InvalidOperationException($"Task '{taskId}' is already registered.");
}
return cts.Token;
}
/// <summary>
/// Attempts to cancel a running task.
/// </summary>
/// <param name="taskId">The unique identifier of the task to cancel.</param>
/// <remarks>
/// This method signals cancellation but does not remove the task from tracking.
/// The task executor should call <see cref="Complete"/> when it observes
/// the cancellation and finishes cleanup.
/// </remarks>
public void Cancel(string taskId)
{
if (_runningTasks.TryGetValue(taskId, out var cts))
{
cts.Cancel();
}
}
/// <summary>
/// Marks a task as complete and releases its associated resources.
/// </summary>
/// <param name="taskId">The unique identifier of the task that has completed.</param>
/// <remarks>
/// This method should be called from a <c>finally</c> block in the task execution
/// to ensure proper cleanup regardless of success, failure, or cancellation.
/// </remarks>
public void Complete(string taskId)
{
if (_runningTasks.TryRemove(taskId, out var cts))
{
cts.Dispose();
}
}
/// <summary>
/// Cancels all running tasks and releases all resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var kvp in _runningTasks)
{
try
{
kvp.Value.Cancel();
kvp.Value.Dispose();
}
catch
{
// Best effort cleanup
}
}
_runningTasks.Clear();
}
}

View file

@ -8,6 +8,8 @@
<Description>Core .NET SDK for the Model Context Protocol (MCP)</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- Suppress the experimental tasks warning -->
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">

View file

@ -25,4 +25,14 @@ public sealed class CallToolRequestParams : RequestParams
/// </remarks>
[JsonPropertyName("arguments")]
public IDictionary<string, JsonElement>? Arguments { get; set; }
/// <summary>
/// Gets or sets optional task metadata to augment this request with task execution.
/// </summary>
/// <remarks>
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
/// </remarks>
[JsonPropertyName("task")]
public McpTaskMetadata? Task { get; set; }
}

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