Move to remote HTTP safely

Understand stateless MCP

Learn what the final 2026-07-28 protocol removed from core HTTP and what per-request context replaces it.

The 2026-07-28 protocol removed sessions from core HTTP. Every request now stands on its own. This changes how you think about a remote server, so let’s spend a lesson on it.

Older HTTP transports started with an initialize handshake and carried an Mcp-Session-Id header on every call. The server had to remember the session. In the modern era, each request carries a _meta envelope with the protocol context needed to process that one request. Nothing is remembered between calls.

That’s why createMcpHandler() builds a fresh server per request. Any healthy instance of your deployment can answer any request. No sticky sessions, no “which instance has my state” problem.

What this rules out

This code is wrong on a stateless server:

let selectedNoteId = ''

Picture the failure. One call sets selectedNoteId. The next call from the same client lands on a different instance where the variable is still empty. It’s hard to reproduce locally, because locally you have one instance. Worse, on a busy single instance the variable can hold another caller’s value.

Stateless protocol, stateful application

Don’t take this too far. Stateless protocol does not mean stateless application.

Notes can live in a database. A cache can speed up reads. A verified identity can pick a tenant. All fine. The rule is about where durable state lives: in a system designed to share it across instances and protect it, not in a module variable or the McpServer object.

Echoed state is untrusted

The modern protocol also changed multi-step interactions. If a server hands state to a client and expects it back later, that returned state is untrusted input. The client could have modified it.

So if you add a requestState field, protect its integrity with a signature, bind it to the caller and to the original operation, and give it an expiry. Treat it like a signed cookie, not like a variable you set a moment ago.

Test the assumption

Start two local HTTP instances of the server on different ports, both reading the same practice dataset. Alternate calls between them: search_notes on the first, get_note on the second, then swap. Every call should succeed. If one needs the other to have run first, you have hidden state somewhere.

Stdio is different, and that’s fine

serveStdio() has a different serving unit. It pins one factory instance to one connection for as long as the client keeps the process alive. That’s not a contradiction, just a different transport lifetime.

The lesson is the same on both sides: keep the capability logic independent of how long a server instance lives. Then stdio and HTTP stay equivalent, and you can switch between them without touching server.ts.

Lesson completed