Implement SEP-2663 Tasks Extension (#1579)

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,109 @@
// Demonstrates the MCP tasks extension (SEP-2663):
//
// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation
// becomes a background task when the client opts in via the per-request _meta marker.
// - The client invokes the same tool two ways:
// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final
// CallToolResult, just like a synchronous call.
// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls,
// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream
// status updates rather than block on a single await.
//
// Both server and client are wired together in-process over an in-memory pipe so the sample
// is self-contained — no separate server process or HTTP transport required.
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.IO.Pipelines;
using System.Text.Json;
Pipe clientToServerPipe = new(), serverToClientPipe = new();
await using McpServer server = McpServer.Create(
new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
new McpServerOptions
{
// Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be
// automatically wrapped as background tasks when the client opts in.
TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 },
ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })],
});
_ = server.RunAsync();
await using McpClient client = await McpClient.CreateAsync(
new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));
Console.WriteLine("=== CallToolAsync (auto-poll) ===");
var auto = await client.CallToolAsync(
new CallToolRequestParams { Name = "run-report" });
Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}");
Console.WriteLine();
Console.WriteLine("=== CallToolRawAsync (manual poll) ===");
var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" });
if (!raw.IsTask)
{
// Either the server doesn't advertise the tasks extension or it chose to run the call
// synchronously despite the client opt-in. Surface the inline result and stop.
Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}");
return;
}
CreateTaskResult created = raw.TaskCreated!;
Console.WriteLine($" task created: id={created.TaskId} status={created.Status} pollIntervalMs={created.PollIntervalMs}");
long pollIntervalMs = created.PollIntervalMs ?? 1000;
int pollCount = 0;
while (true)
{
await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs));
pollCount++;
var state = await client.GetTaskAsync(created.TaskId);
if (state.PollIntervalMs is { } updated)
{
pollIntervalMs = updated;
}
switch (state)
{
case CompletedTaskResult completed:
// The Result property carries the wrapped CallToolResult as a raw JsonElement.
var callToolResult = completed.Result.Deserialize<CallToolResult>()!;
Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}");
return;
case FailedTaskResult failed:
Console.WriteLine($" task failed: {failed.Error}");
return;
case CancelledTaskResult:
Console.WriteLine(" task was cancelled");
return;
case WorkingTaskResult:
Console.WriteLine($" poll {pollCount}: still working …");
continue;
case InputRequiredTaskResult inputRequired:
// The auto-poll path (CallToolAsync above) routes these through the registered
// ElicitationHandler/SamplingHandler automatically. The manual path needs to call
// UpdateTaskAsync with responses for each outstanding key.
Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))");
continue;
}
}
internal static class SlowTools
{
[Description("Runs a short simulated report and returns when it's done.")]
public static async Task<string> RunReport(CancellationToken cancellationToken)
{
// Real-world workloads would do meaningful work here; we just sleep so the polling
// path is observable in the console output.
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
return "report ready";
}
}

View file

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

View file

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