#Remote servers over Streamable HTTP
AddedA remote MCP server is an MCP server that runs as an ordinary web service instead of as a child process of the host. The host's MCP client reaches it at a URL such as https://mcp.example.com/mcp, and every JSON-RPC message travels as an HTTP POST to that one endpoint, answered with JSON or with a Server-Sent Events stream. Transports: stdio and Streamable HTTP in the fundamentals chapter shows those bytes; this topic is about hosting such a server: putting it behind Express, deciding whether it keeps per-client state, and what that decision does to scaling. Writing the server itself (McpServer, tools with zod schemas) is covered in A first server with the TypeScript SDK; the same server object works over either transport.
Why go remote at all? Because a stdio server is a copy per user: every developer installs it, configures its credentials and updates it. A remote server is one deployment that many hosts share. That's how SaaS vendors ship MCP (GitHub, ticket trackers, docs sites), and how a platform team would ship "the device fleet as MCP tools" to every support engineer. The cost is that it now has all the problems of a web service: authentication, multi-tenancy, load balancing, timeouts and an attack surface on the network.
The mental model for the key design choice is a call centre. A stateful server is a call centre where each caller gets a personal agent who remembers the conversation: fast and convenient, but if your next call is routed to a different agent, they've never heard of you. A stateless server is a call centre where any agent can take any call because the caller (or a shared case file) carries everything needed: a little more work per call, but you can add agents freely. In MCP terms, the agent's memory is the session (Mcp-Session-Id), and the shared case file is a database the server consults using an id the client passes back.
Here is a stateful remote server the way SDK 1.32.2's own example (simpleStreamableHttp.ts) structures it: Express 5 via the SDK's createMcpExpressApp, one StreamableHTTPServerTransport and one McpServer per session, kept in a map keyed by session id. Two SDK clients connect over real HTTP on localhost, and a logging fetch prints every exchange.
// A remote MCP server hosted with Express 5, stateful (2025-11-25 sessions), used by two SDK clients over HTTP.
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
// One McpServer per session, so each session gets its own shortlist.
function buildServer() {
const server = new McpServer({ name: "rooms", version: "1.0.0" });
const shortlist = [];
server.registerTool("pick_room", { description: "Add a room to this session's shortlist", inputSchema: { roomId: z.string() } },
async ({ roomId }) => {
shortlist.push(roomId);
return { content: [{ type: "text", text: `shortlist: ${shortlist.join(", ")}` }] };
});
return server;
}
const sessions = new Map(); // Mcp-Session-Id -> transport
const app = createMcpExpressApp(); // binds the Host check to localhost names and parses JSON bodies
const existing = (req, res) => {
const id = req.headers["mcp-session-id"];
const transport = id && sessions.get(id);
if (transport) return transport;
res.status(id ? 404 : 400).json({ jsonrpc: "2.0", error: { code: id ? -32001 : -32000, message: id ? "Session not found" : "Bad Request: no session" }, id: null });
};
app.post("/mcp", async (req, res) => {
if (!req.headers["mcp-session-id"] && isInitializeRequest(req.body)) {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => sessions.set(id, transport),
onsessionclosed: (id) => sessions.delete(id), // runs on DELETE
});
await buildServer().connect(transport);
return transport.handleRequest(req, res, req.body);
}
const transport = existing(req, res);
if (transport) await transport.handleRequest(req, res, req.body);
});
app.delete("/mcp", async (req, res) => {
const transport = existing(req, res);
if (transport) await transport.handleRequest(req, res);
});
app.get("/mcp", (req, res) => res.status(405).set("Allow", "POST, DELETE").end()); // no server-initiated stream here
const http = app.listen(0, "127.0.0.1");
await new Promise((resolve) => http.once("listening", resolve));
const url = new URL(`http://127.0.0.1:${http.address().port}/mcp`);
// Log every HTTP exchange the SDK client makes; session ids are random, so print them as <A> and <B>.
const names = new Map();
const short = (id) => (id ? (names.get(id) ?? names.set(id, `<${"AB"[names.size]}>`).get(id)) : "-");
let headersShown = 0;
const getDone = {}; // the SDK client opens its GET stream in the background; wait for it so the log order is fixed
const logged = (who) => async (input, init = {}) => {
const res = await fetch(input, init);
if (init.method === "GET") setImmediate(getDone[who]);
const sent = new Headers(init.headers).get("mcp-session-id");
const body = init.body ? JSON.parse(init.body).method ?? "response" : "";
console.log(`${who} ${init.method.padEnd(6)} ${body.padEnd(26)} session ${short(sent)} -> ${res.status} ${short(res.headers.get("mcp-session-id"))}`);
if (body === "tools/call" && !headersShown++) { // the full headers of one exchange (minus Date and connection framing)
const show = (h) => [...h].filter(([k]) => !["date", "connection", "transfer-encoding"].includes(k)).map(([k, v]) => `${k}: ${k === "mcp-session-id" ? short(v) : v}`).join(" | ");
console.log(` request: ${show(new Headers(init.headers))}\n response: ${show(res.headers)}`);
}
return res;
};
async function connect(who) {
const opened = new Promise((resolve) => (getDone[who] = resolve));
const transport = new StreamableHTTPClientTransport(url, { fetch: logged(who) });
const client = new Client({ name: `host-${who}`, version: "1.0.0" });
await client.connect(transport);
await opened;
return { client, transport };
}
const text = (r) => r.content[0].text;
const a = await connect("A");
const b = await connect("B");
console.log("sessions on the server:", sessions.size);
console.log("A:", text(await a.client.callTool({ name: "pick_room", arguments: { roomId: "huddle-2" } })));
console.log("B:", text(await b.client.callTool({ name: "pick_room", arguments: { roomId: "board-1" } })));
console.log("A:", text(await a.client.callTool({ name: "pick_room", arguments: { roomId: "focus-3" } })));
const oldId = a.transport.sessionId;
await a.transport.terminateSession(); // HTTP DELETE with the session header
console.log("sessions on the server:", sessions.size);
const stale = await fetch(url, { method: "POST", body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "tools/list" }),
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", "mcp-session-id": oldId } });
console.log("old session id after DELETE ->", stale.status, (await stale.json()).error.message);
await a.client.close();
await b.client.close();
for (const t of sessions.values()) await t.close();
http.close();A POST initialize session - -> 200 <A>
A POST notifications/initialized session <A> -> 202 -
A GET session <A> -> 405 -
B POST initialize session - -> 200 <B>
B POST notifications/initialized session <B> -> 202 -
B GET session <B> -> 405 -
sessions on the server: 2
A POST tools/call session <A> -> 200 <A>
request: accept: application/json, text/event-stream | content-type: application/json | mcp-protocol-version: 2025-11-25 | mcp-session-id: <A>
response: cache-control: no-cache, no-transform | content-type: text/event-stream | mcp-session-id: <A> | x-accel-buffering: no | x-powered-by: Express
A: shortlist: huddle-2
B POST tools/call session <B> -> 200 <B>
B: shortlist: board-1
A POST tools/call session <A> -> 200 <A>
A: shortlist: huddle-2, focus-3
A DELETE session <A> -> 200 -
sessions on the server: 1
old session id after DELETE -> 404 Session not foundcreateMcpExpressApp()returns an Express app that already parses JSON bodies and, because it defaults to host127.0.0.1, rejects requests whoseHostheader isn'tlocalhost,127.0.0.1or[::1](DNS-rebinding protection, shown later in this topic). The routes passreq.bodyas the third argument tohandleRequest, because Express has already consumed the request stream.A POST initializehas no session header and is aninitializerequest, so the route creates a new transport withsessionIdGenerator: () => randomUUID()and a newMcpServerfrombuildServer(), connects them, and lets the transport answer. The response carriesMcp-Session-Id: <A>, andonsessioninitializedstores the transport insessionsunder that id. The spec (2025-11-25) says session ids SHOULD be globally unique and cryptographically secure, and MUST be visible ASCII; a random UUID is both.- The SDK client then sends
notifications/initializedwith the session header (202, no body) and opens the optional GET stream that lets a 2025-11-25 server push messages unrelated to any request. This server answers405, which the spec allows ("the server does not offer an SSE stream at this endpoint"), and the client carries on without it. Client B does the same and gets session<B>:sessions.sizeis2. - The headers of A's first
tools/callshow what the client sends on every request after the handshake:Acceptlisting both JSON and SSE,Mcp-Protocol-Version: 2025-11-25(required on HTTP since 2025-06-18) andMcp-Session-Id: <A>. The server chose an SSE response and setCache-Control: no-cache, no-transformandX-Accel-Buffering: no, which asks an nginx proxy not to buffer the stream. - The shortlists prove that state is per session: A's calls go to A's
McpServer, whoseshortlistarray goes[] → ["huddle-2"] → ["huddle-2", "focus-3"], while B's separate server holds onlyboard-1. That's the convenience of sessions: the server can remember things without the client repeating them. a.transport.terminateSession()sends HTTP DELETE with the session header. The transport closes the session and callsonsessionclosed, which removes it from the map (sessions.sizeis1). A later request with the old id gets404 Session not found, and the 2025-11-25 spec says a client that sees404for its session MUST start over with a newinitialize.- If you removed
onsessionclosed, the map would keep a dead transport forever: sessions are server memory, and a production server also needs an idle timeout and a cap on open sessions (the SDK example rejects new sessions with503past aMAX_SESSIONSlimit).
The same server, run as two instances behind a round-robin "load balancer" (a fetch function that alternates between them), first stateful and then stateless. In stateless mode the route builds a fresh McpServer and transport for every request and uses enableJsonResponse: true, so answers are plain JSON. The shortlist now lives in a store both instances can reach (a Map standing in for Redis or a database), found by a handle the tool returns.
// Two instances of the same MCP server behind a round-robin "load balancer": stateful sessions vs stateless mode.
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const shared = new Map(); // stands in for Redis or a database that every instance can reach
let nextHandle = 1; // a real server would mint random, user-bound handles (see the walkthrough)
function buildServer() {
const server = new McpServer({ name: "rooms", version: "1.0.0" });
server.registerTool("new_shortlist", { description: "Start a shortlist; returns its handle" }, async () => {
const handle = `sl_${nextHandle++}`;
shared.set(handle, []);
return { content: [{ type: "text", text: handle }] };
});
server.registerTool("pick_room", { description: "Add a room to a shortlist", inputSchema: { shortlist: z.string(), roomId: z.string() } },
async ({ shortlist, roomId }) => {
shared.get(shortlist).push(roomId);
return { content: [{ type: "text", text: `${shortlist}: ${shared.get(shortlist).join(", ")}` }] };
});
return server;
}
async function startInstance(name, stateful) {
const app = createMcpExpressApp();
const sessions = new Map();
app.post("/mcp", async (req, res) => {
const id = req.headers["mcp-session-id"];
if (stateful && id) {
if (sessions.has(id)) return sessions.get(id).handleRequest(req, res, req.body);
return res.status(404).json({ jsonrpc: "2.0", error: { code: -32001, message: `Session not found on ${name}` }, id: null });
}
if (stateful && !isInitializeRequest(req.body)) return res.status(400).end();
const transport = new StreamableHTTPServerTransport(stateful
? { sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => sessions.set(sid, transport) }
: { sessionIdGenerator: undefined, enableJsonResponse: true }); // stateless: a fresh pair per request
const server = buildServer();
if (!stateful) res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.get("/mcp", (req, res) => res.status(405).end());
const http = app.listen(0, "127.0.0.1");
await new Promise((resolve) => http.once("listening", resolve));
return { name, http, sessions, url: `http://127.0.0.1:${http.address().port}/mcp` };
}
async function run(stateful) {
console.log(stateful ? "== stateful (sessions in each instance's memory)" : "== stateless (any instance serves any request)");
const pool = [await startInstance("instance-1", stateful), await startInstance("instance-2", stateful)];
let turn = 0;
const balancer = async (input, init) => {
if (init.method === "GET") return fetch(pool[0].url, init); // the client's optional GET stream; both answer 405
const target = pool[turn++ % pool.length];
const res = await fetch(target.url, init);
console.log(` ${(JSON.parse(init.body).method ?? "").padEnd(25)} -> ${target.name}: ${res.status}`);
return res;
};
const client = new Client({ name: "host", version: "1.0.0" });
try {
await client.connect(new StreamableHTTPClientTransport(new URL("http://lb.invalid/mcp"), { fetch: balancer }));
const handle = (await client.callTool({ name: "new_shortlist", arguments: {} })).content[0].text;
for (const roomId of ["huddle-2", "board-1"]) {
const r = await client.callTool({ name: "pick_room", arguments: { shortlist: handle, roomId } });
console.log(` result: ${r.content[0].text}`);
}
} catch (e) {
console.log(` client error: ${e.message}`);
}
await client.close();
for (const i of pool) {
for (const t of i.sessions.values()) await t.close();
i.http.close();
}
}
await run(true);
await run(false);== stateful (sessions in each instance's memory)
initialize -> instance-1: 200
notifications/initialized -> instance-2: 404
client error: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found on instance-2"},"id":null}
== stateless (any instance serves any request)
initialize -> instance-1: 200
notifications/initialized -> instance-2: 202
tools/call -> instance-1: 200
tools/call -> instance-2: 200
result: sl_1: huddle-2
tools/call -> instance-1: 200
result: sl_1: huddle-2, board-1- Stateful:
initializegoes to instance 1, which creates the session in its own memory. The very next message,notifications/initialized, goes to instance 2, which has never seen that session id and answers404. The SDK client turns that intoStreamable HTTP error: Error POSTing to endpoint: …Session not found on instance-2, andconnect()fails. One client couldn't even finish the handshake. - The usual fixes are sticky routing (the balancer hashes
Mcp-Session-Idor a cookie to always pick the same instance) or shared session state. Sticky routing loses the session when that instance restarts or scales in; shared state is hard because a 2025-11-25 session also holds open SSE streams, which can't be moved between processes. That difficulty is the main reason 2026-07-28 removed sessions. - Stateless: with
sessionIdGenerator: undefinedthe transport issues no session id and validates none, soinitializeon instance 1 andnotifications/initializedon instance 2 both succeed (200,202). Each request gets a newMcpServer, which is why the SDK insists on a fresh transport per request: reusing one throwsStateless transport cannot be reused across requests. - The tool calls alternate between instances (
1, 2, 1) and the shortlist still grows correctly,sl_1: huddle-2thensl_1: huddle-2, board-1, because the state is in the shared store under the handlesl_1thatnew_shortlistreturned and the client passed back. This is the explicit handle pattern the 2026-07-28 spec recommends for any state that must span calls. - The handle here is a counter to keep the output fixed. The spec's security guidance says handles SHOULD be random (unguessable), and SHOULD be bound to the authenticated user, for example by storing state under
<user id>:<handle>with the user id taken from the verified token, never from the client. Possession of a handle MUST NOT count as authentication.
| Stateful (2025-11-25 sessions) | Stateless | |
|---|---|---|
| Where per-client state lives | in the process that handled initialize | in a shared store, or nowhere |
| Scaling out | needs sticky routing; a restart loses sessions | any instance, any request; plain round-robin works |
| Server-to-client messages unrelated to a request | possible on the GET stream (2025-11-25) | not available (only on a request's own SSE response) |
| Cost per request | low: server object reused | a new McpServer per request (cheap, but register tools efficiently) |
| Good for | servers that push notifications, long conversations with server-side context | API-style tools, serverless hosting, anything behind a normal load balancer |
| In 2026-07-28 | removed | the only model |
A server you run on localhost over HTTP (some tools do, instead of stdio) is reachable by any web page the user visits, because the browser will send requests to 127.0.0.1. With DNS rebinding, an attacker's page at evil.example first resolves to the attacker's IP, then switches its DNS to 127.0.0.1, so the browser treats requests to the local server as same-origin with the attacker's page. The 2025-11-25 Streamable HTTP transport says servers MUST validate the Origin header on all incoming connections and answer 403 when it's present and invalid, SHOULD bind to 127.0.0.1 rather than 0.0.0.0 when local, and SHOULD authenticate all connections. The SDK puts the Host check in createMcpExpressApp; the transport's own allowedOrigins option is marked deprecated in favour of middleware, so the Origin check below is a few lines of Express.
// What a DNS-rebinding page's requests look like to a localhost MCP server, and the two checks that stop them.
import { request } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
const app = createMcpExpressApp(); // host 127.0.0.1 by default, so it adds Host-header validation
const allowedOrigins = new Set(["http://localhost:6274"]); // e.g. your own local web UI, if you have one
app.use((req, res, next) => { // the transport's own Origin option is deprecated in favour of middleware
const origin = req.headers.origin;
if (origin && !allowedOrigins.has(origin)) return res.status(403).json({ jsonrpc: "2.0", error: { code: -32000, message: `Origin not allowed: ${origin}` }, id: null });
next();
});
app.post("/mcp", async (req, res) => {
const server = new McpServer({ name: "local-files", version: "1.0.0" });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
const http = app.listen(0, "127.0.0.1");
await new Promise((resolve) => http.once("listening", resolve));
const port = http.address().port;
function post(label, headers) { // node:http, because fetch() won't let you set Host yourself
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "x", version: "0" } } });
return new Promise((resolve) => {
const req = request({ host: "127.0.0.1", port, path: "/mcp", method: "POST",
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...headers } }, (res) => {
let text = "";
res.on("data", (d) => (text += d)).on("end", () => {
const json = JSON.parse(text);
console.log(`${label.padEnd(34)} ${res.statusCode} ${json.error ? json.error.message : `serverInfo.name=${json.result.serverInfo.name}`}`);
resolve();
});
});
req.end(body);
});
}
await post("host app (no Origin)", { host: `127.0.0.1:${port}` });
await post("rebinding page, after DNS switch", { host: `evil.example:${port}`, origin: `http://evil.example:${port}` });
await post("cross-site page, real Host", { host: `localhost:${port}`, origin: "https://evil.example" });
await post("your own local UI", { host: `localhost:${port}`, origin: "http://localhost:6274" });
http.close();host app (no Origin) 200 serverInfo.name=local-files
rebinding page, after DNS switch 403 Invalid Host: evil.example
cross-site page, real Host 403 Origin not allowed: https://evil.example
your own local UI 200 serverInfo.name=local-files- The host app sends no
Origin(it's not a browser) andHost: 127.0.0.1:<port>: both checks pass andinitializereturns the server's info. - The rebinding page: after the DNS switch, the browser sends
Host: evil.example:<port>, because that's the name in the page's URL.createMcpExpressApp's Host validation compares the hostname withlocalhost,127.0.0.1and[::1]and answers403 Invalid Host: evil.examplebefore any MCP code runs. - A plain cross-site request from a page that targets
http://localhost:<port>directly has a validHost, but the browser addsOrigin: https://evil.example, and the middleware answers403 Origin not allowed. Host checks stop rebinding; Origin checks stop ordinary cross-site requests. You want both. - Your own local web UI at
http://localhost:6274is on the allowlist and passes. If you bind to0.0.0.0,createMcpExpressAppskips the Host check and prints a warning, unless you passallowedHosts. - This is exactly the class of bug behind CVE-2025-49596 in the MCP Inspector developer tool (Oligo Security's write-up, 27 June 2025; advisory 13 June 2025; CVSS 9.4): its local proxy had no authentication, so a malicious page could reach it (via the browser's
0.0.0.0behaviour or DNS rebinding) and ask it to launch a stdio "server" with any command. Version 0.14.1 added a session token by default and an Origin allowlist. That fix turned out to be incomplete, in exactly the way this model warns about: advisories the Inspector project published on 21 October 2026 (GHSA-vcc9-3xgm-p9v4, GHSA-wmjf-2m46-2352) report that its Origin check let through requests with noOriginheader (browsers omit it on same-origin GETs, which is what a rebound page sends), that there was no Host validation at all, and that with authentication switched off (DANGEROUSLY_OMIT_AUTH) the process-launch route was open again. Fixed versions per those advisories: 1.0.3 for the 1.x line and 2.12.0 for 2.x, released alongside several other fixes the same day, including a 9.6-rated remote code execution through the OAuth callback. If you run the Inspector, keep it current.
- TLS terminates in front. Run the Node process on a private port behind a TLS-terminating proxy or platform; the canonical URL clients use (and OAuth tokens name, see the next topic) is the public
https://one. - Timeouts and buffering. SSE responses are long-lived. Proxies with short idle timeouts or response buffering break them; the SDK sends an SSE keep-alive comment every 15 seconds by default (
keepAliveMs,15000) and setsX-Accel-Buffering: no. Check your load balancer's idle timeout anyway. - Body size. When the transport reads the body itself it refuses bodies over 4 MiB with
413(maxRequestBodySize, default 4194304). When you passreq.bodyfromexpress.json(), Express's own limit applies instead (100 kB by default in body-parser 2.4, which Express 5.3 uses), andcreateMcpExpressAppcallsexpress.json()with that default. If you need another limit, build the app yourself fromexpress(), the SDK'shostHeaderValidationmiddleware andexpress.json({ limit }). - Resumability (2025-11-25 only). Pass an
eventStoreand the transport tags SSE events with ids, so a client whose stream broke can reconnect withLast-Event-IDand get the missed messages. It needs a shared store to work across instances. 2026-07-28 removed it: a client whose response stream breaks re-sends the request with a new id. - Rate limits and quotas per user and per tool belong in front of the MCP handler, like any API; the spec's tools page says servers MUST rate-limit tool invocations.
From the 2026-07-28 changelog and transport page; SDK 1.32.2 can't produce these, so none of this was run here. The protocol on the wire shows the new request shape.
| 2025-11-25 (SDK 1.32.2) | 2026-07-28 |
|---|---|
initialize + notifications/initialized once per session | no handshake; protocol version and client capabilities in every request's _meta; optional server/discover |
Mcp-Session-Id header, 404 for an ended session, DELETE to end it | no sessions; state that spans calls uses explicit handles in tool arguments |
| GET stream for server-initiated messages | removed; change notifications come from a subscriptions/listen POST whose response stays open |
Last-Event-ID resumption with an event store | removed; a broken stream means re-sending the request with a new id |
| server-to-client requests (elicitation, sampling) on the stream | replaced by multi round-trip requests: the server returns resultType: "input_required" and the client retries with the answers |
MCP-Protocol-Version header | still required, plus Mcp-Method on every request and Mcp-Name on tools/call, resources/read and prompts/get, so gateways can route without parsing JSON |
In the load-balanced model, the stateful client's initialize succeeded but connect() still threw. Which request failed, with what status, and what are two ways to fix it without going stateless?
Show answer
The notifications/initialized POST failed with 404, because the balancer sent it to instance 2, which has no record of the session.
initializewas request 1 → instance 1 (turn0). Instance 1 created the session and returned its id inMcp-Session-Id.- The SDK client always follows
initializewithnotifications/initialized, carrying the session id. Round-robin sent it to instance 2 (turn1). - Instance 2's
sessionsmap is empty, so the route answered404with-32001 Session not found on instance-2, and the SDK client raised aStreamable HTTP errorfromconnect(). - Fix one: sticky routing on
Mcp-Session-Id(or a cookie), so every request in a session reaches instance 1. Fix two: shared session state, which in practice means a session-aware gateway, because open SSE streams can't move between processes. - Going stateless removes the problem entirely, which is the direction 2026-07-28 took.