MCP 2026-07-28 one lesson per page
23 lessons
Breaks immediatelyInteractions & Execution · 26%Track 2 · 6 / 14

The server can't interrupt any more. It asks, and you retry

This is the biggest change, and the most tested. Servers no longer send elicitation, sampling or roots requests mid-call. They return input_required, and the client re-sends the original request with the answers.

Listen to this lessonAudio overview in Gemini Notebook · about 15–25 min · opens in a new tab

The deploy that waited for lunch

A developer tells their agent "deploy payments-service to production". The deploy tool needs a human confirmation: type the service name to confirm. The developer is already walking to lunch.

Legacy: the server sent elicitation/create on an open stream and waited. A worker and a socket were held open, and after 60 seconds the corporate proxy killed the idle stream. When the developer came back, the deploy had silently failed.

2026-07-28: the server answers input_required and forgets about it. Nothing is held open. Forty minutes later the developer types "payments-service", the client retries with a new id, and whichever instance receives it reads the signed requestState and deploys.

The security twist: a compromised client edits requestState to swap "staging" for "production". The signature check fails, and the server rejects it. That's why the spec calls requestState attacker-controlled.

  1. The client sends tools/call (id 1).
  2. The server needs something, so it answers with resultType: "input_required". That result holds inputRequests (a map of elicitation/create, sampling/createMessage or roots/list requests), requestState (an opaque string), or both. The original request is now finished.
  3. The client gathers the answers and sends the same request again with a new id, adding inputResponses and the exact requestState.
  4. The server rebuilds its context from the retry alone and completes.

Only tools/call, resources/read and prompts/get may return input_required. The server MUST NOT ask for something the client didn't declare in its capabilities.

A server-initiated request needs a live channel back to the client and memory of the paused call, which means sticky routing again. With Multi Round-Trip Requests (MRTR), each leg is an independent request: whichever instance receives the retry has everything it needs.

Same job, two eras
Compare

This is how it works in 2026-07-28. Switch to Legacy only to see what it replaced.

UserClientServertools/call (id 1)input_required {inputRequests, requestState}Request 1 is finished. Nothing is held open.ask useroctocattools/call (id 2) + inputResponses + stateresult (resultType: complete)
Solid arrows are requests, dashed are responses or notifications. Red ✕ is gone; green is new.

Asking the user for their GitHub username

2026-07-28
// ① the server's answer to tools/call id 1{  "jsonrpc": "2.0",  "id": 1,  "result": {    "resultType": "input_required",    "inputRequests": {      "github_login": {        "method": "elicitation/create",        "params": {          "mode": "form",          "message": "Please provide your GitHub username",          "requestedSchema": {            "type": "object",            "properties": { "name": { "type": "string" } },            "required": ["name"]          }        }      }    },    "requestState": "AEAD-protected blob"  }}
What it used to look like (legacy, for comparison only)
Legacybefore
// during a tools/call, the SERVER sent its own request on the response stream{"jsonrpc": "2.0", "id": "srv-1", "method": "elicitation/create", "params": { "message": "Please provide your GitHub username",             "requestedSchema": { "type": "object",               "properties": { "name": { "type": "string" } } } }}// …and the client had to answer it with a JSON-RPC response{"jsonrpc": "2.0", "id": "srv-1", "result": { "action": "accept", "content": { "name": "octocat" } }}

An excerpt of the spec's example (its second, sampling entry is omitted). The key (github_login) is chosen by the server and must be unique within the request.

The retry

2026-07-28
{  "jsonrpc": "2.0",  "id": 2,  "method": "tools/call",  "params": {    "name": "create_repo",    "arguments": { "repo": "demo" },    "inputResponses": {      "github_login": { "action": "accept", "content": { "name": "octocat" } }    },    "requestState": "AEAD-protected blob",    "_meta": { … per-request fields … }  }}

Same method and arguments. A new id (MUST differ). requestState echoed back byte for byte. The client MUST NOT parse it, change it, or reuse it on any other request.

  • Every place a tool asks the user, calls the model, or reads roots mid-execution stops working. The spec forbids sending requests on the response stream.
  • Your handler must be re-entrant: one logical operation means two (or more) calls, and the second must resume, not start again.
  • requestState comes back attacker-controlled. If it affects authorization or business logic, protect its integrity (HMAC or AEAD) and reject anything that fails verification. Inside it, include the user (principal), a short expiry, and the method plus a digest of the parameters. If something must be one-time-use, enforce that on the server; the state alone can't.
  • If the client leaves out a needed answer, return another input_required rather than an error.
First time here? Set up the test helper (once per terminal)
ShellSetup
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs                 # http://localhost:3000/mcp, logs appear here # 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() {  # usage: mcp <method> '<json body>' [extra curl args...]  curl -sS -N "$MCP" \    -H 'Content-Type: application/json' \    -H 'Accept: application/json, text/event-stream' \    -H 'MCP-Protocol-Version: 2026-07-28' \    -H "Authorization: Bearer ${MCP_USER:-alice}" \    -H "Mcp-Method: $1" "${@:3}" -d "$2" \    -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
ShellTests
META_E='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}'CALL='"name":"create_repo","arguments":{"repo":"demo"}' # 1. First leg: expect resultType "input_required", inputRequests.github_login and a requestStatemcp tools/call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{'"$CALL"','"$META_E"'}}' -H 'Mcp-Name: create_repo' | tee /tmp/leg1.txtSTATE=$(sed -n 's/.*"requestState":"\([^"]*\)".*/\1/p' /tmp/leg1.txt) # 2. Retry: NEW id, same arguments, the answer, and the exact requestStateANSWER='"inputResponses":{"github_login":{"action":"accept","content":{"name":"octocat"}}}'mcp tools/call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{'"$CALL"','"$ANSWER"',"requestState":"'"$STATE"'",'"$META_E"'}}' -H 'Mcp-Name: create_repo'# expect: resultType "complete", "Created github.com/octocat/demo" # 3. Negative tests: each one must be rejected with -32602#  a) tampered statemcp tools/call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{'"$CALL"','"$ANSWER"',"requestState":"x'"$STATE"'",'"$META_E"'}}' -H 'Mcp-Name: create_repo'#  b) replayed by another userMCP_USER=bob mcp tools/call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{'"$CALL"','"$ANSWER"',"requestState":"'"$STATE"'",'"$META_E"'}}' -H 'Mcp-Name: create_repo'#  c) replayed on different argumentsmcp tools/call '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_repo","arguments":{"repo":"prod"},'"$ANSWER"',"requestState":"'"$STATE"'",'"$META_E"'}}' -H 'Mcp-Name: create_repo'#  (d: the state also expires after 10 minutes) # 4. Capability check: without elicitation declared, expect 400 and -32021mcp tools/call '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{'"$CALL"','"$META"'}}' -H 'Mcp-Name: create_repo' 

Answer all 4 correctly and this lesson is marked as learned.

Q1What must the client change when it retries after input_required?

Q2Which requests may a server answer with input_required?

Q3Your requestState records which environment ("staging" or "production") a deploy targets. Why must the server protect its integrity with HMAC or AEAD?

Q4Spot the bug: after input_required, a client retries with "id": 1 (the same id as the first request), the same arguments, inputResponses, and the exact requestState.

Feedback or a correction? Email diego [at] diegozuluaga [dot] dev or open an issue on GitHub.

Content CC BY 4.0 · Code MIT

Tip: ← and → move between lessons. Hover any heading and press # to copy a link to it.