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.

Short answer: MCP progress notifications give a client live feedback while a request is still running. The client places a unique progressToken in request metadata; the receiver may emit notifications/progress with monotonically increasing values, an optional total, and a human-readable message. Track tokens only while their operation is active, throttle updates, reject spoofed or regressing values, and stop every notification at the terminal boundary.

A slow tool with no feedback looks broken. Users retry, clients open duplicate requests, and operators cannot tell whether the server is working, stuck, or waiting on an external dependency. A stream of arbitrary “still running” messages is not much better: it can flood the transport, jump backward, outlive the request, or attach one operation’s status to another.

The Model Context Protocol solves the wire-level part with progress tokens and notifications. Production reliability still depends on the lifecycle around that protocol: token ownership, monotonicity, update frequency, cancellation, task integration, reconnect behavior, and a UI that does not present estimates as guarantees.

Progress is not durability. A progress notification is best-effort interactive feedback. It does not replace an MCP task record, a terminal tool response, or durable application state. If the client disconnects, progress events may be lost.

What MCP progress notifications guarantee

The requester opts in by adding _meta.progressToken to a request. The token must be a string or integer and must be unique across active requests. The receiver may send progress notifications, but it is allowed to send none. Every emitted progress value must increase. The optional total may be omitted when the amount of work is unknown.

Protocol guarantee

A notification identifies an active operation by its original token and carries increasing progress plus optional total and message fields.

Application responsibility

Decide what progress means, prevent token collisions, throttle noisy producers, render uncertainty honestly, and clean up on every terminal path.

For a normal request, the token remains valid until the response or error completes the operation. For a task-augmented request, the same token continues across the task lifetime, even after the initial task-creation result, and becomes invalid when the task reaches completed, failed, or cancelled.

Step 1: issue a unique token with the request

Use an opaque, high-entropy token or a connection-scoped monotonic identifier. Never derive it from a customer email, filename, prompt, or other sensitive input. Scope the registry to one authenticated connection so a token cannot accidentally join operations from different tenants.

{
  "jsonrpc": "2.0",
  "id": 41,
  "method": "tools/call",
  "params": {
    "name": "index_repository",
    "arguments": { "repository": "acme/payments" },
    "_meta": { "progressToken": "prg_01K0C8Y2Q6W7" }
  }
}

Register the token before writing the request to the transport. That avoids a race in which a fast server emits its first update before the client knows where to route it.

type ActiveProgress = {
  requestId: string | number;
  startedAt: number;
  lastProgress: number | null;
  lastTotal: number | null;
  terminal: boolean;
};

const active = new Map<string | number, ActiveProgress>();

function beginProgress(
  token: string | number,
  requestId: string | number,
): void {
  if (active.has(token)) throw new Error("Duplicate active progress token");
  active.set(token, {
    requestId,
    startedAt: Date.now(),
    lastProgress: null,
    lastTotal: null,
    terminal: false,
  });
}

Version note: pin the protocol version and SDK release used by your host. As of this guide, the official TypeScript and Python projects identify their v1.x lines as the supported production releases while v2 remains prerelease work.

Editorial illustration of one long-running MCP request carrying a unique glowing progress token through a sequence of verified updates
One active request owns one progress token; every accepted update follows that token until the operation terminates.

Step 2: emit meaningful monotonic updates

The receiver sends notifications/progress. Use a unit that can only move forward: completed files, processed records, bytes transferred, batches committed, or normalized phase weights. Do not report a raw queue position that can move backward when new work arrives.

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "prg_01K0C8Y2Q6W7",
    "progress": 320,
    "total": 1000,
    "message": "Indexed 320 of 1,000 files"
  }
}

If total work is unknown, omit total and keep progress increasing:

{
  "jsonrpc": "2.0",
  "method": "notifications/progress",
  "params": {
    "progressToken": "prg_01K0C8Y2Q6W7",
    "progress": 4,
    "message": "Scanning dependency graph"
  }
}

A total may change when discovery reveals more work, but the current progress value still cannot regress. In the UI, a changing denominator should be labeled as an estimate. Do not force every workflow into a fake percentage.

Work typeGood progress unitMisleading unit
Repository indexingFiles committed to the indexFiles merely discovered
Batch enrichmentRecords durably processedWorkers started
Artifact downloadVerified bytes writtenEstimated network time
Multi-phase auditMonotonic weighted phasesArbitrary “percent complete” text

Step 3: validate every incoming notification

A client should treat progress as untrusted protocol data. Ignore unknown tokens, notifications received after completion, non-finite numbers, decreasing values, invalid totals, oversized messages, and updates that arrive too quickly. Never let a progress message execute markup, open a URL, or become trusted model instructions.

type ProgressParams = {
  progressToken: string | number;
  progress: number;
  total?: number;
  message?: string;
};

function acceptProgress(p: ProgressParams): ActiveProgress | null {
  const state = active.get(p.progressToken);
  if (!state || state.terminal) return null;
  if (!Number.isFinite(p.progress)) return null;
  if (state.lastProgress !== null && p.progress <= state.lastProgress) return null;
  if (p.total !== undefined && (!Number.isFinite(p.total) || p.total < 0)) return null;
  if (p.message !== undefined && p.message.length > 240) return null;

  state.lastProgress = p.progress;
  state.lastTotal = p.total ?? state.lastTotal;
  return state;
}

Whether equal values are rejected or coalesced is an application choice, but they should not produce repeated visual updates. The specification requires increases between notifications, so logging an equal-value event as a protocol violation is reasonable.

Step 4: throttle without hiding real movement

A tight loop can produce thousands of updates per second. That consumes bandwidth, blocks useful messages, forces needless UI renders, and can become a denial-of-service vector. Throttle at the producer and coalesce at the consumer.

A practical default is to emit when at least one condition is true:

  • 250–500 milliseconds passed since the last notification;
  • progress advanced by a meaningful unit or percentage;
  • the operation entered a new named phase;
  • a user-relevant waiting condition started;
  • the operation is about to terminate.
function shouldEmit(
  now: number,
  lastAt: number,
  progress: number,
  lastProgress: number,
  phaseChanged: boolean,
): boolean {
  return phaseChanged ||
    now - lastAt >= 400 ||
    progress - lastProgress >= 25;
}

Keep the latest coalesced update, not the first. If a transport becomes writable after a pause, send the newest state instead of replaying an obsolete backlog.

Editorial illustration of a noisy stream of MCP progress signals being throttled into a small sequence of clear monotonic checkpoints
Producer throttling and consumer coalescing turn noisy internal work into a bounded sequence of useful checkpoints.

Step 5: close the token on every terminal path

The client must close the registry entry when the request returns a result, returns an error, is cancelled, times out locally, or the connection closes. The server must stop emitting when the operation terminates. Keep a short-lived tombstone if late network messages are possible; that distinguishes a late known token from a completely unknown token without reopening the operation.

function finishProgress(token: string | number): void {
  const state = active.get(token);
  if (!state) return;
  state.terminal = true;
  active.delete(token);
  rememberTombstone(token, Date.now() + 60_000);
}

Do not infer success from progress === total. Only the request result or terminal task state proves completion. A server can finish work after its final measurable unit, and a final commit can still fail.

Step 6: integrate cancellation and MCP tasks

Cancellation and progress are related but separate. A progress message can explain that cancellation is pending, but it does not cancel anything. Route the user’s cancel action through the protocol’s cancellation or task-cancellation mechanism and keep the progress token active until the operation actually reaches a terminal state.

For a task-augmented request, reuse the original progress token throughout the task lifetime. Do not replace it with the task ID, and do not stop accepting progress merely because the initial response returned a task handle. Stop only when the task is completed, failed, or cancelled.

Choose the primitive by outcome: use progress notifications for live feedback while a peer is connected. Use MCP tasks when the operation must survive reconnects, be polled later, expose a durable result, or support lifecycle management independent of one transport session.

Failure-first test matrix

Test invalid lifecycle behavior before celebrating a smooth progress bar. Capture the final registry size, notification count, transport bytes, render count, and terminal result for every case.

CaseExpected result
Valid increasing updates for an active tokenRendered in order; result remains the source of completion
Unknown tokenIgnored and counted; no UI element created
Duplicate active tokenRequest creation fails before transport write
Progress decreases or repeatsRejected or coalesced; never rendered backward
NaN, infinity, or negative totalRejected as invalid protocol data
Ten thousand updates in one secondProducer throttled; consumer render rate remains bounded
Progress arrives after responseMatched to a tombstone and discarded
Connection dropsUI marks feedback interrupted; no success inferred
Cancellation requested mid-phaseCancellation path runs; progress stops only at terminal state
Task-augmented request returns a task handleOriginal token remains active until terminal task status
Message contains markup or a URLRendered as bounded plain text; never executed

Common implementation mistakes

Using progress as a heartbeat

Repeated messages with no increasing value violate the intended model and create false confidence. Use transport liveness separately and emit progress only when work advances.

Turning phase names into fake percentages

“Planning: 40%” is meaningless unless the denominator has a stable definition. Prefer an indeterminate phase label, completed-unit count, or explicit estimate.

Letting notifications outlive the request

Late updates can attach to a recycled token or revive stale UI. Use unique active tokens, terminal cleanup, and short tombstones.

Logging raw progress messages

Messages can contain filenames, customer identifiers, or model-generated text. Bound and sanitize them before display; store a reason code and phase identifier instead of raw text when detailed logs are unnecessary.

Assuming progress makes retries safe

A visible progress bar does not make a tool idempotent. If users may retry after disconnect, use idempotency keys and durable operation state appropriate to the tool’s side effects.

Production rollout sequence

  1. Define units: choose monotonic units and decide when total is trustworthy.
  2. Register: create connection-scoped tokens before requests leave the client.
  3. Validate: reject unknown, terminal, non-finite, regressing, oversized, and over-frequent notifications.
  4. Throttle: coalesce producer and consumer updates under load.
  5. Terminate: close tokens on result, error, cancellation, timeout, task terminal state, and disconnect.
  6. Attack: run the failure-first matrix and prove the registry returns to zero.
  7. Observe: alert on unknown tokens, regressions, late updates, flood suppression, and abandoned operations.

MCP progress production checklist

  • Every active request has a unique string or integer progress token.
  • Tokens are opaque and scoped to one authenticated connection.
  • The client registers the token before sending the request.
  • Progress values increase monotonically.
  • Unknown totals remain absent instead of becoming fake percentages.
  • Messages are bounded, sanitized, and rendered as plain text.
  • Producers throttle and consumers coalesce noisy updates.
  • Unknown, late, invalid, and regressing notifications are rejected.
  • Only a result, error, or terminal task state proves completion.
  • Cancellation has its own protocol path.
  • Task-augmented requests retain the original token until terminal state.
  • Cleanup covers success, error, timeout, cancellation, and disconnect.
  • SDK and protocol versions are pinned and tested together.

Continue building the runtime

Add AI agent observability with OpenTelemetry for durable traces, latency, token, and cost evidence. Use MCP tasks for reconnect-safe lifecycle and durable results. If slow tools run untrusted code, add gVisor sandboxing. For an isolated agent worker with browser, terminal, files, memory, schedules, and scoped secrets, create a GolemWorkers AI agent.

Sources