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
3 changed files with 8190 additions and 11405 deletions

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>