CLAUDE LABJP
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 sessionsAPPS — The same release adds MCP Apps for server-rendered UI and a Tasks extension for long-running workMEMORY — 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 callSPILL — 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 expandBG — MCP tool calls running past two minutes move to the background automatically, keeping the session usable; tune it with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSRESUME — Typing /resume in the agent view opens a picker of past sessions and brings your pick back as a background sessionMCP — The July 28 MCP spec release candidate drops the Mcp-Session-Id header and goes stateless, so remote MCP servers no longer need sticky sessionsAPPS — The same release adds MCP Apps for server-rendered UI and a Tasks extension for long-running workMEMORY — 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 callSPILL — 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 expandBG — MCP tool calls running past two minutes move to the background automatically, keeping the session usable; tune it with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSRESUME — Typing /resume in the agent view opens a picker of past sessions and brings your pick back as a background session
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.

MCP47Remote MCPStatelessArchitecture2Claude Code203

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-07-24
Swapping Agent Config Per Session From One Shared Definition
A design pattern for running one base Managed Agent and overriding its model, prompt, tools, MCP servers, and skills per session with agent_with_overrides — with a validated factory and the operational traps I hit along the way.
API & SDK2026-07-08
Contract-Test Every Tool Before You Submit or Automate an MCP Connector
A connector that works once in a chat can still break silently in an unattended job through misread response shapes or double-fired writes. Here is a small harness that machine-checks tool descriptions, response contracts, idempotency, and latency, with measured numbers.
API & SDK2026-06-30
When a Tool Result Is Too Big and Melts Your Context Window: Designing Cursor-Based Pagination
When a list tool returns hundreds of rows at once, an agent's context can collapse in a single call. Here is a cursor-based pagination design that keeps tool output small and protects your token budget, with working code.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →