{"slug": "requeststate-sealing-the-state-your-mcp-server-hands-to-the-client", "title": "requestState: sealing the state your MCP server hands to the client", "summary": "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.", "body_md": "# requestState: sealing the state your MCP server hands to the client\n\nIf 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.\n\nThis 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.\n\n## In brief\n\n1. **`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.\n2. **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.\n3. **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.\n4. **A multi-step wizard is a state machine the client carries for you,** with new state for each round.\n5. **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.\n\n## `requestState` is attacker-controlled input\n\nAs 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.\n\nTo prevent replay, put these inside the protected payload and check them on the way back:\n\n- the **authenticated principal** , rejecting state presented by someone else;\n- a **short expiry** ;\n- an **identifier of the original request** : the method name plus a digest of its key parameters.\n\nThese 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.\n\nSealing on one side of the round trip, opening on the other:\n\n## What the Python SDK seals for you\n\nMy 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:\n\n| The diagram’s step | What `MCPServer` does | \n|---|---|\n| 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 | \n| Principal | The client ID, issuer and subject of the access token, when the server authenticates requests; an unauthenticated server has no principal to bind | \n| Expiry | 600 seconds by default, starting again each time a round is sealed | \n| Method and arguments | The method, the tool or prompt name (or the resource’s Uniform Resource Identifier, URI), and a digest of the arguments | \n| 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 | \n\nEvery 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.\n\n### More than one instance\n\nBy 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:\n\n``` python\nimport os\n\nfrom mcp.server.mcpserver import MCPServer, RequestStateSecurity\n\n# Every instance reads the same secret, at least 32 bytes, from its environment.\nshared = RequestStateSecurity(keys=[os.environ[\"REQUEST_STATE_KEY\"]])\nmcp = MCPServer(\"reports\", request_state_security=shared)\n```\n\nA 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.\n\n## Multi-step wizards\n\nEach 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:\n\nIn 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:\n\n``` python\nimport json\n\nfrom mcp import MCPError\nfrom mcp.server.mcpserver import Context\nfrom mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult\nfrom mcp.types import MISSING_REQUIRED_CLIENT_CAPABILITY, InputRequiredResult\n\nQUESTIONS = {\"dataset\": \"Which dataset?\", \"range\": \"Which range?\"}  # asked in order\n\ndef ask(ctx: Context, field: str, state: dict) -> InputRequiredResult:\n    caps = ctx.client_capabilities\n    if caps is None or caps.elicitation is None:  # never ask what it can't answer\n        need = {\"requiredCapabilities\": {\"elicitation\": {}}}\n        raise MCPError(MISSING_REQUIRED_CLIENT_CAPABILITY, \"elicitation required\", need)\n    prop = {field: {\"type\": \"string\"}}\n    schema = {\"type\": \"object\", \"properties\": prop, \"required\": [field]}\n    form = ElicitRequestFormParams(message=QUESTIONS[field], requested_schema=schema)\n    return InputRequiredResult(\n        input_requests={f\"pick_{field}\": ElicitRequest(params=form)},\n        request_state=json.dumps({**state, \"asked\": field}),  # sealed by the SDK\n    )\n\n@mcp.tool()\nasync def create_report(ctx: Context) -> str | InputRequiredResult:\n    \"\"\"Create a report. Asks which dataset, then which range.\"\"\"\n    # The SDK has already verified and opened the state: the plaintext we wrote.\n    state = json.loads(ctx.request_state) if ctx.request_state else {}\n    asked = state.pop(\"asked\", None)\n    answer = (ctx.input_responses or {}).get(f\"pick_{asked}\")\n    if asked and isinstance(answer, ElicitResult):  # only the answer we asked for\n        if answer.action != \"accept\":\n            return \"Report cancelled.\"\n        state[asked] = (answer.content or {}).get(asked)\n    for field in QUESTIONS:\n        if not state.get(field):\n            return ask(ctx, field, state)  # a missing answer is simply asked again\n    # Every input is in: only now does the tool create anything.\n    return f\"Report on {state['dataset']} for {state['range']} created.\"\n```\n\nWriting it by hand leaves you three jobs that part 13’s `Resolve` form does for you:\n\n- **Matching answers to questions.** The state records what was asked, and the tool reads only that answer.\n- **Asking again.** A retry without the answer gets the question again, not an error.\n- **Checking the capability.**`ask` raises error`-32021` , a missing required client capability, when the request didn’t declare`elicitation` .\n\n### Running it, and tampering with it\n\nIn 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.\n\n``` php\nimport anyio\nfrom mcp import Client\n\nasync def fill_in(context, params) -> ElicitResult:  # stands in for the user\n    field = next(iter(params.requested_schema[\"properties\"]))\n    picks = {\"dataset\": \"sales\", \"range\": \"Q3\"}\n    return ElicitResult(action=\"accept\", content={field: picks[field]})\n\nasync def main() -> None:\n    async with Client(mcp, elicitation_callback=fill_in) as client:\n        first = await client.session.call_tool(\n            \"create_report\", {}, allow_input_required=True\n        )\n    state, i = first.request_state, len(first.request_state) // 2\n    tampered = state[:i] + (\"A\" if state[i] != \"A\" else \"B\") + state[i + 1:]\n    stranger = MCPServer(\"reports\")  # another instance, with a key of its own\n    twin = MCPServer(\"reports\", request_state_security=shared)  # same name, same key\n    for server in (stranger, twin):\n        server.add_tool(create_report)\n    answer = ElicitResult(action=\"accept\", content={\"dataset\": \"sales\"})\n    for label, server, sent in (\n        (\"tampered\", mcp, tampered),\n        (\"own key\", stranger, state),\n        (\"same key\", twin, state),\n    ):\n        async with Client(server, elicitation_callback=fill_in) as client:\n            try:  # the Client answers any further question and retries for you\n                result = await client.call_tool(\n                    \"create_report\", {},\n                    input_responses={\"pick_dataset\": answer}, request_state=sent,\n                )\n                print(label, result.content[0].text)\n            except MCPError as exc:\n                print(label, exc.error.code, exc.error.message)\n\nanyio.run(main)\ntampered -32602 Invalid or expired requestState\nown key -32602 Invalid or expired requestState\nsame key Report on sales for Q3 created.\n```\n\nThe 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.\n\n## My rule: no side effects before the last input point\n\nThe 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.\n\nIn 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.\n\n## Method and caveats\n\n- Built from chapter 13 of my guide to MCP servers, written against the 2026-07-28 specification.\n- 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.\n- 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.", "url": "https://wpnews.pro/news/requeststate-sealing-the-state-your-mcp-server-hands-to-the-client", "canonical_source": "https://pournasserian.com/writing/mcp-2026-request-state", "published_at": "2026-10-20 00:00:00+00:00", "updated_at": "2026-10-20 23:49:03.425271+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "developer-tools", "ai-tools"], "entities": ["Model Context Protocol", "MCPServer", "mcp 2.3.0", "Python SDK", "AES-256-GCM", "HMAC", "AEAD"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/requeststate-sealing-the-state-your-mcp-server-hands-to-the-client", "markdown": "https://wpnews.pro/news/requeststate-sealing-the-state-your-mcp-server-hands-to-the-client.md", "text": "https://wpnews.pro/news/requeststate-sealing-the-state-your-mcp-server-hands-to-the-client.txt", "jsonld": "https://wpnews.pro/news/requeststate-sealing-the-state-your-mcp-server-hands-to-the-client.jsonld"}}