An MCP Tool That Hangs: Modeling Long-Running Operations as Async Jobs
Quick answer: A blocking MCP tool that wraps a long-running operation is a design error that shows up as a hung agent: the model waits on a single call that can take minutes, the request times out, and any retry starts the job over. The correct pattern is an asynchronous job contract: the first tool call starts the work and immediately returns a job id, a second tool polls the job status, and the agent only fetches the result when the job reports completion. This mirrors how every mature API handles exports, renders, and batch processing, and it keeps the model in control of a state machine instead of trapped in a network call. If an operation can outlive a normal request timeout, it should never be exposed as a synchronous tool.
The temptation is to expose the underlying operation directly. If the server already has a "run report" endpoint that blocks until done, wrapping it in one MCP tool feels natural and requires no extra design. But agents and the transport beneath them do not share the server's patience. Streamable HTTP has request limits, stdio pipelines stall, and a tool that does not return for several minutes leaves the model with no way to reason, cancel, or even show progress. The failure is not slowness; it is the absence of a state the model can inspect.
Blocking vs asynchronous tools
The two approaches differ in how they treat time and failure, and the contrast makes the choice clear.
| Concern | Blocking tool | Async job tools |
|---|---|---|
| Call duration | Open until the work finishes | Returns a job id immediately |
| Timeout behavior | Call dies, state unknown | Job survives, pollable |
| Retry safety | Restarts the whole job | Reads the same job id |
| Progress visibility | None | Status and percent complete |
| Cancellation | Not expressible | Explicit cancel tool |
Retry safety is the most operationally important difference. Against a blocking tool, a timeout followed by a retry has no way to know whether the first request started the work, so duplicate jobs are common. The async pattern makes the job id the handle that makes retries idempotent.
Define the job lifecycle as tools
Expose three small tools instead of one large one: start, get status, and optionally cancel. Each returns quickly and maps to a clear state, so the model drives the lifecycle rather than waiting on it.
// Tool 1: start the job and return immediately
// POST /v1/exports -> { "job_id": "job_8f3a", "status": "queued" }
{
name: "start_export",
description: "Begins a data export and returns a job id. Does not block.",
inputSchema: {
type: "object",
properties: { format: { type: "string", enum: ["csv", "json"] } },
required: ["format"],
},
}
// Tool 2: poll the job; always returns fast
// GET /v1/jobs/{job_id} -> { status, progress, result_url? }
{
name: "get_export_job",
description: "Returns queued, running, succeeded, or failed for a job id.",
inputSchema: {
type: "object",
properties: { job_id: { type: "string" } },
required: ["job_id"],
},
}
The tight input schemas matter here. Requiring job_id on the polling tool prevents the model from inventing a status call without the handle returned by the start tool, and the enum on format keeps invalid requests from ever reaching the server.
Let the agent drive the state machine
With the tools defined, the model's flow is a short loop: start, poll, and only fetch the result on success, backing off between polls. The server owns the job, so a poll that fails on the network does not disturb the work.
// conceptual agent flow
const { job_id } = await tools.start_export({ format: "csv" });
let job;
do {
await sleep(2000); // bounded backoff, not a hang
job = await tools.get_export_job({ job_id });
if (job.status === "failed") throw new Error(job.error);
} while (job.status === "queued" || job.status === "running");
const result = await fetch(job.result_url); // reached only on success
The loop is explicit and inspectable, which means the model can report progress, stop on a failed status, and avoid the duplicate-job problem because every poll and the final fetch reference the same job_id. Server-side, the job store should be keyed by that id and survive a disconnected client.
Generate the tools from the contract
These job endpoints are ordinary HTTP operations, and a local-first workflow can generate the MCP tools from the OpenAPI description rather than hand-maintaining a separate server. The spec already declares the start route, the job route, and the status enum, so generated tools stay aligned with the API and the schemas cannot drift. Hand-written MCP servers, by contrast, tend to hardcode the blocking behavior of the first endpoint and never add the job lifecycle at all.
The governing principle is worth stating plainly: a tool call should return in a bounded, predictable time, and anything that cannot should be modeled as a resource with its own lifecycle. Give the model a job id, a status it can poll, and a way to cancel, and a long-running operation becomes a clean state machine instead of a frozen agent.
Frequently asked questions
What duration counts as "long-running"?
Anything that can approach the transport or client timeout, typically beyond a few seconds, is a candidate. Exports, renders, training, and batch processing almost always warrant the async pattern; a fast lookup does not.
Can I just increase the tool timeout instead?
A larger timeout postpones the failure without adding observability. The model still cannot see progress, cancel, or retry safely, and a dropped connection still leaves the state unknown. Timeout tuning is not a substitute for a job lifecycle.
How do I prevent duplicate jobs on retries?
Return the job id on the first call and make subsequent polls idempotent on that id. If the start call itself is ambiguous, support an idempotency key so a retried start reuses the existing job rather than creating another.
Where should job state live?
On the server, in a store keyed by job id that survives client disconnects. The client only holds the id and polls; it must not be the thing keeping the job alive.
How does the spec help generate these tools?
The OpenAPI paths and status enum define the start and poll operations, so generating MCP tools from the contract produces the correct lifecycle automatically and keeps schemas synchronized as the API evolves.
This is part of the MCP Server Guide — start there for the full design of reliable tools an agent can actually use.