How-to › Expose your API to agents

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.

Audience
API producer
Level
advanced
Topic
Build an MCP server
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A job id plus a poll tool with a poll_after_seconds hintWork that must outlive the connection, or an API that already hands you a job idA job store to keep, two tools instead of one, and an agent that has to obey the hintThe work finishes in seconds, so a second call costs more than it saves
MCP progress notificationsOne call, one result, and a client that stays connected until the endEverything dies with the connection, and a client is free to ignore every notificationThe work is longer than the shortest timeout between you and the client
MCP tasks, execution.taskSupport in 2025-11-25 and an extension sinceClients you know implement the extension, and work that needs a durable handleA capability both sides must declare, and a client population the support matrix does not listYour 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.

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

  1. 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/result with polling, removed tasks/list, and let servers return a task handle unsolicited. The warning was accurate. ↩︎ Back to text

  2. The calling page 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. ↩︎ Back to text

  3. The interval has been given four names on four pages. RFC 9110 spells Retry-After in seconds or as an HTTP-date. The 2025-11-25 tasks page calls it pollInterval and counts milliseconds, the tasks extension calls it pollIntervalMs, and this page calls it poll_after_seconds. Each is the same instruction to wait, and the units meet nowhere. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.