* feat(examples): add SSE transport support to all example servers Add backwards-compatible SSE transport support alongside the current Streamable HTTP transport. Each server now exposes: - /mcp (GET, POST, DELETE) - Streamable HTTP transport (current spec) - /sse (GET) - Legacy SSE transport stream endpoint - /messages (POST) - Legacy SSE transport message endpoint This enables older clients using the deprecated HTTP+SSE protocol (version 2024-11-05) to connect to the example servers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(examples): migrate 6 servers to use shared server utility Refactored the following example servers to use the shared startServer utility from examples/shared/server-utils.ts: - threejs-server - system-monitor-server - cohort-heatmap-server - budget-allocator-server - customer-segmentation-server - scenario-modeler-server Changes for each server: - Removed direct imports: SSEServerTransport, StdioServerTransport, StreamableHTTPServerTransport, cors, express - Removed PORT constant (now handled by shared utility) - Added import for startServer from ../shared/server-utils.js - Replaced entire async main() function with single startServer() call - Preserved all business logic (tool registration, resource registration, helper functions) This reduces code duplication by ~700 lines and ensures consistent transport handling across all example servers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(examples): migrate remaining servers to shared utility Complete the migration of example servers to use the shared server-utils.ts module for transport setup: - basic-server-react - basic-server-vanillajs - wiki-explorer-server - Add shared/server-utils.ts This centralizes all transport handling (stdio, Streamable HTTP, SSE) in one place, reducing code duplication across ~500 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): improve server-utils with stateful sessions and security Address critical issues in the shared server utility: 1. Stateful sessions: Use sessionIdGenerator + onsessioninitialized to persist StreamableHTTPServerTransport across requests 2. Unified session store: Single Map for both transport types with proper type discrimination 3. Error handling: Try/catch on all endpoints with JSON-RPC errors 4. DNS rebinding protection: Use SDK's createMcpExpressApp helper 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): allow connections from any host for development Use host: "0.0.0.0" in createMcpExpressApp to disable DNS rebinding protection, allowing connections from Android emulators and other devices on the network. Also re-add CORS middleware. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(examples): use getPort() helper for transport selection - Add getPort() helper that returns undefined for --stdio, else port - Caller now explicitly passes port to startServer() - port: undefined → stdio mode, port: number → HTTP mode This makes the transport selection explicit at the call site. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): startServer returns promise that resolves/rejects on listen Use httpServer.on('listening') and httpServer.on('error') events to properly resolve or reject the returned promise. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): add error handling to startServer calls Exit with code 1 if server fails to start (e.g., port in use). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor(examples): update server files to use main() pattern with stdio support Update all example server files to use the new main() pattern that supports both HTTP and stdio transports: - Add StdioServerTransport import - Remove getPort import (replaced with direct env var parsing) - Replace direct startServer call with async main() function - Add --stdio flag detection to choose between transports - Use PORT env var with default 3001 for HTTP mode Updated servers: - basic-server-vanillajs - wiki-explorer-server - threejs-server - system-monitor-server - cohort-heatmap-server - budget-allocator-server - customer-segmentation-server - scenario-modeler-server Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): use correct default ports matching run-all.ts Each server now defaults to its assigned port from run-all.ts: - basic-server-react: 3101 - basic-server-vanillajs: 3102 - budget-allocator-server: 3103 - cohort-heatmap-server: 3104 - customer-segmentation-server: 3105 - scenario-modeler-server: 3106 - system-monitor-server: 3107 - threejs-server: 3108 - wiki-explorer-server: 3109 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(examples): expose mcp-session-id header for CORS requests Without exposedHeaders in the CORS config, browsers block JavaScript from reading the mcp-session-id response header. This caused the SDK's StreamableHTTPClientTransport to never capture the session ID, breaking all subsequent requests with "Bad request: not initialized" errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * pretty:fix --------- Co-authored-by: Claude <noreply@anthropic.com>
161 lines
4.9 KiB
TypeScript
161 lines
4.9 KiB
TypeScript
/**
|
|
* Shared utilities for running MCP servers with HTTP transports.
|
|
*
|
|
* Supports:
|
|
* - Streamable HTTP transport (/mcp) - stateful sessions
|
|
* - Legacy SSE transport (/sse, /messages) - backwards compatibility
|
|
*/
|
|
|
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
import cors from "cors";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Request, Response } from "express";
|
|
|
|
export interface ServerOptions {
|
|
/** Port to listen on (required). */
|
|
port: number;
|
|
/** Server name for logging. */
|
|
name?: string;
|
|
}
|
|
|
|
type Transport = StreamableHTTPServerTransport | SSEServerTransport;
|
|
|
|
/**
|
|
* Starts an MCP server with HTTP transports.
|
|
*
|
|
* Provides:
|
|
* - /mcp (GET/POST/DELETE): Streamable HTTP with stateful sessions
|
|
* - /sse (GET) + /messages (POST): Legacy SSE for older clients
|
|
*/
|
|
export async function startServer(
|
|
server: McpServer,
|
|
options: ServerOptions,
|
|
): Promise<void> {
|
|
const { port, name = "MCP Server" } = options;
|
|
|
|
// Unified session store for both transport types
|
|
const sessions = new Map<string, Transport>();
|
|
|
|
// Express app - bind to all interfaces for development/testing
|
|
const app = createMcpExpressApp({ host: "0.0.0.0" });
|
|
app.use(
|
|
cors({
|
|
exposedHeaders: ["mcp-session-id"],
|
|
}),
|
|
);
|
|
|
|
// Streamable HTTP (stateful)
|
|
app.all("/mcp", async (req: Request, res: Response) => {
|
|
try {
|
|
const sessionId = req.headers["mcp-session-id"] as string | undefined;
|
|
let transport = sessionId
|
|
? (sessions.get(sessionId) as StreamableHTTPServerTransport | undefined)
|
|
: undefined;
|
|
|
|
// Session exists but wrong transport type
|
|
if (sessionId && sessions.has(sessionId) && !transport) {
|
|
return res.status(400).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32000, message: "Session uses different transport" },
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
// New session requires initialize request
|
|
if (!transport) {
|
|
if (req.method !== "POST" || !isInitializeRequest(req.body)) {
|
|
return res.status(400).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32000, message: "Bad request: not initialized" },
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
onsessioninitialized: (id) => {
|
|
sessions.set(id, transport!);
|
|
},
|
|
});
|
|
const t = transport;
|
|
t.onclose = () => {
|
|
if (t.sessionId) sessions.delete(t.sessionId);
|
|
};
|
|
await server.connect(transport);
|
|
}
|
|
|
|
await transport.handleRequest(req, res, req.body);
|
|
} catch (error) {
|
|
console.error("MCP error:", error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32603, message: "Internal server error" },
|
|
id: null,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Legacy SSE
|
|
app.get("/sse", async (_req: Request, res: Response) => {
|
|
try {
|
|
const transport = new SSEServerTransport("/messages", res);
|
|
sessions.set(transport.sessionId, transport);
|
|
res.on("close", () => sessions.delete(transport.sessionId));
|
|
await server.connect(transport);
|
|
} catch (error) {
|
|
console.error("SSE error:", error);
|
|
if (!res.headersSent) res.status(500).end();
|
|
}
|
|
});
|
|
|
|
app.post("/messages", async (req: Request, res: Response) => {
|
|
try {
|
|
const transport = sessions.get(req.query.sessionId as string);
|
|
if (!(transport instanceof SSEServerTransport)) {
|
|
return res.status(404).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32001, message: "Session not found" },
|
|
id: null,
|
|
});
|
|
}
|
|
await transport.handlePostMessage(req, res, req.body);
|
|
} catch (error) {
|
|
console.error("Message error:", error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32603, message: "Internal server error" },
|
|
id: null,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
const httpServer = app.listen(port);
|
|
|
|
httpServer.on("listening", () => {
|
|
console.log(`${name} listening on http://localhost:${port}/mcp`);
|
|
resolve();
|
|
});
|
|
|
|
httpServer.on("error", (err: Error) => {
|
|
reject(err);
|
|
});
|
|
|
|
const shutdown = () => {
|
|
console.log("\nShutting down...");
|
|
sessions.forEach((t) => t.close().catch(() => {}));
|
|
httpServer.close(() => process.exit(0));
|
|
};
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
});
|
|
}
|