# How to keep an agent informed while an MCP tool runs for minutes

> Send progress against the client's token, stop when the client cancels, and choose between one long call, a job id with a poll hint, and the tasks extension.

Source: https://voxgig.com/howto/report-progress-from-a-long-running-tool

- Audience: api-producer
- Level: advanced
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress) and
[cancellation](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/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](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3) | 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress) | 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](https://modelcontextprotocol.io/extensions/tasks/overview) | 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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress)
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](https://ts.sdk.modelcontextprotocol.io/v2/servers/logging-progress-cancellation)
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.

```ts title="server.mjs"
    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.

```python title="progress.py"
@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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation)
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.

```bash
node demo.mjs
```

```text output
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](https://py.sdk.modelcontextprotocol.io/handlers/progress/) 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](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling) include
`resetTimeoutOnProgress`, which restarts the request timeout on every notification, and
`maxTotalTimeout`, the cap that still applies.

```bash
node demo.mjs
```

```text output
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.

```ts title="server.mjs"
    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](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3) 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.

```bash
node demo.mjs
```

```text output
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`.

```bash
node --test progress.test.mjs
```

```text output
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](https://modelcontextprotocol.io/extensions/tasks/overview) 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](https://modelcontextprotocol.io/extensions/client-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

- [Upgrade an MCP server to a newer protocol revision](/howto/upgrade-an-mcp-server-to-a-newer-revision)

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

## 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.

[^1]: Tasks arrived in the
[2025-11-25 revision](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
marked experimental, with a warning that their design and behavior may evolve. Eight months later
the [2026-07-28 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
moved them out of the core protocol into an extension, replaced the blocking `tasks/result` with
polling, removed `tasks/list`, and let servers return a task handle unsolicited. The warning was
accurate.

[^2]: The [calling page](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling) of the SDK
says a server that stops answering rejects the call with an `SdkError` coded `REQUEST_TIMEOUT`
once `timeout` elapses. 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.

[^3]: The interval has been given four names on four pages. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3)
spells `Retry-After` in seconds or as an HTTP-date. The
[2025-11-25 tasks page](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
calls it `pollInterval` and counts milliseconds, the
[tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) calls it
`pollIntervalMs`, and this page calls it `poll_after_seconds`. Each is the same instruction to
wait, and the units meet nowhere.