MCP Deep Dive, Part 9: When the Tool Takes Minutes — Streaming and Long-Running Tools Over MCP
A tool that takes minutes shouldn't freeze your agent. Here's streaming, progress notifications, and the async-job pattern for long-running MCP tools.
- Author
- Randhir Jassal
- Published
- Reading time
- 15 min read
- Views
- 16 views
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 part is how to decouple them.
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. On Mattrx, tools range from a 100 ms KPI read to a report render that takes minutes, and the same agent has to stay snappy across all of it. Here's how — progress, streaming, and the async-job pattern — with the naive version that hangs and the version that doesn't.
TL;DR
| Situation | Naive (before) | Responsive MCP (after) |
|---|---|---|
| Long tool | blocks, times out | progress notifications |
| Big result | one blob at the end | streamed partial results (SSE) |
| Minutes-long work | held open, dies | async job: enqueue + poll/subscribe |
| Abandoned run | keeps working | cancellation → stops |
| Idle SSE | proxy reaps it | keepalive + reconnect |
| Model context | flooded with progress | final result only |
- Pick the pattern by duration: < 1s sync, ~1–30s stream, > 30s async job.
- Emit progress notifications (
notifications/progress) so a long tool shows "4/10", not a frozen spinner. - Stream partial results over Streamable HTTP + SSE — the user sees output as it's produced.
- For minutes-long work, enqueue and return a jobId (Service Bus), then poll
report_statusor await a resource — don't hold the call open. - Honor cancellation end to end — MCP's cancelled notification →
CancellationToken→ stop the work. - Keepalive + per-call timeout + reconnect keep long SSE streams alive through proxies.
- Stream to the user; feed the model the final result — don't flood its context with progress events.
- Report enqueue p95 ~90 ms; PuppeteerSharp renders 1.2M / 48h asynchronously.
- Streaming first-token p95 ~300 ms for analytical responses.
- Cancellation frees worker capacity the moment an agent run is abandoned.
The one mental shift: the length of the work should never be the length of the call. Match the mechanism to the duration — return fast work, stream medium work, enqueue slow work — and the agent stays responsive no matter how long the tool actually takes.
The running example: three speeds of Mattrx tool
mattrx-analytics has get_campaign_kpis (≈120 ms — just return it) and streaming analytical answers (~seconds — stream them). mattrx-reports has create_report, which kicks off a PuppeteerSharp PDF render that can take minutes and runs 1.2M times per 48h through Azure Service Bus. One agent calls all three, and the user should never watch a frozen spinner. Here's each speed, done right.
Pick the pattern by duration
How long does the tool take?
< ~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
Before
The tool runs for a minute with zero feedback. The client times out; the user waits blind.
// BEFORE: 60+ seconds of silence. The client times out; the user sees a spinner and gives up.
[McpServerTool(Name = "generate_report")]
public async Task<Report> GenerateReport(ReportArgs args, CancellationToken ct)
=> await renderer.RenderAsync(args, ct);
After
Emit MCP progress notifications so the client can show real progress.
// AFTER: report progress as work proceeds — MCP's progressToken + notifications/progress.
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);
}
Diagnostic: 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. Feedback is the difference between "it's working" and "it's broken."
Mattrx metric: progress notifications on medium-length tools turned "did it hang?" support tickets into a visible progress bar — the work takes the same time, but it no longer feels broken.
2. Return-all-at-once → stream partial results
Before
A tool that produces a lot buffers everything and returns one big blob at the very end.
After
Stream results incrementally over the Streamable HTTP + SSE transport (Part 2), so output appears as it's produced.
// AFTER: stream chunks as they're produced. Neither side buffers the whole result.
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
}
Diagnostic: streaming is both a UX win and a memory win — the client renders chunks as they arrive, and neither the server nor the client holds the entire result in memory. This is exactly what the SSE half of Streamable HTTP (Part 2) is for.
Mattrx metric: streaming first-token p95 sits at ~300 ms for analytical answers — the user starts reading almost immediately instead of waiting seconds for a complete response to materialize.
3. Minutes-long work → the async-job pattern
Before
A five-minute render runs inside the tool call. The HTTP layer, a proxy, or the MCP client times out long before it finishes, and the work is wasted.
After
Enqueue the work and return a handle immediately; a worker does the slow part; a companion tool (or a resource) reports completion.
// AFTER: a genuinely long tool ENQUEUES and returns at once; the slow work runs elsewhere.
[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);
Agent mattrx-reports (MCP) Service Bus + worker User
| create_report -->| | |
| ReportQueued(id) |-- enqueue (~90ms) ------>| |
|<-----------------| |-- PuppeteerSharp render |
| report_status(id)->|-- "rendering" | (seconds..minutes) |
|<-----------------| |-- report.ready -------->| (notify)
| report_status(id)->|-- "ready" + mattrx://reports/{id} |
| read resource -->| (the PDF) | |
Diagnostic: never hold a tool call open for minutes — the MCP call and every HTTP hop under it will time out. For work measured in minutes, enqueue it, hand back a job id, and let the agent poll report_status or read the report as a resource when it's ready. (This is the same enqueue-and-worker pattern as our webhook delivery system — decoupling slow work from the request that triggered it.)
Mattrx metric: create_report returns in p95 ~90 ms while the PuppeteerSharp render (part of 1.2M / 48h) runs asynchronously on Service Bus — the agent moves on instantly and picks up the finished PDF later.
4. Cancellation — stop the work that no one wants
Before
The user navigates away, but the tool keeps rendering pages nobody will read — burning compute and cost.
After
MCP's cancelled notification maps to a CancellationToken (Part 3); honor it end to end.
// The client can cancel a long op (notifications/cancelled). Thread the token so cancelling the
// agent stops the work immediately instead of finishing a render no one is waiting for.
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);
}
Diagnostic: a long tool that ignores cancellation is a capacity leak. Agent runs get abandoned constantly — the user closes the tab, the model changes plan — and every in-flight long tool that keeps going is wasted work. Thread the token from the MCP cancellation notification all the way down.
Mattrx metric: honoring cancellation on long tools frees worker and render capacity the instant a run is abandoned — reclaimed compute that would otherwise finish artifacts nobody reads.
5. Timeouts and keepalive — surviving the network
Before
The SSE stream dies mid-render because a proxy or load balancer reaped an "idle" connection, and the agent hangs forever.
After
Send keepalive pings, bound each call with a timeout, and let the resilient client (Part 4) reconnect.
// Keep long SSE streams alive through proxies with periodic keepalive; without it, an idle proxy
// silently kills a 3-minute stream and the agent waits on a socket that's already dead.
builder.Services.AddMcpServer().WithHttpTransport(o =>
{
o.KeepAliveInterval = TimeSpan.FromSeconds(15); // ping so intermediaries don't reap the connection
});
Diagnostic: the enemy of a long stream is the network in between. Proxies and load balancers close connections they think are idle, and SSE looks idle between chunks. Keepalive pings hold it open; a per-call timeout bounds the worst case; and if it still drops, the reconnecting client from Part 4 recovers.
Mattrx metric: keepalive on the SSE transport is why multi-minute streaming and progress channels survive the gateway and load balancer intact — no silently-dead sockets, no hung agents.
6. Stream to the user, feed the model the result
Before
Every progress event and partial token gets dumped into the model's context — or, worse, the user waits for the whole thing before seeing anything.
After
Two audiences, two channels. Stream progress and tokens to the user for responsiveness; give the model the clean final result to reason over.
// The USER gets live progress + tokens (UX). The MODEL gets the FINAL structured result —
// not every progress event bloating (and confusing, and costing) its context.
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
Diagnostic: don't confuse streaming-for-UX with what the model consumes. 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. Split the streams by audience.
Mattrx metric: keeping progress out of the model's context is part of holding context at 3.5k tokens — the user gets a rich live view while the model gets only the clean result it needs.
The numbers, in one place
| Metric | Naive (before) | Responsive MCP (after) |
|---|---|---|
| Long tool feedback | none (spinner) | progress notifications |
| First-token latency | wait for the whole result | ~300 ms (streamed) |
create_report return | blocks minutes | ~90 ms (enqueued) |
| Report throughput | serial, timing out | 1.2M / 48h async |
| Abandoned long tools | keep running | cancelled promptly |
| Multi-minute streams | reaped by proxies | kept alive + reconnect |
Streaming & long-tool checklist
- Choose the pattern by duration: sync (< 1s), stream (~1–30s), async job (> 30s).
- Emit progress notifications on any tool longer than a second or two.
- Stream partial results over SSE instead of buffering one big blob.
- Enqueue minutes-long work; return a jobId; expose
statusand the result as a resource. - Honor cancellation — thread the token from MCP's cancelled notification to the work.
- Keepalive long SSE streams; set a per-call timeout; rely on the client to reconnect.
- Split the streams — progress/tokens to the user, the final structured result to the model.
The honest stuff: proportion and pitfalls
- Fast tools (< 1s). Just return. Streaming and jobs are pure overhead for a sub-second call.
- Streaming to the model what it doesn't need. Progress events belong in the UI, not the model's context.
- Holding a call open for minutes "just in case." HTTP and proxy timeouts will kill it. If it can take minutes, enqueue it.
- Skipping cancellation on long/async tools. Abandoned work is wasted cost and capacity — always honor the token.
- Async jobs everywhere. A jobId + polling + a status tool + a resource is more moving parts; use it only for genuinely long work.
- Forgetting keepalive. A 3-minute stream through a proxy with a 60s idle timeout dies silently. Ping it.
- Confusing progress with the result. A progress notification is UX; the answer must still come back as a structured result the model can use.
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 — which is exactly the experience users expect and rarely get.
Three habits that keep long tools responsive:
- Match the mechanism to the duration. Sync, stream, or async job — but never block for minutes.
- Separate the user's stream from the model's result. Progress and tokens for the human; the clean result for the model.
- Make long work cancellable and resumable. Honor cancellation, keepalive the stream, reconnect on drop.
In Part 10 we make all of this observable: debugging and observability for MCP in production — tracing an agent run across servers, tools, and these very streams.
Continue the series — MCP Deep Dive
- Why Model Context Protocol Kills Integration Glue Code for Good
- Inside the MCP Architecture: Hosts, Clients, and Servers
- Build a Production-Grade MCP Server From Scratch
- Build an MCP Client That Connects to Any Tool (and Any Model)
- Custom MCP Tools Your AI Agents Can Actually Trust
- MCP Authentication With OAuth and Entra ID, Done Right
- Reaching a Tool Isn't Being Allowed — Least-Privilege Authorization for MCP Agents
- When a Tool Result Is the Attack — Securing MCP Against Prompt Injection and Tool Abuse
- When the Tool Takes Minutes — Streaming and Long-Running Tools Over MCP (you are here)
- Debugging and Observability for MCP in Production
- Rolling MCP Out Across the Enterprise
- Building MCP Servers in C# and .NET 9
- Hosting MCP on Azure at Real Scale
- Wiring MCP Into OpenAI and Agent Frameworks
- Running MCP in Production — Lessons From Mattrx
Further reading
- MCP Deep Dive, Part 4: Build an MCP Client That Connects to Any Tool (and Any Model)
- MCP Deep Dive, Part 10: Debugging and Observability for MCP in Production
- How We Deliver 15 Million Webhooks a Day Without Losing a Single Event
Wrestling with a long-running tool or a streaming UX in MCP and want a second pair of eyes on the pattern? I'm always happy to compare notes — reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.