ext-apps/examples/shared/server-utils.ts
Olivier Chafik 3a30e94501
feat(examples): add SSE transport support and shared server utility (#136)
* 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>
2025-12-12 13:10:10 +00:00

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);
});
}