DEV Community

Ali Suleyman TOPUZ
Ali Suleyman TOPUZ

Posted on Originally published at topuzas.Medium on

LongRunning MCP Tools .NET What Actually Happens When Your ToolCall Outlives the Client's Patience

Long-Running MCP Tools in .NET: What Actually Happens When Your Tool Call Outlives the Client’s Patience

A build log from wiring a slow tool into an MCP server, why it kept “failing” while clearly still running, and the start-and-poll pattern on Durable Functions that fixed it, plus the protocol-level fix that’s coming to replace it.

The first time this bit me, it didn’t look like a timeout. It looked like a bug in my own code.

I had an MCP server exposing a tool that kicked off a document processing job, nothing exotic, just a few chained steps: pull a file, run it through an extraction step, normalize the output, write a summary. On a good day it took ninety seconds. On a bad day, with a bigger file or a cold dependency, it crept past three minutes. I called it from an agent in VS Code, watched the tool call fire, and a little over a minute later got a clean, unambiguous failure. No exception in my logs. No 500. Nothing in Application Insights that looked like a crash. I opened the logs anyway, mostly out of stubbornness, and found the function had kept running after the client gave up, finished successfully, and written its output to blob storage like nothing had happened.

That’s the specific kind of bug that makes you question your own tooling, because everything downstream of the failure looks correct. The client says the call failed. The user (or in my case, the agent) sees an error. And the server-side telemetry shows the operation completing fine, sixty, ninety, however many seconds after anyone was still listening. If the calling agent then retries, which agents do, you now have two copies of an expensive job running for a request that’s already been marked as failed. I’ve seen that turn into duplicate emails, duplicate database writes, and duplicate charges on the API keys of whatever the tool calls downstream. It’s not a cosmetic problem.

This is the write-up of how I actually fixed it: the pattern I ended up shipping, why the two obvious alternatives didn’t hold up, and what changes once the MCP protocol’s own answer to this problem, the Tasks extension, is something clients can actually rely on. I’m going to include real code, because half of what made this click for me wasn’t the architecture diagram, it was seeing what the JSON coming back from the tool actually needs to look like.

Why the call dies even though the server is fine

Model Context Protocol tool calls are, at their core, request and response. A client sends tools/call, the server does the work, the server sends back a result. That's a fine model for a database lookup or a search query. It falls over the moment the work takes real time, because every MCP client enforces its own idea of how long it's willing to hold that connection open, and the protocol spec doesn't pin that number down anywhere. Each client picks its own.

In practice that means the timeout you’re racing against isn’t documented in one place, it’s tribal knowledge you accumulate by getting burned. The MCP TypeScript SDK defaults its request timeout to 60 seconds, configurable through an environment variable or per-request option. Claude Desktop’s tool-call timeout has been reported at around four minutes in the working group’s own discussion of this exact problem. Generic HTTP gateways and load balancers in front of a remote MCP server often clamp things down to 30 to 60 seconds regardless of what the client or server would otherwise allow. None of this is guaranteed to stay put either, a client can ship an update tomorrow and change its number without asking anyone.

Here’s roughly where things stood when I was debugging this, laid out so you can see how little of it is standardized:

CLIENT / LAYER APPROXIMATE TOOL-CALL TIMEOUT
--------------------------------------------------------------------
MCP TypeScript SDK (default) 60 seconds, configurable
Claude Desktop ~4 minutes (per community reports)
Generic reverse proxy / API gateway 30-60 seconds, varies by config
Azure Functions Flex/Premium host up to 30 min (not the binding limit)
Azure Functions Consumption host 5 min default, 10 min max
Enter fullscreen mode Exit fullscreen mode

That last row matters more than it looks. The Azure Functions host timeout and the MCP client timeout are two completely different ceilings, and mixing them up is the mistake I made first. My function could legally run for many minutes on a Premium plan. The client hanging up at 60 or 90 seconds had nothing to do with my host configuration. I spent an embarrassing amount of time turning host.json knobs that were never the problem.

The two things I tried before I tried the right thing

Fire it and hope. My first instinct was to make the tool return immediately with something like “job started,” and just let the work run in the background with no way for the agent to ever check on it again. This technically avoids the timeout, but it trades a hard failure for a silent one. The agent has no mechanism to learn the job succeeded, so it either hallucinates a result, tells the user it’s “in progress” forever, or just drops the thread. For anything where the caller actually needs the output, this isn’t a fix, it’s a different bug wearing the first one’s clothes.

Just hold the connection open longer. The second instinct, bump every timeout you can find until the slow path fits under the ceiling. This works right up until it doesn’t; the client’s timeout is out of your control, and even where it’s configurable, you’re now betting your product experience on every user of every client correctly setting an environment variable they’ve never heard of. On serverless compute specifically, holding a request open for minutes also means paying for an idle, blocked execution the whole time, which is exactly the kind of thing serverless billing punishes you for.

Neither of these is really wrong, they’re just incomplete. What I actually needed was a way to decouple “the work finishing” from “the connection staying open,” while still giving the agent a deterministic way to come back and check.

The pattern: budgeted start, then poll

The fix I landed on, and the one Microsoft’s Azure Functions team has since published as an official sample, splits the single slow tool into two fast ones, backed by a Durable Functions orchestration.

Durable Functions is the piece doing the actual heavy lifting here. It lets you write a stateful, multi-step workflow as ordinary-looking C# code, while the platform transparently checkpoints progress, survives process restarts, and keeps the workflow running independent of any particular HTTP connection. The orchestration doesn’t care whether an MCP client is still listening. It just runs.

On top of that, you expose two tools instead of one:

  • start_mining (or whatever your slow operation actually is) kicks off the Durable orchestration, then waits, but only up to a short, configurable budget, comfortably under the most aggressive client timeout you're targeting, something like 15 to 20 seconds. If the work finishes inside that budget, the tool returns the real result inline and the second tool is never touched. If the budget runs out first, the tool returns a handle instead, an instance id, plus an explicit instruction telling the agent to check back.
  • get_mining_result takes that handle and reports back one of a small number of states: still running, completed with a result, failed with a reason, or unrecognized.

I’m using the naming from the reference sample here (mining blocks, difficulty) because it’s a genuinely good stand-in for “any slow, unpredictable job,” and it’s what I tested against before adapting it to my own document pipeline. The mechanic is proof-of-work style hashing: try inputs against SHA-256 until one produces an output starting with enough leading zeros, chain a few of those together, and you’ve got a workload that’s as slow or as fast as you want depending on one difficulty knob, which is perfect for exercising both the fast inline path and the slow poll path on demand.

Building it

Here’s the shape of it in .NET on the isolated worker model, using the Microsoft.Azure.Functions.Worker.Extensions.Mcp package for the tool triggers and the standard Durable Functions client for orchestration.

Wiring up the tool properties in Program.cs:

using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;

var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
builder.Services
    .AddApplicationInsightsTelemetryWorkerService()
    .ConfigureFunctionsApplicationInsights();
builder
    .ConfigureMcpTool("get_mining_result")
    .WithProperty("workflow_id", "string", "The workflow id returned by start_mining.", required: true);
builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

The tool that starts the work and waits on a budget:

[Function(nameof(StartMining))]
public async Task<object> StartMining(
    [McpToolTrigger("start_mining", "Starts mining a short chain of proof-of-work blocks.")]
        ToolInvocationContext context,
    [McpToolProperty("difficulty", "number", "Leading zero bits required per block.", IsRequired = false)]
        int? difficulty,
    [DurableClient] DurableTaskClient durableClient,
    CancellationToken hostCancellation)
{
    var effectiveDifficulty = difficulty ?? DefaultDifficulty;

    string instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync(
        nameof(MineChainOrchestrator),
        new MiningInput(effectiveDifficulty));
    using var budget = CancellationTokenSource.CreateLinkedTokenSource(hostCancellation);
    budget.CancelAfter(TimeSpan.FromSeconds(WaitBudgetSeconds));
    try
    {
        var metadata = await durableClient.WaitForInstanceCompletionAsync(
            instanceId, getInputsAndOutputs: true, budget.Token);
        return metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed
            ? new { status = "completed", workflow_id = instanceId, result = metadata.ReadOutputAs<MiningResult>() }
            : ToFailedResult(instanceId, metadata);
    }
    catch (OperationCanceledException) when (!hostCancellation.IsCancellationRequested)
    {
        // Budget expired, not a host shutdown. The orchestration keeps running regardless.
        return new
        {
            status = "running",
            workflow_id = instanceId,
            poll_after_seconds = 5,
            next = "Call get_mining_result with this workflow_id."
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

The poll tool, which is deliberately dumb, it just reports state:

[Function(nameof(GetMiningResult))]
public async Task<object> GetMiningResult(
    [McpToolTrigger("get_mining_result", "Gets the status or result of a mining workflow.")]
        ToolInvocationContext context,
    [McpToolProperty("workflow_id", "string", "The workflow id returned by start_mining.", IsRequired = true)]
        string workflowId,
    [DurableClient] DurableTaskClient durableClient)
{
    var metadata = await durableClient.GetInstanceAsync(workflowId, getInputsAndOutputs: true);

    if (metadata is null)
    {
        return new { status = "not_found", workflow_id = workflowId };
    }
    return metadata.RuntimeStatus switch
    {
        OrchestrationRuntimeStatus.Completed => new
        {
            status = "completed",
            workflow_id = workflowId,
            result = metadata.ReadOutputAs<MiningResult>()
        },
        OrchestrationRuntimeStatus.Failed => new
        {
            status = "failed",
            workflow_id = workflowId,
            reason = "error",
            error = metadata.FailureDetails?.ErrorMessage
        },
        OrchestrationRuntimeStatus.Terminated => new
        {
            status = "failed",
            workflow_id = workflowId,
            reason = "terminated"
        },
        _ => new
        {
            status = "running",
            workflow_id = workflowId,
            poll_after_seconds = 5,
            next = "Call get_mining_result again with this workflow_id."
        }
    };
}
Enter fullscreen mode Exit fullscreen mode

And the orchestrator itself, which is where Durable Functions earns its keep, chaining a few activities together while the platform checkpoints progress between each one:

[Function(nameof(MineChainOrchestrator))]
public static async Task<MiningResult> MineChainOrchestrator(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var input = context.GetInput<MiningInput>();
    var blocks = new List<Block>();
    string previousHash = "genesis";
    for (int i = 0; i < ChainLength; i++)
    {
        var block = await context.CallActivityAsync<Block>(
            nameof(MineBlockActivity),
            new MineBlockInput(previousHash, input.Difficulty));
        blocks.Add(block);
        previousHash = block.Hash;
    }
    return new MiningResult(blocks);
}
Enter fullscreen mode Exit fullscreen mode

Nothing here is exotic Durable Functions usage, it’s the plain function-chaining pattern, one activity feeding the next. The interesting engineering isn’t in the orchestration. It’s in what those two tools return.

The part that actually mattered: the JSON is the real interface

I underestimated this at first. I treated the status payload as an implementation detail and spent most of my initial effort on the orchestration code. That was backwards. The orchestration is genuinely simple. The contract between your tools and the calling model is where all the fragile decisions live, because the model is the one reading this JSON and deciding what to do next, and it has no compiler checking that it does the right thing.

A few decisions I’d defend strongly, some the hard way:

workflow_id is a required parameter on get_mining_result, not optional. This sounds like a small thing. It isn't. Making it required means the agent structurally cannot call the poll tool without having first called the start tool, which closes off an entire category of "the model tried to check on a job it never started" failures before they can happen.

The running status always carries a poll_after_seconds hint and a next field spelling out literally what to do. Agents are much more reliable at following an explicit instruction embedded in tool output than at inferring "I should probably check back later" from a bare status string. I was skeptical this would matter as much as it did. It mattered.

not_found is a distinct status from failed. I originally collapsed these into one "error" bucket, and it caused the agent to sometimes retry a workflow id it had gotten wrong instead of recognizing it needed to start over. Separating them cleanly tells the agent "your handle is bad, don't keep polling it, start a new workflow" versus "your handle is fine, the job itself broke."

Here’s the full state table I settled on:

STATUS MEANING AGENT'S NEXT MOVE
-------------------------------------------------------------------------------
completed Done. `result` holds the output. Use the result.
running Still in flight, budget expired. Wait poll_after_seconds,
                                                           call get_mining_result.
failed Terminal. `reason` + `error` explain why. Stop polling, surface
                                                           the error to the user.
not_found No workflow for that id. Don't poll. Start over.
Enter fullscreen mode Exit fullscreen mode

Even with all of that, there’s a failure mode I never fully closed: the poll path still depends on the model correctly remembering, and not quietly inventing, the workflow id it was handed. If it garbles a character or fabricates one entirely, the poll either lands on the wrong instance or matches nothing, which is exactly why get_mining_result returns not_found instead of guessing or silently returning an empty success. I mitigated this, I didn't eliminate it. The budgeted inline wait helps a lot in practice, because most calls in my testing finished within budget and never touched the handle at all, but for the slow tail, the model is still the one holding the id in its context window, and that's a real weak point. More on why that weak point is temporary in a minute.

Running this locally without an Azure bill

I did almost all of my iteration without touching a real Azure subscription, and it’s worth spelling out how, because it’s the difference between a fifteen-minute test loop and a five-minute one.

Durable Functions needs a storage backend to checkpoint orchestration state. Locally, that backend is Azurite, a free, open-source emulator for Azure Storage that runs entirely on your machine, no subscription, no cost, no network dependency once it’s installed:

npm install -g azurite
azurite --skipApiVersionCheck --silent --location ./.azurite
Enter fullscreen mode Exit fullscreen mode

With that running in one terminal, start the Functions host from your project folder in another:

cd src
func start
Enter fullscreen mode Exit fullscreen mode

You’ll see both MCP tools register on startup, along with the orchestration trigger, and the MCP endpoint print out something like:

MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp
Functions:
    StartMining: mcpToolTrigger
    GetMiningResult: mcpToolTrigger
    RunOrchestrator: orchestrationTrigger
Enter fullscreen mode Exit fullscreen mode

Point an MCP-capable client at that local endpoint (VS Code with GitHub Copilot’s agent mode works well for this, via a .vscode/mcp.json entry) and you can exercise both the fast inline path and the slow poll path just by changing the difficulty argument you pass in. Lower difficulty finishes inside the wait budget and comes back inline. The default difficulty is tuned specifically to outlast the budget, so you exercise the poll loop without needing to touch any code.

When you’re ready to actually deploy, the only backend swap is in configuration, not code. Locally, host.json points at the Azure Storage backend served by Azurite. In Azure, the recommended backend is the Durable Task Scheduler, a managed, purpose-built store for Durable Functions state that scales better than the storage-account backend under real load. The orchestration and tool code you write doesn't change between the two, only the host.json extension configuration does.

The setting that’s easy to get backwards

There are two timeouts in this system and they bound completely different things, and getting them confused cost me a debugging session I didn’t need to lose.

SETTING CONTROLS WHERE IT LIVES
------------------------------------------------------------------------------
WaitBudgetSeconds How long start_mining blocks before App setting, your code
                       falling back to a poll handle. (keep it under the
                                                              client's timeout)
functionTimeout How long the Functions host lets a host.json
                       single invocation run before killing
                       it outright.
Enter fullscreen mode Exit fullscreen mode

WaitBudgetSeconds needs to stay comfortably under whatever the calling client's tool-call timeout is, since that's the ceiling you're actually racing against. functionTimeout needs to stay comfortably above your wait budget, since it bounds the whole function execution, not just the wait. Confusing the two, tuning functionTimeout thinking it would fix a client-side hang, is precisely the mistake I made on day one. They're solving different problems and neither one substitutes for the other.

What changes once Tasks lands everywhere

Everything above is a workaround, and a good one, but it’s worth being honest that it’s a workaround for a gap the protocol itself is actively closing.

The 2026–07–28 MCP specification release candidate graduated the experimental Tasks feature from earlier spec versions into a proper extension, redesigned around MCP’s newly stateless protocol core. Under this model, a server can respond to tools/call with a task handle instead of blocking for a final result, and from there the client, not the model, drives the lifecycle: tasks/get polls status, tasks/update submits input if the task needs it, tasks/cancel cancels an in-flight task. A task carries one of a fixed set of statuses (working, input_required, completed, failed, or cancelled) and, once completed, the final result.

Two design choices here directly close the gaps I ran into building the Durable Functions version by hand. Task creation is server-directed, meaning a client has to explicitly advertise support for the extension before a server will ever hand it a task instead of a synchronous result, so there’s no ambiguity about whether the calling side knows how to drive the lifecycle. And critically, the handle lives in the SDK’s bookkeeping, not in the model’s context window. That’s the exact weak spot I couldn’t fully close in my own implementation, the model quietly mangling or hallucinating a workflow_id. If the client SDK is the thing tracking and passing the handle back, that failure mode mostly disappears, because the model never has to remember or retype it correctly in the first place.

The catch, and it’s a real one right now, is that Tasks depends on ecosystem support on both ends. Clients have to advertise the extension, and MCP SDKs across every language have to implement the task lifecycle, before a server can lean on it. As of this writing that support is still rolling out. Which is exactly why the pattern in this article isn’t a stopgap you should feel bad about shipping. It’s the correct answer for today, built on infrastructure, Durable Functions checkpointing and recovery, that isn’t going anywhere regardless of what the MCP spec does next.

Would I still reach for this after Tasks is everywhere?

Probably, in a narrower set of cases, yes. Even once Tasks has broad client and SDK support, I don’t think it replaces Durable Functions so much as it replaces the manual workflow_id-passing part of what I built. The orchestration itself, the checkpointing, the automatic recovery if a host instance recycles mid-job, the ability to fan out into parallel activities and fan back in, none of that is something the MCP protocol is trying to provide. Tasks standardizes the conversation between client and server about whether work is done yet. It has nothing to say about how the work itself survives a restart. For anything genuinely long, the kind of job that might span a host recycle or a deployment, you still want a durable execution engine underneath, Tasks or no Tasks.

What I’d change the day broad Tasks support lands: drop the hand-rolled get_mining_result polling tool and the required workflow_id parameter, and let the SDK's task lifecycle calls hit the same Durable orchestration underneath. The orchestration code in this article barely changes. The fragile part, the JSON contract I spent the most time getting right, gets replaced by something the protocol guarantees instead of something I have to guarantee myself. That's a trade I'll take as soon as it's actually available to me.

Until then, budgeted start, honest poll instructions, and a durable backend that doesn’t care whether anyone’s still listening, that’s what got my document pipeline from “mysteriously fails at ninety seconds” to something that just works, most of the time inline, and reliably on the slow path when it isn’t.

If you’re building an MCP server in .NET and hitting this same wall, the pattern above is adapted from the Azure Functions team’s own sample and their write-up on the Azure SDK blog. Worth cloning directly if you want a working starting point instead of copying snippets out of an article.

Tags: dotnet, azure-functions, durable-functions, model-context-protocol, ai-agents, serverless, mcp

Top comments (0)