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.
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 type | Good progress unit | Misleading unit |
|---|---|---|
| Repository indexing | Files committed to the index | Files merely discovered |
| Batch enrichment | Records durably processed | Workers started |
| Artifact download | Verified bytes written | Estimated network time |
| Multi-phase audit | Monotonic weighted phases | Arbitrary “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.
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.
| Case | Expected result |
|---|---|
| Valid increasing updates for an active token | Rendered in order; result remains the source of completion |
| Unknown token | Ignored and counted; no UI element created |
| Duplicate active token | Request creation fails before transport write |
| Progress decreases or repeats | Rejected or coalesced; never rendered backward |
| NaN, infinity, or negative total | Rejected as invalid protocol data |
| Ten thousand updates in one second | Producer throttled; consumer render rate remains bounded |
| Progress arrives after response | Matched to a tombstone and discarded |
| Connection drops | UI marks feedback interrupted; no success inferred |
| Cancellation requested mid-phase | Cancellation path runs; progress stops only at terminal state |
| Task-augmented request returns a task handle | Original token remains active until terminal task status |
| Message contains markup or a URL | Rendered 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
- Define units: choose monotonic units and decide when total is trustworthy.
- Register: create connection-scoped tokens before requests leave the client.
- Validate: reject unknown, terminal, non-finite, regressing, oversized, and over-frequent notifications.
- Throttle: coalesce producer and consumer updates under load.
- Terminate: close tokens on result, error, cancellation, timeout, task terminal state, and disconnect.
- Attack: run the failure-first matrix and prove the registry returns to zero.
- 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
- MCP specification: Progress — token types, uniqueness, monotonic updates, task lifetime, termination, and rate-limiting guidance.
- MCP specification: Cancellation — cancellation semantics for in-flight requests.
- MCP specification: Tasks — durable task lifecycle and task-augmented request behavior.
- MCP TypeScript SDK v1.x — official supported production implementation line.
- MCP Python SDK v1.x — official stable implementation line.