requestState: sealing the state your MCP server hands to the client Under the Model Context Protocol's 2026-07-28 specification, servers must treat the `requestState` string returned by clients as attacker-controlled input and protect its integrity with HMAC or AEAD when it influences authorization, resource access or business logic, according to a guide published in October 2026. The official Python SDK, `mcp` 2.3.0, seals and verifies `requestState` by default for servers built on `MCPServer`, using AES-256-GCM, binding the client ID, issuer and subject of the access token, a 600-second expiry that restarts each round, and a digest of the method and arguments. The guide recommends binding the authenticated principal, a short expiry and an identifier of the original request to bound replay, enforcing single-use values server-side, and avoiding side effects before the final input point. requestState: sealing the state your MCP server hands to the client If your server has to ask for more mid-call, it has to remember where it was. It hands that memory to the client: a string, requestState , that comes back with the retry and can hold the step the request has reached and the answers so far. What I’d want you to remember is where it waits between rounds: in the client’s hands. That’s why the specification treats it as attacker-controlled input. This is part 14 of my series on what a Model Context Protocol MCP server does under the 2026-07-28 specification, after multi round-trip requests in part 13 https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests . The facts are as I read them in October 2026. In brief 1. requestState comes back from the client, so treat it as hostile. If it influences authorization, resource access or business logic, the server MUST protect its integrity. 2. Bind three things inside the seal: the authenticated principal, a short expiry and the original request. That bounds replay but doesn’t make the state single-use. 3. In Python you don’t write the seal. In the official Python software development kit SDK , MCPServer seals and checks requestState by default; give every instance the same key. 4. A multi-step wizard is a state machine the client carries for you, with new state for each round. 5. My advice: no side effects before the last input point. The client may never come back, so write, send or charge only on the final round. requestState is attacker-controlled input As part 13 showed, a client MUST echo requestState exactly and MUST NOT inspect or modify it. A hostile client can still change it, replay it or hand it to someone else. So the specification’s Multi Round-Trip Requests page https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr says that if the state influences authorization, resource access or business logic, the server MUST protect its integrity, for example with a Hash-based Message Authentication Code HMAC or Authenticated Encryption with Associated Data AEAD , and MUST reject state that fails verification. To prevent replay, put these inside the protected payload and check them on the way back: - the authenticated principal , rejecting state presented by someone else; - a short expiry ; - an identifier of the original request : the method name plus a digest of its key parameters. These bound replay but don’t make the state single-use. If a value may be consumed at most once, such as a one-time redemption, enforce that on the server. Sealing on one side of the round trip, opening on the other: What the Python SDK seals for you My guide sketches this in C with a hand-written codec that seals on the way out and opens on the way back. In the official Python SDK, mcp 2.3.0, a server built on MCPServer needs no codec: MCPServer installs a middleware https://py.sdk.modelcontextprotocol.io/handlers/multi-round-trip/ that does both jobs. It opens and checks the state before your handler runs, so ctx.request state is the plaintext you wrote, and it seals the state your handler returns. Here is how that maps onto the diagram: | The diagram’s step | What MCPServer does | |---|---| | Encrypt and authenticate | AES-256-GCM the Advanced Encryption Standard in Galois/Counter Mode , an AEAD cipher, so the client can neither read the state nor change it unnoticed | | Principal | The client ID, issuer and subject of the access token, when the server authenticates requests; an unauthenticated server has no principal to bind | | Expiry | 600 seconds by default, starting again each time a round is sealed | | Method and arguments | The method, the tool or prompt name or the resource’s Uniform Resource Identifier, URI , and a digest of the arguments | | Beyond the diagram | The server’s name, as the state’s audience; and with the SDK’s Resolve form, a digest of the exact question asked | Every failure gets one answer, a JSON-RPC 2.0 error with code -32602 and the message Invalid or expired requestState ; the reason goes only to the server log. More than one instance By default each MCPServer generates its own key when you create it and keeps it in memory, which suits stdio or a single worker. On Streamable HTTP, every request is its own POST part 7 https://pournasserian.com/writing/mcp-2026-transports , so the retry may reach another instance, or a restarted one, and either rejects the state. My notes say any instance can serve the retry; in Python that holds once they share a key: python import os from mcp.server.mcpserver import MCPServer, RequestStateSecurity Every instance reads the same secret, at least 32 bytes, from its environment. shared = RequestStateSecurity keys= os.environ "REQUEST STATE KEY" mcp = MCPServer "reports", request state security=shared A key shorter than 32 bytes is refused. The first key seals and every key opens, so you can rotate: deploy old, new , then new, old , then new after one expiry period, each phase fully rolled out first. Give every instance the same server name and every other service a different one: the name is the state’s audience, so another service sharing the key can’t use your state. MCPServer refuses shared keys on a server with no name, unless you set an audience= instead. Multi-step wizards Each round can return a new requestState holding the next step, so a multi-step interaction becomes a server-sealed state machine that the client carries for you. A three-round wizard that creates a report: In the SDK you can write this by hand: return an InputRequiredResult with your question and your state, then, on the retry, read the answers from ctx.input responses and the state from ctx.request state . This tool goes on the server above: python import json from mcp import MCPError from mcp.server.mcpserver import Context from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult from mcp.types import MISSING REQUIRED CLIENT CAPABILITY, InputRequiredResult QUESTIONS = {"dataset": "Which dataset?", "range": "Which range?"} asked in order def ask ctx: Context, field: str, state: dict - InputRequiredResult: caps = ctx.client capabilities if caps is None or caps.elicitation is None: never ask what it can't answer need = {"requiredCapabilities": {"elicitation": {}}} raise MCPError MISSING REQUIRED CLIENT CAPABILITY, "elicitation required", need prop = {field: {"type": "string"}} schema = {"type": "object", "properties": prop, "required": field } form = ElicitRequestFormParams message=QUESTIONS field , requested schema=schema return InputRequiredResult input requests={f"pick {field}": ElicitRequest params=form }, request state=json.dumps { state, "asked": field} , sealed by the SDK @mcp.tool async def create report ctx: Context - str | InputRequiredResult: """Create a report. Asks which dataset, then which range.""" The SDK has already verified and opened the state: the plaintext we wrote. state = json.loads ctx.request state if ctx.request state else {} asked = state.pop "asked", None answer = ctx.input responses or {} .get f"pick {asked}" if asked and isinstance answer, ElicitResult : only the answer we asked for if answer.action = "accept": return "Report cancelled." state asked = answer.content or {} .get asked for field in QUESTIONS: if not state.get field : return ask ctx, field, state a missing answer is simply asked again Every input is in: only now does the tool create anything. return f"Report on {state 'dataset' } for {state 'range' } created." Writing it by hand leaves you three jobs that part 13’s Resolve form does for you: - Matching answers to questions. The state records what was asked, and the tool reads only that answer. - Asking again. A retry without the answer gets the question again, not an error. - Checking the capability. ask raises error -32021 , a missing required client capability, when the request didn’t declare elicitation . Running it, and tampering with it In the same file, with REQUEST STATE KEY set I used secrets.token hex 32 , the in-memory client takes the state of round one and sends the first retry three ways: with one character of the state changed, to an instance with its own key, and to one with the shared key. php import anyio from mcp import Client async def fill in context, params - ElicitResult: stands in for the user field = next iter params.requested schema "properties" picks = {"dataset": "sales", "range": "Q3"} return ElicitResult action="accept", content={field: picks field } async def main - None: async with Client mcp, elicitation callback=fill in as client: first = await client.session.call tool "create report", {}, allow input required=True state, i = first.request state, len first.request state // 2 tampered = state :i + "A" if state i = "A" else "B" + state i + 1: stranger = MCPServer "reports" another instance, with a key of its own twin = MCPServer "reports", request state security=shared same name, same key for server in stranger, twin : server.add tool create report answer = ElicitResult action="accept", content={"dataset": "sales"} for label, server, sent in "tampered", mcp, tampered , "own key", stranger, state , "same key", twin, state , : async with Client server, elicitation callback=fill in as client: try: the Client answers any further question and retries for you result = await client.call tool "create report", {}, input responses={"pick dataset": answer}, request state=sent, print label, result.content 0 .text except MCPError as exc: print label, exc.error.code, exc.error.message anyio.run main tampered -32602 Invalid or expired requestState own key -32602 Invalid or expired requestState same key Report on sales for Q3 created. The first two get the same error, and the server log says why: seal , then unknown key . The third finishes the report on an instance that never saw round one. My rule: no side effects before the last input point The specification stops at sealing; my advice goes one step further. The client may never come back, so everything that runs before you return input required must be safe to repeat, or safe never to finish. Validate, look things up and plan freely; write, send, deploy and charge only on the final round. That’s why the wizard creates nothing until both answers are in. In my run, sending the last retry again created the report again, so if the last step must happen at most once, record it on the server. Method and caveats - Built from chapter 13 of my guide to MCP servers, written against the 2026-07-28 specification. - My guide’s C codec becomes the Python SDK’s built-in sealing in mcp 2.3.0, as the package and its documentation show it. Where my notes say any instance can serve the retry, this article follows the SDK, which needs a shared key. - The Python sample ran against mcp 2.3.0 on 9 October 2026 and printed the output shown; the log reasons and the repeated last retry come from the same run.