Configuring MCP servers for multiple simultaneous connections

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The first thing to get straight is that "multiple simultaneous connections" is a property of one transport and not the other. If your server speaks stdio, the client launches it as a subprocess and owns it: one process, one client, no sharing (MCP spec, Transports). You scale that by running more processes, not by making one process multi-client. Concurrency is the job of the Streamable HTTP transport, where "the server operates as an independent process that can handle multiple client connections" over a single HTTP endpoint (MCP spec, Streamable HTTP).

So the real question this guide answers is narrower than it sounds: when many clients hit your one HTTP endpoint at once, how do you keep them from bleeding into each other? Under the current spec revision, 2026-07-28, the answer is anticlimactic. There is nothing to keep separate, because there is nothing to keep. MCP is now a stateless protocol; every request carries everything the server needs, and concurrency is plain concurrent request handling.

There are exactly two spec transports, stdio and Streamable HTTP. The old two-endpoint HTTP+SSE transport from the 2024-11-05 spec is deprecated and exists only for backwards compatibility, so nothing below uses it (MCP spec, Transports).

How a request carries its own context

Streamable HTTP has no persistent socket per client. Every JSON-RPC message is a fresh HTTP POST to the one MCP endpoint (commonly /mcp) — and under 2026-07-28 the server does not correlate those requests into anything at all.

How it used to work, because you will meet servers and clients that still do it: through 2025-11-25, a client opened with an InitializeRequest, the server minted an Mcp-Session-Id and returned it on the InitializeResult, the client echoed that header on every later request, a missing header earned a 400, a terminated session a 404, and an HTTP DELETE ended it. The 2026-07-28 revision removed the whole apparatus: initialize, notifications/initialized, the session header, the standalone GET stream, and Last-Event-ID resumability. A server on this revision that receives that traffic should answer 405 Method Not Allowed to a GET or DELETE and simply ignore an Mcp-Session-Id or Last-Event-ID header (MCP spec, Streamable HTTP).

What replaced it is per-request metadata. Each request declares its own protocol version and client capabilities in _meta, so the server never has to remember anything about the connection it arrived on:

POST /mcp HTTP/1.1
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "location": "Seattle, WA" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": { "name": "ExampleClient", "version": "1.0.0" },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}

Three rules follow, and they are the whole model:

  1. io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required on every request. A request missing either is malformed: the server MUST reject it with -32602 and HTTP 400. io.modelcontextprotocol/clientInfo is a SHOULD, and servers SHOULD return io.modelcontextprotocol/serverInfo in each result's _meta (MCP spec, Base Protocol).
  2. The client MUST send MCP-Protocol-Version on every POST — from the very first one, since there is no initialization to send it after — plus the new required Mcp-Method, and Mcp-Name on tools/call, resources/read, and prompts/get. Header values MUST match the body; a mismatch or a missing required header is HTTP 400 with -32020 HeaderMismatch. An unsupported version is HTTP 400 with UnsupportedProtocolVersionError (MCP spec, Streamable HTTP).
  3. Servers advertise themselves through server/discover, which every server MUST implement. It returns supported protocol versions, capabilities, and identity. Clients MAY call it first, but nothing negotiates: each request stands or falls on its own declared version.

Statelessness is normative, not a mode: "no state should be inferred from previous requests, even those on the same connection or stream," and state that must span requests "MUST be referenced by an explicit identifier the client passes on each request" (MCP spec, Base Protocol). Concurrency, then, is just N clients POSTing to one endpoint with no shared bookkeeping between them.

The concurrency primitive: there isn't one any more

Concurrency used to come down to one question: when a request hits your single /mcp endpoint, which client's session does it belong to? Under 2026-07-28 that question has no answer, because it has no subject. A current server keeps no session map, does no routing by session ID, and holds nothing between requests.

The TypeScript SDK reflects this in its packaging. The 2026-07-28 line ships as split packages — @modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core, plus HTTP adapters for Node, Express, Hono, and Fastify — at 2.0.0. (The v1 monolith @modelcontextprotocol/sdk is at 1.30.0 and is still the npm latest tag; it is the legacy line, not a dead one.) The HTTP entry point is a handler factory, and that is the entire concurrency story:

import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';

const handler = createMcpHandler(() => {
    const server = new McpServer({ name: 'my-server', version: '1.0.0' },
        { capabilities: { tools: {} } });
    return server;
});

createMcpHandler(factory) serves 2026-07-28 per request. Its default legacy: 'stateless' setting also serves 2025-era traffic, statelessly — which matters, because as of today essentially every shipping client still speaks 2025-11-25 or earlier. If you already run a sessionful HTTP server and want to keep that path intact, you can route the two eras separately with createMcpHandler(factory, { legacy: 'reject' }) in front of your existing handler (typescript-sdk, supporting 2026-07-28). Either way, the dual-era behavior is the SDK's default and the thing that keeps your server reachable.

The legacy session map, for reference

If you maintain a server written against 2025-11-25 or earlier — or you are deliberately keeping a legacy path alongside a modern one — this is the pattern that transport required. Do not build a new server this way; it is here because a lot of production code still looks like it. It uses the v1 monolith (@modelcontextprotocol/sdk), whose StreamableHTTPServerTransport maps Mcp-Session-Id to a per-session transport:

// LEGACY (pre-2026-07-28 clients only): @modelcontextprotocol/sdk 1.x
import { randomUUID } from "node:crypto";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";

const transports: Record<string, StreamableHTTPServerTransport> = {}; // session id -> transport

app.post("/mcp", async (req, res) => {
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (sessionId && transports[sessionId]) {
    return transports[sessionId].handleRequest(req, res, req.body);
  }
  if (!sessionId && isInitializeRequest(req.body)) {
    const transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (id) => { transports[id] = transport; },
    });
    transport.onclose = () => { if (transport.sessionId) delete transports[transport.sessionId]; };
    await buildServer().connect(transport); // your configured McpServer, one per session
    return transport.handleRequest(req, res, req.body);
  }
  res.status(400).json({ error: "No valid session ID" });
});

For the full runnable version of that handler (Express wiring, the GET and DELETE routes, error handling), see the SDK's stateful example, pinned to a v1 tag.

Two things about it are worth carrying forward as history rather than practice. sessionIdGenerator was the single switch between stateful and stateless; under 2026-07-28 there is no switch, because there are no sessions to generate IDs for. And eventStore existed to make SSE streams resumable via Last-Event-ID; resumability was removed outright, so a broken stream is not replayed — the client re-issues the request with a new request ID.

Stateless is the protocol, not a mode you pick

The old version of this section was a trade-off table: stateful bought you resumable streams and server-initiated messages at the cost of session affinity, stateless bought you scale-out at the cost of both. That choice is gone. 2026-07-28 makes statelessness normative, and the things stateful mode used to buy no longer exist in either mode:

  • Resumable SSE streams are removed protocol-wide. Neither shape has them.
  • Server-initiated JSON-RPC requests are removed. A server that needs sampling, elicitation, or roots returns an InputRequiredResult and the client retries the original call with inputResponses — Multi Round-Trip Requests. This works fine over a stateless POST because the whole exchange is carried in request and result bodies.
  • Long-lived notifications still exist, but a client asks for them explicitly with subscriptions/listen, whose response stream stays open. That stream's state is scoped to the request, not to the connection, so it needs no session either. Request-scoped notifications like notifications/progress still flow on the originating request's own response stream, which is exactly where a mid-call progress update belongs.

So no sticky routing, no shared session store, no affinity annotations on your ingress: any replica answers any request. The one real decision left is dual-era support — whether your deployment also serves the initialize-handshake clients that make up almost all traffic today. A modern-only server fails against every legacy client, so the answer in production is yes — and most Tier 1 SDKs handle both eras by default, so the work is mostly not turning it off. Check your SDK's own default before assuming that, though: the Go SDK is the exception, negotiating every connection down to legacy 2025-11-25 sessions unless you explicitly set StreamableHTTPOptions.Stateless = true.

Note that "stateless protocol" is not the same as "stateless application." You can keep as much per-tenant data in your own database as you like; what you cannot do is infer it from the connection. State that spans calls has to be an explicit server-minted handle passed back as an ordinary tool argument — the spec's Stateful Tools pattern — and it should be unguessable and bound server-side to the authenticated caller, since possession of a handle is not authentication.

The same model in Python

The official Python SDK reached 2.0.0 for the 2026-07-28 revision, and it renamed the thing everyone imports. FastMCP is now MCPServer, and the module mcp.server.fastmcp is now mcp.server.mcpserver — the old import path was removed, not deprecated. The transport switches moved off the constructor and onto run() at the same time (python-sdk migration guide).

from mcp.server.mcpserver import MCPServer

mcp = MCPServer("Demo")

@mcp.tool()
def greet(name: str = "World") -> str:
    return f"Hello, {name}!"

if __name__ == "__main__":
    mcp.run(
        transport="streamable-http",
        host="0.0.0.0",
        port=9000,
        json_response=True,
        stateless_http=True,
    )

In mcp 1.x (the final 1.x release is 1.29.0) that same server read FastMCP("Demo", json_response=True, stateless_http=True) with mcp.run(transport="streamable-http"). Two traps in the rename: the constructor's positional order changed to name, title, description, instructions, ..., so v1 code passing instructions positionally now silently lands it in title, and the decorator API otherwise carries over unchanged, which makes the breakage easy to miss.

stateless_http=True serves each request with a fresh transport and carries nothing between requests, which is the shape the protocol now assumes anyway; json_response=True returns a single JSON object instead of holding an SSE stream open. When you mount the app yourself rather than calling mcp.run(), the host app's lifespan must enter mcp.session_manager.run() — a mounted sub-app's own lifespan never runs, so nothing else starts it (python-sdk migration guide).

from contextlib import asynccontextmanager
from starlette.applications import Starlette
from starlette.routing import Mount

@asynccontextmanager
async def lifespan(app):
    async with mcp.session_manager.run():
        yield

app = Starlette(
    lifespan=lifespan,
    routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))],
)

This mounting style is also how you run several MCP servers in one process, each on its own path, each entered in the lifespan. That is a cleaner way to host multiple logical servers than spawning one process per server.

Browser clients and CORS

If any of your clients run in a browser, CORS is where a working server stops working. The header list changed with the revision. There is no longer an Mcp-Session-Id response header for JavaScript to read, so nothing needs expose_headers for it; what the browser now has to be allowed to send is the required request-metadata headers. A cross-origin POST carrying MCP-Protocol-Version, Mcp-Method, and Mcp-Name will be blocked at the preflight unless they are on allow_headers, and the endpoint only needs POST now that GET and DELETE are gone.

from starlette.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://your-app.example.com"],
    allow_methods=["POST"],
    allow_headers=[
        "Content-Type",
        "Authorization",
        "MCP-Protocol-Version",
        "Mcp-Method",
        "Mcp-Name",
    ],
)

If you also serve legacy clients, that older path additionally needs GET and DELETE in allow_methods, Mcp-Session-Id in both allow_headers and expose_headers, and Last-Event-ID in allow_headers. The same applies in any frontend stack; the header names are the constant, the CORS plumbing is whatever your server framework uses.

Lock down the endpoint before you expose it

Serving multiple remote clients means the endpoint is reachable, and the spec is blunt about the consequence. Servers MUST validate the Origin header to defend against DNS rebinding and MUST answer 403 Forbidden when a present Origin is invalid; they SHOULD bind to 127.0.0.1 rather than 0.0.0.0 when running locally, and SHOULD authenticate connections (MCP spec, Streamable HTTP). DNS rebinding is the attack where a malicious web page tricks a victim's browser into talking to an MCP server bound on their machine; Origin validation is what stops it.

That requirement lands on your deployment, not on a single library switch. The cleanest place to enforce it is at your edge: HTTP middleware or a reverse proxy that rejects any request whose Origin or Host is not on your allowlist. The v1 transport carried a built-in enableDnsRebindingProtection toggle (with allowedHosts/allowedOrigins), off by default because it needs an allowlist you supply, and by 1.29.0 those options were already deprecated in favor of external middleware. The 2.0.0 packages finished the move: hostHeaderValidation() and localhostHostValidation() now live in @modelcontextprotocol/express with their (allowedHostnames: string[]) signature unchanged, and framework-agnostic validateHostHeader / hostHeaderValidationResponse helpers are exported from @modelcontextprotocol/server (typescript-sdk, upgrade to v2). Either way, reach for the proxy or middleware layer first.

Authentication for remote HTTP servers is OAuth 2.1 in the current spec, with the MCP server acting as a protected resource; that is its own topic, but the short version is that "multiple connections" and "unauthenticated" should not appear together on anything public. It matters more now than it used to: with no session to carry identity forward, the token on each individual request is the only thing establishing who the caller is.

What to actually configure

Strip away the framing and the configuration surface for concurrent MCP is small and specific:

  • Transport. stdio is one client per process. Streamable HTTP is the concurrent one, on a single POST endpoint. There is no third option in the current spec.
  • Era. Dual-era, essentially always: serve 2026-07-28 and keep the legacy initialize path, because that is what today's clients speak. createMcpHandler(factory) in TypeScript and the Tier 1 SDKs generally do this by default; { legacy: 'reject' } is the opt-out, not the opt-in.
  • No session map. Nothing to key, nothing to register, nothing to evict. If you still run a legacy path, its map is scoped to that path alone.
  • Headers. MCP-Protocol-Version, Mcp-Method, and Mcp-Name on every request, matching the body, surviving your proxy — and allowed through CORS for browser clients.
  • Cross-call state. An explicit server-minted handle passed as a tool argument, unguessable and bound to the authenticated user. Never the connection.
  • Security. Origin validation on with a 403 on failure, localhost binding when local, auth before exposure.

Everything beyond that, the connection pools to your own database, the rate limits, the per-tenant quotas, is ordinary backend engineering that is not specific to MCP and should be designed the way you would design it for any HTTP service.

Once your server is taking real concurrent traffic, the operational questions move from "how do connections work" to "what is actually going through the endpoint": which tools get called, what arguments clients send, which protocol version each caller is on, where calls stall or error. That request-level visibility is the gap AgentCat fills for production MCP servers.