{"id":"1bd55af1-2313-46e4-b8d9-7823ad41bd29","title":"MCP's 2026-07-28 Spec: The Stateless Rewrite, and What Your Server Needs to Change","content":"# MCP's 2026-07-28 Spec: The Stateless Rewrite, and What Your Server Needs to Change\n\nMeta Description: The July 2026 MCP spec removes sessions and the initialize handshake. Verified changes, real requests against a C# server, gateway routing, and a migration checklist.\n\nThe 2026-07-28 revision of the Model Context Protocol is the biggest change to its HTTP transport since Streamable HTTP replaced HTTP+SSE, and it breaks an assumption a lot of servers quietly rely on: that a connection has a session, and that the session remembers things.\n\nThe 2026-07-28 revision removes protocol-level sessions and the `initialize` handshake. Every request now stands alone and carries everything the server needs. That is a genuine simplification for anyone running MCP behind a load balancer, and a migration task for anyone who stored state against `Mcp-Session-Id`.\n\nThis post goes through what changed using the specification's own changelog, then checks the important parts against a running server: the official C# SDK, a small stateless server, and real requests and responses. The responses below are captured output, reformatted only for readability.\n\n## What Changed\n\nThe spec's changelog lists nine major changes. The ones that affect most servers:\n\n1. **No sessions.** The `Mcp-Session-Id` header is gone from Streamable HTTP, and list endpoints (`tools/list`, `resources/list`, `prompts/list`) no longer vary per connection. Servers that need state across calls use \"explicit, server-minted handles passed as ordinary tool arguments.\"\n2. **No handshake.** The `initialize` and `notifications/initialized` exchange is removed. Every request carries its protocol version and client capabilities in `_meta`.\n3. **`server/discover`.** Servers must implement this RPC to advertise supported versions, capabilities and identity. Clients may call it first, but do not have to.\n4. **`subscriptions/listen`.** One long-lived POST-response stream replaces the standalone GET endpoint and `resources/subscribe`. Clients opt in to the notification types they want.\n5. **Multi Round-Trip Requests.** Servers no longer send their own requests to the client (elicitation, sampling, roots). They return an `InputRequiredResult`, and the client retries the original request with the answers attached.\n6. **Tasks are an extension.** Long-running tasks move out of the core into the official `io.modelcontextprotocol/tasks` extension, with polling through `tasks/get` and a new `tasks/update`.\n7. **No stream resumption.** `Last-Event-ID` and SSE event IDs are gone. A broken response stream loses the in-flight request, and the client must re-issue it with a new request ID.\n\nThe smaller changes matter in production too. List results now carry required `ttlMs` and `cacheScope` fields so clients can cache them. Servers should return `tools/list` in a deterministic order, which the spec says helps client caching and improves LLM prompt cache hit rates. And error codes were reorganised: `-32020` to `-32099` are now reserved for the specification.\n\n### What Is Deprecated\n\nDeprecated features keep working for a minimum of twelve months under the new lifecycle policy, but new code should not adopt them:\n\n- **Roots, Sampling and Logging.** The suggested replacements are passing directories or files as tool parameters or resource URIs, calling your LLM provider's API directly, and logging to stderr or using OpenTelemetry.\n- **The old HTTP+SSE transport.** Migrate to Streamable HTTP.\n- **Dynamic Client Registration (RFC 7591)** as a registration mechanism, in favour of Client ID Metadata Documents. It remains available for authorization servers that do not support the newer approach.\n\n## On the Wire: Real Requests Against a Real Server\n\nReading a changelog is not the same as seeing the bytes. I built a minimal stateless server with the official C# SDK (`ModelContextProtocol.AspNetCore`, which resolved to version 2.2.0), ran it locally and sent it requests in the new format.\n\nFirst, `server/discover` with the required per-request metadata and headers:\n\n```bash\ncurl -s http://localhost:5299/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'MCP-Protocol-Version: 2026-07-28' \\\n  -H 'Mcp-Method: server/discover' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{\"_meta\":{\n        \"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\n        \"io.modelcontextprotocol/clientInfo\":{\"name\":\"curl-check\",\"version\":\"1.0.0\"},\n        \"io.modelcontextprotocol/clientCapabilities\":{}}}}'\n```\n\nThe server answered:\n\n```json\n{\n  \"result\": {\n    \"supportedVersions\": [\"2026-07-28\"],\n    \"capabilities\": { \"logging\": {}, \"tools\": {} },\n    \"ttlMs\": 0,\n    \"cacheScope\": \"private\",\n    \"resultType\": \"complete\",\n    \"_meta\": { \"io.modelcontextprotocol/serverInfo\": { \"name\": \"mcpcart\", \"version\": \"1.0.0.0\" } }\n  },\n  \"id\": 1,\n  \"jsonrpc\": \"2.0\"\n}\n```\n\nYou can see several spec changes in one response: `supportedVersions`, the required `resultType`, the cacheability fields, and the server identifying itself in `_meta`. There was no handshake before it and there is no session after it.\n\nNow a `tools/call` with the routing headers. `Mcp-Method` mirrors the JSON-RPC `method`, and `Mcp-Name` mirrors `params.name` (or `params.uri` for resources):\n\n```bash\ncurl -s http://localhost:5299/mcp \\\n  -H 'Content-Type: application/json' \\\n  -H 'Accept: application/json, text/event-stream' \\\n  -H 'MCP-Protocol-Version: 2026-07-28' \\\n  -H 'Mcp-Method: tools/call' \\\n  -H 'Mcp-Name: create_cart' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\n        \"name\":\"create_cart\",\"arguments\":{},\n        \"_meta\":{ \"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\n                  \"io.modelcontextprotocol/clientInfo\":{\"name\":\"curl-check\",\"version\":\"1.0.0\"},\n                  \"io.modelcontextprotocol/clientCapabilities\":{} }}}'\n```\n\nIt returned HTTP 200 with the tool result. Then the same call with a deliberately wrong header, `Mcp-Name: add_item` while the body still says `create_cart`:\n\n```json\n{\n  \"error\": {\n    \"code\": -32020,\n    \"message\": \"Header mismatch: Mcp-Name header value 'add_item' does not match body value 'create_cart'.\"\n  },\n  \"id\": 3,\n  \"jsonrpc\": \"2.0\"\n}\n```\n\nHTTP status 400. This is the spec's server validation rule working as written: a server that reads the body must reject a request whose headers disagree with it. The reason is security, not tidiness. If a gateway routes or rate-limits on the header while the server executes based on the body, a client could send a cheap header with an expensive body. Validating that they match closes that gap.\n\nOne more observation. The same server also answered an old-style `tools/list` with no `_meta` and no headers, the way the previous revision's clients send it. In stateless mode this SDK version appears to serve both eras on one endpoint. I only tested this one server, so verify your own SDK version before you assume the same.\n\n## Replacing Sessions With Handles\n\nIf your server never stored anything against a session, most of this is free. If it did, the spec's answer is the handle pattern: a tool creates state and returns an identifier, and later calls pass it back as an ordinary argument.\n\nHere is a small cart server that works statelessly. I compiled it against the SDK and ran the calls in the previous section against it.\n\n```csharp\nusing System.ComponentModel;\nusing System.Security.Cryptography;\nusing Microsoft.Extensions.Caching.Memory;\nusing ModelContextProtocol.AspNetCore;\nusing ModelContextProtocol.Server;\n\nvar builder = WebApplication.CreateBuilder(args);\nbuilder.Services.AddMemoryCache();\nbuilder.Services.AddMcpServer()\n    .WithHttpTransport(o => o.SessionMode = HttpServerSessionMode.Stateless)\n    .WithTools<CartTools>();\n\nvar app = builder.Build();\napp.MapMcp(\"/mcp\");\napp.Run();\n\n[McpServerToolType]\npublic sealed class CartTools(IMemoryCache cache)\n{\n    private static readonly TimeSpan Lifetime = TimeSpan.FromHours(1);\n\n    [McpServerTool(Name = \"create_cart\")]\n    [Description(\"Create an empty cart and return its cartId. Pass that cartId to every other cart tool.\")]\n    public string CreateCart()\n    {\n        var cartId = Convert.ToHexString(RandomNumberGenerator.GetBytes(16));\n        cache.Set(cartId, new List<string>(), Lifetime);\n        return cartId;\n    }\n\n    [McpServerTool(Name = \"add_item\")]\n    [Description(\"Add one SKU to the cart identified by cartId. Returns the cart contents.\")]\n    public string AddItem(\n        [Description(\"The cartId returned by create_cart.\")] string cartId,\n        [Description(\"The SKU to add.\")] string sku)\n    {\n        if (!cache.TryGetValue(cartId, out List<string>? items) || items is null)\n            return \"Unknown or expired cartId. Call create_cart to start a new cart.\";\n\n        lock (items) { items.Add(sku); }\n        cache.Set(cartId, items, Lifetime);\n        return $\"Cart {cartId} now holds: {string.Join(\", \", items)}\";\n    }\n}\n```\n\nI ran `create_cart`, then called `add_item` with the returned handle in a separate request with no session, and it worked. A bogus handle returned the corrective message instead of throwing, which matters because the consumer is a model that can act on a sentence.\n\nThree things before you copy this into production:\n\n- **Use a shared store.** `IMemoryCache` works on one instance. Behind a load balancer, which is the whole point of going stateless, use `IDistributedCache` or a database, or every second request lands on a node that has never heard of the cart.\n- **Bind the handle to the caller.** A random handle is unguessable, but if a handle leaks it should not be usable by someone else. Store the authenticated principal alongside it and check it on every call.\n- **Treat the handle as a capability with a lifetime.** Expire it, and return a clear message when it has expired so the model knows to start again.\n\n## Gateways Get Simpler, With One Caveat\n\nThe header requirement exists so intermediaries can route and meter without parsing JSON. An nginx rule that throttles one expensive tool looks like this. I have not run this configuration, so treat it as a starting point and test it in your own environment.\n\n```nginx\n# Only trust the mirrored headers for revisions that require header/body validation.\nmap $http_mcp_protocol_version $mcp_trusted {\n    default      0;\n    \"2026-07-28\" 1;\n}\n\nmap \"$mcp_trusted:$http_mcp_method:$http_mcp_name\" $heavy_key {\n    default                              \"\";\n    \"1:tools/call:export_report\"         $binary_remote_addr;\n}\n\n# An empty key means \"do not count this request\".\nlimit_req_zone $heavy_key zone=mcp_heavy:10m rate=5r/m;\n\nserver {\n    location /mcp {\n        limit_req zone=mcp_heavy burst=2 nodelay;\n        proxy_pass http://mcp_backend;\n        proxy_buffering off;   # SSE responses must not be buffered\n    }\n}\n```\n\nThe caveat is in the spec, and I have paraphrased it in the first map: intermediaries that enforce policy on the mirrored headers should check that `MCP-Protocol-Version` indicates a version requiring header and body validation, and reject the request if it is older or absent, rather than trusting header values nobody validated. A legacy client can send any header it likes. Also note the spec recommends servers send `X-Accel-Buffering: no` on SSE responses so proxies do not buffer the stream.\n\nFor tools you want to meter by argument, the spec adds `x-mcp-header`, which lets a server mirror a designated tool parameter into an `Mcp-Param-{Name}` header. Only primitive parameters qualify, and values that are not plain ASCII are Base64-encoded with a documented sentinel format.\n\n## Authorization Changes\n\nThe authorization changes are modest but worth reading if you run your own authorization server:\n\n- Clients must validate the `iss` parameter in authorization responses against the recorded issuer before redeeming the code (RFC 9207).\n- Client credentials are bound to the authorization server that issued them. Clients must key stored credentials by issuer, must not reuse them elsewhere, and must re-register when the authorization server changes.\n- Clients must specify an appropriate `application_type` during Dynamic Client Registration.\n- Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents.\n\n## What About Copilot Studio?\n\nIf you connect MCP servers to Copilot Studio, note what Microsoft's documentation says and does not say. The current page states Copilot Studio supports the Streamable transport, and that SSE has not been supported since August 2025. It does not say which specification revision Copilot Studio speaks. So do not remove support for the previous revision from a server that Copilot Studio agents call until you have tested a real agent against it. The spec's own backward compatibility guidance helps here: a server that supports only the new revision should ignore an `Mcp-Session-Id` header and return 405 for GET or DELETE from old clients, and a server can serve both eras.\n\nIf you read my earlier post on wiring a .NET MCP server to an order system, the `tools/list` curl in it uses the previous revision's request shape. It still worked against this SDK version, and the stateless mode it uses is the direction the spec moved.\n\n## Migration Checklist\n\n- Upgrade the SDK and read its release notes. The official SDKs (TypeScript, Python, Go, C# and a beta Rust one, according to the MCP blog) have been updated, but check your version\n- Search for `Mcp-Session-Id`, `initialize` and any per-connection caches. Each is state you must move into a handle or a shared store\n- Replace Roots, Sampling and Logging use with tool parameters, direct provider calls and OpenTelemetry. You have at least twelve months\n- Move server-initiated requests (elicitation) to the multi round-trip pattern\n- Return `tools/list` in a deterministic order, and set sensible `ttlMs` and `cacheScope`\n- Add gateway rules on `Mcp-Method` and `Mcp-Name`, gated on the protocol-version header\n- If you use Dynamic Client Registration, plan the move to Client ID Metadata Documents\n- Test with the real clients that matter to you before you retire anything\n\nThe direction is clear. MCP is being reshaped from a protocol that assumed a long-lived local process into one that looks like the rest of your HTTP infrastructure: stateless, cacheable, routable, and honest about which requests it rejects. The migration cost is real for stateful servers, and small for everyone else.\n\n## Sources\n\n- [Key changes, MCP specification 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/changelog)\n- [Streamable HTTP transport, MCP specification 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)\n- [The 2026-07-28 Specification, Model Context Protocol Blog](https://blog.modelcontextprotocol.io/posts/2026-07-28/)\n- [Connect your agent to an existing MCP server](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent), Microsoft Learn\n- [Model Context Protocol C# SDK](https://github.com/modelcontextprotocol/csharp-sdk)\n","excerpt":"The July 2026 MCP spec removes sessions and the initialize handshake. Verified changes, real requests against a C# server, gateway routing, and a migration checklist.","slug":"mcps-2026-07-28-spec-the-stateless-rewrite-and-what-your-server-needs-to-change","authorId":"1","author":{"id":"1","username":"ajith","email":"contact@ajithjoseph.com","name":"Ajith joseph","bio":"Full-stack developer passionate about React, .net core and AI","avatarUrl":"images/users/ajith.jpg","createdAt":"2025-03-02T00:00:00"},"createdAt":"2026-09-29T17:53:09.437","updatedAt":"2026-09-29T17:53:09.437","likesCount":0,"commentsCount":0,"featured":true,"tags":[{"id":4,"name":"dotnet"},{"id":8,"name":"ai"},{"id":87,"name":"agents"},{"id":155,"name":"architecture"},{"id":158,"name":"mcp"}],"readingTimeMinutes":10,"difficultyLevel":"intermediate"}