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_required round trip, field by field;
  • list the checks a server runs on requestState, and the attack each one stops;
  • say which methods may return input_required and 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

  1. The client calls deploy. The load balancer happens to pick replica A.
  2. A needs a confirmation first. Instead of asking mid-call, it ends the call with input_required: the question goes in inputRequests, and what A has worked out so far (deploy, staging) is sealed in a signed requestState ticket. Then A forgets the whole thing.
  3. The host shows the question to the user, who confirms. This only happens because the client said it supports elicitation.
  4. 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.
  5. 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.
  6. 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

requestState ticketreadable payload . signature env: "staging" sub: "bob"← principal req: digest(deploy, staging)← this request exp: minted + 120 s← expiry sig: HMAC(server key, payload) "prod" ← edited The retry Replica A mints the ticket at t = 0 and forgets.Nothing is sent yet. Bearer bob-token · id 3deploy {env: staging} + ticket + yes Bearer bob-token · id 3deploy + ticket edited to prod Bearer alice-token · id 9deploy {env: staging} + bob's ticket Bearer bob-token · id 3same retry, sent 5 minutes later Bearer bob-token · id 4deploy {env: prod} + staging ticket Bearer bob-token · id 5the honest retry, sent a second time Clock 0 s 120 s: expires t = 0 s, minted t = 8 s t = 12 s t = 20 s t = 300 s t = 30 s t = 40 s Server checks on the retry Signature matches (HMAC) Principal = the token's caller Not expired Same method and arguments Answer included ––––– ✓✓✓✓✓ ✗–––– ✓✗––– ✓✓✗–– ✓✓✓✗– Mint: the server writes what it needs into the payload and signs it with a key only servers hold.Every replica shares that key, so any of them can check a ticket another one issued. Accepted: deployed to staging.Right user, untouched ticket, in time, same request, with the answer. Refused: signature mismatch.The payload is readable, so it is easy to edit, but the signature was computed over the original. Refused: bound to another principal.The ticket is genuine, but it says bob, and alice's token says alice. Refused: expired.A short expiry limits how long a leaked ticket is useful. The client starts over with a fresh call. Refused: ticket belongs to a different request.The digest says deploy to staging. A confirmation for staging cannot be spent on prod. Accepted again! Signing does not make a ticket single-use.If running twice would hurt, the server must remember redeemed tickets itself (the spec says MUST).
  1. 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.
  2. Honest retry at 8 s by bob, with the answer: every check passes.
  3. Tampered. The client edits staging to prod. The signature check fails, so nothing else is even looked at.
  4. Replayed by someone else. alice gets hold of bob's ticket. The signature is fine; the principal check fails.
  5. Too late. bob retries after 5 minutes. The expiry check fails.
  6. Spent on another request. bob attaches the staging ticket to a deploy to prod. The request digest check fails.
  7. 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_required is allowed only on tools/call, prompts/get and resources/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?

    • Because it is large
    • Because it comes back from the client, so it is attacker-controlled input
    • Because the spec requires all strings to be signed
    The server trusts requestState to rebuild its work. Unsigned, a client could rewrite it (staging to prod) and the server would act on the edit.

    Which request can NOT return input_required?

    • tools/call
    • resources/read
    • tools/list
    Only tools/call, prompts/get and resources/read may return it.

    alice replays a valid, unexpired ticket that was minted for bob. What stops her?

    • Nothing: the signature is valid
    • The ticket is bound to the principal, and alice's token says she is alice
    • The server remembers that the ticket belongs to bob
    The server remembers nothing. The binding (to bob) is inside the signed ticket, and the identity on the retry comes from alice's bearer token, so the check fails. The lab's stateless demo shows exactly this.

    bob sends his valid retry twice within the expiry. With only signature, principal, expiry and digest checks, what happens?

    • The second one fails the signature check
    • Both are accepted; preventing a second use needs server-side tracking
    • The second one fails the expiry check
    Nothing in a signed ticket changes when it is used. The spec says where one-time redemption matters, the server MUST enforce it itself, for example by recording used ticket ids until they expire.

    What may a client do with requestState?

    • Decode it to show the user what is pending
    • Echo it back exactly on the retry, nothing else
    • Merge it with the previous one
    Clients MUST echo it exactly and MUST NOT inspect, parse or modify it. It is the server's private context, even when it happens to be readable.

    Recap