Last week I migrated a production MCP server from stdio to the new Streamable HTTP spec. Six hours, fourteen bugs, and one accidental denial-of-service attack later, it was working.
If you are running an MCP server today, you are probably still on stdio. The client spawns your server as a subprocess and talks over stdin/stdout. It works fine for local tools in Claude Desktop or Cursor. But the moment you want to run your server remotely on a VM, behind a load balancer, or as a shared team service, stdio does not cut it. You need HTTP.
Streamable HTTP is the new transport spec that lets clients connect over HTTP with SSE for streaming responses. The SDK added support for it in late 2025, and it is quickly becoming the standard for production MCP deployments. But migrating from stdio is not as simple as swapping a transport class. Here are the six things that broke when I tried.
Problem 1: Session State Does Not Exist Anymore
On stdio, the process IS the session. When your server starts, the client spawns it fresh, sends a few messages, and kills it. Each connection gets a clean slate with no shared state.
With HTTP, the server stays alive. Multiple clients can connect to the same process. The initialize handshake now means something different.
const server = new Server({
name: "my-server",
version: "1.0.0"
})
server.setRequestHandler(InitializeRequest, async (request) => {
currentClientName = request.params.clientInfo.name
})
I had session-scoped caches keyed by a global variable. Second client connected? First cache got silently nuked. Tools started returning stale or empty results for no obvious reason.
The fix was trivial. Key everything by session ID:
const sessionCaches = new Map<string, Map<string, any>>()
server.setRequestHandler(InitializeRequest, async (request, extra) => {
const sessionId = extra.sessionId
if (!sessionCaches.has(sessionId)) {
sessionCaches.set(sessionId, new Map())
}
})
function getSessionCache(sessionId: string): Map<string, any> {
return sessionCaches.get(sessionId) ?? new Map()
}
If you do not know to look for this, it creates a maddening bug where the server randomly forgets configuration between requests.
Problem 2: SSE Backpressure
Streamable HTTP uses Server-Sent Events for tools that take a long time to respond. The client POSTs a request, gets back a 202 with a Location header pointing to an SSE stream, and listens for the result.
const response = await fetch("http://server:3456/mcp", {
method: "POST",
body: JSON.stringify(request)
})
const stream = new EventSource("http://server:3456/mcp/stream/abc-123")
stream.addEventListener("message", (event) => {
const data = JSON.parse(event.data)
})
Clean on paper. But when the client reads slowly, the SSE buffer grows. When it hits the Node.js 16KB default highWaterMark, your HTTP server starts rejecting connections.
I discovered this when my web scraper tool started failing randomly under parallel loads.
const activeStreams = new Map<string, AbortController>()
server.setRequestHandler(ToolCallRequest, async (request, extra) => {
const streamId = extra.streamId
const controller = new AbortController()
activeStreams.set(streamId, controller)
request.onerror = () => {
controller.abort()
activeStreams.delete(streamId)
}
})
I bumped the buffer to 64KB and added a 60-second idle timeout per stream.
Problem 3: Three Different Timeouts
On stdio, client and server share a process lifetime. No network timeout to worry about.
On HTTP, you need to configure at least three:
- Connection timeout - how long to keep the TCP connection alive (default: 60s)
- SSE keepalive - how often to send a heartbeat so the client knows the server is alive
- Tool execution timeout - how long a single tool call can run before the client gives up
The default SSE keepalive interval is 30 seconds. If your tool runs for 45 seconds, the client disconnects before getting a result. No error, no retry, just a hanging request.
const transport = new StreamableHTTPServerTransport({
sseOptions: {
keepaliveInterval: 15_000,
keepaliveTimeout: 10_000,
},
httpOptions: {
requestTimeout: 300_000,
}
})
Keepalive needs to be shorter than your shortest long-running tool.
Problem 4: CORS Is Real Now
When your MCP client runs in a browser or Electron app, CORS becomes your problem.
import cors from "cors"
app.use(cors({
origin: [
"http://localhost:3000",
"https://claude.ai",
"app://obsidian.md",
],
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type", "Accept", "MCP-Version"],
credentials: true,
}))
Claude Desktop sends from localhost:3000, Cursor from file://, Obsidian Copilot from app://obsidian.md. Your server needs to allow all of them.
Problem 5: The Accidental DDoS
On stdio, one request = one process. Cleanup is automatic.
On HTTP, I had a setInterval polling PostgreSQL every 30 seconds. After fifty parallel connections I had fifty queries running every thirty seconds. The pool saturated and down went the server.
// Bad: one timer per connection
server.setRequestHandler(InitializeRequest, () => {
setInterval(() => warmCache(), 30_000)
})
// Fix: singleton timer
let cacheWarmInterval: NodeJS.Timeout | null = null
server.setRequestHandler(InitializeRequest, () => {
if (!cacheWarmInterval) {
cacheWarmInterval = setInterval(() => warmCache(), 30_000)
}
})
Audit every timer. If it does not need to be per-session, make it a singleton.
Problem 6: Graceful Shutdown
Kill a stdio process and the OS cleans up. Kill an HTTP server with active SSE connections, and clients hang for minutes.
process.on("SIGTERM", async () => {
console.log("Shutting down. Draining", activeStreams.size, "connections")
for (const [id, controller] of activeStreams) {
controller.abort()
}
activeStreams.clear()
await transport.close()
await new Promise(r => setTimeout(r, 2000))
process.exit(0)
})
The MCP spec recommends SIGTERM and draining. Do not skip it.
What I Still Have Not Figured Out
- Load balancing. With stdio this was not a question. With HTTP, I need sticky sessions or a shared state store. Neither feels right for a twelve-tool MCP server.
- Rate limiting. Without process-per-request isolation, one abusive client can starve the others.
- Auth. The spec leaves it to the implementer. Using a bearer token for now, but it feels fragile.
If you have dealt with any of these - especially load balancing - I would love to hear how you handled it. The MCP transport spec is evolving fast, and right now a lot of us are figuring out the stdio-to-HTTP migration at the same time.
Top comments (0)