Guide · HTTP Frameworks
MCP server Express — HTTP transport with Express.js
Express.js remains the most widely deployed Node.js HTTP framework, and it integrates cleanly with the MCP SDK's StreamableHTTPServerTransport. This guide walks through building a production-grade MCP server on Express — covering session management, CORS for browser clients, graceful shutdown, and a health endpoint that AliveMCP can probe every minute to keep you ahead of any outage.
TL;DR
Install @modelcontextprotocol/sdk, express, and cors. Mount StreamableHTTPServerTransport on a POST /mcp route, store sessions in a Map<string, StreamableHTTPServerTransport>, and expose a GET /health endpoint. Wire up AliveMCP to probe /health every 60 seconds so you're alerted the moment the server goes dark.
Project setup and dependencies
Start with a plain TypeScript Node.js project and install the four packages you need: the MCP SDK, Express itself, the cors middleware, and uuid for generating session identifiers.
npm init -y
npm install @modelcontextprotocol/sdk express cors uuid
npm install -D typescript @types/express @types/cors @types/node @types/uuid tsx
Create a tsconfig.json targeting ES2022 with "moduleResolution": "bundler" so that the MCP SDK's ESM package exports resolve correctly under ts-node and tsx.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
The MCP SDK ships as an ES module. If you run the compiled output with node dist/index.js you also need "type": "module" in package.json. During development, tsx src/index.ts handles transpilation automatically.
Creating the Express app with StreamableHTTPServerTransport
The StreamableHTTPServerTransport from @modelcontextprotocol/sdk/server/streamableHttp.js wraps Node.js's IncomingMessage and ServerResponse directly — which Express exposes through req and res. Each MCP session gets its own transport instance stored in a Map keyed by the session ID that the client sends in the Mcp-Session-Id header.
import express, { Request, Response } from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
const app = express();
// Session store: one transport per active MCP session
const sessions = new Map<string, StreamableHTTPServerTransport>();
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*',
allowedHeaders: ['Content-Type', 'Mcp-Session-Id', 'Authorization'],
exposedHeaders: ['Mcp-Session-Id'],
}));
app.use(express.json());
// Health endpoint — monitored by AliveMCP
app.get('/health', (_req, res) => {
res.json({ status: 'ok', sessions: sessions.size, ts: Date.now() });
});
app.post('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
// Resume existing session
const transport = sessions.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
return;
}
// New session — must be an initialize request
if (!isInitializeRequest(req.body)) {
res.status(400).json({ error: 'Expected initialize request for new session' });
return;
}
const newId = uuidv4();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newId,
onsessioninitialized: (id) => { sessions.set(id, transport); },
});
transport.onclose = () => { sessions.delete(newId); };
const server = createMcpServer(); // see next section
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.get('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !sessions.has(sessionId)) {
res.status(404).json({ error: 'Unknown session' });
return;
}
await sessions.get(sessionId)!.handleRequest(req, res);
});
app.delete('/mcp', async (req: Request, res: Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
await sessions.get(sessionId)!.close();
sessions.delete(sessionId);
}
res.status(204).end();
});
Notice that the GET /mcp route handles server-sent events for clients that open a long-lived SSE stream for notifications. The DELETE /mcp route lets well-behaved clients explicitly clean up their sessions, keeping the sessions map lean.
Defining tools with McpServer
The McpServer class from @modelcontextprotocol/sdk/server/mcp.js provides a fluent API for registering tools, resources, and prompts. Each call to server.tool() or server.resource() is decoupled from the transport, so you can reuse the same factory function across tests and the live server.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
function createMcpServer(): McpServer {
const server = new McpServer({
name: 'my-express-mcp',
version: '1.0.0',
});
server.tool(
'echo',
'Echoes the provided message back to the caller',
{ message: z.string().describe('Message to echo') },
async ({ message }) => ({
content: [{ type: 'text', text: message }],
})
);
server.tool(
'current_time',
'Returns the current server UTC time',
{},
async () => ({
content: [{ type: 'text', text: new Date().toISOString() }],
})
);
return server;
}
Each invocation of createMcpServer() builds a fresh server instance tied to a single transport and therefore a single session. This isolation prevents one client's tool state from leaking into another's — a common source of subtle bugs in naive MCP server implementations.
The MCP HTTP transport has no built-in health-check primitive. A client connecting to a broken server only discovers the failure when it tries to call a tool and gets a transport error. That's why probing the /health endpoint from outside — using a service like AliveMCP — is the only reliable way to detect outages before real users encounter them.
CORS configuration for browser-based MCP clients
Browser-hosted AI agents and IDE extensions that make direct HTTP requests to your MCP server are subject to the same-origin policy. Without correct CORS headers, preflight requests will fail and the client will never reach your /mcp route. The cors npm package makes this straightforward in Express.
import cors from 'cors';
const corsOptions: cors.CorsOptions = {
// In production, list specific origins instead of '*'
origin: (origin, callback) => {
const allowed = (process.env.ALLOWED_ORIGINS ?? '').split(',').filter(Boolean);
if (!origin || allowed.length === 0 || allowed.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS: origin ${origin} not allowed`));
}
},
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Mcp-Session-Id', 'Authorization'],
exposedHeaders: ['Mcp-Session-Id'],
credentials: true,
maxAge: 86400, // cache preflight for 24 h
};
app.use(cors(corsOptions));
app.options('/mcp', cors(corsOptions)); // explicit preflight handler
The Mcp-Session-Id header must appear in both allowedHeaders (so the browser sends it) and exposedHeaders (so the browser JavaScript can read the value the server returns). Forgetting either half breaks session resumption silently — the client just starts a new session on every request instead of reusing the existing one.
For authentication flows where the MCP client sends an Authorization: Bearer <token> header, see the MCP server authentication guide which covers token validation middleware you can drop into this same Express app.
Graceful shutdown and SIGTERM handling
Container orchestrators — Kubernetes, ECS, Fly.io — send SIGTERM before forcibly killing your process. You have a window (typically 30 seconds) to finish in-flight requests and close open transports. If you skip this step, clients with active SSE streams get a hard disconnect and their next tool call returns a confusing error.
const PORT = Number(process.env.PORT ?? 3000);
const httpServer = app.listen(PORT, () => {
console.log(`MCP server listening on port ${PORT}`);
});
async function shutdown(signal: string) {
console.log(`${signal} received — shutting down`);
// Stop accepting new connections
httpServer.close();
// Close all active MCP sessions gracefully
const closePromises = Array.from(sessions.values()).map((t) => t.close());
await Promise.allSettled(closePromises);
sessions.clear();
console.log('All sessions closed');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
The Promise.allSettled call ensures that even if one transport's close() throws, the remaining sessions are still cleaned up. After shutdown, AliveMCP will detect the /health endpoint going offline and fire an alert — giving you visibility into whether a rolling deployment completed successfully or left the server in a degraded state.
For backward compatibility with clients that only support the older SSE transport (pre-2025 SDK versions), you can also mount an SSEServerTransport from @modelcontextprotocol/sdk/server/sse.js on a separate /sse route. See the SSE transport guide for details on running both transports side by side.
Frequently asked questions
Why does my Express MCP server work locally but fail behind a reverse proxy?
The most common culprit is the proxy stripping or not forwarding the Mcp-Session-Id header. Nginx requires an explicit proxy_pass_header Mcp-Session-Id; directive. Also confirm that the proxy is not buffering the SSE response — set proxy_buffering off; and proxy_cache off; on the GET /mcp route. If the proxy adds SSL termination, also verify that the Host and X-Forwarded-Proto headers are forwarded so any redirect logic in your app produces correct URLs.
How do I persist MCP sessions across Express process restarts?
The in-memory Map approach doesn't survive restarts. For persistence, serialize session state to Redis using a library like ioredis, keyed by session ID. On startup, rehydrate the map from Redis. However, note that StreamableHTTPServerTransport itself is stateful in memory (it holds open HTTP response objects), so true cross-process session reuse isn't straightforward — in practice, clients that reconnect after a restart simply start a new session. The more important goal is that AliveMCP detects the restart and alerts you before clients encounter errors.
Can I run multiple MCP servers on the same Express app?
Yes. Mount each server factory under a different path prefix, e.g. /mcp/v1 and /mcp/v2, each with its own session map and transport factory. The client specifies the path in its transport configuration. This pattern is useful for versioning your MCP API or serving different tool sets to different client tiers without running separate Node.js processes.
What HTTP status codes should my MCP Express server return?
The MCP spec over HTTP says: 200 for a successful tool call response (the body is JSON-RPC), 202 Accepted when using the streaming pattern where the real response comes on the SSE channel, 400 for malformed requests (not an isInitializeRequest for a new session), 404 for unknown session IDs on GET/DELETE, and 500 for unhandled server errors. Returning the correct codes lets intermediate proxies, load balancers, and monitoring tools interpret your server's health accurately.
How does AliveMCP monitor an Express MCP server?
AliveMCP sends an HTTP GET to your /health endpoint (or any URL you configure) every 60 seconds from multiple geographic regions. If it receives a non-2xx response or the request times out, it immediately fires an alert via email, Slack, webhook, or PagerDuty. Because the MCP protocol itself has no built-in heartbeat mechanism, this external probe is the only way to know your server is reachable before a client discovers the problem mid-session. You can configure AliveMCP at alivemcp.com in under two minutes — just paste your health URL and set your notification channel.
Further reading
- MCP server health checks — designing a robust /health endpoint
- MCP server SSE transport — backward-compatible streaming
- MCP server authentication — JWT and API key middleware
- MCP server rate limiting — protecting tool call endpoints
- MCP server Docker — containerizing and deploying MCP servers
- MCP server uptime monitoring — tools and strategies