Agentic AI Foundation Logo
Migrate Sessions to Stateless Requests with MCP 2026-07-28

Migrate Sessions to Stateless Requests with MCP 2026-07-28

Vikram VaswaniJuly 29, 2026

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.

Conclusion

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:

Aspect2025-11-25 (Stateful)2026-07-28 (Stateless)
Session managementServer stores sessions in memoryNo protocol session state
Client metadataClient sends in 'initialize', then stored on serverClient sends in _meta on every request
Server lifecycleOne server bound to each transport sessionOne server instance created for each request
RoutingSticky (required only if sessions are used)Any instance
Cross-request application stateMay be tied to a protocol sessionPassed 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.


Share

Author

  • Vikram Vaswani

    Vikram Vaswani

    Vikram Vaswani is a developer advocate, technical writer, and open source consultant with more than 20 years of experience leading developer education, documentation strategy, and community engagement for AI, cloud-native, and open source technologies. He specializes in translating complex technologies into compelling developer experiences that drive adoption, influence product direction, and grow technical communities. Vikram is the author of seven technical books on open source programming, published by McGraw-Hill and Pearson and translated into multiple languages. He has written more than 550 technical articles and is a frequent speaker at leading international technology conferences. He is an Ambassador for the Agentic AI Foundation and holds an MBA from the University of Oxford.

    View All Posts
subscription section bg
Subscribe

Subscribe to the AAIF Briefing

Weekly signal on standards, governance, and the people building the future. No fluff. Just what matters.

About AAIF