A tool that exports a report takes four minutes. The agent’s client shows nothing for the first sixty seconds, decides the call is dead, times out, and retries. The export runs to the end anyway, twice, because nothing told the server that the client had gone.
What you get
You will end up with a progress-reporting tool that stops when the client cancels, a job tool that survives a dropped connection, and a test that proves both. This is for you if a tool call outlives the client’s patience.
Short answer
Read the progressToken the client put in _meta, send notifications/progress with a rising
value and a total or a message, and check the request’s AbortSignal before every unit of work.
That keeps one call and one result. Return a job id with a poll_after_seconds hint instead
when the work must outlive the connection, and use the tasks extension only when your clients
implement it.
You will need
Node 22 or later, a running MCP server, and an operation that takes longer than a client waits.
Verified 2026-09-25 against Node 22.22.2, @modelcontextprotocol/server 2.1.0,
@modelcontextprotocol/client 2.1.0, and zod 4.4.2, with the Python variant against mcp
2.2.0. The two mechanisms are the
progress and
cancellation
patterns of the specification, and both are optional on every side.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
A job id plus a poll tool with a poll_after_seconds hint | Work that must outlive the connection, or an API that already hands you a job id | A job store to keep, two tools instead of one, and an agent that has to obey the hint | The work finishes in seconds, so a second call costs more than it saves |
| MCP progress notifications | One call, one result, and a client that stays connected until the end | Everything dies with the connection, and a client is free to ignore every notification | The work is longer than the shortest timeout between you and the client |
MCP tasks, execution.taskSupport in 2025-11-25 and an extension since | Clients you know implement the extension, and work that needs a durable handle | A capability both sides must declare, and a client population the support matrix does not list | Your clients are the ones in the support matrix, which lists no task support |
Progress notifications are the cheapest of the three, because they use one call and one result, and the least durable, because a dropped connection leaves nothing behind. A job id survives the drop at the cost of a store and a second tool, and it relies on the agent waiting as long as the hint says. Tasks are the cleanest shape on paper, and they need a client that implements an extension.1
Send progress against the token the client sent
A client that wants progress puts a progressToken in the request’s _meta. The
specification
requires the progress value to increase on every notification, allows total and message
to be absent, and lets a server send nothing at all. The tool below reads the token from the
handler context, which is where the
TypeScript SDK
puts it, and checks the request’s AbortSignal before starting each page. The page in flight
gets the same signal, as fetch would, so it stops part-way rather than finishing.
async ({ pages }, ctx) => {
const progressToken = ctx.mcpReq._meta?.progressToken
const signal = ctx.mcpReq.signal
let completed = 0
Object.assign(work, { completed: 0, stopped: false, reason: undefined, notifications: 0 })
for (let page = 1; page <= pages; page++) {
// The client went away, or asked to stop. Do not start the next page.
if (signal.aborted) break
try {
// The page in flight gets the signal too, so it stops part-way rather than finishing.
await doUnit(signal)
} catch (err) {
if (signal.aborted) break
throw err
}
completed = work.completed = page
// The client opted in by sending a progressToken. Without one, send nothing.
if (progressToken !== undefined) {
work.notifications++
await ctx.mcpReq.notify({
method: 'notifications/progress',
params: { progressToken, progress: page, total: pages, message: `page ${page} of ${pages}` },
})
}
}
if (signal.aborted) Object.assign(work, { stopped: true, reason: signal.reason })
return { content: [{ type: 'text', text: `exported ${completed} of ${pages} pages` }] }
},
work is a module-level record of what the last call did. It exists so the demo and the tests
can read how many pages were exported after the client went away, which the client cannot ask.
The Python SDK folds the token check into one method. report_progress on the handler’s
Context is a no-op when the caller sent no token, so the tool reports unconditionally.
@mcp.tool()
async def export_report(pages: int, ctx: Context) -> str:
"""Export a report, one page at a time. Reports progress per page."""
for page in range(1, pages + 1):
await anyio.sleep(0.1)
# report_progress is a no-op when the caller sent no progress token.
await ctx.report_progress(page, total=pages, message=f"page {page} of {pages}")
return f"exported {pages} pages"
That file ran here against mcp 2.2.0 with an in-process client and printed three progress
lines and the result. It is not part of the captured output, because the runner that re-checks
these pages carries no Python packages.
Stop when the client goes away
The demo drives the server in-process through the same HTTP handler a deployment would mount,
so the wire is the 2026-07-28 one. On that transport the
cancellation page
says closing the response stream is the cancellation signal, and on stdio the client sends
notifications/cancelled instead. Either way the SDK aborts ctx.mcpReq.signal, and the loop
in export_report checks it once per page.
node demo.mjs
export_report, with a total
progress 1/4 page 1 of 4
progress 2/4 page 2 of 4
progress 3/4 page 3 of 4
progress 4/4 page 4 of 4
result exported 4 of 4 pages
scan_feed, no total known
progress 1 1 items scanned so far
progress 2 2 items scanned so far
progress 3 3 items scanned so far
result scanned 3 items
export_report, cancelled by the client after page 3
progress 1/10 page 1 of 10
progress 2/10 page 2 of 10
progress 3/10 page 3 of 10
client rejected: REQUEST_TIMEOUT
server stopped after page 3, signal reason CONNECTION_CLOSED
The third block is the one the page is about. The client cancelled on the third notification,
the call rejected, and the server exported no fourth page. The rejection code reads
REQUEST_TIMEOUT although nothing timed out. The SDK files an explicit cancellation under the
same code it uses for a timeout, and the reason the client gave travels in the error’s
message.2 On the server the
signal’s reason is CONNECTION_CLOSED, because on this wire the client’s cancellation closed
the stream rather than sending a message.
The second block is the shape to copy when the total is unknown. total is left out, progress
still rises, and the message says what a bar cannot. The
Python SDK’s progress page puts the
rule in one line. A client can show activity from that and not a percentage, so do not invent a
total to get a prettier bar.
Give the client a timeout it can keep
A timeout is the client’s own cancellation, and progress is what stops it firing on a call that
is alive. The
client call options include
resetTimeoutOnProgress, which restarts the request timeout on every notification, and
maxTotalTimeout, the cap that still applies.
node demo.mjs
export_report, no progress token, client timeout 250 ms
client rejected: REQUEST_TIMEOUT
export_report, progress resets the same 250 ms timeout
client resolved: exported 4 of 4 pages
The same four-page export, under the same 250 millisecond timeout, fails without progress and completes with it. A client that opts in to progress and resets its timeout on each notification is the only client for which one long call is safe. Every other client has a ceiling, and you do not know what it is.
Return a job id when the work must outlive the connection
The second shape splits the work from the call. start_export records a job, starts it, and
returns at once with the id and a hint. export_status answers for the job and repeats the hint
while it runs.
async ({ pages }) => {
const job = { id: `job_${jobs.size + 1}`, status: 'running', done: 0, total: pages, polls: 0, result: null }
jobs.set(job.id, job)
runJob(job, doUnit)
const output = { job_id: job.id, status: job.status, poll_after_seconds: POLL_AFTER_SECONDS }
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }
},
The hint is the whole design. An agent reads poll_after_seconds from the structured result and
waits that long, which is the Retry-After idea from
RFC 9110 carried inside a tool
result.3 The tool’s description says the same thing in words, because the description is the
only part of the contract a model reads before its first call.
node demo.mjs
start_export, then poll export_status when the hint says
started {"job_id":"job_1","status":"running","poll_after_seconds":1}
polled {"job_id":"job_1","status":"done","done":3,"total":3,"result":"exported 3 pages"}
polls 1
start_export, then poll without waiting
polls 20, answered running: 20
One poll after the hint finds the job done. Twenty polls with no wait find it running twenty
times, which is what an agent does when the hint is missing or ignored. The store here is a
Map in the server process, so it shows the shape and not the durability. A job id that
survives a client disconnect and not a server restart has solved half the problem. Nothing
awaits a job, so runJob catches its own failure and reports failed to the next poll. An
uncaught rejection there would end the server, and every call on it.
Check it worked
Eleven tests pin the behavior, including the cancellation over both wires: the 2026-07-28
handler, where the client closes the stream, and an in-memory pair, where the client sends
notifications/cancelled.
node --test progress.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 6157.527954
The assertion that matters is work.completed equal to 3 after a cancel on the third
notification, on both wires. A test that only checks the client’s rejection would pass against
a server that keeps exporting.
When it goes wrong
A client shows a progress bar that never moves. The notifications carry progress with no
total and no message, so the client has a number and nothing to divide it by. Send the total
when you know it, and a message when you do not.
The client cancels and the server keeps working to the end. The tool checked the signal once, at
the top, and then ran a loop that never looked again. Check before each unit, and hand the same
signal to fetch and to anything else that accepts an AbortSignal, so the work in flight
unwinds too.
Progress values repeat or go backwards. The specification requires each value to increase, even when the total is unknown, so a client owes such a sequence nothing. Count units done rather than percentages that round to the same number.
A poll answers no job after a deploy. The job store lived in the process, and the process was
replaced. Put jobs somewhere that outlives a restart, and keep the Not retryable. text on the
error, so an agent stops polling instead of looping on an id that will never return.
When not to do this
Do not return a job id for work that finishes in seconds. Two tool calls cost an agent two turns and the tokens of both results, and the poll hint asks it to wait longer than the work took.
Do not send progress with neither a total nor a message. A bare rising number is what produces the unmoving bar, and the specification allows you to send nothing at all, which is better than sending something a client cannot draw.
Do not hold one call open for minutes through intermediaries you do not control. The tasks extension exists because proxies and clients impose timeouts that make blocking impractical beyond a few seconds, and progress notifications do not reset a timeout the client never told you about.
Do not build on the tasks extension until the clients you support implement it. The extension support matrix has columns for four extensions, and tasks is not among them, so a server that answers with a task handle today is answering clients that cannot read it.
Related how-tos
Last verified
Verified 2026-09-25 against Node 22.22.2, @modelcontextprotocol/server 2.1.0,
@modelcontextprotocol/client 2.1.0, and zod 4.4.2. Every output block is what the command
preceding it printed. The Python variant in progress.py ran against mcp 2.2.0 on Python 3.11
and is not part of the captured output.
Footnotes
-
Tasks arrived in the 2025-11-25 revision marked experimental, with a warning that their design and behavior may evolve. Eight months later the 2026-07-28 changelog moved them out of the core protocol into an extension, replaced the blocking
tasks/resultwith polling, removedtasks/list, and let servers return a task handle unsolicited. The warning was accurate. ↩︎ Back to text -
The calling page of the SDK says a server that stops answering rejects the call with an
SdkErrorcodedREQUEST_TIMEOUToncetimeoutelapses. The demo shows the same code on a call the client aborted itself, three pages in. From the caller’s side the two events share one shape, a promise that rejects with no result, so the SDK gives them one name and puts the difference in the message. ↩︎ Back to text -
The interval has been given four names on four pages. RFC 9110 spells
Retry-Afterin seconds or as an HTTP-date. The 2025-11-25 tasks page calls itpollIntervaland counts milliseconds, the tasks extension calls itpollIntervalMs, and this page calls itpoll_after_seconds. Each is the same instruction to wait, and the units meet nowhere. ↩︎ Back to text