A tool call that returns in forty milliseconds needs no ceremony. A tool call that reindexes a repository, walks a paginated API, or runs a migration can take two minutes — and for those two minutes the client has nothing to show but a spinner and a growing suspicion that something has broken. Progress is MCP’s answer: a lightweight, opt-in side channel on which a server can say ‘still working, 340 of 1,200 files done’ while the real response is still pending. It is deliberately a small feature: it carries no results, it guarantees nothing, and a client that ignores it entirely still gets a correct answer. But it is the difference between an agent that feels responsive and one that feels frozen. This piece walks the whole model.
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.
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.
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.
Progress across nested and delegated work
Real operations are rarely flat. A server asked to ‘analyse this repository’ may clone, parse, call a downstream service, and summarise — four phases with wildly different durations, possibly with their own internal progress. The client, though, holds exactly one token for the request it made, and that token must not leak into unrelated work.
The pattern that works is aggregate, then re-express. Give each phase a weight reflecting its expected share of the whole, track sub-progress internally, and publish a single monotonic number against the client’s token. Weights need only be roughly right; what matters is that the published value never regresses when a phase ends. If the server is itself an MCP client of another server, it can mint its own token downstream and fold that stream into its own — but never forward a child’s raw counts upward, because two denominators on one token is how bars start jumping.
Timeouts, liveness, and the handoff to cancellation
Clients time out requests to protect themselves from servers that die without closing a connection. That protection is blunt, because a slow-but-healthy operation and a dead one look identical from the outside — unless progress is flowing. A progress notification is evidence of liveness, and the standard treatment is to reset the inactivity timer whenever one arrives, so a server that keeps reporting keeps its request alive.
That concession needs a bound, or it becomes an attack surface: a server that emits progress forever holds a client’s slot forever. The safe shape is two limits — a short inactivity timeout that progress refreshes, plus an absolute maximum duration that nothing refreshes. Past the hard ceiling the client gives up regardless of how chatty the server is. And when the user simply does not want to wait, progress is only half the story: it makes a long operation observable, while cancellation makes it stoppable — a separate mechanism covered in its own right.
What good progress looks like
Pulled together, the guidance is short enough to check a server against:
| Rule | Why |
|---|---|
| Only report when a token was supplied | Progress is opt-in; unsolicited updates are noise |
| Never let the value go backwards | Monotonicity is what makes a bar renderable |
Omit total rather than guess it | A fake denominator produces a bar that lies |
| Throttle to human-readable rates | Too chatty floods; too sparse looks hung |
| Put nothing load-bearing in a notification | Delivery is best-effort; the response is the contract |
Keep message user-facing and clean | It may be displayed verbatim; diagnostics belong in logs |
A client’s obligations mirror these: render partial information without waiting for a tidy 100%, degrade to an indeterminate indicator when no total arrives, and never treat a gap in the stream as a failure. Progress is a courtesy channel, and both sides should build as though it might vanish.
notifications/progress carrying that token, a monotonically increasing progress value, an optional total, and an optional human-readable message. Because it is a JSON-RPC notification it is never acknowledged and never retried, so nothing load-bearing may ride on it — the response remains the only contract. The craft is cadence: throttle to roughly a human reading speed, add updates at real phase boundaries, omit the total rather than fabricate one, and aggregate nested work into a single number that never regresses. A live progress stream also serves as proof of liveness, so pair a progress-refreshed inactivity timeout with an absolute ceiling. Progress makes a long operation observable; cancellation is what makes it stoppable.