◉CLAUDE LABJP
●2.1.309 — Sandbox and permission checks are tighter, and hooks and PowerShell on Windows are fixed (10/22)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 37 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.309 — Sandbox and permission checks are tighter, and hooks and PowerShell on Windows are fixed (10/22)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 37 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-07-26Advanced

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.

MCP59Remote MCPStatelessArchitecture3Claude Code273

✦ Premium Article

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.mjs
import 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 memory
function 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 trip
const 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:

InstancesSession-resident failuresStateless failures
10 / 200 (0.0%)0 / 200 (0.0%)
2100 / 200 (50.0%)0 / 200 (0.0%)
3133 / 200 (66.5%)0 / 200 (0.0%)
4150 / 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 pipefail
ROOT="${1:-src}"
[ -d "$ROOT" ] || { echo "no such dir: $ROOT" >&2; exit 2; }
 
SCORE=0
hit() { # 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.

or
Unlock all articles with Membership →
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.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-08-19
The listen Stream Kept Reopening Against My Serverless MCP Server
No errors anywhere, yet connections to the MCP server kept piling up overnight. Here is what happens when a fixed platform timeout meets a resubscribe loop, reproduced in a few dozen lines.
⬡ API & SDK2026-10-21
When claude -p Seems to Ignore Its Task, Check Argument Order Before Blaming the Model
A task passed to claude -p is ignored and the agent starts exploring the repo. One GitHub report says swapping -p and --append-system-prompt changed that. Here is a sentinel script that tests whether your instructions arrived, plus calling patterns that don't depend on order.
⬡ API & SDK2026-09-21
The morning a deprecation notice appeared, I checked versions before editing config
Claude Desktop and Cowork stop accepting older managed-configuration spellings on October 7. Here is how I sorted mine into what to fix now, what to fix after upgrading, and what changes meaning on its own.