MCP Tasks vs Streaming vs Webhooks: Handling Long-Running Agent Tool Calls

MCP Tasks vs Streaming vs Webhooks: Handling Long-Running Agent Tool Calls

MCP Tasks vs Streaming vs Webhooks: Handling Long-Running Agent Tool Calls

A transient CFD run on a mid-size mesh takes forty minutes. A full CAD regeneration across a
parametric assembly takes twenty. A nightly ETL rollup over a year of sensor telemetry takes an hour
and a half. None of these can hold an HTTP response open, and every agent platform that tries to
wire them up as tools hits the same wall at roughly the thirty-second mark. MCP Tasks is the
Model Context Protocol’s answer: the server hands back a durable handle instead of a result, and the
client polls. But it is one of three defensible answers, and the other two — holding a streamable
HTTP response open, or going fully out-of-band with a job ID and a webhook — are not strictly worse.
They fail differently. This post is about where each one breaks, in the specific places that matter
when the work is a simulation rather than a database query.

What this covers: the verified mechanics of the Tasks extension in the 2026-07-28 revision, a
six-dimension engineering comparison against streaming and webhooks, why moving Tasks out of core
was the architecturally significant event, and who owns the result when the agent session dies
mid-job.

Context and Background

For most of MCP’s life the answer to “what if a tool takes a long time?” was “don’t”. Tools were
modelled on function calls: the client sends tools/call, the server does the work, the server
returns a CallToolResult. That model is fine when the work is a lookup. It falls apart the moment
the tool wraps a solver, a build pipeline, a robot trajectory, or anything that queues.

The protocol authors first addressed this in the 2025-11-25
revision
, which
introduced tasks as an experimental core feature. That design had the client opt in per request
by attaching a task field carrying a requested ttl, and it exposed four methods: tasks/get for
status, tasks/result for the final payload, tasks/list for enumeration, and tasks/cancel.
Critically, tasks/result blocked until the task reached a terminal status — a design that solves
the timeout problem on paper and reintroduces it in production, because a blocking call over HTTP is
still a held-open connection.

Nine months of real deployments later, the 2026-07-28
revision
rewrote the feature and
moved it out of the core specification entirely. MCP Tasks now live in an official extension identified
as io.modelcontextprotocol/tasks, contributed by AWS and maintained in its own ext-tasks
repository with a release cadence independent of the core spec. The blocking tasks/result method is
gone. tasks/list is gone. The three surviving methods are tasks/get, tasks/update, and
tasks/cancel.

That revision also made the protocol core stateless — no initialize handshake, no Mcp-Session-Id
header — which we covered separately in the
2026-07-28 stateless migration walkthrough;
this post assumes that context rather than repeating it. The relevant consequence here is narrow but
sharp: in a stateless protocol, a long-running operation can no longer hide its state in the
transport. It has to be named, and the name has to be something the client can carry.

The Three Architectures for a Long-Running Tool Call

Direct answer: there are three viable patterns for a tool call that outlives an HTTP response.
MCP Tasks returns a taskId immediately and the client polls tasks/get until a terminal status.
Streamable HTTP holds one SSE response stream open and pushes notifications/progress until the
final JSON-RPC response closes it. The out-of-band pattern returns an ordinary tool result containing
a job ID, and a worker delivers completion through a webhook to infrastructure the MCP server does not
own.

MCP Tasks polling compared with SSE streaming and out-of-band webhooks for long-running agent tool calls

Figure 1: Three server-side strategies for a single tools/call that will take forty minutes.

The figure traces one tool invocation down three paths. The Tasks path converts the call into a state
machine the client drives. The streaming path keeps the original request alive as a socket and
delivers the answer on it. The out-of-band path abandons the request/response relationship entirely
and treats the tool call as a submission. Each path has a different owner for the in-flight work, and
that ownership question determines almost everything else — retry semantics, observability, cost on
failure, and whether the result survives the agent that asked for it.

MCP Tasks make the work addressable

The defining property of MCP Tasks is that it gives the in-flight operation a name. Once
the server returns a CreateTaskResult, there is a taskId that is meaningful independent of any
connection, any client process, and any agent session. The server is required to have durably created
the task before it responds — the specification is explicit that a server MUST NOT return
CreateTaskResult until a tasks/get for that ID would resolve, and that in eventually-consistent
storage it must wait for consistency first. That single requirement is what makes the handle
trustworthy: there is no window where the client holds an ID the server will disclaim.

This is a stronger guarantee than it sounds. It rules out the common pattern of writing the job row
asynchronously after acknowledging the request, which is exactly the shortcut that produces “task not
found” races under load. It also means the cost of creating a task is a durable write, which is worth
knowing before you decide to wrap every tool in one.

Streaming keeps the work attached to the socket

The streaming option is the one most teams reach for first, because it requires no new concepts. In
Streamable HTTP, a server may answer any tools/call POST with Content-Type: text/event-stream
instead of application/json, emit notifications/progress and notifications/message events scoped
to that request, and terminate the stream with the final JSON-RPC response. The client sees a normal
request that took a long time and produced useful intermediate output.

The cost is that the work is now bound to a TCP connection. The 2026-07-28 revision made this binding
explicit and unforgiving: closing the SSE response stream MUST be treated by the server as
cancellation of that request. There is no ambiguity to exploit. If a load balancer reaps the
connection, the server is obliged to stop working on your simulation.

Webhooks put the work outside MCP entirely

The third option declines to model the problem in MCP at all. The tool returns a plain, immediate
CallToolResult containing a job identifier from whatever system actually runs the work — a Slurm
job ID, an Argo workflow name, a Step Functions execution ARN. Completion is delivered by that system
to a webhook endpoint the platform team operates. The agent learns the job finished either because a
second tool (get_job_result) is polled by the orchestration layer, or because the webhook handler
writes the result somewhere the next turn of the agent will read.

This is the classic async-API answer and it is genuinely the most robust of the three, because the
durability guarantees come from a queue and a database rather than from a protocol extension. It is
also the one that requires the most infrastructure you must build and operate yourself, and the one
that pushes the most complexity into the part of the system MCP was supposed to standardise.

What the MCP Tasks Extension Actually Specifies

Every method name and field below is from the published MCP Tasks specification rather than inferred,
because the surface changed substantially between revisions and a lot of secondary writing still
describes the 2025-11-25 shape.

Sequence diagram of the MCP Tasks lifecycle from tools/call through tasks/get polling to a completed result

Figure 2: The MCP Tasks lifecycle, including a mid-flight input_required pause and the gateway
routing header.

The sequence shows the full round trip. The client attaches its extension capability to the
tools/call; the server elects to create a task and returns a CreateTaskResult; the client polls;
the server pauses for user input and resumes; the client collects the result. Note that the gateway
appears twice for a reason covered below.

Capability negotiation is now a single flag

In the 2025-11-25 design, a client had to satisfy a three-level check before it could create a task.
The server declared capabilities.tasks with sub-flags for list, cancel, and
requests.tools.call; then, for tool calls specifically, each tool in tools/list carried an
execution.taskSupport value of "required", "optional", or "forbidden", which layered on top of
the capability. A client that got this wrong received -32601 Method not found.

MCP Tasks collapses all of that into one empty object. The client declares support by including
io.modelcontextprotocol/tasks in the extensions map inside
_meta["io.modelcontextprotocol/clientCapabilities"] on each request; the server advertises the same
identifier in its server/discover capabilities. There are no extension-specific settings defined,
so an empty object means “supported”.

The inversion that comes with it matters more than the simplification. Task creation is now
server-directed. The client cannot ask for a task and cannot decline one. It declares once, per
request, that it is capable of handling a task handle, and from then on the server decides on a
per-request basis whether to materialise one. A conforming client must therefore be prepared for
either a CallToolResult or a CreateTaskResult in response to any tool call it makes. The
discriminator is the resultType field, which the extension adds "task" to alongside the core
values "complete" and "input_required".

The corresponding server obligation is strict: a server MUST NOT return CreateTaskResult to a
client that did not include the extension capability on that request. If the server genuinely cannot
service the request synchronously and the client did not declare support, it must fail with
-32021 (Missing Required Client Capability) and name the required extension in the error’s data
field. This is a clean failure — the client learns precisely what it lacks — but it is still a
failure, and it means a server that only knows how to do long work is unusable by a client without
the extension.

The task object and its state machine

A Task carries a taskId, a status, an optional statusMessage, ISO 8601 createdAt and
lastUpdatedAt timestamps, a ttlMs that is either an integer or null for unlimited, and an
optional pollIntervalMs. Both ttlMs and pollIntervalMs are explicitly allowed to change over the
lifetime of a task, which is a useful affordance: a server can widen the polling interval as a queue
backs up, or shorten it as a job nears completion.

The five statuses are working, input_required, completed, failed, and cancelled. The last
three are terminal. tasks/get returns a status-specific shape — a completed task inlines a result
field matching what the original request would have returned, a failed task inlines an error field,
and an input_required task inlines an inputRequests map.

One distinction here is a genuine migration hazard. In 2025-11-25, a tool call that returned
isError: true moved the task to failed. In the extension, it does not: failed is reserved
strictly for JSON-RPC protocol errors during execution, and a tool that completes with
isError: true reaches completed with the error inside result. If you ported a client by
renaming methods, your error handling is now wrong in a way that will only show up when a tool fails.

Mid-flight input replaces held-open elicitation

The input_required status exists because long jobs sometimes need a human. A structural analysis
tool that discovers a mesh defect may want to ask whether to auto-repair and continue or abort. In
the old design, this was handled by the client opening a tasks/result stream and receiving a
server-initiated elicitation/create request on it — which is precisely the bidirectional pattern
the stateless core removed.

MCP Tasks replaces it with a pull. When the task needs something, it moves to input_required,
and the next tasks/get response carries an inputRequests map keyed by server-chosen identifiers,
each value being a request such as an elicitation/create payload. The client answers with
tasks/update, passing inputResponses keyed to the same identifiers. The server acknowledges with
an empty result.

Two details bite in practice. First, inputRequests is a point-in-time snapshot, so a client that
polls again before the user has answered will see the same request repeated — the spec tells clients
to deduplicate on the key rather than prompting twice. Second, the acknowledgement is eventually
consistent
: the server may accept your inputResponses and return the ack before tasks/get
reflects the change, so a client that immediately re-polls and still sees input_required has not
necessarily failed. Keys are guaranteed unique over a task’s lifetime and are never reused, which is
what makes safe deduplication possible at all.

Cancellation is advisory, not a kill switch

tasks/cancel signals intent. The server must acknowledge with an empty result, and that is the
entire obligation. Cancellation is explicitly cooperative: the task’s status may remain working
after the ack, and it may ultimately reach a terminal status other than cancelled if the work
finished first. The spec also notes that the core notifications/cancelled notification MUST NOT
be used for task cancellation.

For a simulation platform this is the honest design. You cannot un-submit a job that a scheduler has
already dispatched to a compute node, and a protocol that pretended otherwise would be lying. But it
means that “cancel” in your agent UI is a request, not a guarantee, and your cost model should assume
a cancelled job still bills for the compute it consumed before the signal landed.

Notifications exist, but progress does not

Servers may push notifications/tasks carrying the full DetailedTask — the same payload
tasks/get would have returned at that moment — and clients opt in by listing taskIds in a
subscriptions/listen request. The server acknowledges with the subset of IDs it agreed to.

What you cannot get is granular progress. The extension states plainly that
notifications/progress and notifications/message must not be sent on the listen stream for a
task, and are not supported on tasks in general in this specification. Everything you want to say
about how far along the solver is has to fit in the free-text statusMessage field, polled at
pollIntervalMs.

This is the sharpest functional gap between MCP Tasks and streaming, and it is underreported. If your
value proposition is “the user watches the residuals converge”, Tasks alone will not deliver it. You
either accept coarse status text, or you carry progress on a side channel that MCP does not define.

Why Moving Tasks Out of Core Is the Interesting Part

It would be easy to read the relocation as bookkeeping — same feature, different document. It is not.
Making long-running work an extension — rather than a core primitive — is a statement that the protocol authors do not consider it
universal enough to be mandatory, and that has consequences that propagate well past the spec text.

The mechanical consequence is that extensions are always disabled by default and require explicit
opt-in from the developer. SDKs are not obliged to implement any extension to be protocol-conformant;
maintainers have full autonomy over which ones they support. So “does this client support MCP Tasks?”
is now a per-client, per-SDK, per-version question with no protocol-level guarantee behind it, and
the answer is frequently no. The concrete shape of that gap is visible in a
Microsoft Agent Framework issue filed by
a team migrating Foundry-hosted agents to MCP-native tools: their server implemented 2026-07-28 with
the Tasks extension, the framework’s native connector was still speaking 2025-11-25 semantics and
never advertised the extension capability, and the workaround was to stand up a compatibility
endpoint that spoke the old task model — tools/call, tasks/get, tasks/result — alongside the
new one. Direct SDK integration worked; the framework’s connector layer did not. That is the tax of
an opt-in extension in its first months.

The second consequence is for anyone building a gateway. Under 2025-11-25, a proxy could reason about
tasks because tasks were core: the capability was in a known place in the initialize response and the
method names were reserved. Now a gateway must understand an extensions map whose contents are
open-ended, keyed by reverse-DNS identifiers, and versioned independently of the core spec. If your
gateway terminates MCP and re-originates it upstream — which is what most enterprise deployments
do — you are now responsible for correctly intersecting capabilities. Advertise the extension
downstream and you have promised behaviour your upstream may not implement. Strip it and you have
silently disabled long-running tools for every client behind you.

The third consequence is versioning. Extensions evolve on their own schedule, and the published
guidance is to prefer capability flags or settings-object versioning over minting a new identifier,
reserving a new identifier such as io.modelcontextprotocol/my-extension-v2 for unavoidable breaking
changes. For the Tasks extension specifically, the settings object is currently empty. That empty
object is where any future negotiation — maximum TTL, supported request types beyond tools/call,
progress support — would have to live. Designing a gateway today that assumes {} is the only legal
value is a bet you will lose.

There is a fourth consequence that is easy to miss: graceful degradation is now the server author’s
problem. The extensions framework expects a server that offers an extension-enhanced capability to
still behave sensibly for clients that do not support it. For a UI extension that means returning
plain text. For MCP Tasks, there is often no sensible fallback — a forty-minute job cannot be made
synchronous — which is exactly why -32021 exists. Servers wrapping genuinely long work should
document that the extension is mandatory rather than pretending to degrade.

The Six-Dimension Comparison

Dimension MCP Tasks Streamable HTTP / SSE Out-of-band webhook + job ID
Client implementation cost Moderate — polymorphic results, poll loop, durable ID storage Low — SDK handles the stream High — you build submission, callback, storage, correlation
Resume after disconnect Yes — re-poll tasks/get with the same ID No — no Last-Event-ID, no event IDs, reissue as a new request Yes — the job is unaffected by client state
Behaviour behind idle-timeout proxies Safe — every call is short Fragile — idle reaping equals cancellation Safe — nothing is held open
Behaviour behind buffering proxies Unaffected Breaks silently without X-Accel-Buffering: no Unaffected
Progress granularity Coarse — statusMessage at pollIntervalMs Fine — arbitrary notifications/progress Whatever you build
Tracing One trace per poll unless you propagate context Single span covers the whole operation Trace crosses a queue boundary
Retry safety Poll is idempotent; creation is not Reissue re-executes the tool You control it
Who owns the result The MCP server, until ttlMs expires Nobody after the stream closes Your infrastructure, indefinitely
Works with today’s clients Only where the extension is implemented Everywhere Everywhere, outside the protocol

Client implementation complexity

MCP Tasks are more work than they look. A conforming client must handle polymorphic results on every tool
call, run a poll loop that honours a server-supplied and mutable pollIntervalMs, deduplicate
inputRequests by key, distinguish failed from completed-with-isError, and — the requirement
most implementations skip — persist task IDs to durable storage so polling can resume after a crash
or restart. Skipping that last one converts the extension’s main advantage into decoration.

Streaming is close to free on the client because the SDK owns it: you make a call, you get a result,
and progress notifications surface as callbacks. Webhooks are the most expensive by a wide margin,
because nothing is given to you — submission, idempotent callback handling, result storage,
correlation back to an agent session, and authentication of the callback are all yours.

Resume after disconnect

This is where the 2026-07-28 revision drew the sharpest line, and it is worth stating precisely
because it is a regression that surprises people: SSE stream resumability was removed. The
Last-Event-ID header and SSE event IDs are gone from Streamable HTTP. A broken response stream loses
the in-flight request, and the client must re-issue it as a new request with a new request ID.

Comparison of disconnect behaviour for MCP Tasks, SSE streaming and webhook job handles

Figure 3: What a dropped connection at minute twelve costs under each pattern.

Combine that with the cancellation rule — closing the SSE response stream MUST be treated as
cancellation — and the streaming path has a brutal failure mode. An idle-timeout reap at minute
twelve does not merely disconnect the client; it instructs the server to stop. The forty-minute job
dies, and reissuing means paying for the first twelve minutes twice. On a compute-billed simulation
workload, that is a line item, not an inconvenience.

MCP Tasks and webhooks both survive because the handle outlives the connection. The difference is who
guarantees it: for MCP Tasks, the server itself, bounded by ttlMs; for webhooks, your own job system,
bounded by your retention policy.

Load balancers, gateways, and proxies

Three distinct proxy behaviours matter, and they hit the three patterns differently.

Idle timeouts. An AWS Application Load Balancer ships with a 60-second idle timeout by default,
and Amazon API Gateway’s default integration timeout is 29 seconds (raisable since 2024, but still a
ceiling). Under MCP Tasks, none of this is a problem: tools/call returns in milliseconds and every
tasks/get is a short request. Under streaming, every one of these defaults is a countdown against
your job unless the server emits keep-alives and you raise every timeout in the chain. Note the
asymmetry — the spec’s keep-alive guidance is aimed at the long-lived subscriptions/listen stream,
and an SSE response stream for a tool call that produces no output for ten minutes is exactly as
vulnerable.

Buffering. A reverse proxy that buffers responses defeats SSE silently: events accumulate and
arrive in a burst, or not at all. The spec’s mitigation is the X-Accel-Buffering: no response
header, which servers SHOULD send when initiating an SSE stream, and which nginx honours. It is a
hint, not a guarantee, and it does nothing for intermediaries that ignore it.

Routing. This is the subtle one, and it is where MCP Tasks does something clever. When
tasks/get, tasks/update, or tasks/cancel is sent over Streamable HTTP, the client MUST set
the Mcp-Name header to the value of params.taskId. The stated purpose is to let transport
intermediaries and load balancers route subsequent requests for a task to the server instance holding
its state — and the spec says this is typically required for correctness.

Read that carefully, because it undercuts the headline. The 2026-07-28 core went stateless so any
request could land on any instance behind a round-robin balancer. MCP Tasks reintroduce affinity through
the back door: unless your task state is in shared durable storage rather than instance memory,
tasks/get needs to reach the right pod. The protocol gives you a clean, header-based hook to
implement that — consistent hashing on Mcp-Name is straightforward in Envoy or nginx — but it is
infrastructure you must actually configure. A gateway that ignores Mcp-Name and round-robins
tasks/get across a fleet with instance-local task state will produce intermittent -32602 “task
not found” errors that look like data corruption.

There is a related trap worth flagging: servers MUST validate that header values match the
corresponding body values and reject mismatches with -32020 (HeaderMismatch). A well-meaning
gateway that rewrites Mcp-Name for its own routing convenience will cause every task poll to fail.

Observability and tracing

Streaming gives you the cleanest trace: one span from request to final response, with progress events
as span events inside it. Duration, status, and failure all live in one place.

MCP Tasks fragment this. The tool call and each poll are separate HTTP requests, and by default they are
separate traces. The fix is in the core spec rather than the extension: the 2026-07-28 revision
documents OpenTelemetry trace context propagation conventions for _meta, using the traceparent,
tracestate, and baggage keys. If your client propagates a consistent context across the initial
tools/call and every subsequent tasks/get, you can stitch the operation back together. If it does
not — and most first-pass implementations do not — you get N disconnected spans and no way to measure
end-to-end latency.

There is a compensating advantage. Because Mcp-Method and Mcp-Name are required headers on every
POST, a gateway can meter, rate-limit, and attribute task polling without parsing a single JSON body.
Counting Mcp-Method: tasks/get by Mcp-Name gives you per-task poll pressure straight from access
logs. That is a genuinely nice property that neither streaming nor webhooks offer for free.

Webhooks have the hardest observability story, because the trace crosses a queue and comes back on a
different connection from a different process. You need explicit correlation IDs carried through the
job system, and the callback handler has to re-enter the trace deliberately.

Failure semantics, retries, and idempotency

Under MCP Tasks, the failure surface is well-partitioned. Protocol faults are JSON-RPC errors: -32602
for an unknown or expired taskId, -32603 for internal errors, -32021 for a missing capability.
Execution faults are statuses: failed carries the JSON-RPC error that killed it, completed carries
whatever the tool returned including tool-level errors. That partition is clean and testable.

Retry safety is more nuanced. tasks/get is trivially idempotent, so a flaky poll costs nothing. But
task creation is not. If a client sends tools/call, the server durably creates the task and starts
a forty-minute solve, and the response is lost in transit, the client has no protocol-level way to
discover the orphaned task — tasks/list was deliberately removed, for the good security reason that
a poorly scoped list can leak other callers’ task IDs. A naive retry submits a second job. You have
now paid twice and the first result will expire unread.

The specification defines no idempotency key for task creation, so this has to be solved above the
protocol: the conventional fix is a caller-supplied idempotency token as an ordinary tool argument,
with the server deduplicating on it and returning the existing CreateTaskResult. This is worth
building deliberately rather than discovering. It is also the single strongest argument for the
webhook pattern on expensive workloads, since mature job systems have had idempotent submission for
decades.

Streaming has the worst retry story of the three: a reissued request is a fresh execution with no
deduplication anywhere, and the original may still be running if the server was slow to honour the
cancellation implied by the closed stream.

One more security property deserves a mention because it shapes how you store handles. The extension
notes that a server MAY treat task IDs as bearer tokens for stored state, requires them to be
generated with enough entropy that a third party cannot enumerate or guess them, and requires
authentication and authorization checks on each task-related request. Treat a taskId as a
credential in your logs and your agent transcripts, not as an opaque correlation string — the same
secret-handling discipline the rest of an
MCP server security architecture
applies to tokens.

Who Owns the Result When the Session Ends

This is the question almost every comparison skips, and for agent platforms it is the one that
generates incident reports.

Ownership of a long-running job handle across context turnover and client restarts

Figure 4: Where the handle lives determines whether the result is recoverable.

Consider the realistic sequence. A user asks an agent to run a parameter sweep. The agent calls the
tool, receives a taskId, reports “I’ve started the run, this will take about forty minutes”, and
continues the conversation. Over the next half hour the conversation grows, the context window fills,
and the harness compacts history — summarising away the turn that contained the raw taskId. Or the
user closes the session. Or the client pod is evicted during a deploy.

The job is still running. Nobody is holding the handle.

The protocol’s answer is partial but real: clients SHOULD persist task IDs to durable storage so
that polling can resume after a crash or restart. That covers the client-restart case cleanly. It does
not cover context turnover, because that is not a client-lifecycle event at all — the client process
is perfectly healthy, it is the model’s view of the conversation that lost the reference.

The practical implication is that a taskId must never live only in the transcript. It belongs in
application state that the orchestration layer owns — the same durable layer that carries approvals
and audit records in a
governed long-running agent architecture
with the transcript holding at most a human-readable pointer. If your agent’s only record of a running forty-minute simulation is a sentence
it wrote to the user, that job is one compaction away from being orphaned — still consuming a compute
node, still billing, and destined to expire when ttlMs elapses with nobody reading the result.

Server-side, the ownership window is bounded and the bound is soft. Servers MAY mark a task
failed at any point after the TTL elapses and delete it at any time afterwards, and it is compliant
behaviour to return “task not found” for a purged task. There is no protocol obligation to hold a
completed result until someone collects it. A server that sets ttlMs to one hour for a job that
takes fifty minutes is giving clients a ten-minute collection window, which is not enough margin for a
client restart during a deploy.

Webhooks handle this dimension best, and it is their strongest argument. Because completion is pushed
into infrastructure the platform owns, the result lands in a durable store regardless of what happened
to the agent, the session, or the context window. Reattaching a finished job to a later conversation
becomes a query, not a race. Streaming handles it worst: when the session ends, the stream closes,
the close means cancellation, and there is no artefact at all.

A Worked Example: A Transient CFD Sweep

Make it concrete. A digital-twin platform exposes a run_cfd_case tool over MCP. Each case is a
transient run on a fixed mesh, taking 35–50 minutes on a reserved node. Engineers ask the agent for
sweeps of six to twelve cases. The platform runs MCP servers as three replicas behind an
ingress-nginx controller on Kubernetes, with an ALB in front.

If you pick streaming. The tools/call for each case holds an SSE stream for the better part of
an hour. You must raise the ALB idle timeout well past the worst-case run, raise
proxy_read_timeout on the ingress, set X-Accel-Buffering: no, and emit SSE comment keep-alives
during the long silent stretch while the solver iterates. Do all of that and it works, and the
engineer gets genuinely lovely live residual output. Miss one timeout in the chain and a reaped
connection cancels a job at minute thirty-eight. Twelve concurrent cases means twelve long-lived
connections pinned across three replicas, which makes rolling deploys destructive: draining a pod
kills every case it is hosting.

If you pick MCP Tasks. Each case returns a taskId in milliseconds. The agent fires all twelve
without holding anything open. Polling at a server-suggested 30-second interval costs 12 × 120 = 1,440
short requests over an hour — trivial load, and every one of them attributable in access logs by
Mcp-Name. You must configure consistent hashing on Mcp-Name at the ingress, or move task state to
shared storage, or tasks/get will hit the wrong replica. You lose live residuals and fall back to
statusMessage text like “iteration 4200 of 12000, residual 3.1e-4”. You must persist the twelve task
IDs outside the transcript, and you must add an idempotency argument to run_cfd_case so a retried
submission does not book a second node. Rolling deploys are still a problem unless task state is
external, but they degrade to failed polls rather than lost work.

If you pick webhooks. run_cfd_case submits to the existing scheduler and returns its job ID
immediately as ordinary tool output. The scheduler’s completion hook posts to your service, which
writes results to object storage and an index row. A second tool, get_cfd_result, reads that index.
Nothing in the MCP layer is long-running, so every proxy concern evaporates and replicas are fully
fungible. You inherit the scheduler’s idempotent submission and its retention policy for free. The
cost is that the agent has no native notion that work is pending — you need orchestration logic to
decide when to re-check — and you have built and must now operate a callback endpoint, its
authentication, and its retry handling.

For this workload the defensible choice is MCP Tasks if the client in use implements the extension,
and webhooks if it does not or if the compute is expensive enough that idempotent submission is
non-negotiable. Streaming is the wrong tool, and it is the one most teams try first.

Trade-offs, Gotchas, and What Goes Wrong

MCP Tasks is young and adoption is uneven. The official documentation says plainly that host
support varies by client and points readers to a client matrix. Treat “supports MCP” and “supports
MCP Tasks” as entirely separate questions, and verify against the specific client and SDK version
your users run before designing around it.

Porting by renaming methods will break your error handling. tasks/result is gone, tasks/list
is gone, and the failed-versus-completed boundary moved. A client ported mechanically from
2025-11-25 will mis-handle every tool that returns isError: true.

Polling is not free at scale. pollIntervalMs is a suggestion and servers MAY rate-limit
clients that poll faster than the recorded value. A thousand concurrent tasks at a five-second
interval is 200 requests per second of pure overhead. Have servers widen the interval for queued work
and narrow it near completion — the field is explicitly allowed to change.

ttlMs is a deletion licence, not a promise. Set it generously relative to the job, and assume
that a client outage longer than the remaining TTL means the result is gone.

Cancellation does not stop billing. The ack is the obligation; stopping the work is not. Budget
for cancelled jobs consuming compute to completion.

The Mcp-Name header is load-bearing. Any intermediary that rewrites or drops it breaks task
polling, and any server that validates headers against the body will reject the rewritten request with
-32020.

Task IDs are credentials. They may be used as bearer tokens for server state. Scrub them from
transcripts and logs you would not scrub a session token from.

Practical Recommendations

Start by answering one question: does the work outlive a single HTTP response under your actual proxy
configuration? If it does not — and most tools do not — none of this applies and a plain
CallToolResult is correct. Wrapping fast tools in MCP Tasks adds a durable write and a poll loop for
nothing.

If the work is long, the choice is driven by two constraints rather than by preference. First, does
the client implement the Tasks extension? If not, you are choosing between streaming and out-of-band,
and streaming is only viable if you control the entire proxy chain. Second, is the compute expensive
enough that a duplicate submission is a real cost? If it is, you need idempotent submission, which the
extension does not provide and mature job systems do.

Where streaming is genuinely right is the narrow case of work in the 30-second-to-5-minute band where
live progress is the product and the deployment is behind infrastructure you own end to end. Outside
that band it is a liability.

A checklist before you ship:

  • Persist every taskId to durable application state, never only to the conversation transcript.
  • Add a caller-supplied idempotency argument to any tool that creates expensive work.
  • Configure gateway affinity on Mcp-Name, or move task state to shared storage and skip affinity.
  • Propagate traceparent through _meta on the initial call and every poll, or accept disconnected traces.
  • Handle completed-with-isError separately from failed.
  • Set ttlMs to at least twice the expected job duration to leave a collection window.
  • Verify your target client’s extension support before committing to the design.
  • Treat tasks/cancel as advisory in both your UI copy and your cost model.

Frequently Asked Questions

What methods does the MCP Tasks extension define?

Three: tasks/get to poll a task’s current status and retrieve its result or error, tasks/update
to supply inputResponses when a task is waiting on client input, and tasks/cancel to signal
cancellation intent. The blocking tasks/result and the enumerating tasks/list methods from the
2025-11-25 experimental core were both removed. Servers may additionally push notifications/tasks
to clients that opted in via subscriptions/listen.

Can a client request that a tool call run as a task?

No. Task creation is server-directed in the current extension. The client declares support by
including io.modelcontextprotocol/tasks in its per-request capabilities, and the server decides on a
per-request basis whether to return a CreateTaskResult instead of the standard result. This inverts
the 2025-11-25 design, where the client attached a task field to the request. Clients must therefore
handle either result shape on any tool call, discriminating on resultType.

Does MCP Tasks support progress notifications?

Not in this revision. The MCP Tasks specification states that notifications/progress and notifications/message
must not be sent on the listen stream for a task and are not supported on tasks in general. Progress
has to be conveyed through the free-text statusMessage field on the task object, observed at the
pollIntervalMs cadence. If fine-grained live progress is essential, a held-open streamable HTTP
response or a side channel outside MCP is currently the only option.

Why can’t I just hold the SSE stream open for a long tool call?

You can, but the 2026-07-28 revision made it fragile in two ways. SSE resumability was removed — there
are no event IDs and no Last-Event-ID header — so a broken stream loses the in-flight request and
must be reissued as a new request. And closing the response stream MUST be treated by the server
as cancellation of that request, so an idle-timeout reap by a load balancer actively kills your job
rather than merely disconnecting you.

How do I stop task polling from hitting the wrong server replica?

Set the Mcp-Name header to the taskId on tasks/get, tasks/update, and tasks/cancel — the
extension requires this — and configure your gateway to route on it, for example with consistent
hashing. The header exists specifically so intermediaries can reach the instance holding the task’s
state. The alternative is to keep task state in shared durable storage so any replica can serve any
poll, which is the better answer if you do rolling deploys.

What happens to a running task if the agent’s context window turns over?

The task keeps running; only the reference is lost. The specification tells clients to persist task
IDs durably so polling can resume after a crash or restart, but context compaction is not a client
lifecycle event — the process is fine, the model’s view is not. Store task IDs in orchestration state
rather than relying on the transcript. Otherwise the job runs to completion, nobody polls it, and the
server is free to discard it once ttlMs elapses.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *