Long-Running MCP Tools in C#: Call a Six-Second Tool as a Task and Poll It
Call a six-second MCP tool as a task and poll it to completion with the C# Tasks extension: server and client code that ran, the client methods, and what to fix before production.
By Ajith Joseph · · Updated · 5 min read · intermediate
A tool call that takes a few seconds is fine. One that builds a report, renders a document or waits on a human can take minutes, and a plain request and response is the wrong shape: HTTP timeouts, proxies and impatient clients all get in the way. The MCP Tasks extension is the protocol's answer, and the C# SDK ships it as its own package.
I built a server with one slow tool and a client that calls it as a task, and ran them against each other on the 2.4.0 packages, the newest on NuGet as of November 7. The API names below come from the package's own documentation and the code compiled and ran.
Install
The Tasks support lives in a separate package, and every MCP package must be on the same version:
dotnet add package ModelContextProtocol.AspNetCore --version 2.4.0
dotnet add package ModelContextProtocol.Extensions.Tasks --version 2.4.0
Mixing, say, AspNetCore 2.3.1 with Tasks 2.4.0 fails restore with a version conflict (NU1107), because the extension depends on the exact core version. This is also what the SDK's 2.1 notes mean when they say the old experimental task support was replaced by an extension with no compatibility with it.
The server
using System.ComponentModel;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddMcpServer()
.WithHttpTransport() // stateless by default since SDK 2.1
.WithTasks(new InMemoryMcpTaskStore()) // long-running tool calls become pollable tasks
.WithTools<ReportTools>();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();
[McpServerToolType]
public sealed class ReportTools
{
[McpServerTool(Name = "build_quarterly_report")]
[Description("Builds a quarterly sales report. Takes a while, so clients should call it as a task.")]
public static async Task<string> BuildQuarterlyReport(
[Description("Quarter such as 2026-Q3")] string quarter,
CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(6), cancellationToken); // stands in for real work
return quot;Report for {quarter} is ready: 1,284 orders, 96.4% shipped on time.";
}
}
Tasks are implemented as a call-tool filter. By default every tool is treated as task-capable; the WithTasks overload that takes a configuration callback lets you choose behaviour per call, for example by reading tool metadata, so quick tools stay quick. When I connected a classic client, the server's initialize result advertised the extension under io.modelcontextprotocol/tasks, which is how a client learns it can use tasks.
The client
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
await using var client = await McpClient.CreateAsync(
new HttpClientTransport(new HttpClientTransportOptions
{
Endpoint = new Uri("http://localhost:5312/mcp"),
}));
var request = new CallToolRequestParams
{
Name = "build_quarterly_report",
Arguments = new Dictionary<string, System.Text.Json.JsonElement>
{
["quarter"] = System.Text.Json.JsonSerializer.SerializeToElement("2026-Q3"),
},
};
var result = await client.CallToolWithPollingAsync(request, 1000); // poll every 1000 ms
Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text);
My run printed the report text after 6.2 seconds: the six-second tool plus one polling interval. CallToolWithPollingAsync calls the tool, and if the server creates a task, polls it until it finishes. If the server answers immediately, you get the result straight away.
When you want control instead of one call
The client extension methods expose the individual steps:
CallToolAsTaskAsyncstarts the call and hands back the created task.GetTaskAsyncfetches the current state by id.UpdateTaskAsyncsends input to a task that is waiting for it.CancelTaskAsyncstops it.
A created task carries a task id, a status and message, timestamps, a time to live and a suggested poll interval. The status values in the package cover working, input required, completed, failed and cancelled. Input required is the interesting one: a task can pause and ask the client a question, which is how a long job asks a person to confirm something without holding a connection open.
Before you ship it
- The in-memory store is per process.
InMemoryMcpTaskStorekeeps tasks in one server's memory. Behind a load balancer, or after a restart, tasks vanish or sit on the wrong instance. ImplementIMcpTaskStoreagainst a shared database or cache. The interface is small: create a task, get it, mark it completed, failed or cancelled, and store and resolve input requests. - Decide how long results live. The time to live is part of the task. Pick a value that matches how long your users come back for results.
- Make the work cancellable. Honour the
CancellationTokenin the tool, as above, or cancelling a task will not actually stop the job. - Authorise task access. A task id is a handle to a result. Check that the caller who asks for it is allowed to see it.
- Keep short tools short. Use the per-call configuration to avoid creating tasks for calls that finish in milliseconds.
What I could not verify
I tested one server and one client over local HTTP, and only the polling path. I did not test the input-required flow, task cancellation mid-run, or behaviour with several server instances. I also did not check how Copilot Studio or other hosts treat tasks, so confirm host support before you depend on it. For the broader changes in the SDK, see my notes on the 2.x release train.