Index Ai Academy
Phase 9 · Model Context Protocol · Building servers

Progress, subscriptions and multi-round-trip requests

Long-running work, change notifications and asking the user mid-call.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Progress
  2. Progress only if asked
  3. Subscriptions: hearing about changes
  4. Asking the user mid-call: multi-round-trip requests
  5. requestState is attacker-controlled
  6. Signing and checking a requestState
  7. 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.

Worked example

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)
It runs right here in your browser, with nothing to install.

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.

Quick check
What does this print?
done, total = 3, 12
print(f"{done / total:.0%}")
  1. 3%
  2. 12%
  3. 0.25
  4. 25%
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.

Quick check
A client opened subscriptions/listen with only toolsListChanged: true. A resource it cares about changes. Does the server notify it?
  1. Only if the resource is small
  2. Yes, but only on HTTP
  3. No: the server sends only the notification types the client opted in to
  4. Yes: servers send every change to every listener
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):

  1. The client calls tools/call as usual.
  2. 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..."}
  1. The client shows the question (here, an elicitation form) and collects the answer.
  2. The client retries the original request with a new id, adding inputResponses (answers keyed like inputRequests) and echoing requestState:
{"name": "refund_order", "arguments": {"order_id": "ord_51"},
 "inputResponses": {"confirm": {"action": "accept", "content": {"confirm": true}}},
 "requestState": "eyJvcmRlci...Q.9c1f..."}
  1. 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.

Try it now

Nothing is held open in between, which is why stateless servers can do this.

Quick check
What does a client do after receiving an InputRequiredResult?
  1. Sends the answers as a notification
  2. Waits on the same request for the server to continue
  3. Opens a subscriptions/listen stream
  4. Gathers the requested input, then sends the original request again (new id) with inputResponses and the requestState
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.

Quick check
Why must a server sign or encrypt the requestState it hands to the client?
  1. The client sends it back, so it could be altered; a signature lets the server detect tampering
  2. To make the response smaller
  3. So the model can read it
  4. Because JSON can't hold plain state
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.

Worked example

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]))
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

How does a client ask for progress updates on a request?

  1. It puts a progressToken in the request's _meta
  2. It opens subscriptions/listen with progress: true
  3. It polls with a progress/get method
  4. Servers always send progress

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson