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:

// 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 failureHTTP 200 on /health?MCP protocol probe detects it?
Tool registration failed at startup (db dependency missing)YesYes — tools/list returns empty array
Fastify reply.raw missing — SSE closes immediatelyYesYes — SSE stream closes before first event
Koa ctx.respond = false missingYesYes — SSE stream closes within 100ms
Session Map not evicting stale sessions — OOM approachingYesPartially — slow response time detectable before OOM crash
SIGTERM handler missing — sessions dropped on every deployYesYes — 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:

Deep dives

Know when your MCP server is down — before users do

AliveMCP probes your MCP endpoint every 60 seconds with a full protocol check — initialize, tools/list, sentinel tool call — regardless of which framework you use. Free for public endpoints.

Start monitoring free