Most MCP tools return in milliseconds, and life is easy. Then someone adds a tool that generates a PDF, runs a multi-step analysis, or queries a billion-row table — and the whole agent freezes on a spinner until the connection times out. The length of the work has quietly become the length of the call, and that's the bug.
This is Part 9 of a 15-part deep dive on Model Context Protocol (MCP). The security trio (Parts 6-8) is behind us; now we shift to responsiveness — keeping an agent snappy across tools that range from a 100 ms KPI read to a report render that takes minutes.
Pick the pattern by duration
< ~1s SYNC return the result directly
~1-30s STREAM progress notifications + streamed partial results (SSE)
> ~30s ASYNC JOB enqueue -> return a jobId -> poll status / await a resource
Rule of thumb: never hold a single tool call open for minutes.
1. Block -> progress notifications
A tool that runs for a minute with zero feedback times the client out. Emit MCP progress notifications instead:
public async Task<Report> GenerateReport(
ReportArgs args, IProgress<ProgressNotification> progress, CancellationToken ct)
{
for (var i = 0; i < args.Pages; i++)
{
await renderer.RenderPageAsync(args, i, ct);
progress.Report(new(progress: i + 1, total: args.Pages, message: $"rendered page {i + 1}/{args.Pages}"));
}
return await renderer.FinalizeAsync(args, ct);
}
MCP has a first-class progress channel — a progressToken on the request and notifications/progress flowing back. Use it, and a long tool shows "page 4 of 10" instead of a spinner that ends in a timeout.
2. Return-all-at-once -> stream partial results
Stream chunks over Streamable HTTP + SSE so output appears as it's produced:
public async IAsyncEnumerable<AnalysisChunk> AnalyzeCampaigns(
AnalyzeArgs args, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var finding in analyst.StreamAsync(args, ct))
yield return finding; // each chunk flushed to the client as it lands
}
Streaming is both a UX win and a memory win — first-token p95 sits at ~300 ms for analytical answers, and neither side buffers the whole result.
3. Minutes-long work -> the async-job pattern
A five-minute render inside a tool call dies to a timeout long before it finishes. Enqueue it and return a handle immediately:
[McpServerTool(Name = "create_report")]
[Description("Start generating a report. Returns a jobId immediately; poll report_status or await report.ready.")]
public async Task<ReportQueued> CreateReport(CreateReportArgs args, CancellationToken ct)
{
var jobId = await reports.EnqueueAsync(principal.TenantId, args, ct); // -> Azure Service Bus
return new ReportQueued(jobId, Status: "queued"); // p95 ~90ms; render is async
}
[McpServerTool(Name = "report_status")]
[Description("Check a report job: queued | rendering | ready (with a resource URI) | failed.")]
public async Task<ReportStatus> ReportStatus(string jobId, CancellationToken ct)
=> await reports.StatusAsync(principal.TenantId, jobId, ct);
create_report returns in p95 ~90 ms while the PuppeteerSharp render (part of 1.2M / 48h) runs asynchronously on Service Bus.
4. Cancellation — stop the work no one wants
MCP's cancelled notification maps to a CancellationToken; honor it end to end:
public async Task<Report> GenerateReport(ReportArgs args, CancellationToken ct)
{
for (var i = 0; i < args.Pages; i++)
{
ct.ThrowIfCancellationRequested(); // stop promptly on cancel
await renderer.RenderPageAsync(args, i, ct);
}
return await renderer.FinalizeAsync(args, ct);
}
A long tool that ignores cancellation is a capacity leak — abandoned runs keep burning compute on artifacts nobody reads.
5. Timeouts and keepalive — surviving the network
Proxies and load balancers reap "idle" connections, and SSE looks idle between chunks. Ping it:
builder.Services.AddMcpServer().WithHttpTransport(o =>
{
o.KeepAliveInterval = TimeSpan.FromSeconds(15); // so intermediaries don't reap the connection
});
Keepalive holds it open; a per-call timeout bounds the worst case; a resilient reconnecting client recovers if it still drops.
6. Stream to the user, feed the model the result
Two audiences, two channels:
progress.Report(...); // -> user's progress bar
stream.WriteToUser(chunk); // -> user sees tokens as they land
return ToolResults.Structured(finalKpis); // -> the model gets the clean result
Humans want to see progress; the model wants the answer. Feeding every "rendered page 4/10" event into the model's context is noise it pays tokens for and reasons worse over.
The model to carry forward
Decouple the length of the work from the length of the call. Fast tools return; medium tools stream progress and partials; long tools enqueue and hand back a handle. Then keep the channel alive (keepalive, reconnect) and let it die cleanly (cancellation). Do that and the agent feels instant regardless of whether the tool underneath takes 100 milliseconds or ten minutes.
Originally published at prepstack.co.in. Part 10 makes all of this observable: debugging and observability for MCP in production.
Top comments (1)
The async-job pattern also needs durable semantics around the handle. I would make
jobIdtenant-scoped, idempotent for a caller-supplied request key, and resumable after client reconnect. Status should distinguishcancel_requestedfromcancelled, expose an expiry, and return a stable final resource URI so retryingcreate_reportcannot enqueue duplicate work. Progress is UX; those state-machine guarantees are what make a minutes-long MCP tool operationally safe.