Framework guide · 2026-06-26 · MCP + HTTP Frameworks
MCP HTTP Transport Across Five Node.js Frameworks
The MCP HTTP transport always resolves to the same three routes: POST /mcp to receive JSON-RPC messages and establish sessions, GET /mcp to open an SSE stream for server-to-client pushes, and DELETE /mcp to cleanly terminate a session. The wire format is identical regardless of which Node.js framework sits underneath. What differs is how each framework's middleware pipeline, body parsing, and response model interact with the MCP SDK's StreamableHTTPServerTransport — and each has at least one integration gotcha that silently breaks the protocol for first-time implementors. This post synthesizes five deep-dives — Express, Fastify, Hono, NestJS, and Koa — into a unified picture: what each framework needs to integrate MCP, what breaks without it, and why external protocol monitoring is necessary regardless of which framework you pick.
Five frameworks at a glance
The table below captures each framework's critical integration requirement, the silent failure mode when that requirement is missed, and the monitoring gap that results.
| Framework | Critical integration requirement | Silent failure if missed | Monitoring gap |
|---|---|---|---|
| Express | Session Map<sessionId, transport> + SIGTERM drain handler |
Sessions lost on deploy; clients must reinitialize after every rolling update | HTTP 200 responses even during drain window — only a protocol probe catches dropped sessions |
| Fastify | addContentTypeParser + reply.raw hijack |
Schema validation rejects MCP body; SSE stream closes immediately without reply.raw |
Fastify logs show no error; MCP client sees connection drop on first SSE frame |
| Hono | c.req.raw for Fetch Request; @hono/node-server adapter on Node.js |
Edge runtime gets TypeError on c.env.incoming without Node adapter; stateless design required on Workers |
Regional edge failures invisible without multi-region probing |
| NestJS | McpModule DI registration + OnModuleDestroy lifecycle hook |
Tools registered in constructor fail if DI dependencies unavailable at construction time; sessions leak on shutdown without OnModuleDestroy |
NestJS DI silently swallows constructor errors; HTTP 200 from healthcheck route but MCP handshake fails |
| Koa | ctx.respond = false on all MCP routes |
Koa finalizes response after middleware chain — SSE stream closes before first event is sent | SSE connection appears to open (HTTP 200) but closes within 100ms; invisible without streaming-aware probe |
The shared foundation: what all five frameworks need
Before diving into framework-specific differences, here is what every MCP HTTP transport implementation must have regardless of framework:
- Three route handlers:
POST /mcp(new sessions and tool calls),GET /mcp(SSE stream),DELETE /mcp(session termination) - Session registry: A
Map<sessionId, StreamableHTTPServerTransport>that routes incoming requests to the correct transport instance - CORS with Mcp-Session-Id exposed:
Access-Control-Expose-Headers: Mcp-Session-Idso browser-based MCP clients can read the session ID from the response header - SIGTERM drain: On shutdown, close all transports cleanly before the process exits — abrupt exit drops every active SSE connection simultaneously
- Health endpoint: A
GET /healththat returns a machine-readable status — used by load balancers and external monitors to verify the server is accepting connections
// The minimal session registry pattern — same across all five frameworks
const sessions = new Map<string, StreamableHTTPServerTransport>();
// On SIGTERM — clean up before exit
process.on('SIGTERM', async () => {
for (const transport of sessions.values()) {
await transport.close();
}
server.close(() => process.exit(0));
});
What differs across frameworks is how the raw Node.js IncomingMessage and ServerResponse objects (which the MCP SDK needs) are accessed through each framework's abstraction layer. This is the root cause of every framework-specific gotcha described below.
Express: the familiar baseline
Express passes req and res as thin wrappers around the raw Node.js objects. The MCP SDK can use them directly — no adapter layer needed. This makes Express the lowest-friction starting point for MCP server development.
import express from 'express';
import cors from 'cors';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = express();
app.use(cors({ exposedHeaders: ['Mcp-Session-Id'] }));
app.use(express.json());
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport = sessionId ? sessions.get(sessionId) : undefined;
if (!transport) {
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' });
registerTools(mcpServer);
await mcpServer.connect(transport);
if (transport.sessionId) sessions.set(transport.sessionId, transport);
}
await transport.handleRequest(req, res, req.body);
});
app.get('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
const transport = sessions.get(sessionId);
if (!transport) { res.status(404).end(); return; }
await transport.handleRequest(req, res);
});
app.delete('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string;
const transport = sessions.get(sessionId);
if (transport) { await transport.close(); sessions.delete(sessionId); }
res.status(200).end();
});
app.get('/health', (_, res) => res.json({ status: 'ok' }));
The Express integration has one common trap: the session Map grows unboundedly if clients disconnect without sending a DELETE /mcp. Implement TTL-based eviction to clean up orphaned sessions from crashed clients. A session not used in 30 minutes should be evicted and its transport closed.
Silent failure mode: Express returns HTTP 200 during SIGTERM drain if the app hasn't yet stopped accepting new connections. A rolling deploy that sends SIGTERM and immediately starts routing new requests to the old instance will get 200 responses from a server that's draining — tools calls succeed for in-flight sessions, but new initialize requests silently fail to establish sessions that survive the restart.
Fastify: two integration hurdles
Fastify's performance comes from its strict request/response pipeline and schema validation. Both create friction with MCP's SDK, which needs raw Buffer access to the request body and raw ServerResponse access for SSE streaming.
Hurdle 1 — body parsing: Fastify parses JSON bodies into JavaScript objects before your route handler sees them. The MCP SDK needs the raw Buffer (or string) to parse and validate the JSON-RPC message itself. Solution: register a custom content-type parser that captures the raw body:
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
try {
const parsed = JSON.parse(body.toString());
done(null, { parsed, raw: body });
} catch (err) {
done(err as Error, undefined);
}
});
Hurdle 2 — SSE streaming: Fastify's response model sends the response through its serializers and then closes the connection. SSE requires keeping the connection open and writing events incrementally. Solution: use reply.raw to bypass Fastify's response handling and write directly to the Node.js ServerResponse:
fastify.get('/mcp', async (request, reply) => {
const sessionId = request.headers['mcp-session-id'] as string;
const transport = sessions.get(sessionId);
if (!transport) { reply.code(404).send(); return; }
// reply.hijack() tells Fastify not to touch the response after this point
reply.hijack();
// Pass reply.raw (Node.js ServerResponse) to the MCP SDK
await transport.handleRequest(request.raw, reply.raw);
});
Without reply.raw and reply.hijack(), Fastify closes the response socket immediately after the route handler returns — the SSE stream sends one empty chunk and closes. The MCP client sees a connection drop with no error, and the silent failure mode is that the agent retries initialize in a loop, never establishing a session.
Fastify's rate limiting via @fastify/rate-limit can be keyed by Mcp-Session-Id instead of IP address — this is the correct approach for MCP servers where multiple clients behind a NAT share an IP but have distinct session IDs.
Hono: Fetch-API-native and edge-ready
Hono is built on the Fetch API (Request/Response globals) rather than Node.js's IncomingMessage/ServerResponse. This makes it the only framework in this group that runs natively on Cloudflare Workers, Deno Deploy, and Bun without a compatibility shim — but it creates an integration challenge on Node.js where the MCP SDK expects the Node.js stream types.
On Node.js, install @hono/node-server which adds the c.env.incoming and c.env.outgoing context properties that expose the underlying Node.js streams:
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { cors } from 'hono/cors';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = new Hono();
app.use('*', cors({ exposeHeaders: ['Mcp-Session-Id'] }));
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (c) => {
const sessionId = c.req.header('mcp-session-id');
let transport = sessionId ? sessions.get(sessionId) : undefined;
if (!transport) {
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
const mcpServer = createMcpServer();
await mcpServer.connect(transport);
if (transport.sessionId) sessions.set(transport.sessionId, transport);
}
// c.env.incoming / c.env.outgoing are the Node.js req/res streams
const { incoming, outgoing } = c.env as { incoming: IncomingMessage; outgoing: ServerResponse };
incoming.body = await c.req.json(); // Pre-parse JSON for the transport
await transport.handleRequest(incoming, outgoing, incoming.body);
return new Response(null); // Hono requires a Response return value
});
On Cloudflare Workers, where there are no Node.js streams, the pattern is stateless: each request creates a new transport with sessionIdGenerator: undefined, processes the MCP call, and returns. There is no session Map — all state that needs to persist across calls must be stored in Durable Objects or KV. This is the correct architecture for Workers' isolated execution model.
Silent failure mode: Regional edge failures. A Hono MCP server deployed to Cloudflare Workers may function correctly in one PoP (point of presence) but silently error in another if a binding — a KV namespace, a Durable Object, a secret — is misconfigured in that region. Cloudflare's own dashboard shows the server as "healthy" because the Worker process is running; only an external probe from that region catches the failure.
NestJS: dependency injection and lifecycle hooks
NestJS's module system enforces a structured architecture that maps cleanly onto MCP server components — McpController for routes, McpToolsService for tool registration, McpSessionService for the session registry. The DI container manages lifecycle, which is useful but introduces two integration requirements that have no equivalent in Express or Fastify:
Requirement 1 — Tool registration in onModuleInit, not the constructor: NestJS providers are constructed before all dependencies are resolved. A service that injects DatabaseService and tries to call it in the constructor will fail — the database connection isn't ready yet. Register MCP tools in onModuleInit, which NestJS calls after all dependencies are available:
@Injectable()
export class McpToolsService implements OnModuleInit {
constructor(
private readonly db: DatabaseService,
private readonly sessions: McpSessionService,
) {}
// Do NOT register tools in constructor — db is not ready yet
onModuleInit() {
this.sessions.server.tool(
'query_users',
'Query the user table',
{ limit: z.number().default(10) },
async ({ limit }) => {
const users = await this.db.query('SELECT * FROM users LIMIT ?', [limit]);
return { content: [{ type: 'text', text: JSON.stringify(users) }] };
}
);
}
}
Requirement 2 — OnModuleDestroy for graceful shutdown: NestJS's shutdown hooks call onModuleDestroy on every provider when app.close() is called (which in turn is triggered by SIGTERM if you call app.enableShutdownHooks()). Use this to drain the session registry:
@Injectable()
export class McpSessionService implements OnModuleDestroy {
private sessions = new Map<string, StreamableHTTPServerTransport>();
server = new McpServer({ name: 'my-mcp-server', version: '1.0.0' });
async onModuleDestroy() {
for (const transport of this.sessions.values()) {
await transport.close();
}
}
}
Silent failure mode: NestJS's DI container silently swallows errors thrown in provider constructors in some configurations — the provider is registered as "undefined" and dependent services receive null. An MCP tool that tries to use a failed service throws Cannot read properties of undefined (reading 'query') at call time, not at startup. The NestJS health endpoint returns HTTP 200 because the controller started — but every tool call returns a JSON-RPC error response. External monitoring that calls a sentinel tool catches this; HTTP 200 monitoring misses it entirely.
Koa: the ctx.respond = false requirement
Koa's async middleware model is elegant, but it includes automatic response finalization: after every middleware chain completes (including await next()), Koa writes whatever is in ctx.body to the socket and closes the connection. This happens even if the route handler has not explicitly called res.end().
MCP's SSE stream requires keeping the connection open for the lifetime of the session — potentially minutes. When Koa finalizes the response after the middleware chain, the SSE stream closes immediately after the first event (or before the first event if you're using Koa's JSON body handling).
The fix is a single line: ctx.respond = false. This tells Koa to skip its response finalization entirely and leave the underlying Node.js socket open for the route handler to manage:
import Koa from 'koa';
import Router from '@koa/router';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const app = new Koa();
const router = new Router();
const sessions = new Map<string, StreamableHTTPServerTransport>();
router.post('/mcp', async (ctx) => {
ctx.respond = false; // REQUIRED — without this, Koa closes the connection immediately
const sessionId = ctx.get('mcp-session-id');
let transport = sessionId ? sessions.get(sessionId) : undefined;
if (!transport) {
transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
const mcpServer = createMcpServer();
await mcpServer.connect(transport);
if (transport.sessionId) sessions.set(transport.sessionId, transport);
}
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
});
router.get('/mcp', async (ctx) => {
ctx.respond = false; // Also required here for SSE streaming
const transport = sessions.get(ctx.get('mcp-session-id'));
if (!transport) { ctx.status = 404; ctx.respond = true; return; }
await transport.handleRequest(ctx.req, ctx.res);
});
Koa's body parsing via koa-body must also be configured with includeUnparsed: true — the MCP SDK needs to see the raw request body to parse it as JSON-RPC before Koa's body parser has consumed the stream.
Silent failure mode: Without ctx.respond = false, the SSE connection opens (HTTP 200 with correct headers) and immediately closes. The MCP client sees a successful HTTP upgrade followed by an immediate EOF on the stream. This looks like a network interruption, not a server error — the client retries, hits the same issue, and the retry loop exhausts the backoff budget. Nothing in Koa's error log or metrics shows a failure. An external probe that attempts to receive the first SSE event (not just open the connection) catches this immediately.
Framework selection guide
No framework is universally better for MCP servers — the right choice depends on your deployment target, team familiarity, and performance requirements:
| Use case | Recommended framework | Why |
|---|---|---|
| Getting started, team knows Node.js | Express | Lowest friction, most examples, no integration surprises |
| High-concurrency (1000+ sessions) | Fastify | Lower overhead per request; schema validation catches bad clients early |
| Edge deployment (Cloudflare Workers, Deno) | Hono | Only framework that runs natively on all edge runtimes without shims |
| Enterprise app with existing NestJS codebase | NestJS | MCP integrates cleanly into existing DI module structure; auth guard reuse |
| Team prefers async/await middleware style | Koa | Clean middleware composition; ctx.respond = false is the only major gotcha |
The shared monitoring gap
All five frameworks have the same monitoring blind spot: framework-level health checks (NestJS's TerminusModule, Express's /health route, Koa's middleware returning 200) verify that the HTTP server is responding. They do not verify that the MCP protocol is functioning correctly.
An MCP server can return HTTP 200 on /health while silently failing in four ways that a health check route cannot detect:
| Silent failure | HTTP 200 on /health? | MCP protocol probe detects it? |
|---|---|---|
| Tool registration failed at startup (db dependency missing) | Yes | Yes — tools/list returns empty array |
Fastify reply.raw missing — SSE closes immediately | Yes | Yes — SSE stream closes before first event |
Koa ctx.respond = false missing | Yes | Yes — SSE stream closes within 100ms |
| Session Map not evicting stale sessions — OOM approaching | Yes | Partially — slow response time detectable before OOM crash |
| SIGTERM handler missing — sessions dropped on every deploy | Yes | Yes — new sessions after SIGTERM fail to initialize |
AliveMCP probes the full MCP protocol every 60 seconds: it sends initialize, calls tools/list, and runs a lightweight sentinel tool. If any layer in the framework-to-protocol stack fails, the probe catches it. The public status page shows your server's health in real time so users see evidence of reliability before they commit to integrating with your MCP server.
Cross-cutting checklist
Before shipping an MCP server on any of these frameworks, verify each item:
- Three routes implemented: POST, GET, DELETE on the same path (
/mcpor your chosen endpoint) - Session registry with eviction: TTL-based cleanup to prevent memory growth from crashed clients
- CORS with exposed Mcp-Session-Id: Browser-based clients cannot read session IDs without
Access-Control-Expose-Headers: Mcp-Session-Id - SIGTERM drain: Close all transports cleanly before process exit
- Framework-specific gotcha fixed: Fastify
reply.raw, Koactx.respond = false, NestJSonModuleInitregistration - Protocol smoke test after every deploy: Curl the
initializeendpoint and verify the response includesprotocolVersion - External protocol monitoring: Connect to AliveMCP so you are alerted when any of the above degrades in production
Deep dives
- MCP server with Express.js — session management, CORS, and graceful shutdown
- MCP server with Fastify — raw body parser and reply.raw for SSE streaming
- MCP server with Hono — Fetch-API-native and edge-deployable
- MCP server with NestJS — DI module structure and lifecycle hooks
- MCP server with Koa — ctx.respond = false and async middleware
- MCP server zero-downtime deployment — session drain and rolling updates
- MCP server health checks — protocol probes and readiness verification