CLAUDE LABJP
MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attributionMCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
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.

MCP51Remote MCPStatelessArchitecture3Claude Code225

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-08-15
The Connection That Dies Mid-Thought Is Being Killed by Your Relay, Not the Upstream
Put a proxy or Worker in front of Claude Code and long thinking pauses start dying. The cause is neither the model nor the upstream — it is your relay holding the byte stream. Six relay behaviors compared side by side, plus how to verify yours before it ships.
API & SDK2026-08-01
Swapping Tools Mid-Conversation Without Losing Your Prompt Cache
Tools can now be added and removed between turns, but the tool block sits at the very front of the cache prefix. I measured 12 mutation patterns with fingerprints and built a stable-core plus volatile-tail registry with a guard that refuses unsafe changes.
📚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 →