{"slug": "stateless-mcp-requests-meta-resulttype-and-explicit-handles", "title": "Stateless MCP requests: _meta, resultType and explicit handles", "summary": "The 2026-07-28 Model Context Protocol (MCP) revision requires every request to carry its own context in `_meta`, including the protocol version and the client's capabilities, so servers can no longer rely on a handshake, session, or connection identity, according to part 5 of a series on the specification. Under the revision, servers return only what the current request declared it can handle and answer `-32021` when an operation needs an undeclared capability, while every result names its kind in `resultType` as `complete`, `input_required`, or `task`, and clients MUST treat results from earlier-protocol servers that omit `resultType` as `complete`. State that outlives a single call lives in explicit opaque handles that creation tools return and later calls pass back, with the server checking the caller and expiry on every call.", "body_md": "# Stateless MCP requests: _meta, resultType and explicit handles\n\nIf I had to keep one rule of the 2026-07-28 revision in mind while writing a server, it would be this one: every request carries everything the server needs to process it. You can’t rely on an earlier handshake, on a session, or on knowing which connection a request came from. That rule decides how you read a request, what you send back, and where you keep anything that has to last longer than a single call.\n\nThis is part 5 of my series on what a Model Context Protocol (MCP) server does under the 2026-07-28 specification, after [server instructions in part 4](https://pournasserian.com/writing/mcp-2026-server-instructions). The facts are as I read them in October 2026.\n\n## In brief\n\n1. **Every request carries its own context in `_meta`.** The protocol version and the client’s capabilities are required on each request, so a server reads them from the request in hand.\n2. **Capabilities can change from one request to the next.** A server returns only what this request said it can handle, and answers`-32021` when the operation needs a capability the request didn’t declare.\n3. **Every result names its kind in `resultType`:**` complete` ,`input_required` or`task` . A client MUST treat a result from an earlier-protocol server that omits it as`complete` .\n4. **State across calls lives in explicit handles.** A creation tool returns an opaque id, later calls pass it back, and the server checks the caller and the expiry every time.\n\n## The request envelope\n\nEvery request MUST carry two `_meta` fields: the protocol version and the client’s capabilities. The specification’s examples often leave them out to stay short, but real traffic always carries them. Here is a `tools/call` with its envelope:\n\n```\n{\n  \"jsonrpc\": \"2.0\",\n  \"id\": 42,\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"search_docs\",\n    \"arguments\": { \"query\": \"rotate storage keys\" },\n    \"_meta\": {\n      \"io.modelcontextprotocol/protocolVersion\": \"2026-07-28\",\n      \"io.modelcontextprotocol/clientCapabilities\": {\n        \"elicitation\": { \"form\": {}, \"url\": {} },\n        \"extensions\": { \"io.modelcontextprotocol/ui\": { \"mimeTypes\": [\"text/html;profile=mcp-app\"] } }\n      },\n      \"io.modelcontextprotocol/clientInfo\": { \"name\": \"ExampleClient\", \"version\": \"1.0.0\" },\n      \"progressToken\": \"req-42\",\n      \"traceparent\": \"00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01\"\n    }\n  }\n}\n```\n\nThese are the keys you’ll meet:\n\n| `_meta` key | Direction | Status | Purpose | \n|---|---|---|---|\n| `io.modelcontextprotocol/protocolVersion` | Request | MUST | Protocol revision for this request | \n| `io.modelcontextprotocol/clientCapabilities` | Request | MUST | What the client supports for this request, extensions included | \n| `io.modelcontextprotocol/clientInfo` | Request | SHOULD | Client name and version | \n| `io.modelcontextprotocol/logLevel` | Request | Optional, part of the deprecated Logging feature | Opt in to `notifications/message` for this request only | \n| `progressToken` | Request | Optional | Ask for progress notifications | \n| `traceparent` ,`tracestate` ,`baggage` | Request | Convention, from [Specification Enhancement Proposal (SEP) 414](https://modelcontextprotocol.io/seps/414-request-meta) | World Wide Web Consortium (W3C) trace context | \n| `io.modelcontextprotocol/serverInfo` | Result | SHOULD | Server name and version | \n| `io.modelcontextprotocol/subscriptionId` | Notification | MUST on listen streams | Ties a notification to its `subscriptions/listen` request | \n\nApart from `progressToken` and the trace-context keys, `_meta` keys carry a prefix: `io.modelcontextprotocol/` for the protocol’s own, and a vendor’s own for a vendor key (such as `anthropic/alwaysLoad`), so the two don’t collide. `traceparent`, `tracestate` and `baggage` are left without one on purpose, so they stay compatible with OpenTelemetry conventions. [Part 3](https://pournasserian.com/writing/mcp-2026-server-discover) covers `clientInfo`, `serverInfo` and what happens when a request names a version the server can’t serve.\n\n### Capabilities are per request\n\nBecause capabilities arrive with each request, two requests from the same client can declare different ones: one may declare the [Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview) and the next may not. The [specification’s overview](https://modelcontextprotocol.io/specification/2026-07-28/basic/index) puts it plainly: a server MUST NOT rely on capabilities the client has not declared. So decide what to return from the request in hand:\n\n- Never return a task handle to a request that didn’t declare the Tasks extension.\n- Never put an elicitation into `inputRequests` (what an`input_required` result asks for, below) unless that request declared`elicitation` .\n- If the operation can’t be done without a capability the request didn’t declare, the server MUST return a `MissingRequiredClientCapabilityError` (`-32021` ) whose`data.requiredCapabilities` lists what is missing. On HTTP, the response status MUST be 400 Bad Request.\n\nThe specification only asks you to decide per request. My advice goes further: build one request-context object at the start of each request, holding the version, the capabilities, the client info, the caller’s identity and the trace context, and pass it explicitly to every handler. Never cache a capability decision in a static or per-connection field. Caching it per connection is a common bug when porting a 2025-era server to 2026-07-28: it works in local tests with one client and breaks in production behind a load balancer.\n\n## What comes back: `resultType`\n\nEvery result carries a required `resultType`, which tells the client what to do next:\n\n| `resultType` | Meaning | Defined by | \n|---|---|---|\n| `\"complete\"` | A normal, final result | Core | \n| `\"input_required\"` | The server needs more input; the client must retry with `inputResponses` | Core, [Multi Round-Trip Requests (MRTR)](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr) | \n| `\"task\"` | Work continues asynchronously; poll `tasks/get` | Tasks extension | \n\nClients MUST treat a result from an earlier-protocol server that omits the field as `\"complete\"`. Put together, a client branches like this:\n\n`input_required` is how a stateless server asks for more, through MRTR. `task` comes from the Tasks extension, so by the rule above only a request that declared that extension may receive one.\n\n## State without sessions: explicit handles\n\nThe protocol has no notion of a state handle. To the wire, a handle is just a string returned in one tool result and passed as an argument to the next. A server that needs state across calls (a shopping cart, an open browser context, a database transaction) returns a handle from a creation tool and accepts it on later calls. [Part 2](https://pournasserian.com/writing/mcp-2026-what-changed) explains why sessions went away; this is what takes their place.\n\nHere is the pattern with two server instances and one shared store, where each handle is kept with its owner and a time to live (TTL):\n\nAny instance can serve any request, and the only shared dependency is whatever store holds the explicit state. That fits serverless platforms, autoscaling and rolling deployments well.\n\nThe specification’s guidance on designing handles:\n\n- **Authorization.** On an authenticated server, a handle only names the state; holding it grants nothing, so check the caller’s authorization against the handle on every call. On an unauthenticated server the handle is necessarily a bearer token, so give it enough entropy (for example, a version 4 universally unique identifier, or UUIDv4) and a bounded lifetime.\n- **Opacity.** A handle that encodes internal structure invites parsing or guessing.\n- **Lifetime.** State the retention policy in the creation tool’s description, for example “baskets expire after 24 hours of inactivity”.\n- **Expiry errors.** A call with an expired or unknown handle returns a tool execution error that says so, so the model can recover by creating a new handle.\n\n### In Python\n\nHere is the handle pattern with the official Python SDK, `mcp` 2.3.0; the comments mark the two stand-ins, the store and the caller.\n\n``` python\nimport secrets\nimport time\n\nfrom mcp.server import MCPServer\nfrom mcp.server.mcpserver.exceptions import ToolError\n\nmcp = MCPServer(\"shop\")\n\n# A dict stands in for the shared store that every server instance reads.\nbaskets: dict[str, dict] = {}\nLIFETIME = 24 * 60 * 60  # seconds of inactivity before a basket expires\n\ndef current_caller() -> str:\n    # Stub: an authenticated server takes the caller from the validated access token.\n    return \"user-123\"\n\n@mcp.tool()\ndef create_basket() -> dict[str, str]:\n    \"\"\"Create a shopping basket. Baskets expire after 24 hours of inactivity.\"\"\"\n    basket_id = \"bsk_\" + secrets.token_urlsafe(16)  # opaque, random, hard to guess\n    baskets[basket_id] = {\"owner\": current_caller(), \"expires\": time.time() + LIFETIME, \"items\": []}\n    return {\"basket_id\": basket_id}\n\n@mcp.tool()\ndef add_item(basket_id: str, sku: str) -> str:\n    \"\"\"Add an item to a basket made by create_basket.\"\"\"\n    basket = baskets.get(basket_id)\n    # Someone else's basket gets the same answer as one that never existed.\n    if basket is None or basket[\"owner\"] != current_caller():\n        raise ToolError(\"Unknown basket. Call create_basket to start a new one.\")\n    if basket[\"expires\"] < time.time():\n        del baskets[basket_id]\n        raise ToolError(\"Basket expired. Call create_basket to start a new one.\")\n    basket[\"items\"].append(sku)\n    basket[\"expires\"] = time.time() + LIFETIME\n    return f\"Added {sku}. The basket holds {len(basket['items'])} item(s).\"\n```\n\nTwo choices in it are mine, not the specification’s: the expiry moves forward on every use, to match “24 hours of inactivity”, and a basket that belongs to someone else gets the same answer as one that doesn’t exist. Raising `ToolError` gives a tool execution error: the result has `isError` set, and its text is your message after a prefix the SDK adds. The model reads it and can call `create_basket` again. The SDK’s in-memory client shows a successful call and an unknown handle:\n\n``` python\nimport anyio\nfrom mcp import Client\n\nasync def main():\n    async with Client(mcp) as client:\n        created = await client.call_tool(\"create_basket\", {})\n        basket_id = created.structured_content[\"basket_id\"]\n        added = await client.call_tool(\"add_item\", {\"basket_id\": basket_id, \"sku\": \"sku-42\"})\n        print(added.content[0].text)\n        unknown = await client.call_tool(\"add_item\", {\"basket_id\": \"bsk_nope\", \"sku\": \"sku-42\"})\n        print(unknown.is_error, unknown.content[0].text)\n\nanyio.run(main)\nAdded sku-42. The basket holds 1 item(s).\nTrue Error executing tool add_item: Unknown basket. Call create_basket to start a new one.\n```\n\n## Method and caveats\n\n- Built from my guide to MCP servers in 2026 (its chapter on stateless requests, and the handle pattern from its design-patterns chapter), written against the 2026-07-28 specification as of October 2026.\n- The Python sample was run against `mcp` 2.3.0 on 8 October 2026 with the SDK’s in-memory client; the output shown is what it printed.\n- Where the specification is more precise than my notes (the `-32021` error lists the missing capabilities in`data.requiredCapabilities` , and on HTTP it comes with status 400), this article follows the specification.\n\n## Series What an MCP server actually does in 2026 Part 5 of 35\n\n1. [What an MCP server does in 2026: much more than a list of tools](https://pournasserian.com/writing/mcp-2026-what-a-server-does)\n2. [MCP goes stateless: what changed in the 2026-07-28 specification](https://pournasserian.com/writing/mcp-2026-what-changed)\n3. [server/discover: the one method every MCP server must implement](https://pournasserian.com/writing/mcp-2026-server-discover)\n4. [Server instructions: the paragraph every model reads first](https://pournasserian.com/writing/mcp-2026-server-instructions)\n5. Stateless MCP requests: _meta, resultType and explicit handles\n6. [Caching hints and pagination in MCP](https://pournasserian.com/writing/mcp-2026-caching-and-pagination)\n7. [MCP transports in 2026: stdio and Streamable HTTP](https://pournasserian.com/writing/mcp-2026-transports)", "url": "https://wpnews.pro/news/stateless-mcp-requests-meta-resulttype-and-explicit-handles", "canonical_source": "https://pournasserian.com/writing/mcp-2026-stateless-requests", "published_at": "2026-10-09 00:00:00+00:00", "updated_at": "2026-10-10 16:48:30.039711+00:00", "lang": "en", "topics": ["agent-protocols", "ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Model Context Protocol", "Specification Enhancement Proposal (SEP) 414", "OpenTelemetry", "World Wide Web Consortium (W3C)", "Anthropic"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/stateless-mcp-requests-meta-resulttype-and-explicit-handles", "markdown": "https://wpnews.pro/news/stateless-mcp-requests-meta-resulttype-and-explicit-handles.md", "text": "https://wpnews.pro/news/stateless-mcp-requests-meta-resulttype-and-explicit-handles.txt", "jsonld": "https://wpnews.pro/news/stateless-mcp-requests-meta-resulttype-and-explicit-handles.jsonld"}}