●MCP — The July 28 MCP spec release candidate drops the Mcp-Session-Id header and goes stateless, so remote MCP servers no longer need sticky sessions●APPS — The same release adds MCP Apps for server-rendered UI and a Tasks extension for long-running work●MEMORY — The Python 0.116.0, TypeScript 0.110.0, and Go 1.56.0 SDKs now send agent-memory-2026-07-22 on every memory store call●SPILL — Output from agent_toolset and MCP tools past 100K characters now spills to a file in the sandbox, with the model receiving a truncated preview it can expand●BG — MCP tool calls running past two minutes move to the background automatically, keeping the session usable; tune it with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●RESUME — Typing /resume in the agent view opens a picker of past sessions and brings your pick back as a background session●MCP — The July 28 MCP spec release candidate drops the Mcp-Session-Id header and goes stateless, so remote MCP servers no longer need sticky sessions●APPS — The same release adds MCP Apps for server-rendered UI and a Tasks extension for long-running work●MEMORY — The Python 0.116.0, TypeScript 0.110.0, and Go 1.56.0 SDKs now send agent-memory-2026-07-22 on every memory store call●SPILL — Output from agent_toolset and MCP tools past 100K characters now spills to a file in the sandbox, with the model receiving a truncated preview it can expand●BG — MCP tool calls running past two minutes move to the background automatically, keeping the session usable; tune it with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●RESUME — Typing /resume in the agent view opens a picker of past sessions and brings your pick back as a background session
Rebuilding a Remote MCP Server That Never Needed Mcp-Session-Id
The MCP spec release candidate drops the session header. Here is how I audited my own remote server for session coupling and moved it to signed cursors, with measurements.
The release candidate for the 2026-07-28 MCP specification removes the Mcp-Session-Id header. It reads like a one-line diff, and it touches the foundation of every remote MCP server.
I run a small MCP server as part of my automation work as an indie developer, a single instance, and I read the note assuming it was somebody else's problem. Then I opened the source and counted. Three maps keyed by session ID. One of them was holding a pagination offset.
That is a structure that breaks the moment a second instance appears behind a load balancer. And as long as you run one instance, every test passes.
What follows is the record of counting that coupling and moving it onto signed cursors. The spec is still a release candidate, so please confirm the details against the MCP specification before you migrate anything.
The failure that only happens halfway
Before touching the code, I wanted to see the failure shape. Here is a dependency-free Node harness: several "instances" reading the same data source, a client that round-robins across them, and two implementations of the same paginated tool.
// roundrobin-repro.mjs — run with: node roundrobin-repro.mjsimport crypto from "node:crypto";const ITEMS = Array.from({ length: 500 }, (_, i) => `item-${i}`);const PAGE = 20;// (A) Session-resident: the offset lives in this instance's memoryfunction makeStatefulInstance(name) { const sessions = new Map(); // sessionId -> offset return { name, open(sessionId) { sessions.set(sessionId, 0); }, listItems(sessionId) { if (!sessions.has(sessionId)) { const e = new Error("session not found"); e.code = 404; // this is what fills the logs throw e; } const off = sessions.get(sessionId); sessions.set(sessionId, off + PAGE); return ITEMS.slice(off, off + PAGE); }, };}// (B) Stateless: an opaque, signed cursor makes the round tripconst SECRET = crypto.randomBytes(32);const sign = (body) => crypto.createHmac("sha256", SECRET).update(body).digest("base64url");function encode(state) { const body = Buffer.from(JSON.stringify(state)).toString("base64url"); return `${body}.${sign(body)}`;}function decode(token) { const [body, mac] = String(token).split("."); if (!body || !mac) throw new Error("malformed cursor"); const a = Buffer.from(mac), b = Buffer.from(sign(body)); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad signature"); return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));}function makeStatelessInstance(name) { return { name, listItems(cursor) { const off = cursor ? decode(cursor).off : 0; const slice = ITEMS.slice(off, off + PAGE); const next = off + PAGE < ITEMS.length ? encode({ off: off + PAGE }) : null; return { items: slice, nextCursor: next }; }, };}const CALLS = 200;function runStateful(n) { const pool = Array.from({ length: n }, (_, i) => makeStatefulInstance(`i${i}`)); const sid = crypto.randomUUID(); pool[0].open(sid); // the initialize request landed on node 0 let fail = 0; for (let k = 0; k < CALLS; k++) { try { pool[k % n].listItems(sid); } catch { fail++; } } return fail;}function runStateless(n) { const pool = Array.from({ length: n }, (_, i) => makeStatelessInstance(`i${i}`)); let cursor = null, fail = 0; for (let k = 0; k < CALLS; k++) { try { const r = pool[k % n].listItems(cursor); cursor = r.nextCursor; // null means we wrap back to the start } catch { fail++; } } return fail;}for (const n of [1, 2, 3, 4]) { const s = runStateful(n), l = runStateless(n); console.log( `instances=${n} stateful_fail=${s}/${CALLS} (${(s / CALLS * 100).toFixed(1)}%)` + ` stateless_fail=${l}/${CALLS} (${(l / CALLS * 100).toFixed(1)}%)` );}
The numbers I got:
Instances
Session-resident failures
Stateless failures
1
0 / 200 (0.0%)
0 / 200 (0.0%)
2
100 / 200 (50.0%)
0 / 200 (0.0%)
3
133 / 200 (66.5%)
0 / 200 (0.0%)
4
150 / 200 (75.0%)
0 / 200 (0.0%)
The failure rate tracks (n-1)/n, which is unsurprising. The row that caught my attention was the first one.
With a single instance, nothing fails. Your dev machine and your staging environment will never show this bug as long as they run one process. It appears in production only, only after sticky sessions come off, and even then only half the time.
Seeing that table changed the order of my migration plan. Before editing any code, check how many instances you actually run.
Counting the coupling instead of reviewing for it
Manual review misses this kind of cross-cutting dependency. Chasing the identifier sessionId is not enough, because the same coupling hides behind names like transports or pagerState.
So I wrote a weighted scanner instead.
#!/usr/bin/env bash# mcp-session-audit.sh — find session coupling in a remote MCP server# usage: ./mcp-session-audit.sh <src directory>set -uo pipefailROOT="${1:-src}"[ -d "$ROOT" ] || { echo "no such dir: $ROOT" >&2; exit 2; }SCORE=0hit() { # hit <weight> <label> <regex> local w="$1" label="$2" re="$3" out n out=$(grep -rInE "$re" "$ROOT" \ --include='*.ts' --include='*.js' --include='*.mjs' --include='*.py' 2>/dev/null) n=$(printf '%s' "$out" | grep -c .) if [ "$n" -gt 0 ]; then SCORE=$((SCORE + w * n)) printf '\n[%s] %s hit(s) (weight %s)\n' "$label" "$n" "$w" printf '%s\n' "$out" | sed 's/^/ /' | head -12 fi}hit 3 "direct session header reads" 'mcp-session-id|Mcp-Session-Id|MCP_SESSION_ID'hit 3 "memory keyed by session id" 'sessionIdGenerator|(transports|sessions|sessionStore)\s*[:=]\s*(new Map|\{)'hit 2 "per-session in-memory state" '\[[a-zA-Z_]*[Ss]essionId\]|\.get\(\s*sessionId|\.set\(\s*sessionId'hit 2 "cleanup tied to session end" 'onsessionclosed|onclose|DELETE\s+/mcp|session.*(delete|close|terminate)'hit 1 "sticky infrastructure config" 'sticky|affinity|ip_hash|sessionAffinity'printf '\n=== coupling score: %s ===\n' "$SCORE"if [ "$SCORE" -eq 0 ]; then echo "OK - no session coupling detected"elif [ "$SCORE" -le 6 ]; then echo "MINOR - fix the call sites one by one"else echo "MAJOR - the state needs a new home, not a new lookup"fi
To check that it actually catches things, I ran it against a 17-line sample built from the patterns you see in most tutorials.
[direct session header reads] 1 hit(s) (weight 3) sample/src/server.ts:4: const sessionId = req.headers["mcp-session-id"] as string | undefined;[memory keyed by session id] 2 hit(s) (weight 3) sample/src/server.ts:2:const transports = new Map<string, StreamableHTTPServerTransport>(); sample/src/server.ts:8: sessionIdGenerator: () => randomUUID(),[per-session in-memory state] 3 hit(s) (weight 2) sample/src/server.ts:5: let transport = sessionId ? transports.get(sessionId) : undefined; sample/src/server.ts:15: const offset = pagerState[sessionId] ?? 0; sample/src/server.ts:16: pagerState[sessionId] = offset + 20;=== coupling score: 15 ===MAJOR - the state needs a new home, not a new lookup
Fifteen points across seventeen lines. Code that grew out of the reference sample scores high per line, so the absolute number matters less than the trend. Treat it as a tool for answering "did this reach zero after the migration?"
A note on the weights. Header reads and transport retention (weight 3) break loudly the moment the header disappears. Per-session state (weight 2) breaks quietly. Sticky infrastructure config (weight 1) does not break anything when removed. The weights track how hard the failure is to notice, not how dramatic it looks.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦An audit script that scores session coupling (a 17-line sample file scored 15)
✦A signed-cursor implementation measured at 88 bytes, 3.809us to sign and 5.122us to verify
✦Why the bug shows up at 50% with 2 instances and 75% with 4, yet never with 1
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
When the audit lights up, the first fix that comes to mind is usually the same one: move the in-memory map into Redis. I reached for that too.
This is where my expectation was exactly backwards.
// shared-store-check.mjsconst shared = new Map(); // stands in for Redisfunction makeInstance() { return { listItems(sessionId) { if (!sessionId) { const e = new Error("no session id in request"); e.code = 400; throw e; } if (!shared.has(sessionId)) shared.set(sessionId, 0); const off = shared.get(sessionId); shared.set(sessionId, off + 20); return off; }};}
A shared store solves exactly one problem: any instance can read the same state. What the spec removes is the key you use to look that state up. Here is what I measured:
Condition
Failures out of 200
Observed behaviour
Shared store, session header present
0 (0.0%)
Pagination advances correctly
Shared store, no header (post 07-28)
200 (100.0%)
No key, everything fails
Quick fix: mint a fresh UUID per request
0 (0.0%)
Always page one, store grew to 201 entries
The third row is the dangerous one. Not a single error. Your error-rate dashboard stays flat, and all 200 tool calls report success. The only difference is that every response is the same first page.
The agent keeps working, cheerfully consuming the same twenty items over and over. Meanwhile the shared store accumulated 201 entries that will never be read again, so memory grows without a ceiling.
A 500 would have been far kinder. For detecting failures that never raise, the "measure something other than error rate" approach I described in observing Claude Code with OpenTelemetry applies directly here.
Sorting state into three boxes
Once you accept that the key is gone, the question stops being "where do I move this state" and becomes "who should have owned it." I ended up with three boxes.
State you can simply drop
Transport instances, per-request log buffers, client info captured at initialize. Nothing here needs to survive a call boundary.
Most of my transports map fell into this box. Creating and discarding the transport per request also deleted the cleanup logic that hung off onsessionclosed. This is the cheapest part of the migration, and you can do it before the header goes away.
State you hand back to the client
Pagination offsets, active filters, intermediate progress in a multi-step flow. Anything that exists so the next call can resume.
None of it belongs on the server. Emit it as an opaque token in the tool result and take it back as an argument. MCP already expresses pagination as a cursor / nextCursor round trip, so this stays inside the existing convention.
State keyed by a domain identifier
Draft posts, in-progress uploads, pending approvals. Losing these when a connection drops is a real bug.
A shared store is the right answer here, but the key must not be a session ID. Rekey to something like draft:{userId}:{draftId}. Once I did that, work survived disconnects, and I got a "resume the thing that died halfway" path almost for free.
Implementing the signed cursor
Box two is where the real work is. Since the client now carries the state, design for tampering from the start.
// cursor.mjsimport crypto from "node:crypto";const CURSOR_SECRET = Buffer.from(process.env.MCP_CURSOR_SECRET ?? "", "base64");if (CURSOR_SECRET.length < 32) { // Fail at boot. Every instance must receive the identical value. throw new Error("MCP_CURSOR_SECRET must be >=32 bytes (base64)");}const CURSOR_VERSION = 1;const CURSOR_TTL_SEC = 900;export function encodeCursor(state) { const payload = { v: CURSOR_VERSION, exp: Math.floor(Date.now() / 1000) + CURSOR_TTL_SEC, ...state }; const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); const mac = crypto.createHmac("sha256", CURSOR_SECRET).update(body).digest("base64url"); return `${body}.${mac}`;}export class CursorError extends Error { constructor(reason) { super(`invalid cursor: ${reason}`); this.code = -32602; // aligns with JSON-RPC Invalid params this.reason = reason; }}export function decodeCursor(token) { if (typeof token !== "string" || token.length > 4096) throw new CursorError("malformed"); const dot = token.indexOf("."); if (dot <= 0) throw new CursorError("malformed"); const body = token.slice(0, dot), mac = token.slice(dot + 1); const expected = crypto.createHmac("sha256", CURSOR_SECRET).update(body).digest("base64url"); const a = Buffer.from(mac), b = Buffer.from(expected); // Check length first, or timingSafeEqual throws instead of returning false if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new CursorError("signature"); let payload; try { payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { throw new CursorError("payload"); } if (payload.v !== CURSOR_VERSION) throw new CursorError(`version ${payload.v}`); if (typeof payload.exp !== "number" || payload.exp < Math.floor(Date.now() / 1000)) { throw new CursorError("expired"); } return payload;}
What I verified locally:
Check
Result
Round trip carrying offset and query
Restored, token 106 bytes
Minimal token size
88 bytes
Body swapped, signature reused
Rejected as signature (code -32602)
Token with exp in the past
Rejected as expired
Boot without the secret set
Process refuses to start
Sign, mean over 200,000 runs
3.809us
Verify, mean over 200,000 runs
5.122us
Sign plus verify is roughly 9us. Against a tool call that hits an external API in, say, 20ms, that is under 0.05% of the round trip. I had been quietly worried about the cost of going stateless; the measurement was off by orders of magnitude from my worry.
A few judgement calls worth spelling out.
Failing at boot when the secret is missing is deliberately harsh. If one instance in the pool never received the key, only the calls that land on that instance return "invalid cursor" — the same probabilistic failure mode as row three of the earlier table, and the hardest kind to chase. Refusing to start surfaces it in seconds.
I set exp to 15 minutes because most agent workflows I run finish inside that window. A server handling long research tasks might reasonably stretch it. I would not recommend removing the expiry entirely.
Signing rather than encrypting is also intentional. Only put values in the cursor that you would not mind the client reading. The moment something sensitive wants to go in there, that state belongs in box three, not box two.
Exposing the cursor in the tool definition
Server-side work is only half of it. If the schema does not mention the cursor, the model has no way to know it may send one back.
server.registerTool("list_articles", { description: "Lists articles newest first. To continue, pass the nextCursor from the previous response as cursor.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Optional search term" }, cursor: { type: "string", description: "Pass the previous response's nextCursor verbatim. Do not edit its contents.", }, }, },}, async ({ query, cursor }) => { let state = { off: 0, q: query ?? "" }; if (cursor) { try { state = decodeCursor(cursor); } catch (e) { // Telling the model how to recover beats raising: it retries correctly next turn return { isError: true, content: [{ type: "text", text: `Cursor is invalid (${e.reason}). Call again without cursor to start over.` }], }; } } const page = await fetchArticles(state.q, state.off, 20); const next = page.length === 20 ? encodeCursor({ off: state.off + 20, q: state.q }) : null; return { content: [{ type: "text", text: JSON.stringify({ items: page, nextCursor: next }) }], };});
The "do not edit its contents" line exists because models do try to construct cursors by guessing at the format. Saying the token is opaque, in words, cut down those attempts noticeably.
Returning isError rather than throwing comes from the same place. An expired cursor is not an exception; it is a state you recover from by starting over. Put the recovery instruction in the message and the model finds its way back on the next turn.
The order I migrated in
I did not flip everything at once. This sequence leaves a rollback point at each step.
Add the audit script to CI and record today's score. Capture the number before you start lowering it, so later steps have something to compare against.
Clear out box one. Make transports disposable and delete the onsessionclosed cleanup. Behaviour is unchanged, so this can ship early and safely.
Add the cursor path alongside the old one. If cursor arrives, use the new logic; if not, fall back. This is your dual-support window.
Scale to two instances and run the reproduction harness against a production-like setup. Only after failures read zero should sticky sessions come off.
Confirm the audit score is zero, then delete the session-resident path and the header reads.
Step four carries the weight. If you drop the sticky setting before validating a two-instance topology, the cutover itself becomes your first real test. Doing it in advance meant that on the day, I changed exactly one line of configuration.
Removing the load balancer setting comes last on purpose. Leaving it in place while it goes unused is safer than removing it and needing it back.
Make it countable, then move it
The part that helped most was not rewriting the code. It was turning the failure into a number before touching anything.
Having "0% at one instance, 50% at two" on the table in front of me meant I never trusted a green test suite. The audit score plays the same role at the other end: it tells you the migration is finished with a number rather than a feeling.
If you do one thing, point the audit script at your own MCP server and record the score. Zero means 28 July is a non-event for you. Anything else, and you already know which box each piece of state belongs in.
The specification is still a release candidate, so please verify against the primary source before you write the migration. I hope the measurements save you an evening.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.