DEV Community

kirandeepjassal-crypto
kirandeepjassal-crypto

Posted on • Originally published at prepstack.co.in

MCP Deep Dive, Part 9: When the Tool Takes Minutes — Streaming and Long-Running Tools Over MCP

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.
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The async-job pattern also needs durable semantics around the handle. I would make jobId tenant-scoped, idempotent for a caller-supplied request key, and resumable after client reconnect. Status should distinguish cancel_requested from cancelled, expose an expiry, and return a stable final resource URI so retrying create_report cannot enqueue duplicate work. Progress is UX; those state-machine guarantees are what make a minutes-long MCP tool operationally safe.