If you wrote an MCP server a few months ago and it’s suddenly looking a little old-fashioned…welcome to the club!

The latest MCP specification update (2026-07-28) includes a number of changes: a new extensions framework, OAuth/OpenID authorization updates, a formal deprecation policy, and many more. But perhaps the biggest change is the removal of connection-level sessions and initialization handshakes. Basically, sessions are out, stateless requests are in.

Under the previous approach (2025-11-25), an initialization handshake was mandatory, but assigning a session ID and persisting per-client state was an implementation choice, not a requirement of the protocol itself. With the acceptance of SEP-2567 (explicit state handles) and SEP-2575 (stateless MCP), the protocol drops session support entirely. Now, each request is self-contained and context must be passed explicitly in request metadata and tool call parameters.

Why does this matter? Under the older model, for servers that chose a stateful pattern, the server had to “remember” which client it was talking to. That meant creating sessions, storing transports in memory, cleaning them up, and ensuring that load balancers routed subsequent requests back to the same server instance. However, with MCP 2026-07-28, session support is no longer available in any form.

For circa-2025 MCP servers built around session IDs, this change requires maintainers to restructure their MCP state management implementation. To see how this works in practice, I pulled out one of my early MCP server attempts - a stateful one - and tried to migrate it to the new stateless model.

Before

Here’s the relevant extract of code I started with, corresponding to MCP 2025-11-25. The complete source code is available for viewing, I updated it slightly to work with the current stable release of the MCP TypeScript SDK (v1.29):

typescript
async function main() {
  const port = parseInt(process.env.PORT || '3000', 10);


  const app = express();


  app.use(express.json());


  const transports: Record<string, StreamableHTTPServerTransport> = {};


  app.all('/mcp', async (req: Request, res: Response) => {
    try {
      const sessionId = req.headers['mcp-session-id'] as string | undefined;


      let transport: StreamableHTTPServerTransport | undefined;


      // check for existing session
      // or create new one
      if (sessionId && transports[sessionId]) {
        transport = transports[sessionId];
      }
      else if (isInitializeRequest(req.body)) {
        transport = new StreamableHTTPServerTransport({
          sessionIdGenerator: () => randomUUID(),
          onsessioninitialized: (newSessionId) => {
            transports[newSessionId] = transport!;
          },
        });


        transport.onclose = () => {
          if (transport?.sessionId) {
            delete transports[transport.sessionId];
          }
        };


        // bind a fresh server to this transport session
        const server = createMcpServer();
        await server.connect(transport);
      }


      if (!transport) {
        // return 400
        // ...
      }


      await transport.handleRequest(req, res, req.body);
    } catch (error) {
      console.error('[mcp-error]', error);
      if (!res.headersSent) {
        // return 500
        // ...
      }
    }
  });


  app.listen(port, '0.0.0.0', () => {
    console.error(`[server] MCP Server listening at http://0.0.0.0:${port}/mcp`);
  });
}

In the session-based model, every client first calls initialize. The server creates a transport, assigns it a session ID, stores it in memory, and returns that session ID to the client. Every subsequent request must include the same Mcp-Session-Id header so the server can recover the correct transport and continue processing requests.

For the client, using this server is a three-step dance.

  1. Initialize.

    bash
    curl -i -X POST http://localhost:3000/mcp \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -d '{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
          "protocolVersion": "2025-11-25",
          "capabilities": {},
          "clientInfo": { "name": "curl-test", "version": "1.0.0" }
        }
      }'
  2. Extract the session ID from the response:

    bash
    HTTP/1.1 200 OK
    mcp-session-id: 0e015839-a6a1-4857-b3e8-62e757307e43
    
    
    event: message
    data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"users-mcp","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
  3. Attach the session ID to subsequent tool requests:

    bash
    curl -X POST http://localhost:3000/mcp \
      -H "Content-Type: application/json" \
      -H "Accept: application/json, text/event-stream" \
      -H "Mcp-Session-Id: 0e015839-a6a1-4857-b3e8-62e757307e43" \
      -d '{
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
          "name": "list-users",
          "arguments": {}
        }
      }'

Because every client is tied to a server-managed session, requests must continue reaching the same instance. In practice, that usually means sticky sessions or a shared session store such as Redis.

After

Under MCP 2026-07-28, the server no longer “remembers” clients between requests. Instead, every request contains the information required to process it. This implies two changes:

  1. Client metadata (like protocol version and client capabilities,) moves from initialize to request metadata (_meta).

  2. Any application state that needs to survive across requests must be represented explicitly - for example, as opaque handles returned by one tool call and supplied to the next.

The updated server code uses the current beta release of the MCP TypeScript SDK (v2.0) and now becomes significantly simpler:

typescript
async function main() {
  const port = parseInt(process.env.PORT || '3000', 10);


  const app = express();


  const mcpHandler = createMcpHandler(createMcpServer, {
    // change to 'reject' to block legacy clients
    legacy: 'stateless',
  });
  const nodeHandler = toNodeHandler(mcpHandler);


  app.all('/mcp', async (req: Request, res: Response) => {
    try {
      await nodeHandler(req, res);
    } catch (error) {
      console.error('[mcp-error]', error);
      if (!res.headersSent) {
        // return 500
        // ...
      }
    }
  });


  app.listen(port, '0.0.0.0', () => {
    console.error(`[server] MCP Server listening at http://0.0.0.0:${port}/mcp`);
  });
}

This version makes a few changes:

  • It replaces the @modelcontextprotocol/sdk package with the newer @modelcontextprotocol/server package.
  • It uses createMcpHandler() and toNodeHandler() instead of constructing a StreamableHTTPServerTransport manually.
  • The transport registry, session lookup, UUID generation and cleanup callbacks disappear completely.
  • A fresh McpServer instance is created for each request, allowing the handler to remain independent of previous requests.
  • Protocol negotiation and legacy request support are handled automatically by the SDK.

For the client, context must be passed in every request via _meta. Here’s an example:

bash
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Mcp-Method: tools/call" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Name: list-users" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "_meta": {
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientInfo": {
          "name": "curl-test",
          "version": "1.0.0"
        },
        "io.modelcontextprotocol/clientCapabilities": {}
      },
      "name": "list-users",
      "arguments": {}
    }
  }'

Client metadata and protocol information are now transferred with each request in _meta, rather than during initialization. API keys or OAuth tokens were already supplied on every HTTP request and remain in the authorization headers, not _meta. Resource identifiers continue to live in request parameters or tool arguments.

This makes things much simpler, as each request is now self-contained. Also, because the protocol layer no longer maintains state, ordinary HTTP load balancing works normally. For example:

yaml
apiVersion: v1
kind: Service
metadata:
  name: mcp-server
spec:
  selector:
    app: mcp-server
  ports:
    - port: 80
      targetPort: 3000
  type: LoadBalancer
  # no session affinity required
  sessionAffinity: None

Remember that statelessness doesn’t imply idempotency: tool calls can still have side effects. If your server needs to maintain continuity to avoid these effects, you can use durable shared state (a database, a cache) or explicit handles passed between calls.

Compatibility

You might be wondering: if this is actually stateless, why is there still an Mcp-Session-Id in the list of allowed headers?

The reason is compatibility. The new stateless approach in MCP 2026-07-28 is not a breaking change; existing deployments keep working and handlers created with the new SDKs can serve both 2025-era and 2026-era deployments. This is achieved through the additional legacy parameter to createMcpHandler().

  • When legacy is set to stateless, the handler accepts both 2025-era and 2026-era requests. Modern 2026 clients use the new stateless protocol directly. However, for legacy 2025 clients, the handler serves requests through a stateless fallback layer; it does not preserve the original initialize/session flow.

  • When legacy is set to reject, the handler only accepts connections from clients using the 2026 protocol. Clients attempting to connect with the older protocol will see this message:

bash
HTTP/1.1 400 Bad Request


{"jsonrpc":"2.0","error":{"code":-32022,"message":"Unsupported protocol version: 2025-11-25","data":{"supported":["2026-07-28"],"requested":"2025-11-25"}},"id":1}

If you actually need to keep serving stateful legacy clients, the new handler won’t work for that. In this case, you will need two handlers running in parallel: your original stateful handler for legacy requests (which requires the Mcp-Session-Id header), and a new strict handler with legacy set to reject for modern clients only. Read more about compatibility.

Summary

If you’re looking to adopt the new stateless approach in MCP, here’s a quick summary of the main changes to be aware of:

Aspect 2025-11-25 (Stateful) 2026-07-28 (Stateless)
Session management Server stores sessions in memory No protocol session state
Client metadata Client sends in initialize, then stored on server Client sends in _meta on every request
Server lifecycle One server bound to each transport session One server instance created for each request
Routing Sticky (required only if sessions are used) Any instance
Cross-request application state May be tied to a protocol session Passed explicitly or resolved through durable storage or opaque handles

If you’d like to see the differences in approach for yourself, the GitHub repository has the same server implemented in two branches: mcp-2025-11-25 and mcp-2026-07-28 branches. Switch between them or use this diff to see the change in session-management code, and why the stateless implementation is simpler to deploy and scale.