2026-07-16

MCP Progress Notifications: Track Long Tool Work

Implement MCP progress tokens with monotonic updates, throttling, lifecycle cleanup, cancellation, task integration, and failure-first tests.

MCP Progress Notifications: Track Long Tool Work cover illustration

A progress bar can be worse than silence when it lies about ownership. MCP progress is not a stream of decorative percentages. Each notification belongs to one active request token, its numeric value must move forward, and its right to exist ends with the request—unless the request created a task. A receiver that does not model those boundaries will eventually show progress for the wrong operation, revive a completed row, or keep a cancelled job looking alive.

The difficult part is not sending notifications/progress. It is deciding whether an arriving notification is still allowed to update anything. That decision needs a small lifecycle registry, a terminal tombstone, and a rendering policy that can absorb bursts without discarding the final movement.

For this review, a local receiver harness replayed valid and invalid sequences against the MCP 2025-11-25 rules. It rejected eight malformed or stale cases, then reduced 10,000 valid updates to 101 rendered frames while preserving update 10,000 as the last visible value. No production traffic, credentials, or user content entered the test.

The number is not identity. Two requests may both report progress 4. The progressToken tells the receiver which active request owns that number. Treating the token as a label for a UI row rather than a lease on an active operation is the root of most lifecycle bugs.

The smooth bar that hid three protocol violations

Imagine a long indexing tool that reports 1, 2, and 4. The UI looks healthy. Then a reconnect delivers 4 again, the original request finishes, and a queued notification arrives with 5. A permissive client renders all five messages and congratulates itself for being resilient.

It has accepted an equal value even though progress must increase. It has updated a token after the request became terminal. If the reconnect also reused that token for another active request, the client has lost ownership entirely. None of those failures is visible in a screenshot of the bar. They appear only in the receiver's event record.

The fix is deliberately unglamorous: register the token when the request starts, compare every update with the last accepted value, close the token exactly once, and retain a bounded tombstone long enough to distinguish a late message from a token the receiver never knew.

Requirements for token ownership

A request that wants progress includes a string or integer token in its metadata. The receiver may send no progress at all, and it chooses the frequency. When it does send a notification, the token must be the one supplied for that active request. The specification requires tokens to be unique across concurrent active requests.

At request start

Reject a token already present in the active registry. Silent overwrite makes two operations indistinguishable.

At notification receipt

Require an active owner, finite numeric values, and movement beyond the last accepted progress.

At response

Flush the last buffered update, remove the active entry, and add a short-lived terminal tombstone.

At task creation

Keep the original token active; the first task response is not the end of task-augmented work.

A token registry belongs at the protocol boundary, not inside the progress-bar component. Several surfaces may consume the same event: a terminal row, an activity drawer, a timeout monitor, and an audit log. If each surface invents its own ownership rules, they will disagree under cancellation and reconnects.

The receiver should be boring

The smallest useful state record needs little more than the token, request identity, last accepted progress, optional total, latest message, task flag, render timestamp, and terminal state. It should not retain tool arguments or response content merely to animate a bar.

function acceptProgress(token, next, now) {
  const state = active.get(token)
  if (!state) throw new Error(closed.has(token) ? "late" : "unknown")
  if (!Number.isFinite(next.progress)) throw new Error("non-finite")
  if (state.last !== null && next.progress <= state.last) {
    throw new Error("progress did not increase")
  }

  state.last = next.progress
  state.pending = sanitize(next)
  if (now - state.lastRenderAt >= renderIntervalMs) flush(state, now)
}

This example is receiver policy, not a drop-in SDK implementation. It is intentionally stricter around non-finite values and active-token reuse. That is useful because JSON transports and loosely typed adapters can otherwise turn null, strings, or language-specific infinities into surprising comparisons.

Sanitize the optional message as plain status text. A remote tool's progress message should not become HTML, a command, or a trusted filename. Human-readable does not mean trusted presentation markup.

Editorial illustration of one progress token passing through a sequence of active lifecycle gates
The token owns one active path. Completion closes that path; task augmentation extends it instead of creating a second identity.

Monotonic means no repeats

The protocol says progress must increase with each notification. Equal values are therefore not harmless heartbeats. A sender that wants to say “still working” can update the optional message, but it cannot repeat the same progress value as a compliant progress event.

Strict comparison also exposes restart bugs. A worker that restarts its local counter at zero after a retry cannot continue the old token. It either needs to preserve the counter's logical position or start a new request with a new token. Hiding the regression in the UI does not repair the event stream.

total is optional and may be a floating-point number. Its absence is meaningful: the sender does not know a trustworthy denominator. Do not manufacture one from elapsed time or from the largest progress value seen so far. If a product chooses an additional policy such as rejecting zero or negative totals, document it as local validation rather than a claim that the MCP specification states that rule.

Validation from 10,000 updates

The local harness exercised 13 scenarios. Eight deliberately invalid sequences were rejected: duplicate active token, unknown token, equal progress, regressing progress, non-finite progress, invalid local-policy total, post-completion update, and the wrong cancellation method for a task. Valid request and task lifecycles reached one terminal state each.

SequenceReceiver decisionOperational reason
1 → 2 → 4 → responseAccept and closeOne owner, increasing values, one terminal boundary
3 → 3 or 3 → 2RejectEqual and regressing values both violate monotonic progress
Update after responseReject as lateA terminal tombstone prevents a completed row from reviving
10,000 increasing updates in ten secondsAccept all; render 101Transport validity and UI cadence are separate concerns
Task update after initial task responseAcceptThe original token remains active for the task lifetime
Progress with no totalRender indeterminateThe receiver must not invent a percentage

The flood test used a 100 ms render interval. It accepted every monotonic event into protocol state, coalesced presentation work, and forced the pending final event through when the request closed. That last flush is easy to miss: a throttle that only schedules on wall-clock intervals may leave the UI one step behind forever.

Throttling without losing the ending

Rate limiting is recommended on both sides. The sender should avoid flooding the transport; the receiver should avoid turning each valid event into a layout pass, database write, or notification. Those are related controls, not substitutes.

Keep the latest pending event per token. Render at a bounded cadence. On response, error, cancellation, or terminal task status, synchronously flush the pending event before closing—or deliberately replace it with the terminal state. A queue that keeps every intermediate message defeats the point of coalescing and can become its own memory leak.

Editorial illustration of a dense stream of progress events being reduced to a measured sequence of visible updates
Coalescing changes render frequency, not protocol truth. The receiver still validates every event and preserves the last accepted movement.

Measure two rates. Track notifications accepted per token and frames rendered per surface. If only the second number exists, an event storm can consume memory and CPU before the UI throttle gets a chance to help.

Failure modes after terminal state

A late notification may be caused by network reordering, a sender that failed to stop its timer, or a race between response serialization and a worker callback. The receiver does not need to guess which one happened before protecting the UI. It closes the token and refuses the update.

A bounded tombstone makes the refusal observable. Without it, both a completely unknown token and a recently completed token collapse into “missing.” Keep the token hash or safe identifier, terminal reason, final progress, and close time for a short diagnostic window. Do not retain full messages indefinitely.

Unknown-token handling is an interoperability choice. A robust client may ignore and count the event rather than tear down the entire connection. The important part is that it must not attach the event to a convenient active row. Recovery should never trade away identity.

Cancellation has two different exits

Ordinary in-flight requests use notifications/cancelled with the request ID and an optional reason. The notification travels in the same direction as the request it cancels. A receiver should stop work, release resources, and avoid returning a normal response when cancellation wins the race. The initialize request is not cancellable.

Task-augmented requests are different. They use tasks/cancel, and only if the relevant task cancellation capability was negotiated. Sending an ordinary cancellation notification for a durable task confuses request lifetime with task lifetime. The harness rejected that path before accepting the task-specific cancellation.

Races are expected. A response may cross a cancellation notification in transit. Record which terminal event won locally, ignore later terminal duplicates, and clean up once. “Exactly once” here describes the receiver's state transition, not a guarantee that the network carries only one terminal-looking message.

Tasks keep the original token alive

Tasks are experimental in protocol version 2025-11-25. They let a request return an initial task handle while work continues as a durable state machine. The requestor polls tasks/get, eventually obtains the result through tasks/result, and may also receive task status notifications when supported.

The progress token from the original task-augmented request remains valid across the task lifetime. Receiving the initial task response is not grounds to delete it. Progress ends only when the task reaches completed, failed, or cancelled. In the harness, the token accepted another increasing update after the initial response and closed on terminal completion.

Capability negotiation comes first. A client must not assume task support because a tool seems long-running. The server advertises task capabilities, and tool metadata declares whether task execution is required, optional, or forbidden. A progress UI should surface unsupported task behavior as a protocol mismatch, not quietly fall back to pretending the initial response represents the whole operation.

Unknown work should look unknown

When total is absent, show activity, elapsed time, the latest safe message, and perhaps the current raw progress unit. Do not draw a percentage. An indeterminate indicator is more honest than a smooth estimate that repeatedly jumps backward as the inferred denominator changes.

Even when total is present, label the unit when the product knows it: files, batches, rows, bytes, or phases. A naked 42 / 100 suggests precision without saying what was measured. The protocol transports a number; the application still owns the meaning.

Messages can be useful for coarse phase changes—“reading manifests,” “validating records,” “writing index”—but they should not carry secrets, raw tool arguments, or unbounded logs. Progress is an operator signal, not a second logging channel.

Timeouts should not become immortal

MCP lifecycle guidance recommends timeouts for sent requests. An implementation may reset a timeout clock when corresponding progress arrives because movement is evidence of work. It should still enforce a maximum timeout. Otherwise one noisy or malicious peer can keep a request alive indefinitely with tiny valid increments.

Use two clocks: an idle timeout since the last accepted progress or response activity, and an absolute deadline from request start. Rejecting repeated or regressing updates matters here too; a noncompliant heartbeat must not reset the idle clock.

  • Record request start, last accepted progress time, and absolute deadline.
  • Reset only the idle clock on a valid notification for the active token.
  • Cancel ordinary work with notifications/cancelled when the timeout wins.
  • Use task cancellation semantics for durable task-augmented work.
  • Close the token once and refuse any later attempt to revive it.

The record that survives the animation

A useful incident artifact needs fewer fields than a trace and more structure than a screenshot: token hash, request or task ID, direction, protocol version, start time, last accepted progress, whether total was known, accepted and rejected counts, render count, last activity, terminal reason, and close time.

That record distinguishes three cases that look identical to a user staring at a frozen bar: the sender stopped emitting, the receiver rejected invalid movement, or the UI failed to render accepted state. It also reveals a fourth case—a completed token receiving late traffic—that a normal success banner would hide.

The tested receiver policy stayed bounded. It preserved one final value after 10,000 increasing inputs, rejected eight invalid lifecycle cases, kept an unknown total unknown, and used distinct cancellation paths for requests and tasks. Those are local test results, not claims about every MCP SDK.

For a managed runtime, deploy an AI agent on GolemWorkers. For adjacent protocol work, see MCP server setup and transport boundaries and session lifecycle isolation.

The protocol records behind the receiver

  • MCP Progress — token ownership, monotonic progress, optional totals and messages, task lifetime, and rate limiting.
  • MCP Cancellation — request cancellation direction, races, cleanup, and the task exception.
  • MCP Tasks — experimental task capabilities, polling, results, terminal states, and cancellation.
  • MCP Lifecycle — capability negotiation, request timeouts, progress-aware idle timing, and absolute deadlines.

The operating rule is simple enough to remember during an incident: validate ownership first, movement second, and presentation third. A truthful indeterminate indicator is preferable to a precise bar whose token no longer owns any work.