The gap a long operation leaves

MCP’s core interaction is a JSON-RPC request and a matching response. That pairing is clean, but it is silent in the middle: between the moment a client sends tools/call and the moment a result comes back, the wire carries nothing. For a slow call that silence is a vacuum both the user and the client runtime will fill with the wrong conclusion.

The user’s wrong conclusion is ‘it crashed’ — and they hit stop, or retry, or abandon the task. The runtime’s wrong conclusion is a timeout: no bytes for sixty seconds looks identical to a hung server, so the client tears the request down while the work was two-thirds finished. Both failures share a root cause — absence of traffic is ambiguous. Progress notifications resolve that ambiguity by making liveness observable. They do not make the operation faster; they make it legible, which matters almost as much.

Advertisement

Opting in — the progress token

Progress in MCP is pull-initiated, push-delivered. A server does not decide unilaterally to narrate its work. The client requests narration by attaching a progress token to the request — a value it places in the request’s metadata (params._meta.progressToken), typically a string or an integer that the client generates and keeps unique among its in-flight requests.

That token is the correlation handle and the consent signal at once. If the client supplied one, the server may emit progress notifications carrying it, and the client can route each one back to the exact request it belongs to. If the client supplied no token, the server must stay quiet — there is nothing to correlate against, and unsolicited progress is noise. This opt-in shape is doing real work: a batch pipeline with no user watching pays nothing, while an interactive client asks for updates only on the calls it intends to surface.

Advertisement

What a progress notification carries

The notification itself is intentionally thin. It travels as notifications/progress and carries the progressToken it answers to, a progress value, an optional total, and — in current revisions of the spec — an optional human-readable message.

progress is the amount of work done so far, expressed in whatever unit the server chooses: files, rows, bytes, pages, steps. It must increase from one notification to the next for a given token. That monotonicity rule is not pedantry — a client that renders a bar has to be able to treat each update as strictly newer than the last, and a value that sometimes goes backwards makes every UI built on it flicker. total, when known, is the denominator: with both numbers a client can show a real percentage. Crucially, none of these fields carries results. Progress describes the operation; the response delivers its output.

Progress is a notification, not a request

This is the design decision everything else follows from. A JSON-RPC notification has no id and therefore gets no reply: the sender emits it and moves on. Nobody acknowledges a progress update, nobody can confirm it arrived, and no retry logic exists for one.

The consequence is that progress is best-effort and unreliable by design, and both sides must be built for that. A server must never let correctness depend on a notification landing — the final response is the only load-bearing message. A client must render whatever it receives without assuming completeness: updates can be dropped by a saturated transport, coalesced, or simply never sent. A client that has seen progress: 900 of 1000 and then receives the response has not lost anything; it should snap the bar to done rather than wait for a final update that will never come. Treat every progress stream as one that can stop at any point without meaning failure.

Cadence — the two failure modes

Choosing how often to emit is the whole craft, and the failure modes sit on either side. Emit on every unit of work and a server processing fifty thousand rows produces fifty thousand notifications, flooding the channel, drowning out other traffic, and forcing a client into re-renders it cannot keep up with. Emit twice in three minutes and the operation looks hung between updates, which is exactly the perception progress existed to prevent.

The usable middle is a time-based floor with work-based coalescing: count internally on every item, but only send when some interval has elapsed since the last notification — roughly once or twice a second for interactive work, slower for background jobs. Add an update at genuine phase boundaries, because ‘now uploading’ is information a timer cannot produce. Human perception sets the ceiling: nobody reads a bar moving thirty times a second, and nobody believes one that has not moved in twenty.

Indeterminate progress when the total is unknown

Often the server genuinely does not know the denominator. It is streaming a cursor-paginated API with no count, walking a directory tree as it discovers it, or waiting on a third party that reports no size. In those cases total is simply omitted, and the notification carries a rising progress value alone.

The temptation is to invent a total anyway — guess ten thousand, show a percentage, and adjust later. Resist it. A fabricated denominator produces the worst UI outcome there is: a bar that reaches 90% and sits there, or one that jumps backwards when the estimate is revised, teaching the user that your progress lies. Honest indeterminate progress is better. A client seeing no total should render an indeterminate affordance — a moving spinner, an activity pulse — and show the raw count as text: ‘1,240 records processed.’ That communicates liveness and scale without promising a completion moment nobody can predict.

The message field — narration, not logging

A number tells the user how much; the optional message tells them what. ‘340 / 1200’ is far less reassuring than ‘340 / 1200 — indexing src/components’, because the second proves the server is doing something recognisable rather than spinning.

Write it as UI copy, not as a log line. It should be short enough to fit one line beside a progress bar, phrased for the person who asked for the operation, and free of stack traces and internal state. It may be displayed verbatim, so it must never carry secrets, tokens, absolute paths that leak a filesystem layout, or raw customer data. If what you want to emit is diagnostic detail for an operator rather than reassurance for a user, that belongs on MCP’s logging channel, which exists precisely so that debugging output does not have to ride along inside a progress bar’s caption.