Skip to content

ADR-012: Per-Session McpServer Factory Pattern

  • Status: Accepted
  • Date: 2026-05-30
  • Supersedes: Part of ADR-009 (eager startup remains, but the shared global server is removed)

Context

The MCP TypeScript SDK enforces a strict contract: each McpServer instance can only be connected to one Transport. Calling server.connect(transport) on an already-connected server throws "already connected transport, call close()".

Our original design eagerly created a single globalMcpServer at startup (per ADR-009) and reused it across all incoming sessions. This worked for the first session but threw on the second.

The official SDK example (simpleStreamableHttp.ts) uses a getServer() factory function that returns a fresh McpServer per session.

Observed Symptoms

  • Users connecting from a second browser tab or after OIDC callback saw "already connected transport, call close()".
  • The error cascaded: the MCP endpoint returned 500, the UI showed "Failed to connect to MCP Host client", and subsequent tool calls failed.

Decision

Adopt the official MCP SDK per-session factory pattern:

ModePattern
StatelessFresh McpServer + Transport per HTTP request
StatefulFresh McpServer + Transport per session, stored in activeSessions map keyed by mcp-session-id
TestFresh McpServer connected to an injected globalTransport on first use
StartupThrowaway McpServer validates RBAC/tool registration, then is discarded (ADR-009 preserved)

The globalMcpServer global variable is removed entirely.

Implementation

typescript
// Factory: each session gets its own McpServer
const server = await createMcpServer(getK8sContext());
registerUiApp(server, DIST_DIR);
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: () => uuidv7(),
  onsessioninitialized: (sessId) => {
    activeSessions.set(sessId, { server, transport });
  },
  onsessionclosed: (sessId) => {
    activeSessions.delete(sessId);
    void server.close().catch(() => {});
  },
});
await server.connect(transport);

Alternatives Considered

1. Call server.close() before reconnecting

Rejected — McpServer.close() tears down handlers and state. Reconnecting a closed server produces undefined behavior and defeats the purpose of stateful sessions.

2. Keep globalMcpServer for the first session, create new ones after

Rejected — introduces two code paths, special-cases the first session, and is fragile under concurrent initialization. The factory pattern is simpler and uniform.

3. Single server with transport multiplexing

The MCP SDK does not support multiplexing multiple transports on one server. This is by design — each McpServer+Transport pair represents an isolated session with its own state.

Consequences

Positive

  • Bug fixed: Multiple concurrent sessions work correctly.
  • Follows SDK convention: Aligns with the official getServer() pattern, making future SDK upgrades safer.
  • Simpler state: No global mutable server reference to manage.

Negative

  • Per-session overhead: Each session creates a new McpServer and re-registers tools. In practice this is ~5ms and acceptable — createMcpServer() performs no I/O (RBAC evaluation is cached).
  • Startup validation is throwaway: The eagerly-created validation server at boot is discarded. This is intentional — it exists only to fail-fast on RBAC misconfiguration (ADR-009).

References