Progress, subscriptions and multi-round-trip requests
Long-running work, change notifications and asking the user mid-call.
Create a free account Try part of it here
Why it matters
Real tools aren't always instant and self-contained. An export takes a minute, so the user wants a progress bar. A tool list changes when an admin installs a plugin. A refund tool should ask "are you sure?" before moving money. MCP handles all three. The 2026-07-28 revision also rebuilt the "ask the user" mechanism so that servers stay stateless, which is an elegant pattern worth knowing in its own right.
What you'll be able to do
- Report progress on long-running requests with progress tokens
- Open a subscriptions/listen stream for the change notifications a client wants
- Ask the user for input mid-call with an InputRequiredResult, and finish on the retry
- Protect requestState against tampering and replay with a signed, expiring payload
What it covers
- Progress
- Progress only if asked
- Subscriptions: hearing about changes
- Asking the user mid-call: multi-round-trip requests
- requestState is attacker-controlled
- Signing and checking a requestState
- Mistakes with long-running work
Progress
A pizza tracker tells you "in the oven" and "out for delivery", and only if you asked to follow your order. MCP progress works the same way. A client that wants updates puts a progressToken (a string or number it chooses) in the request's _meta. While working, the server sends notifications that quote it:
{"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": "export-7", "progress": 300, "total": 1000, "message": "Exported 300 of 1000 rows"}}
progress must go up with every update, even when total is unknown. Progress notifications travel on the same stream as the request's reply. That's the shared channel on stdio, and the request's own event stream on HTTP. No token means no progress notifications.
Progress only if asked
A client opts in by sending a token. This server only sends updates if there is one, and it quotes the token back so the client knows which job each update belongs to.
import json def export_rows(total, meta): updates = [] token = meta.get("progressToken") for done in range(250, total + 1, 250): if token is not None: updates.append({"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": token, "progress": done, "total": total}}) return updates, {"exported": total} updates, result = export_rows(1000, {"progressToken": "export-7"}) print(len(updates), "updates, then", result) print(json.dumps(updates[0]["params"])) updates, result = export_rows(1000, {}) print(len(updates), "updates, then", result)
Same work, same result. With no token, the server stays silent, which keeps simple clients simple. Notice the notifications have no id: they never get a reply.
done, total = 3, 12 print(f"{done / total:.0%}")
Show the answer
25%
3 / 12 is 0.25, and the .0% format writes that as 25%. A progress message carries how far along the work is, so a client can show it.
Subscriptions: hearing about changes
Your bank can text you about low balances and leave out everything else. In the same way, to learn when things change, a client sends one long-lived request, subscriptions/listen, naming exactly which notifications it wants:
{"jsonrpc": "2.0", "id": 40, "method": "subscriptions/listen",
"params": {"notifications": {"toolsListChanged": true, "resourceSubscriptions": ["docs://help/doc_returns"]},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
The server first sends notifications/subscriptions/acknowledged, then, as things happen, notifications/tools/list_changed or notifications/resources/updated. It sends only the types the client asked for, each tagged with io.modelcontextprotocol/subscriptionId in its _meta. The response stays open until either side closes it. A client that hears tools/list_changed drops its cached tool list and fetches a fresh one.
This replaced two older mechanisms, the HTTP GET stream and resources/subscribe, with one that works the same on stdio and HTTP.
Note: The 2026-07-28 revision also deprecated three client features: sampling (servers asking the client's model to generate text), roots (the client telling servers which folders to use) and protocol-level logging. They still work during a twelve-month deprecation window, but new servers should call model APIs directly, take paths as tool arguments, and log to stderr or OpenTelemetry.
toolsListChanged: true. A resource it cares about changes. Does the server notify it?Show the answer
No: the server sends only the notification types the client opted in to
Subscriptions are opt-in by type. To hear about that resource, the client lists its URI in resourceSubscriptions.
Asking the user mid-call: multi-round-trip requests
A refund tool shouldn't move money until the user confirms. In legacy MCP, the server would send its own request to the client in the middle of a call and wait. That needed the server to hold the call open and remember where it was: state again.
Think of a bank clerk who needs your signature. Instead of holding your place, they hand you a form and a stamped slip, and serve the next person. When you come back with both, any clerk can finish the job. The 2026-07-28 revision works that way, with multi-round-trip requests (MRTR):
- The client calls
tools/callas usual. - The server sees it needs input. Instead of a normal result, it returns an InputRequiredResult and ends the request:
{"resultType": "input_required",
"inputRequests": {"confirm": {"method": "elicitation/create", "params": {
"mode": "form", "message": "Refund $49.00 for order ord_51?",
"requestedSchema": {"type": "object", "properties": {"confirm": {"type": "boolean"}}, "required": ["confirm"]}}}},
"requestState": "eyJvcmRlci...Q.9c1f..."}
- The client shows the question (here, an elicitation form) and collects the answer.
- The client retries the original request with a new id, adding
inputResponses(answers keyed likeinputRequests) and echoingrequestState:
{"name": "refund_order", "arguments": {"order_id": "ord_51"},
"inputResponses": {"confirm": {"action": "accept", "content": {"confirm": true}}},
"requestState": "eyJvcmRlci...Q.9c1f..."}
- The server has everything it needs in this one request, so it finishes.
An elicitation answer has an action: "accept" (with content matching the schema), "decline" or "cancel". The server needs no memory between the two requests. Anything it wants to carry over goes in requestState, an opaque string the client must echo back untouched.
MRTR works for tools/call, prompts/get and resources/read, and the client must have declared the matching capability, such as elicitation.
Nothing is held open in between, which is why stateless servers can do this.
Show the answer
Gathers the requested input, then sends the original request again (new id) with inputResponses and the requestState
The first request is finished. The retry carries everything, so any server copy can complete it.
requestState is attacker-controlled
That stamped slip passes through the customer's hands, so the bank must be able to tell if someone altered it. requestState passes through the client in the same way, so the spec says servers must treat it as attacker-controlled input. If it influences money, access or data, it must be integrity protected: signed (or encrypted) so changes are detected. It should also be checked against replay, someone reusing an old copy, by including:
- who it was issued to (reject it from anyone else);
- a short expiry;
- what request it belongs to, such as the tool name and order id (reject it on any other call).
That's exactly the signed-token pattern from Phase 8: a JSON payload, base64url-encoded, plus an HMAC signature. Signing stops edits and expiry limits replay, but it doesn't make a state single-use. If something must happen at most once, like a refund, the server still checks its own records (the order shouldn't already be refunded).
In plain words: anything you hand to a client and get back could have been changed. Sign it, and check the signature when it returns.
Show the answer
The client sends it back, so it could be altered; a signature lets the server detect tampering
Anything that round-trips through the client is attacker-controlled. Verify it before trusting it.
Signing and checking a requestState
The payload is readable but can't be changed without the server's secret.
import base64, hashlib, hmac, json SECRET = b"server-only-secret" def sign_state(payload): body = base64.urlsafe_b64encode(json.dumps(payload, sort_keys=True).encode()).decode().rstrip("=") return body + "." + hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest() def read_state(state): body, _, sig = state.partition(".") if not hmac.compare_digest(sig, hmac.new(SECRET, body.encode(), hashlib.sha256).hexdigest()): return None return json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4))) s = sign_state({"tool": "refund_order", "order_id": "ord_51", "amount": 49.0, "user": "ada", "exp": 1700000300}) print(s[:40] + "...") print(read_state(s)) tampered = sign_state({"tool": "refund_order", "order_id": "ord_51", "amount": 4900.0, "user": "ada", "exp": 1700000300}) print(read_state(tampered.split(".")[0] + "." + s.split(".")[1]))
The last line pastes a payload with a bigger amount onto the original signature, and read_state rejects it (None). The exercise adds the other checks: the expiry, the user, and that the state belongs to this tool and this order.
Mistakes with long-running work
The server doesn't wait for the user. With multi-round-trip requests, the request ends when input is needed. Code that tries to hold it open, and pause for the answer, brings back the very state the 2026 revision removed. Finish the request, and pick up from the state when the next one arrives.
requestState comes back from the client, so it isn't trustworthy. It looks like your own data, since you created it. But it has been through the client's hands, and it could have been changed. Sign it and check the signature, as in this lesson's example. Keep the amount and the user out of it unless they're signed too.
Send only what was asked for. A client subscribes to specific kinds of change. Sending others wastes its attention and may break it.
Old features linger. Sampling and roots are deprecated. You'll still see them in older servers, but don't build new ones on them.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 6 review cards for spaced repetition
Try a quiz question
How does a client ask for progress updates on a request?