Migrating a Real MCP Server to the 2026-07-28 Spec

The 2026-07-28 revision made MCP stateless. Working through what that breaks in a server that already existed: sessions, multi round-trip requests, cacheable lists, and a governed extensions framework.
The 2026-07-28 MCP specification shipped on July 28, replacing 2025-11-25. It is the largest revision since authorization was added. MCP is now a stateless request/response protocol: the initialize handshake is gone, sessions are gone, and server-initiated requests are gone.
The changelog lists all of that. Working out which of your own design decisions were resting on the protocol takes longer.
So this walks the new spec through a server that already existed. Last month I sketched meetup-ops, a community operations server for running meetups, with five tools, one resource, and one prompt, built to prepare follow-up messages for a human to approve rather than send them itself. (That post has the full design; you do not need it to follow this one.) Three weeks later the protocol underneath it changed.
The Web Already Ran This Experiment
MCP was born local. The first spec, November 2024, assumed a desktop app spawning a server as a child process on the same machine and talking to it over stdio. In that world state is free. One client, one server, one long-lived process; the connection and the conversation are the same thing, and the server remembers you between calls because a pipe between two processes can do nothing else.
Remote MCP kept that assumption after it stopped being true. Streamable HTTP arrived in the 2025-03-26 revision and brought the Mcp-Session-Id header with it: a shared identifier that let a stateless protocol impersonate a pipe. It worked, but everything around it had to cooperate. Serving a session-holding server meant sticky routing so every request landed on the one instance that remembered the client, resumable streams so a dropped connection did not lose the conversation, and a shared session store the moment you ran a second instance.
The web ran this exact experiment and published the results. Cookies made HTTP remember; sticky sessions and session-replication clusters followed; and after years of operating those, the industry moved the state out of the connection, into databases and into signed tokens that the client carries and presents back. A server that must remember you cannot be replaced mid-conversation, so past a certain scale, nobody lets servers remember.
The 2026-07-28 revision is MCP landing on the same answer, the web's two decades compressed into twenty months. Every request carries its own context in _meta, so any replica can serve it. Cross-call state is named by handles and lives in a database. Even the new requestState field, an opaque blob the server hands the client to carry between retries, is the signed cookie reinvented: state pushed to the client, sealed so the server can trust it on return.
None of this made MCP servers stateless. My ledger is as stateful as it ever was. What the protocol dropped is the promise to carry that state on my behalf.
The full inventory is in the changelog; the short version is that everything connection-shaped went. The handshake, the session header, server-initiated requests, the GET notification stream, SSE resumability, ping. Tasks left the core for an extension, and every result now declares what kind of result it is. Each of these breaks a server written against 2025-11-25. Here is what they did to mine.
The Handshake Is Gone
Previously a connection began with initialize, the server replied with its capabilities, and both sides carried that agreement for the life of the session. Now each request is self-describing (SEP-2575). Client context travels in _meta:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "list_checked_in_attendees",
"arguments": { "eventId": "blr-2026-08" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "goose", "version": "1.9.0" },
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
Servers identify themselves in each result's _meta under io.modelcontextprotocol/serverInfo. Version mismatches return UnsupportedProtocolVersionError. Clients that want to negotiate up front call server/discover first, which every server MUST implement.
The practical consequence is deployment shape. A meetup-ops server can now sit behind an ordinary round-robin load balancer with no sticky routing and no shared session store. Two consecutive tools/call requests from the same organizer can land on different instances and neither instance needs to have seen the other's traffic.
Sessions Were Doing Work You Now Have to Name
Existing servers break here silently, because nothing in the code says the session was load-bearing.
A session was an implicit place to keep things. If prepare_delivery_outbox stashed the outbox it had just built and get_delivery_ledger read it back, the session made that work without either tool declaring a dependency. That affordance is gone. The spec's replacement is explicit (SEP-2567): servers that need cross-call state use server-minted handles passed as ordinary tool arguments.
So prepare_delivery_outbox now returns an identifier, and the tools that follow take it as input:
{
"outboxId": "obx_01J9Z4KQ8XN2",
"eventId": "blr-2026-08",
"prepared": 47,
"skipped": 3,
"exceptions": []
}
get_delivery_ledger takes outboxId. mark_delivery_sent takes outboxId and the external message identifier. Nothing depends on which instance handled the previous call.
I read this as a good change rather than a tax. The original design already required that prepare_delivery_outbox use durable state, so that running it twice could not assign two benefit codes to the same attendee, and that mark_delivery_sent accept the same message identifier repeatedly without creating duplicate state. Statelessness makes that discipline mandatory instead of optional. The state that matters (assignments, delivery status, exception records) was always supposed to be in a durable ledger. The session was never a safe place for it, only a convenient one.
Handles are also attacker-controlled input. An outboxId arrives from the client on every call, so the server must authorize it against the caller rather than trusting that possession implies permission.
Human Approval Is Now a Protocol Pattern
The change I find most interesting for community operations is Multi Round-Trip Requests (MRTR, SEP-2322).
Servers can no longer initiate requests to clients. A server that needs user input mid-call returns an interim result instead. All results now carry a resultType field; "input_required" signals that the server needs something before it can finish:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_recipients": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "47 attendees are queued. Approve sending benefit codes?",
"requestedSchema": {
"type": "object",
"properties": { "approved": { "type": "boolean" } },
"required": ["approved"]
}
}
}
},
"requestState": "AEAD-protected blob"
}
}
The client gathers the input and retries the original request with an inputResponses map keyed to the same identifiers, echoing requestState back verbatim. The retry must use a different JSON-RPC id, because these are two independent requests rather than a continuation. InputRequiredResult is permitted only on tools/call, resources/read, and prompts/get.
requestState is opaque to the client and carries whatever context the server needs to resume. The spec is blunt about the security posture: servers MUST treat it as attacker-controlled, MUST protect its integrity with HMAC or AEAD when it influences authorization or business logic, and SHOULD bind it to the authenticated principal, a short TTL, and a digest of the originating request. Integrity protection alone bounds the replay window but does not guarantee single-use. For a one-time redemption like assigning a benefit code, the server still has to enforce that invariant itself.
An approval token that can be replayed is an approval that can assign a code twice.
I deliberately left a bulk-send tool out of the first version of meetup-ops, because preparing and sending are different risk levels. MRTR does not change that judgment, but it does change what a send tool would look like: the approval gate is now expressible in the protocol, rather than improvised as a second tool call the model is merely instructed to make first.
Long Operations Moved to an Extension
Tasks left the experimental core and became io.modelcontextprotocol/tasks (SEP-2663). The redesign replaces the blocking tasks/result with polling via tasks/get, adds tasks/update for client-to-server input, and removes tasks/list.
A server returns a CreateTaskResult (resultType: "task") containing a taskId, ttlMs, and a suggested pollIntervalMs. The task must be durably created before the response is sent. Statuses are working, input_required, completed, failed, and cancelled, the last three terminal.
This overlaps with MRTR: a task that needs input moves to input_required and surfaces an inputRequests map, which the client answers via tasks/update. The rule of thumb is duration. MRTR handles a pause the client can resolve in seconds. Tasks handle work that outlives the connection, like a batch preparation across a few thousand registrations, or an approval gate waiting on a human who has stepped away from their desk.
Servers MUST NOT return a task to a client that did not declare the extension in its per-request capabilities.
Lists Are Cacheable
tools/list, prompts/list, resources/list, resources/read, and resources/templates/list now return required ttlMs and cacheScope fields (SEP-2549). ttlMs is a freshness hint in milliseconds; cacheScope is "public" or "private" and controls whether shared intermediaries may cache the response.
For meetup://events/{id}/brief the scope is "private". An event brief is internal, and a shared cache in front of the server is not somewhere it belongs. A short TTL is fine, since briefs change but not per-request.
Servers SHOULD also return tools from tools/list in a deterministic order. A stable tool list means a stable prompt prefix, which means LLM prompt cache hits, and sorting your tool registry is close to free.
Two other transport-level details. Streamable HTTP POSTs now require Mcp-Method and Mcp-Name headers, so gateways can route, meter, and rate-limit without parsing the JSON body, which means you can rate-limit prepare_delivery_outbox at the edge. And SSE resumability is gone: no Last-Event-ID, no redelivery. A broken stream loses the in-flight request and the client must re-issue it as a new request with a new id. Idempotent tools survive this. Non-idempotent ones do not.
Extensions Became a Governed System
Extensions were previously an informal convention. They now have reverse-DNS identifiers ({vendor-prefix}/{extension-name}), dedicated ext-* repositories in the MCP GitHub organization, delegated maintainers, and versioning independent of the core spec. Official extensions use io.modelcontextprotocol; a third party uses a reversed domain it owns. Extensions are disabled by default and require explicit opt-in.
Negotiation is symmetric: clients declare extensions in io.modelcontextprotocol/clientCapabilities in each request's _meta, servers advertise theirs in the server/discover response. If one side does not support an extension, the other falls back to core behavior or rejects the request if the extension is mandatory.
Official extensions today cover authorization (OAuth client credentials, Enterprise-Managed Authorization), MCP Apps for server-rendered interactive UI in sandboxed iframes, and Tasks. Experimental work incubates in experimental-ext-* repositories tied to a working group, and graduates through the SEP Extensions Track with at least one reference implementation required before review.
This is the mechanism I care most about, because it is where skills land. SEP-2640, the Skills Extension (identifier io.modelcontextprotocol/skills), is Extensions Track and still in review, from the Skills Over MCP Working Group. Its design serves agent skills as ordinary MCP Resources under skill:// URIs, with the primary content at skill://<skill-path>/SKILL.md and supporting files as siblings. The catalog surface is still being settled between resources/list enumeration, resource templates, and an index document.
Skills work by progressive disclosure: the model learns that a skill exists from a small catalog entry, and reads the full instructions only when it needs them. Skills are resources, resources/read now carries ttlMs and cacheScope, and list results no longer vary per connection, so a skill catalog is cacheable across clients rather than re-fetched per session.
The Revised meetup-ops Interface
| Primitive | Name | Does | Changed by this revision |
|---|---|---|---|
| Resource | meetup://events/{id}/brief | Event context, no attendee records | Returns ttlMs; cacheScope: "private" |
| Tool | list_checked_in_attendees | Reads approved attendees with a check-in timestamp | Nothing; still read-only |
| Tool | prepare_delivery_outbox | Assigns each new attendee one benefit code, queues unsent messages | Mints and returns outboxId; may return input_required |
| Tool | get_delivery_ledger | Returns assigned, pending, sent, exception counts | Takes outboxId as an argument |
| Tool | mark_delivery_sent | Records the external message id after a confirmed send | Takes outboxId; idempotency now load-bearing |
| Prompt | prepare_checkin_followup | Walks a user through preparing the queue | May return input_required |
Tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) are unchanged and still hints. The server still enforces its own guarantees.
What I Am Deprecating
The spec adopted a formal feature lifecycle: Active, Deprecated, Removed, with a minimum twelve-month window between deprecation and earliest removal. For features deprecated in this release, that puts the earliest possible removal at July 28, 2027.
| Deprecated | Suggested migration |
|---|---|
| Roots | Pass directories or files as tool parameters, resource URIs, or server config |
| Sampling | Integrate directly with an LLM provider API |
| Logging | stderr on stdio, or OpenTelemetry |
| HTTP+SSE transport | Streamable HTTP |
| Dynamic Client Registration | Client ID Metadata Documents (CIMD) |
Authorization also hardened: authorization servers SHOULD include the iss parameter per RFC 9207 and clients MUST validate it before redeeming the code; clients MUST key persisted credentials by issuer and re-register when the authorization server changes; and DCR now requires an appropriate application_type to avoid OpenID Connect redirect URI conflicts.
Error codes were also renumbered, which is quieter but will bite. -32000 to -32019 stays implementation-defined, -32020 to -32099 is reserved for the specification, and the codes introduced in this cycle moved accordingly: HeaderMismatch to -32020, MissingRequiredClientCapability to -32021, UnsupportedProtocolVersion to -32022. Resource-not-found also changed from -32002 to -32602 to match JSON-RPC's Invalid Params. If you assert on error codes in tests, they will fail.
A Migration Checklist
- Find every place a session was holding state. Replace it with a server-minted handle taken as a tool argument, or move it to the durable ledger where it belonged.
- Implement
server/discover. - Add
resultTypeto every result. Treat a missingresultTypefrom an older server as"complete". - Convert server-initiated
elicitation/create,sampling/createMessage, androots/listcalls to MRTR. Sign and TTL-bound yourrequestState. - Add
ttlMsandcacheScopeto all five list-and-read endpoints. Sorttools/list. - Emit
Mcp-MethodandMcp-Nameon Streamable HTTP POSTs. - Audit non-idempotent tools for stream-loss retries now that SSE resumability is gone.
- Update error code assertions.
- Declare extensions explicitly. They are off by default.
Tier 1 SDKs (TypeScript, Python, Go, and C#) supported 2026-07-28 at release; Rust is in beta. The MCP Inspector remains the fastest way to verify discovery, schemas, and error handling before wiring a server into a goose recipe.
What Still Belongs to a Human
meetup-ops started from one rule: the server prepares, a human approves, and only then does anything reach an attendee. Open interfaces do not remove accountability. This revision sharpens that point rather than softening it.
MCP now has a real place to put a human decision. input_required is a first-class result, and an approval gate is a protocol state with a signed, expiring token behind it rather than a convention improvised in a system prompt.
The protocol can guarantee that someone was asked. It cannot guarantee that the question was the right one, that the person had enough context to answer it well, or that approving forty-seven recipients in a single form meant anything more than clicking through. A signed requestState proves an approval was not replayed. It proves nothing about whether the approval was considered.
References
- MCP 2026-07-28 key changes: https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Release announcement: https://blog.modelcontextprotocol.io/posts/2026-07-28/
- Cloudflare on the stateless MCP: https://blog.cloudflare.com/mcp-v2/
- Multi Round-Trip Requests: https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
- Extensions overview: https://modelcontextprotocol.io/docs/extensions/overview
- Tasks extension: https://modelcontextprotocol.io/extensions/tasks/overview
- Feature lifecycle and deprecation policy: https://modelcontextprotocol.io/community/feature-lifecycle
- Skills Over MCP Working Group: https://modelcontextprotocol.io/community/working-groups/skills-over-mcp
- SEP-2640, Skills Extension: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640
- AAIF migration guide: https://aaif.io/blog/mcp-2026-07-28-whats-changing-and-how-to-migrate
- MCP Inspector: https://modelcontextprotocol.io/docs/tools/inspector
Related
Agents for Community Managers: Part 1July 17, 2026 · 7 min