teachyou.ai academy
← All posts
MCPModel Context Protocolserver architectureAI agentssession management

Stateful MCP Sessions: Managing Connection State

Pramod Dutta · Jun 25, 2026 · 13 min read

AUTHOR: Pramod Dutta

MCP sessions are the reason a Model Context Protocol server can remember which tools a client negotiated, which resources it subscribed to, and what happened three tool calls ago. Get session handling wrong and you get servers that forget context between calls, leak state across unrelated users, or crash the moment two clients connect at once. This article walks through how MCP sessions actually work, where state lives in each transport, and how to write a stateful server that survives reconnects, concurrent clients, and process restarts.

What an MCP session actually is

A session in MCP is not a database row and it is not automatically an HTTP session either. It is the lifetime of a single logical connection between one client and one server, starting at the initialize handshake and ending when either side closes the connection or the server evicts it.

The handshake looks like this on the wire:

Client -> Server: initialize
  { protocolVersion, capabilities, clientInfo }

Server -> Client: initialize response
  { protocolVersion, capabilities, serverInfo }

Client -> Server: notifications/initialized

Three things get pinned down during that handshake, and all three are session state:

  • Protocol version: client and server agree on a specific MCP protocol revision. A server that supports multiple revisions has to remember which one this session negotiated, because response shapes can differ between revisions.
  • Capabilities: the client declares whether it supports things like roots or sampling; the server declares whether it supports tools, resources, prompts, logging, and whether each of those supports change notifications. Everything downstream in the session has to respect what was negotiated here, not what the server is capable of in general.
  • Client and server info: name and version strings, mostly useful for logging and for servers that behave differently for different client implementations.

None of this is optional bookkeeping. If a server forgets which capabilities a session negotiated and sends a notifications/resources/list_changed to a client that never declared support for resource subscriptions, that is a protocol violation, not a minor bug.

The two transports, two very different state models

MCP defines transports separately from the session concept, and the two transports that matter in 2026, stdio and Streamable HTTP, handle session state in almost opposite ways.

stdio: the process is the session.

When a client launches an MCP server as a subprocess and talks to it over stdin/stdout, there is exactly one client, one server process, and one session, for the lifetime of that process. This is the simplest possible model:

import sys
import json

class StdioSession:
    def __init__(self):
        self.initialized = False
        self.client_capabilities = None
        self.subscriptions = set()

    def handle_message(self, message: dict) -> dict | None:
        method = message.get("method")
        if method == "initialize":
            self.client_capabilities = message["params"]["capabilities"]
            return self._initialize_response(message["id"])
        if method == "notifications/initialized":
            self.initialized = True
            return None
        if not self.initialized:
            raise RuntimeError("received request before initialization completed")
        return self._dispatch(message)

Because stdio is one process per session, in-memory instance state (a plain dict, a set, an object attribute) is a perfectly safe way to store session state. There is no risk of leaking one client's subscriptions into another client's response, because there is only ever one client. The tradeoff is that stdio servers do not survive a client crash or a network hop; they are meant for local, single-user tool integrations like a CLI coding agent talking to a locally installed server.

Streamable HTTP: many sessions, one process.

Streamable HTTP is where session management actually gets interesting, because a single server process now serves many concurrent clients over HTTP, and HTTP itself is stateless. MCP bridges that gap with the Mcp-Session-Id header.

The flow:

  1. Client POSTs initialize to the server's MCP endpoint with no session header.
  2. Server creates a session, generates a session ID (a cryptographically secure, globally unique string), and returns it in the Mcp-Session-Id response header.
  3. Client includes that same Mcp-Session-Id header on every subsequent request.
  4. Server looks up session state by that ID before processing each request.
import { randomUUID } from "node:crypto";

const sessions = new Map();

function createSession() {
  const sessionId = randomUUID();
  sessions.set(sessionId, {
    initialized: false,
    clientCapabilities: null,
    subscriptions: new Set(),
    lastSeen: Date.now(),
  });
  return sessionId;
}

app.post("/mcp", async (req, res) => {
  const existingId = req.headers["mcp-session-id"];
  const body = req.body;

  if (body.method === "initialize") {
    if (existingId) {
      return res.status(400).json({ error: "session already initialized" });
    }
    const sessionId = createSession();
    const session = sessions.get(sessionId);
    session.clientCapabilities = body.params.capabilities;
    session.initialized = true;
    res.setHeader("Mcp-Session-Id", sessionId);
    return res.json(buildInitializeResponse(body.id));
  }

  const session = sessions.get(existingId);
  if (!session) {
    return res.status(404).json({ error: "unknown or expired session" });
  }
  session.lastSeen = Date.now();
  return res.json(await dispatch(body, session));
});

A few details in that flow are load-bearing:

  • The session ID must be unpredictable. Treat it like a bearer token, because that is functionally what it is: anyone who has the Mcp-Session-Id can send requests as that session.
  • The server, not the client, decides whether sessions are supported at all. A stateless server can simply never emit a session ID, and clients treat its absence as "no session tracking here."
  • If a server issues a session ID, it must reject requests carrying an unknown or expired one with an HTTP 404, so the client knows to re-initialize rather than silently continuing with a broken session.
  • Streamable HTTP also allows the server to terminate a session explicitly by responding to a client's DELETE request against the same endpoint, which is how well-behaved clients release resources on clean shutdown instead of relying purely on timeouts.

What actually needs to live inside session state

It is tempting to treat "session state" as a synonym for "everything the server knows," but that produces bloated, hard-to-scale sessions. In practice, four categories belong there and everything else should not.

1. Negotiated capabilities. As covered above, this determines what messages are legal to send in each direction for the rest of the session's life.

2. Active subscriptions. If a client subscribes to a resource with resources/subscribe, the server has to remember that subscription per-session so it knows which sessions to notify with notifications/resources/updated when the underlying resource changes, and it has to clean the subscription up when the session ends.

class SessionSubscriptions:
    def __init__(self):
        self._by_session: dict[str, set[str]] = {}

    def subscribe(self, session_id: str, resource_uri: str):
        self._by_session.setdefault(session_id, set()).add(resource_uri)

    def unsubscribe(self, session_id: str, resource_uri: str):
        self._by_session.get(session_id, set()).discard(resource_uri)

    def sessions_watching(self, resource_uri: str) -> list[str]:
        return [
            sid for sid, uris in self._by_session.items()
            if resource_uri in uris
        ]

    def drop_session(self, session_id: str):
        self._by_session.pop(session_id, None)

3. In-flight request tracking, for cancellation. MCP supports notifications/cancelled for long-running tool calls. To honor a cancellation, the server needs a per-session map from request ID to whatever cancellation token or task handle it is running, so it can look up the right work item and stop it.

4. Progress tokens. When a client asks for progress updates on a long tool call by attaching a progressToken to a request, the server tracks that token for the duration of the call so it can emit notifications/progress against the right token as the operation advances.

What does not belong in session state: application data that should outlive the connection (user accounts, documents, anything you would otherwise put in a database), and anything that needs to be shared across sessions for the same logical user. Put that in your actual data layer and have the session hold a reference (a user ID, a workspace ID), not the data itself.

Concurrency: one session, multiple requests in flight

Streamable HTTP allows a client to have multiple requests outstanding against the same session at once, since HTTP requests are independent and a client is not blocked waiting for one tool call to finish before starting another. Your session state has to be safe under that concurrency, which usually means either:

  • A lock per session (fine for low-to-moderate call volume; simplest to reason about).
  • Immutable or copy-on-write structures for subscription sets, so reads never block on a slow tool call.
  • An actor-style model where each session has its own single-threaded queue and all mutations happen serially within it.

The bug to watch for is two concurrent tool calls in the same session racing to mutate a shared session dict without synchronization, which shows up as intermittent, hard-to-reproduce corruption under load rather than a clean crash.

import asyncio

class Session:
    def __init__(self, session_id: str):
        self.id = session_id
        self.subscriptions: set[str] = set()
        self._lock = asyncio.Lock()

    async def add_subscription(self, uri: str):
        async with self._lock:
            self.subscriptions.add(uri)

    async def remove_subscription(self, uri: str):
        async with self._lock:
            self.subscriptions.discard(uri)

Session lifecycle: creation, expiry, and cleanup

A production MCP server needs an explicit policy for each phase of the lifecycle, not just the happy path.

Creation. Reject a second initialize on a session that already has one (that is a client bug, and silently re-initializing hides it). Generate session IDs with a real source of randomness, not a counter or timestamp.

Idle expiry. Clients disappear without sending a clean DELETE: laptops sleep, tabs close, processes get killed. Track lastSeen per session and sweep expired sessions on a timer.

const SESSION_TTL_MS = 30 * 60 * 1000;

setInterval(() => {
  const now = Date.now();
  for (const [id, session] of sessions) {
    if (now - session.lastSeen > SESSION_TTL_MS) {
      cleanupSession(id, session);
      sessions.delete(id);
    }
  }
}, 60 * 1000);

function cleanupSession(id, session) {
  for (const uri of session.subscriptions) {
    subscriptionRegistry.unsubscribe(id, uri);
  }
  for (const controller of session.inFlightControllers.values()) {
    controller.abort();
  }
}

Explicit termination. Honor client-initiated DELETE requests against the session endpoint by running the same cleanup path immediately instead of waiting for the TTL sweep. This matters for well-behaved clients that shut down cleanly; making them wait 30 minutes for their subscriptions to be released is wasteful and, at scale, a memory leak you inflict on yourself.

Server-initiated termination. A server can also decide to end a session (a deploy, a resource limit, an auth token expiring). When it does, the next request against that session ID should get a 404, and the client's job is to detect that and re-initialize a fresh session rather than treating it as a fatal error.

Scaling stateful sessions past one process

The in-memory Map or dict examples above work until you need more than one server process, at which point a client's session lives on exactly one instance and a load balancer that routes its next request to a different instance breaks everything. Three approaches handle this, in increasing order of complexity:

  • Sticky sessions. Configure the load balancer to route by Mcp-Session-Id (or a cookie derived from it) so every request for a given session lands on the same process. Simple, no code changes, but it caps how evenly you can spread load and it turns a single-process crash into a hard session loss for everyone pinned to it.
  • Externalized session store. Move subscriptions, capability negotiation results, and lightweight state into Redis or a similar store, keyed by session ID, so any process can serve any session. This is the right default for most production deployments: it decouples session survival from any one process's uptime.
  • Fully stateless servers. Some MCP servers avoid the problem entirely by not maintaining session state at all: every tool call carries whatever context it needs as arguments, and the server treats each request independently. This only works if your tools genuinely do not need cross-call memory (subscriptions, multi-step workflows with server-held intermediate state). It is the easiest model to scale, and it is worth asking, before building elaborate session infrastructure, whether your server actually needs statefulness or whether it is state that the client could just resend.

If you externalize session state, keep the session store's data model small and serializable: session ID, negotiated capabilities, subscription set, and timestamps. Do not try to serialize live objects like open file handles or database connections into Redis; reconstruct those per-request from lightweight references instead.

Debugging session issues

A short checklist for the failure modes that show up most often in practice:

  • "Session not found" on every request after the first. Almost always a client bug: it is not echoing the Mcp-Session-Id header back on subsequent requests. Confirm with a raw HTTP trace before assuming the server is at fault.
  • State from one client showing up for another. A shared mutable object (a module-level dict, a class attribute used as if it were instance state) is being used for what should be per-session data. Search for anything declared outside the session object that gets mutated inside a request handler.
  • Subscriptions silently stop firing after a deploy. If sessions are in-memory and you deploy a new process, every session and its subscriptions vanish with the old process. Either accept that clients must re-subscribe after a deploy (and make sure they do), or move subscriptions to an external store.
  • Works with one client, breaks under load testing. Classic sign of an unsynchronized mutation of shared session state under concurrent requests. Add a lock or move to an actor model, then re-run the load test.
  • Memory grows without bound over days of uptime. Missing or broken TTL sweep. Confirm the cleanup timer is actually running and actually removing entries, not just marking them.

FAQ

Does every MCP server need to be stateful? No. If your tools are pure functions of their arguments (a calculator, a lookup against an external API that needs no prior context), a stateless server is simpler, easier to scale horizontally, and has nothing to leak between clients. Add session state only when you have a concrete need for it: subscriptions, multi-step tool workflows, or per-connection capability tracking.

Is the `Mcp-Session-Id` the same thing as a user's login session? No, and conflating the two is a common security mistake. The MCP session tracks protocol-level state for one connection. Authentication and authorization are a separate concern, typically handled via OAuth tokens or API keys passed alongside the MCP traffic. A single authenticated user can have multiple concurrent MCP sessions, and an MCP session by itself should not be treated as proof of identity.

Can a client have multiple sessions open to the same server at once? Yes. Nothing in the protocol prevents a client from opening several independent sessions, and some clients do this deliberately to isolate workflows. Your server's session store needs to handle many concurrent sessions per client without assuming a one-to-one mapping between clients and sessions.

What happens to in-progress tool calls when a session expires? That is up to the server's cleanup logic, but the correct behavior is to cancel or abort any in-flight work tied to that session rather than letting it run to completion against a session that no longer exists to receive the result. The cleanup examples above call abort() on any tracked controllers for exactly this reason.

Should session state be persisted to disk? Usually not directly. Session state is inherently ephemeral, tied to a connection that could end at any moment, so durable storage adds complexity without much benefit. If you need survivability across restarts, an external in-memory store like Redis with a TTL is a better fit than a database table, since it gives you the expiry semantics you want for free.

How do I test stateful session handling before shipping? Write tests that specifically exercise the concurrency and lifecycle edges, not just the happy path: two simultaneous requests on one session mutating shared state, a request against an expired session ID, a client that never sends DELETE and has to be swept by the TTL timer, and a subscription that must stop firing after its session ends. These are the scenarios that pass in casual manual testing and fail under real traffic.

Stateful MCP Sessions: Managing Connection State · TeachYou Academy