@modelcontextprotocol/server-everything
Version:
MCP server that exercises all the features of the MCP protocol
339 lines (338 loc) • 14.4 kB
JavaScript
// The Streamable HTTP transport manager: one `/mcp` endpoint serving both
// protocol eras (#4852).
//
// - 2026-07-28 traffic (a request carrying the per-request `_meta` envelope)
// goes to the SDK's `createMcpHandler`, which builds a fresh server per
// request: no sessions, `server/discover` instead of `initialize`, change
// notifications on `subscriptions/listen` streams published through the
// handler's bus, and input requests in-band (`input_required`).
// - 2025-era traffic keeps the sessionful wiring it had: one server per
// session, created on the initialize POST, with GET for the standalone SSE
// stream, DELETE to end the session, and an event store for resumability.
// The SDK's default 2025-era serving is stateless, which would leave no
// channel for server-to-client requests, so elicitation, sampling and roots
// would stop working for 2025-era HTTP clients; routing them here keeps them
// working, the pattern the SDK documents for an existing sessionful
// deployment.
//
// A request with an `Mcp-Session-Id`, a GET and a DELETE are 2025-era by
// definition, since 2026-07-28 has none of them. A session-less POST is
// classified by the SDK's own `isLegacyRequest`, from a body parsed once and
// handed to whichever side serves it.
//
// `createApp()` builds the Express app without binding a port or installing a
// signal handler (#4854), so a test can serve it on a free port, drive it with
// a real client, and call its shutdown handler directly; each app has its own
// session map and its own 2026-07-28 handler. `startStreamableHttpServer()` is
// what the launcher runs.
import { createMcpHandler, isJSONRPCRequest, isLegacyRequest, } from "@modelcontextprotocol/server";
import { localhostOriginValidation, NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest, } from "@modelcontextprotocol/node";
import express from "express";
import { createServer } from "../server/index.js";
import { randomUUID } from "node:crypto";
import cors from "cors";
import { bindHost, hostAndPort, listenOrExit } from "./listen.js";
import { InMemoryEventStore } from "./in-memory-event-store.js";
/** The most bytes of a refused POST's body read to find its request id. */
const MAX_ID_BODY_BYTES = 64 * 1024;
/**
* The JSON-RPC `id` of a POST's body, or `null` when the body is not a single
* JSON-RPC request (a notification, a response, a batch, any other JSON,
* malformed JSON, or larger than `MAX_ID_BODY_BYTES`). Used only for a POST that is refused before the SDK
* reads its body, so the error can still carry the request's id (#4982).
*/
async function readRequestId(req) {
try {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_ID_BODY_BYTES)
return null;
chunks.push(chunk);
}
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
return isJSONRPCRequest(message) ? message.id : null;
}
catch {
return null;
}
}
/**
* Answer a request whose `Mcp-Session-Id` this app does not know, or has
* ended: `404 Not Found`, which the spec makes the client's signal to start a
* new session (#4982). Same shape as the SDK's own "Session not found".
*/
function sessionNotFound(res, id = null) {
res.status(404).json({
jsonrpc: "2.0",
error: { code: -32001, message: "Session not found" },
id,
});
}
/** The most bytes of a session-less POST body parsed to classify it (4 MiB, the SDK's own bound). */
const MAX_BODY_BYTES = 4 * 1024 * 1024;
/** Parses a session-less POST's JSON body, to classify it and to serve it. */
const parseJson = express.json({
limit: MAX_BODY_BYTES,
type: () => true,
// Any JSON value parses, so a primitive body reaches the SDK, which answers
// it as an invalid request (-32600); -32700 is only for text that is not JSON.
strict: false,
});
/**
* Answer a session-less POST whose body could not be parsed the way the SDK
* answers one: a JSON-RPC error with a null id, as 400 (not JSON) or 413 (too
* large). Anything else goes on to Express's error handling.
*/
function bodyError(error, _req, res, next) {
if (error.type === "entity.parse.failed") {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32700,
message: "Parse error: the request body is not valid JSON",
},
id: null,
});
}
else if (error.type === "entity.too.large") {
res.status(413).json({
jsonrpc: "2.0",
error: { code: -32600, message: "Request body too large" },
id: null,
});
}
else {
next(error);
}
}
/**
* Build the Streamable HTTP transport's Express app: Origin validation, CORS,
* the `/mcp` POST, GET and DELETE routes, the map from session id to
* transport that they share, the 2026-07-28 handler, and the shutdown handler
* that closes them.
*/
export function createApp() {
const app = express();
// A browser page may reach this server only from a localhost origin. The
// SDK's guard answers any other Origin with a 403 before a route or the
// CORS middleware sees it, preflights included; a request with no Origin
// (a non-browser client) passes. CORS then grants the origin that passed,
// so the Inspector's direct connect mode keeps working from localhost.
const allowOrigin = localhostOriginValidation();
app.use((req, res, next) => {
if (allowOrigin(req, res))
next();
});
app.use(cors({
origin: true,
methods: "GET,POST,DELETE",
preflightContinue: false,
optionsSuccessStatus: 204,
exposedHeaders: [
"mcp-session-id",
"last-event-id",
"mcp-protocol-version",
],
}));
// The 2026-07-28 side: a fresh server per request, built for the modern
// era. Its change notifications go out on the handler's subscription bus,
// which reaches every open subscriptions/listen stream that asked for them.
// It refuses 2025-era traffic, which the routes below never send it.
const modern = createMcpHandler(() => createServer({
era: "modern",
publish: {
toolsChanged: () => modern.notify.toolsChanged(),
promptsChanged: () => modern.notify.promptsChanged(),
resourcesChanged: () => modern.notify.resourcesChanged(),
resourceUpdated: (uri) => modern.notify.resourceUpdated(uri),
},
}).server, {
legacy: "reject",
onerror: (error) => console.log("Error serving MCP request:", error),
});
const serveModern = toNodeHandler(modern);
// Map sessionId to server transport for each client
const transports = new Map();
// A session-less POST's body is parsed once, to classify it and serve it.
// A POST with a session id is a 2025-era session's, and its transport reads
// the body itself.
app.post("/mcp", (req, res, next) => req.headers["mcp-session-id"] ? next() : parseJson(req, res, next), bodyError);
// Handle POST requests for client messages
app.post("/mcp", async (req, res) => {
console.log("Received MCP POST request");
try {
// Check for existing session ID
const sessionId = req.headers["mcp-session-id"];
// A session-less POST that carries the 2026-07-28 envelope is served
// by the modern handler; everything else stays on the 2025-era path.
if (!sessionId &&
!(await isLegacyRequest(await toWebRequest(req, req.body)))) {
await serveModern(req, res, req.body);
return;
}
let transport;
if (sessionId && transports.has(sessionId)) {
// Reuse existing transport
transport = transports.get(sessionId);
}
else if (!sessionId) {
const { server, cleanup } = createServer();
// New initialization request
const eventStore = new InMemoryEventStore();
transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // Enable resumability
onsessioninitialized: (sessionId) => {
// Store the transport by session ID when a session is initialized
// This avoids race conditions where requests might come in before the session is stored
console.log(`Session initialized with ID: ${sessionId}`);
transports.set(sessionId, transport);
},
});
// Set up onclose handler to clean up transport when closed
server.server.onclose = async () => {
const sid = transport.sessionId;
if (sid && transports.has(sid)) {
console.log(`Transport closed for session ${sid}, removing from transports map`);
transports.delete(sid);
cleanup();
}
};
// Connect the transport to the MCP server BEFORE handling the request
// so responses can flow back through the same transport
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
return;
}
else {
// A session ID this app does not know, or has already ended
sessionNotFound(res, await readRequestId(req));
return;
}
// Handle the request with existing transport - no need to reconnect
// The existing transport is already connected to the server
await transport.handleRequest(req, res);
}
catch (error) {
console.log("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: req?.body?.id,
});
return;
}
}
});
// Handle GET requests for SSE streams
app.get("/mcp", async (req, res) => {
console.log("Received MCP GET request");
const sessionId = req.headers["mcp-session-id"];
if (!sessionId) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
if (!transports.has(sessionId)) {
sessionNotFound(res);
return;
}
// Check for Last-Event-ID header for resumability
const lastEventId = req.headers["last-event-id"];
if (lastEventId) {
console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
}
else {
console.log(`Establishing new SSE stream for session ${sessionId}`);
}
const transport = transports.get(sessionId);
await transport.handleRequest(req, res);
});
// Handle DELETE requests for session termination
app.delete("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"];
if (!sessionId) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: req?.body?.id,
});
return;
}
if (!transports.has(sessionId)) {
sessionNotFound(res);
return;
}
console.log(`Received session termination request for session ${sessionId}`);
try {
const transport = transports.get(sessionId);
await transport.handleRequest(req, res);
}
catch (error) {
console.log("Error handling session termination:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Error handling session termination",
},
id: req?.body?.id,
});
return;
}
}
});
// Handle server shutdown
const shutdown = async () => {
console.log("Shutting down server...");
// End the 2026-07-28 requests and subscription streams in flight
await modern.close();
// Close all active transports to properly clean up resources
// (a snapshot, since closing a transport removes it from the map)
for (const [sessionId, transport] of [...transports]) {
try {
console.log(`Closing transport for session ${sessionId}`);
await transport.close();
transports.delete(sessionId);
}
catch (error) {
console.log(`Error closing transport for session ${sessionId}:`, error);
}
}
console.log("Server shutdown complete");
process.exit(0);
};
return { app, shutdown };
}
/**
* Start the Streamable HTTP server as the launcher does: build the app, bind
* it to `PORT` (default 3001) on `HOST` (default loopback), and install its
* shutdown handler for SIGINT.
*/
export function startStreamableHttpServer() {
console.log("Starting Streamable HTTP server...");
const { app, shutdown } = createApp();
// Start the server
const PORT = process.env.PORT || 3001;
const HOST = bindHost();
const httpServer = listenOrExit(app, PORT, HOST, `MCP Streamable HTTP Server listening on ${hostAndPort(HOST, PORT)}`);
// Handle server shutdown
process.on("SIGINT", shutdown);
return httpServer;
}