# One trip, two MCP clients: 2026-07-28 MCP revision

> A website starts a demo trip booking journey, a Chrome side panel finishes it, and nothing passes between them. What the 2026-07-28 stateless core makes possible, and what 'stateless' did and didn't mean before it.

*Published 2026-07-30 · 8 min read*

---

MCP just shipped its `2026-07-28` revision, and the headline change is a big one: **the protocol session is gone.** No more `initialize` / `notifications/initialized` handshake, no more `Mcp-Session-Id`. I read through the [release post](https://blog.modelcontextprotocol.io/posts/2026-07-28/) and the [changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog), and built a demo against it. Those two links cover the full list of changes better than I would, so let me walk you through the demo instead and point out the nuances along the way 🤔.

> The highlight of this release is a stateless protocol core - MCP is transforming from a bidirectional stateful protocol into a request/response stateless protocol.

<Note>
  **"But I could already do stateless MCP, right?"** Yes and no 😅 - two different things used to hide behind that one word.

  **The session ID was always optional.** A server *may* mint an `Mcp-Session-Id`, never *must*, and the TypeScript SDK has a switch for exactly that: `sessionIdGenerator: undefined`, no session IDs minted, none validated. The SDK calls it *stateless mode* - and that name is where the confusion starts, because the switch turns off the session ID, not the handshake.

  **The agreement behind it was not.** `initialize` did more than say hello: it agreed a protocol version and exchanged capabilities, and both sides were expected to respect them for the rest of the connection. That agreement is the *stateful* in the quote above. So even a "stateless" server did the full handshake first, across separate requests that could each land on a different backend. The SDK's own *"was I initialized?"* check sits inside its session validation, so switching the session off skipped that check too - which is the only reason a second request could land on an instance that never saw the first.

  So it worked, but the agreement died with the instance that made it. The next one knew nothing about the client, because capabilities were declared in an `initialize` it never saw. And it could never ask the client anything - the *bidirectional* half of the quote - because the answer to an `elicitation/create` or a `sampling/createMessage` would have landed on some other instance.

  So the honest name for what you could already do is *sessionless* - and `2026-07-28` is what makes it actually stateless. With no handshake, every request carries the protocol version and the client's capabilities in `_meta`, so a fresh instance always knows what the client can do. MRTR then gives it a way to ask a question and get the answer back without holding anything open. Per-request serving stopped being a workaround and became the design.
</Note>

## Demo - MCP Travel

Starting something on your laptop and finishing it on your phone is normal everywhere else on the web. I wanted to see what that looks like when the two ends are separate MCP clients that never talk to each other 😀.

So I started experimenting: <RepoLink href="https://github.com/Kulikowski/mcp-2026-07-28-playground">Kulikowski/mcp-2026-07-28-playground</RepoLink>, a small trip-booking demo built on the v2 TypeScript SDKs with the protocol pinned to `2026-07-28`.

<PostImage
  src="/mcp-travel-demo.png"
  alt="The demo running in three panels. Left: the MCP Travel website at step 3 of 4, reviewing the itinerary - a completed 'Add destination' MCP App card, a chat message 'The route is ready. Show me the itinerary.', and a 'View itinerary' App with a Load my itinerary button. Middle: a protocol log - tools/call going through the proxy on :8090, results answered by the trip servers on :3001 and :3002, notifications/resources/updated followed by a refreshed resources/read, and a client-side ui/notifications/tool-input notification whose JSON carries trip_id, phase 'reviewing', client_id 'website' and version 4. Right: the Chrome extension side panel showing 'Continue an unfinished trip' with Kasper's trip at 'Reviewing the itinerary', trip_o2whnfxl, 2 stops, state v4, and buttons to continue it or start a new one."
  width={3788}
  height={1807}
  quality={90}
  caption="One trip, two clients: the website (left) is reviewing the itinerary, the protocol log (middle) shows the wire calls - consecutive ones answered by different backends through the :8090 gateway - next to labelled client-side rows like the App bridge, and the side panel (right) has already spotted the unfinished trip."
/>

The setup: a website guides you through building a trip across a few server-provided [MCP Apps](https://modelcontextprotocol.io/extensions/apps/overview), and a Chrome side panel can pick up an *unfinished* trip and continue it as a completely separate client. Nothing passes between them - the panel finds the trip on the server and carries on.

You might expect a second client picking up unfinished work to need the first one's session. It never worked that way: a session belonged to one client, and a second client turning up with the same `Mcp-Session-Id` is the shape of the thing the old spec called *session hijacking*. And with the SDK's stateless switch from the opening note there was no session to pick up in the first place. Your application's own state is a different thing: it names the work, not the client, so two clients touching one piece of it is the point rather than the problem - as long as the server checks who is asking.

You could always build this yourself - nothing ever stopped a tool from taking a `trip_id` argument 😅. What changed isn't that it became possible, it's that the handle stopped being your own thing sitting *next to* the protocol: state that spans requests [**must** now be referenced by an explicit identifier](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness) the client passes on every call, and [a whole section on stateful tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#stateful-tools) shows what that looks like - the server mints the handle and gets it back as an ordinary tool argument. And the [security advice](https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices#state-handle-hijacking) is to bind that handle to the authenticated *user*, not to a client - which is what makes a second client turning up with the same trip an ordinary request.

The handshake going away helps too. `initialize` came first even when the server minted no session at all, so the side panel had to say hello before it could ask which trips were unfinished. Now the first request can be the actual question.

Picking the trip up in a second client is exactly why every trip carries a `phase` and a `version`. With no session, a second client has two questions nothing answers for it anymore: *where were we?* - that's `phase`, telling the side panel which App to mount next - and *has it changed since I last looked?* - that's `version`, a counter that goes up with every accepted change (the trip's version, not another protocol version).

<Note>
  **Stateless doesn't mean state-free.** A trip obviously has state - `trip_id`, `phase`, `version`, none of them defined by MCP. And the spec is blunt about the checking part: possession of a handle is never authentication. It even names the failure, *state handle hijacking*, right where *session hijacking* used to live.
</Note>

The demo's gateway never opens the JSON-RPC body. It routes on two new required headers from [the transport spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http), `Mcp-Method` and `Mcp-Name`, which mirror the method and the name or URI sitting in that body.

`Mcp-Name` only rides along on `tools/call`, `resources/read` and `prompts/get`, so there isn't that much to route on. The demo pins `resources/read` to a fixed backend by method, pins two tools by name, and round-robins everything else. `subscriptions/listen` is pinned by method too - a long-lived notification stream has to sit on one backend anyway.

That only works because any server that reads the body must reject a request where header and body disagree - without that rule, the headers would be an easy way to lie to your infrastructure. The headers are spec; the routing rules are just this demo showing them off. A real gateway would do more homework before trusting them: the transport spec tells intermediaries to check the protocol version first, because header-body validation is only required from `2026-07-28` on.

Caching is the next thing you can watch happen. Results like `tools/list` now carry two hints from [the caching spec](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching): `ttlMs`, how long the result stays fresh, and `cacheScope` - `public` for any shared cache, `private` for one authorization context only.

In the demo the side panel calls `tools/list` twice on startup, and the second call is served from the SDK's own cache - confirmed by watching a network counter not move. The default is not a generous one: the spec says a missing `ttlMs` counts as `0`, and the SDK treats a missing `cacheScope` as `private`, so nothing is served from cache unless the server asked for it. Both fields are required from `2026-07-28` on, so that fallback is really there for older servers. And the scope is decided per resource - the demo's trip data is `private`, while the App markup (also `resources/read`) is `public`, because it's the same HTML for everybody.

Now, interesting questions: *what if two clients change the same trip?* and *what if the same call arrives twice?* 🤔

The first one is easy to picture: the website and the side panel are both looking at the same trip, and with no session there's nothing declaring either one the owner. This is exactly what the `version` counter is for: every change to an existing trip says which version it thinks it's changing (`expected_version`), and if the trip moved on in the meantime, the write is refused with `stale_state` instead of quietly overwriting the other client's work. None of those fields are MCP, the spec leaves this entirely to you. It just matters more now that explicit handles are *the* pattern for cross-request state.

The second: what happens if `book_trip` gets called twice? You get the same confirmation code back, and nothing gets booked twice.

That's not just politeness - with the new **Multi Round-Trip Requests (MRTR)**, the same `tools/call` arriving twice is the normal case rather than an anomaly. A server can no longer hold a call open to ask the client something; it returns a result with `resultType: "input_required"` and the questions attached, and the client answers by *calling the tool again* as a new, independent request. Nothing is resumable anymore either, so a dropped stream comes back as a fresh call too. That's why, when the server asks *"this fare cannot be refunded, book it anyway?"*, the very first thing the tool does on the retry is check "did I already book this trip?" before doing anything else.

Before you ship an MRTR flow of your own, read [the MRTR spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr) closely: the optional `requestState` blob that carries server context across that round trip is attacker-controlled input, and the spec has a whole recipe for protecting it.

<Idea>
  Here's the bit worth being precise about: MCP has never had a protocol-level idempotency key or built-in request dedup - not before `2026-07-28`, not after. The old session was never tied to recognizing a repeated call either, so idempotency was always your job, and it stays your job. What the spec gives you is vocabulary to declare your answer out loud: tool `annotations` like `idempotentHint` (not new in this release either) - and annotations from untrusted servers are a claim, not a contract.
</Idea>

## A few takes

I'll drop a few personal opinions here, take them as exactly that 😅, happy to have a chat about it!

1️⃣ **The best thing about this release is how *familiar* it makes MCP look.** Protocols don't win by being clever, they win by looking *familiar* to what already exists. `ttlMs` and `cacheScope` are `Cache-Control: max-age` and `public` / `private` - the caching spec calls them *analogous* itself. Trace context rides in `_meta` as `traceparent` and `tracestate`, which is the OpenTelemetry convention rather than a new MCP one. And the new headers exist for the same reason HTTP puts the method in the request line: so the box in the middle can route without reading the body 😎.

2️⃣ **You can read the whole release as MCP announcing where it thinks it's going.** A handshake and a session were fine when MCP was a pipe between your editor and a local dev tool. A stateless core with gateway-friendly headers is built for fleets of servers behind load balancers, serving agents at scale. And look at what the same release deprecated: Roots, Sampling and Logging - local files, the client's own model, the client as your log viewer. They all still work, but new implementations are told not to add them. Specs are roadmaps if you read them that way - and this one points away from the local machine 💡.

3️⃣ **MRTR is the most agentic feature here.** A server can now ask a human a question mid-tool-call without anyone holding a connection open - the question travels as data, the answer comes back as a retry. That matters once the agent runs in the cloud instead of on your laptop: the human might answer in a minute or in an hour, and nothing has to keep a connection alive while they think. How long that hour can be (😅) is the server's call though - the spec asks for a short expiry inside `requestState`, so the wait is bounded by whatever the server signed into it.

## What's next

MCP Apps is the thing I keep coming back to in this release, and I've been writing about WebMCP for two posts straight. Server-provided UI on one side, browser-native tools on the other... you can probably guess the experiment I want to run next - MCP Apps *via* WebMCP. No idea yet if it will actually work, but that's half the fun 🤔.

More soon. ✨
