Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Serverless sessions

Audience:engineerarchitectsecurityMCP spec 2026-07-28

Plain-language explanation

TL;DR: this page used to be a catalogue of workarounds for running a stateful protocol on stateless infrastructure. The 2026-07-28 revision made the catalogue the spec’s own model. Protocol sessions are gone (SEP-2567): there is no Mcp-Session-Id header, no initialize handshake, and no connection that means anything beyond the one request it carries. A server that needs state across calls mints an explicit, opaque handle and takes it back as an ordinary tool argument. That is exactly the shape a Vercel Function always forced, because each request is an invocation that may land on a fresh instance, and Fluid compute reuses instances as a cost and latency optimization, never as a promise. Cross-call state still has exactly three places it can live: nowhere (the server is stateless), inside the data you hand the client (handles it presents back), or an external store (Redis) behind those handles. What changed is that the first two stopped being coping strategies and became the protocol: the platform did not bend to the protocol, the protocol met the platform.

Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

The analogy: a support line where a different agent may answer every call. The old protocol let the company pretend otherwise by issuing a conversation number the switchboard tracked; the 2026-07-28 revision dropped the pretense. Either every call is self-contained, or you carry your case number and any agent can pull up the file it names. What was never guaranteed (getting the same agent twice) is now not even modeled, which kills the bug class where code accidentally depended on it.

Formal protocol perspective

The 2026-07-28 model, in five rules:

  • No sessions. The Mcp-Session-Id header is removed from Streamable HTTP (SEP-2567). Servers do not mint ids, and a modern server ignores one sent by a legacy client. List endpoints (tools/list, prompts/list, resources/list) no longer vary per connection.
  • No handshake. The initialize/notifications/initialized exchange is removed (SEP-2575). Every request carries its protocol version and client capabilities in _meta (io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientCapabilities), so any instance can serve any request cold, first contact included. A version mismatch is a per-request UnsupportedProtocolVersionError, not a broken connection.
  • Cross-call state is explicit. Servers that need it use server-minted handles passed as ordinary tool arguments. The handle is data in the message body, visible in the schema, not ambient correlation in a transport header.
  • Nothing is resumable. SSE event ids and Last-Event-ID are gone. A broken response stream loses the in-flight request, and the client re-issues it as a new request with a new request id.
  • One stream is deliberately long-lived. subscriptions/listen carries opted-in change notifications on its own response stream; after a break the client reopens it, and nothing missed in between is replayed.

Under 2025-11-25 this page argued that the spec’s escape hatch (“the server MAY terminate the session at any time” plus the mandatory client-side re-initialize) already licensed serverless deployment. The revision went further: it deleted the thing the escape hatch was escaping from. There is no session to terminate, no 404-and-reinitialize loop, and no lifecycle state machine to keep consistent across instances; see Transports for the wire mechanics and The 2026-07-28 stateless revision for the full change set.

The one thing that was never outsourceable and still is not: authentication is per request. There was never a session to authenticate with, and now there is not even one to be tempted by. Every inbound request is verified on its own. More below under Security implications.

Request / lifecycle flow

One workflow, two invocations, two different instances, held together by a handle and the store it names:

Job storeInstance BInstance AClientJob storeInstance BInstance AClientinstance suspended or reclaimedPOST tools/call submit_jobwrite job state under handle abcresult carrying handle abcPOST tools/call get_job_result with handle abcread job state for handle abcjob result
Job storeInstance BInstance AClientJob storeInstance BInstance AClientinstance suspended or reclaimedPOST tools/call submit_jobwrite job state under handle abcresult carrying handle abcPOST tools/call get_job_result with handle abcread job state for handle abcjob result
Mermaid sequence diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
sequenceDiagram
    participant C as Client
    participant A as Instance A
    participant B as Instance B
    participant S as Job store
    C->>A: POST tools/call submit_job
    A->>S: write job state under handle abc
    A-->>C: result carrying handle abc
    Note over A: instance suspended or reclaimed
    C->>B: POST tools/call get_job_result with handle abc
    B->>S: read job state for handle abc
    B-->>C: job result

Delete the store participant and the diagram still works two ways: if no tool ever returns a handle there is nothing to look up (the stateless case), and if the state rides inside the handle (signed and encoded), Instance B already has everything it needs. The failure mode is the fourth, undrawn version: Instance A keeps the job in a module-scope Map, Instance B has never heard of abc, and the client gets an “unknown handle” error (or worse, a wrong answer) that no local test ever reproduced. The protocol no longer has a state home that even resembles instance memory; if you end up there, you chose it by accident.

Where cross-call state can live

Nowhere: the stateless server

Most tool servers need nothing between calls. Discovery lists are recomputed on every invocation from code, each tools/call is self-contained, and nothing needs to survive because nothing hangs off any prior request. This was always the right default posture on Vercel; under 2026-07-28 it is also simply what the protocol assumes. There is no handshake to fake, no lifecycle phase to track, and list results are required not to vary per connection, which is only trivially true when they are computed from code. If your configureServer closes only over configuration and per-request inputs, you are already the spec’s baseline.

Inside the data: handles

The revision’s answer for everything else: opaque values (job ids, cursors, pagination tokens) that you mint, hand out in results, and require back in later arguments. The changelog’s own words are “explicit, server-minted handles passed as ordinary tool arguments” (SEP-2567). The client becomes the courier of its own context, any instance can serve the follow-up, and no session affinity exists to miss. This is the backbone of the async-jobs pattern: a tools/call that would outlive the invocation returns a handle immediately, and later calls present the handle to poll progress or fetch results.

Promotion into the spec did not relax the discipline; it raised the stakes, because handles are now the primary state mechanism rather than one workaround among several. Handles cross a trust boundary twice, so mint and verify them accordingly: generate them from a CSPRNG (or sign them) so they are unguessable, validate them on the way back in like any other untrusted input, and never encode authority or secrets in one. A handle should be a claim check, not a capability: possessing it identifies a job, and the server still checks that the authenticated principal owns that job before answering.

An external store: Redis

The store’s job description shrank. It is no longer where “the session” lives, because there is no session; it is where the state behind your handles lives when that state is too big or too mutable to ride inside the handle itself: job records for the async-jobs pattern, expensive computed context you refuse to redo, the change detection feeding a subscriptions/listen stream. On Vercel that means a Marketplace Redis (or Postgres) reachable from every instance; Deployment covers when you genuinely need one.

Two disciplines make this workable. First, key by handle and owning principal, and give every key a TTL: expiry is your retention policy, and an expired handle is a clean tool-level failure (“unknown handle”, fail closed), not a protocol event. Second, notice what left the list: the 2025-11-25 reasons to run Redis included resumable SSE event logs and per-session subscription registries, and both evaporated with resumability and sessions themselves. Nothing remains on the legacy side either: mcp-handler 2.x removed HTTP+SSE and its Redis dependency, and its 2025-era fallback is stateless Streamable HTTP that issues no session id, so serving old clients adds no store (Transports has the details).

Execution limits and instance lifetime

Instance reuse is an optimization, not a guarantee

Fluid compute (the default for new Vercel projects since April 2025) keeps instances warm, routes multiple concurrent invocations into the same instance on the Node.js runtime, and prefers reusing an idle instance over cold-starting a new one. Consequences worth internalizing:

  • Module scope survives sometimes. Anything at module top level (a Map, a pool, a cache) persists across the invocations an instance happens to serve, and vanishes when the instance does. Treat module scope as a cache with zero durability, never as a source of truth. The test: your server must return correct answers with instance reuse disabled entirely. The protocol now agrees: no message in 2026-07-28 implies memory of a previous message unless your own tool contract says so.
  • Concurrency shares that scope. With in-function concurrency, two requests (potentially two different users) run in the same process at the same time. Request-scoped data in module scope is not just a staleness bug; it is a cross-principal leak.
  • Errors are isolated, not free. An uncaught exception or unhandled rejection is logged and lets in-flight requests finish before the process stops; it does not take down concurrent requests, but it does cost you the instance and its warm state.

maxDuration and long tool calls

An invocation has a hard ceiling. With Fluid compute the default maxDuration is 300 seconds on every plan; the maximum is 300s on Hobby and 800s on Pro and Enterprise, with an extended 1800s maximum in beta for supported Node.js, Bun, and Python runtime versions (configured per function, not as a project default; Secure Compute deployments stay capped at 800s during the beta).

A tools/call cannot outlive its invocation, and neither can the SSE stream that answers it, so maxDuration is the forcing function for tool design: any operation that can approach the ceiling must become an async job (return a handle now, do the work behind a Queue or Workflow, let the client poll), because a timeout at 800 seconds gives the client no result, no error semantics, and no idempotency story. The revision endorses the shape: return-a-handle-and-poll is how its own tasks extension works, and unsolicited handles from any tool are legitimate. Vercel’s Queues (public beta) and Workflows (generally available) are the platform-native homes for that work; the pattern page maps them. Do not spend your budget another way either: waitUntil and Next.js after() schedule work past the response within the same invocation and the same timeout. They are for logging and cleanup, not for pretending you have a background daemon.

Cold starts

The first request to a fresh instance pays module initialization, and under 2026-07-28 that is the whole bill: there is no handshake to run before useful work, because the first tools/call carries its own protocol version and capabilities in _meta. Vercel reduces the cost with bytecode caching (Node.js 20+, production deployments only) and pre-warming, but your side of the bargain is to keep module scope cheap: import heavy SDKs lazily inside the handlers that need them, and defer client construction until first use. A cold start is also a state wipe, which is now protocol-invisible (nothing was supposed to be in memory anyway), but measure cold-start latency separately from warm latency or your metrics will average the truth away. Per the SDK status note above, wire captures against the released SDK still show a legacy initialize round before the first tool call, so today the cold path still includes it.

Connection pools and attachDatabasePool

Fluid suspends idle instances rather than killing them, and a suspended instance’s open sockets die silently: the next invocation to reuse it inherits a pool full of dead connections and fails on first query. @vercel/functions ships attachDatabasePool for exactly this; call it right after creating the pool and it releases idle clients before the function suspends. It supports pg, MySQL2, MariaDB, MongoDB, Redis (ioredis), and Cassandra pools. The idiom, in the module scope you are treating as a cache:

import { Pool } from "pg";
import { attachDatabasePool } from "@vercel/functions";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
attachDatabasePool(pool);

This applies to your job store too: the Redis that backs your handles has its own connections to keep honest.

Streams and keepalive

An SSE stream is an HTTP response, so it is bounded by the invocation that produces it: no stream outlives maxDuration. Intermediaries add a second bound: HTTP/1.1 proxies and load balancers commonly drop connections that go idle, usually without telling either end. The 2026-07-28 answer is blunt where the old one was clever: there is no resumption. A request’s response stream that breaks takes the in-flight request with it, and the client re-issues the request under a new id; since well-designed tools are idempotent or handle-based, the retry is cheap. The one intentionally long-lived stream, subscriptions/listen, is where keepalive effort belongs: the server sends X-Accel-Buffering: no when opening it and emits periodic SSE comment lines through quiet stretches, and when it still dies (at maxDuration, if nothing else) the client reopens it and re-runs the list calls it cares about, because missed notifications are not replayed. Plan the listen stream as a series of bounded invocations, not a permanent connection, and remember that detecting changes to notify about is itself cross-instance state: the instance holding the stream open is not necessarily the instance whose tool call changed the data, so change signals flow through the store. Bound the stream count, too: mcp-handler 2.1.1 forwards a maxSubscriptions option (SDK default 1024) to the handler, and a stateless tool server that emits no change notifications should set it to 0, which rejects subscriptions/listen without opening an SSE stream at all; a server that does notify should size it to the concurrency one invocation can honestly hold open for its maxDuration, not to the default.

Common misconceptions

  • Misconception: Fluid compute reuses instances, so my server is effectively stateful. Reality: reuse is best-effort. It will hold your in-memory state exactly long enough to pass review and demo, then drop it under a deploy, a scale-out, or an idle reclaim. Correctness may not depend on reuse; only latency may.
  • Misconception: the spec removed sessions, so servers cannot have state anymore. Reality: it removed ambient state. Explicit state is fully supported and finally first-class: mint a handle, store what it names, demand it back. What died is the idea that the transport remembers anything for you.
  • Misconception: handles are just session ids with a new name. Reality: a session id was transport-level correlation that arrived in a header, applied to everything, and tempted implementers into treating it as identity. A handle is application data: it appears in your tool schema, names one piece of state, gets validated like any argument, and carries no authority. The narrowing is the security model.
  • Misconception: maxDuration bounds the conversation. Reality: it bounds one invocation. A conversation spans arbitrarily many invocations over hours; only an individual request, and the SSE stream answering it, lives inside the limit.
  • Misconception: an MCP server on Vercel needs Redis. Reality: only real cross-invocation state needs a store: job records behind handles, or change detection for subscriptions/listen. Serving 2025-era clients adds nothing, because the handler’s fallback is stateless and HTTP+SSE is gone from mcp-handler 2.x. A stateless tool server deploys with no infrastructure beyond the function, and that is now the protocol’s default posture, not a lucky special case.
  • Misconception: waitUntil or after() gives me background processing. Reality: they extend work within the current invocation and its timeout. Durable background work belongs to Queues, Workflows, or Cron, reached through the async-jobs pattern.

Debugging notes

  • Symptom: flows work in next dev and break deployed. Likely cause: next dev is one long-lived process, so instance-memory state accidentally works locally. Where to look: module scope for a Map of jobs, cursors, or anything keyed by caller; decide which of the three homes the state actually belongs in.
  • Symptom: intermittent “unknown handle” errors that no one can reproduce. Likely cause: handle state pinned to the instance that minted it, or a store TTL shorter than real workflows. Where to look: whether every instance can resolve a handle it did not mint; TTLs versus observed time between submit and get_result.
  • Symptom: requests fail with 400 and a HeaderMismatch or unsupported-version error body, or a client keeps trying to initialize. Likely cause: an era mismatch between client and server revisions; per the wire-status note above, an SDK Client left at its default negotiation still opens with the legacy handshake, which the handler answers on its stateless fallback. Where to look: the MCP-Protocol-Version header and _meta of the failing request, and Transports for the fallback rules each side is expected to follow.
  • Symptom: one user sees another user’s data, rarely. Likely cause: request-scoped data cached in module scope colliding under in-function concurrency. Where to look: every module-level variable written during a request; this is a security incident, not a quirk. See Session handling.
  • Symptom: first call after a quiet period fails with database connection errors. Likely cause: pooled sockets died during instance suspension. Where to look: whether attachDatabasePool wraps every pool, including the Redis client.
  • Symptom: a long tool call dies near 300 or 800 seconds with no MCP error. Likely cause: maxDuration ended the invocation mid-call. Where to look: function duration in runtime logs; restructure the tool as an async job.
  • Symptom: clients stop hearing list-changed notifications after a while. Likely cause: the subscriptions/listen stream ended at maxDuration or an intermediary timeout, and nothing reopened it, or the change happened on an instance that had no way to signal the one holding the stream. Where to look: listen-stream lifetimes in logs against maxDuration; whether change events flow through the store rather than instance memory.

Security implications

  • There is no session to hijack, and none to lean on. The 2025-11-25 guidance said sessions MUST NOT be used for authentication; 2026-07-28 removed the temptation along with the sessions. Every request authenticates independently: on this stack withMcpAuth verifies the bearer token on every invocation, which the serverless shape makes natural, since no instance can trust its memory anyway. See Authorization and Authentication.
  • Handles inherit the threat model sessions left behind. They are now the protocol’s primary cross-call mechanism, so the discipline is load-bearing: CSPRNG-random or signed so they are unguessable, validated strictly on the way in, owner-checked against the authenticated principal on every use, and expired via TTL so a leaked handle ages out. A handle that is the authorization is an insecure direct object reference with extra steps. See Session handling and Input validation.
  • Handles travel in message bodies, and sometimes into headers. Unlike the old session id they are not automatically on every request, but they do appear in results, arguments, and logs, and a tool parameter annotated with x-mcp-header gets mirrored into an Mcp-Param-* header visible to every intermediary on the path. Never mirror a handle or any sensitive argument. See Transports.
  • A shared store widens the blast radius. Instance memory dies with the instance; Redis remembers. Key records by <user_id>:<handle> so a guessed handle cannot cross accounts, scope the store’s credential to this project, encrypt in transit, set TTLs, and keep secrets out of job records. See Trust boundaries.
  • Shared instances change your logging posture. With concurrent requests in one process, ambient logging context (a module-level “current user”) will interleave principals in your audit trail. Carry request context explicitly. See Monitoring & audit.

Runnable example

  • examples/minimal-server (in the repository) is the stateless baseline in the flesh: configureServer closes over nothing mutable, and app/api/mcp/route.ts wires it through createMcpHandler, so any instance can serve any request. Deploy it, call the echo tool twice from MCP Inspector, and note that nothing about correctness depended on which instance answered; then notice the tests exercise the same configureServer over an in-memory transport with no HTTP at all, which is only possible because there is no hidden instance state to fake.
  • examples/async-jobs-server (in the repository) is the handle case: a job tool returns an opaque CSPRNG handle immediately, progress and results are fetched by presenting it back, and unknown handles fail closed. Watch the negative tests especially; they are the claim-check discipline from this page, asserted.

Bibliography