When the server needs input
Servers may no longer start a JSON-RPC request. So how does a deploy tool ask “are you sure?” It ends the call early, hands the client a signed ticket, and lets the client call again with the answer. This pattern is called multi round-trip requests, MRTR.
By the end of this lesson you can
- trace an
input_requiredround trip, field by field; - list the checks a server runs on
requestState, and the attack each one stops; - say which methods may return
input_requiredand which inputs a server may ask for; - read the SDK code that implements it, and the raw output of the lab.
The round trip
When a server needs the user (elicitation), the host's model (sampling) or the roots, it does not ask mid-call. It finishes the current request with a result of type input_required. That result carries two things: the questions (inputRequests) and the server's half-finished work, packed into a signed string called requestState. Think of requestState as a ticket. The client gets the answers, then sends the same request again with a new id, the answers in inputResponses, and the ticket copied back unchanged.
One deploy, two replicas, a ticket in the client's hand
- The client calls
deploy. The load balancer happens to pick replica A. - A needs a confirmation first. Instead of asking mid-call, it ends the call with
input_required: the question goes ininputRequests, and what A has worked out so far (deploy, staging) is sealed in a signedrequestStateticket. Then A forgets the whole thing. - The host shows the question to the user, who confirms. This only happens because the client said it supports elicitation.
- The client sends the same call again: a new id, the answer in
inputResponses, and the ticket byte for byte. This time the balancer picks replica B. - B has never seen this deploy. It checks the ticket (next figure), reads the work back out of it, and finishes. No replica had to remember anything.
- The other ending: had anyone edited the ticket (say, staging changed to prod), its signature would no longer match and B would refuse it.
What the server checks on the ticket
The ticket comes back from the client, so the server must treat it as attacker-controlled input. Each field inside it exists to stop one specific misuse. The clock matters: a ticket is only good for a short time.
Two words first. The principal is whoever the server authenticated for this request. The spec does not define it further. On HTTP it comes from the validated bearer token (bob-token means bob). On stdio there is no token: one process serves one user, so binding the ticket to a principal is trivial there. A digest is a short fingerprint (a hash) of the original method and arguments.
Inside the ticket, against the clock
- Mint. At t = 0, replica A writes the work (env), the caller (sub), a digest of the request and an expiry into the payload, then signs it.
- Honest retry at 8 s by bob, with the answer: every check passes.
- Tampered. The client edits staging to prod. The signature check fails, so nothing else is even looked at.
- Replayed by someone else. alice gets hold of bob's ticket. The signature is fine; the principal check fails.
- Too late. bob retries after 5 minutes. The expiry check fails.
- Spent on another request. bob attaches the staging ticket to a deploy to prod. The request digest check fails.
- Sent twice. bob replays his own valid retry within the 120 s. Every check passes again: the checks stop edits and theft, not repeats. One-time use has to be tracked server-side.
Spec rules
- Servers MUST treat requestState as attacker-controlled. When it influences authorization, resource access or business logic it MUST be integrity-protected (HMAC or AEAD), and state that fails verification MUST be rejected.
- Servers SHOULD put the principal, a short expiry and an identifier of the originating request inside the protected payload, and check each one.
- These checks do not make a ticket single-use. Where one-time redemption matters, the server MUST enforce it itself.
- Clients MUST echo requestState exactly, and MUST NOT inspect, parse or modify it.
- The retry is a new request: it MUST use a new id.
input_requiredis allowed only ontools/call,prompts/getandresources/read, and only for inputs the client declared it supports.
Common misconception
“Signed means secret.” An HMAC-signed ticket is readable by anyone: base64 is not encryption. Signing stops edits. If the client must not even read the contents, use AEAD (authenticated encryption).
Old clients, same handler
A shim is a small compatibility adapter that sits between new code and an old caller. The TypeScript SDK v2 ships one, LegacyInputRequiredShim, on by default. When a 2025-era client connects, your handler still returns inputRequired(…); the shim turns each question into the old-style server-to-client request (elicitation/create, sampling/createMessage or roots/list), waits for the answer, and runs your handler again with it. So you write the tool once. It works on stdio and on sessionful HTTP. On the SDK's default stateless legacy HTTP there is no channel to ask over, so the call fails cleanly. It is an SDK feature, not part of the spec.
The SDK version
With the TypeScript SDK v2, a tool asks for input by returning inputRequired(…); on the retry, acceptedContent reads the answer.
// TypeScript SDK v2
server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => {
const schema = z.object({ confirm: z.boolean() });
const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', schema);
if (answer?.confirm !== true) {
return inputRequired({
inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: schema }) },
requestState: await codec.mint({ env }, ctx), // signed, bound to the user, expires
});
}
return { content: [{ type: 'text', text: `deployed to ${env}` }] };
});
Misuse the ticket
The setup: bob called deploy to staging and got back input_required with a ticket. You play the client, or someone who stole the ticket. Pick a move and watch which server check stops it. Only one move gets the deploy through cleanly.
Client sends
Server checks
Server answers
Run it raw
The lab has a zero-dependency server and client that speak both eras and print every line. Real output, trimmed:
$ node mcp/01-lifecycle-demo/client.mjs
=== MODERN (2026-07-28): probe -> call -> input_required -> retry ===
--> {"id":2,"method":"tools/call","params":{"name":"deploy","arguments":{"env":"prod"},"_meta":{…}}}
<-- {"id":2,"result":{"resultType":"input_required","inputRequests":{"confirm":{"method":"elicitation/create",…}},"requestState":"eyJlbnYiOiJwcm9kIiwiZXhwIjoxNzkyMTA4MTQ1NjcyfQ.jn2RabY9…"}}
--> {"id":3,"method":"tools/call","params":{…,"requestState":"eyJlbnYi…","inputResponses":{"confirm":{"action":"accept",…}}}}
<-- {"id":3,"result":{"resultType":"complete","content":[{"type":"text","text":"deployed to prod"}]}}
--> {"id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1900-01-01",…}}}
<-- {"id":4,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25"]}}}
[server] stdin closed -> exit (graceful shutdown)
Break it
Change one character of requestState before the retry and the server refuses it (-32602 bad requestState). Then remove the HMAC check in server.mjs and watch it accept anything: that is why the spec says MUST. Note the raw server signs an exp but never checks it, and binds no principal; the SDK server's codec does both. More exercises in the labs lesson.
Quick check
Why must requestState be integrity-protected?
Which request can NOT return input_required?
tools/call, prompts/get and resources/read may return it.alice replays a valid, unexpired ticket that was minted for bob. What stops her?
bob sends his valid retry twice within the expiry. With only signature, principal, expiry and digest checks, what happens?
What may a client do with requestState?
Recap
- Servers no longer send requests; they return
input_requiredwithinputRequestsand arequestStateticket. - The client asks the user (or the model, or for roots), then retries with a new id,
inputResponsesand the ticket unchanged. - Ticket checks: signature (MUST, HMAC or AEAD), then SHOULD-level principal, expiry and request digest. None makes it single-use; that needs server-side tracking.
- Because the ticket carries the work, the retry can land on any replica.