On July 28th, the MCP maintainers shipped the 2026-07-28 revision. No more initialize. No more initialized. No more Mcp-Session-Id. David Soria Parra called it “MCP’s most important release since remote MCP first launched over a year ago,” and I couldn’t agree more. In the weeks leading up to the release, I had the opportunity to have Angie Jones on the podcast to talk through some of the changes, community, and excitement on Episode 80 of The Cloud Gambit Podcast - (have a listen!)
I maintain gridctl, an MCP gateway that sits between your LLM client and a fleet of downstream MCP servers. Sessions were not a detail in that design. They were the spine. Identity, access scoping, telemetry, and rate limiting all hung off a session created at handshake time. The spec removing sessions was a big deal that took some time to plan out.
This post is about what it actually took to support the new revision without breaking the old one, plus a second standards drop nine days later that turned out to be the easier half of the story.
What Actually Changed#
The headline is that MCP went from a bidirectional stateful protocol to plain request/response. That single change cascades:
- Sessions are gone. Every request carries its own protocol version, client capabilities, and client identity in a
_metablock. There’s no handshake to negotiate against. server/discoverreplacesinitialize. A read-only method that tells you what the server is, with no side effects.- Multi Round-Trip Requests (MRTR) replace server-initiated requests. Instead of holding an open stream so the server can ask the client something, the server returns
resultType: "input_required"and the client comes back withinputResponses. - Method and tool names travel in HTTP headers.
Mcp-MethodandMcp-Namemirror fields from the body. - List results are cacheable. Responses carry
ttlMsandcacheScope. - Roots, Sampling, Logging, and HTTP+SSE are deprecated with a 12-month support window.
That header change is the one I want to sit on for a second, because it exists specifically for people building what I’m building. If the method and tool name are in headers, a gateway can route and authorize a request without parsing the JSON body at all. That’s a real gift to intermediaries! A gift that I would love discussing over Christmas dinner, but alas, people in my family would just think I lost the rest of my marbles.
The spec is careful here: headers are a mirror, not a source of truth. Any server that actually processes the request has to validate the header against the body and reject a mismatch with error code -32020. Trusting the header blindly is a request-smuggling primitive.
You Cannot Just Flip#
Let’s talk constraints that shape things. A gateway has two sides. Upstream, your LLM client connects to it. Downstream, it connects to some number of MCP servers you don’t control. Claude Desktop might be on the new revision while three of your five downstream servers are still on 2025-11-25 and one is a hosted endpoint that will upgrade whenever its vendor feels inclined to do so.
So “support the new spec” really means “speak both revisions concurrently, per peer, in both directions.” I ended up calling them eras, and the whole trick was deciding where an era is allowed to exist:
| |
Era gets resolved at the transport boundary and never travels inward. The router doesn’t know. The access policy doesn’t know. Telemetry doesn’t know. If era had leaked into the middle of the gateway, every one of those subsystems would have grown a branch, and the branches would have drifted out of sync within a month.
Downstream negotiation is a probe. Send server/discover, see what comes back:
| |
Note the asymmetry: only positive evidence of a modern peer selects the stateless era. Auth challenges and 5xx responses reject outright rather than getting classified. A legacy server that silently swallows unknown methods must not be mistaken for a modern one, so the probe is timeout-bounded and the fallback runs inside the caller’s deadline.
The nicest case is the dual-era server. If the probe comes back with -32022 (unsupported protocol version), that error is required to carry the list of versions the server does support. So the client reads the list, finds a handshake-era version it also speaks, and silently downgrades to initialize on the same connection. Version negotiation without a handshake, mined out of an error payload. If no version is mutual, it fails loudly with the actual diagnosis instead of a generic connection error.
There’s also an escape hatch, because probes are heuristics and heuristics are wrong sometimes, no?:
| |
The Bug That Made Me Respect the Flip#
Any experience like this will present things that you didn’t see coming.
A downstream server negotiates the handshake era at startup. Its vendor redeploys it as stateless-only. My health check was a bare reachability GET, and the flipped server cheerfully answers that GET with a 200. So the server read healthy forever while every single tool call against it failed. The only recovery was restarting the gateway.
That’s one painful class of a monitoring bug. False alarms annoy you. A false all-clear, lies to you.
The fix was to stop asking “are you there” and start asking “are you still who you said you were.” The handshake-era health check now sends a protocol-level ping, and on a method-not-found answer it confirms with a read-only server/discover probe before failing health. Two probes instead of one, because lax legacy servers without ping and proxies that reject unauthenticated requests need to keep reading healthy exactly as they did before. Only positive evidence of a modern peer, or actual transport unreachability, fails the check.
Then the HTTP client had to become reconnectable, so a flipped server can converge on its own: re-resolve the generation on the live client, clear the stale session, refresh tools, re-verify pins. No restart.
Here it is happening. Seven servers behind one gateway, split across both generations. legacy-http and pinned-handshake point at the same endpoint on port 9001, so when that server redeploys as stateless-only, the two diverge on camera: the auto-negotiated one re-resolves to STATELESS and picks up the extra tool the modern mock exposes, while the pinned one stays on HANDSHAKE and keeps reading Healthy.

That last frame is the honest cost of the escape hatch. Pinning a generation disables flip detection for that server, because the pin makes adopting the other era impossible and failing health would strand it with no recovery path. So pinned-handshake sits there reading healthy against a peer that no longer speaks its protocol. Same false all-clear as the original bug, except this time it’s a documented consequence of an operator’s choice rather than a defect.
If you build anything that negotiates a protocol version once and caches it, go look at your health check right now. Ask whether it would notice the peer changing its mind. Mine wouldn’t, and it took a spec revision to clue me in.
SSE clients skip flip detection entirely, since they’re pinned to the handshake generation and re-negotiation can never move them. Turns out a chunk of dual-stack work is knowing which cases can’t happen.
The Interesting Part: Being a Middlebox#
Once both eras worked, some genuinely fun problems showed up that only exist if you’re sitting in the middle. New things are like siblings. The oldest knows everything, the youngest thinks they know everything, and then you have that messy middle.
Header mirroring has an encoding problem. Mcp-Name carries a tool name or a resource URI. HTTP header values are ASCII. Tool names and URIs are not necessarily ASCII. The spec’s answer is a base64 sentinel, and getting the validation right is the key:
| |
Pay attention to that third condition. A tool literally named =?base64?abc?= has to be rejected as unsafe and encoded, or a caller could hand-craft a name that decodes into a different name. It’s the same class of problem as escaping your escape character, a problem I’ve only encountered a million times over in my career.
Cache metadata doesn’t aggregate the way you’d guess. tools/list from a gateway is a fold over the whole fleet. If one server says its list is good for 5 minutes and another says 30 seconds, what does the gateway say? And what happens when one server is still on the old revision and has no opinion at all?
| |
Minimum across the fleet, zero if anyone is legacy, private unless everyone is public. Conservative in all three directions, because a stale tool list is an agent calling a tool that no longer exists. I should get in the studio and drop an album with that wordplay.
There’s a nice follow-on here. Gridctl serves its own skill library as MCP prompts and resources, and those were initially inheriting the fleet aggregate. That’s wrong: skills come from a local registry and their cache lifetime has nothing to do with your tool servers. One legacy server in your stack was pinning every skill list to uncacheable for no reason. They now carry their own metadata (60 seconds, private), which is a small thing that only becomes visible once you stop treating “the gateway” as one uniform surface.
MRTR breaks if you rename anything. This is my favorite problem in the whole project, and it is probably one that only exists for middleboxes. When a downstream server returns input_required, it hands back a requestState blob that is opaque and must be echoed back byte-exact on the retry. Fine. Except a gateway aggregating five servers renames every tool to server__tool, so on the retry it has no idea which of the five servers minted that blob. The handle is opaque to me by contract, and it’s also unroutable.
The answer is a self-identifying envelope stamped on the way out (don’t let the door hit you) and unwrapped on the way back:
| |
That comment cost me some real time. Go’s JSON marshaler will quietly replace invalid UTF-8 in a string with the replacement character. If a server’s requestState is a raw binary handle, relaying it as a JSON string corrupts it, and the retry fails with an error that points nowhere near the actual cause. Base64 the inner value and the round-trip is exact no matter what the origin chose. A retry carrying an envelope gridctl didn’t mint is rejected rather than guessed at.
There’s a companion problem worth naming: a compliant server won’t send an input request unless the client declared the matching capability, and from the server’s point of view the client is the gateway, whose capability set is empty. Without relaying the real upstream client’s declared capabilities downward, MRTR is silently unreachable through any gateway. Cross-era MRTR (a stateless server asking a handshake-era client for input) is a deliberate gap that reports a clear error, since a session-era client has no way to echo requestState at all.
Everything is validated against the real suite. The official @modelcontextprotocol/conformance suite runs in CI against both generations, pinned to an exact alpha rather than a floating tag, with per-generation expected-failure baselines. The baselines have anti-rot semantics in both directions: an unlisted failure is a regression, and a listed entry that passes is a stale baseline. Both exit 1. Running the suite for real surfaced five wire deviations I’d have shipped otherwise, including an empty tools/list marshaling as null instead of [], which conformance clients read as a failed call.
Nine Days Later, Packaging#
While all that was happening, a second thing landed. On August 6th, a group including Amazon, Cursor, Microsoft, OpenAI, and Vercel published Agent Plugins 1.0: an open, vendor-neutral package format for distributing Agent Skills and MCP servers across clients without reorganizing files or rewriting manifests.
It’s a small spec by design. A manifest, fixed locations for skills/ and mcp.json, validation and failure-isolation rules, portable path variables, and reverse-domain namespaces for client-specific extensions. It deliberately excludes distribution, registries, permissions, sandboxing, and trust verification, leaving those to clients. It’s independently governed rather than an AAIF project, though MCP itself moved to AAIF in December 2025.
I’d landed gridctl packs in main a few days before that spec published (the first public tag with them is today’s rc.1), and I want to be precise about the relationship, because “we conform to the new standard” is a claim people make too easily. I’m in the market of being honest, so here goes:
A gridctl pack is a git repo with a gridctl-pack.yaml at the root:
| |
The repo ships skills/, agents/, and rules/ directories. gridctl pack add <repo> clones it, runs the same security scan and --trust gate as skill add, and imports exactly the manifest’s selection. gridctl pack apply projects it onto every detected client and wires them to the gateway.
Same shape as Agent Plugins: a manifest at the root, fixed component directories, and portability as the guiding principle:
| |
Building on the layout the ecosystem already used meant the standard landed as confirmation rather than a migration. But it is not conformance. Gridctl reads gridctl-pack.yaml, not the Agent Plugins manifest, and it has no mcp.json support, because gridctl’s MCP servers come from stack.yaml where they can carry transports, secrets, scoping, and rate limits. Reading a conforming plugin directory is work I haven’t done yet. I’d rather say that plainly than claim a checkmark I haven’t earned.
Where the Design Actually Paid Off#
The part I keep coming back to is the boring one. Packs are a thin composition layer with no engine of their own:
| |
Every projection a pack applies gets tagged with the pack name in a shared lockfile. That tag is what makes pack status and cascade removal exact: pack remove deletes what the pack wrote and nothing that looks similar. A resource you authored yourself is never claimed. A resource tagged by another pack is refused rather than stolen.
That’s the same instinct as keeping era at the transport edge. When a new format shows up, you want it to be a parser plus a lookup table over machinery that already works. Two standards landed nine days apart this summer. One of them was a rewrite of the transport layer and a genuinely embarrassing health-check bug. The other cost me a struct.
Try It#
All of this is in gridctl 0.1.0-rc.1, shipped today. Dual-stack is unconditional, not behind a flag.
| |
gridctl doctor gained a per-server generation check, and gridctl status --json reports what each peer negotiated, so a mixed-era fleet is visible instead of inferred.
If you’re building anything that sits between an agent and a tool server, the 2026-07-28 revision is worth reading closely rather than skimming. The stateless model is a better protocol. It’s also a reminder that “we negotiate the version at connect time” is an assumption with a shelf life.




