* Add Playwright E2E tests with screenshot golden testing - Add E2E tests for all 8 MCP server examples - Screenshot golden images for visual regression testing - CI workflow for running E2E tests - npm scripts: test:e2e, test:e2e:update, test:e2e:ui 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add default demo code for Three.js example When no code is provided, show a rotating green cube demo. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add E2E testing documentation to CONTRIBUTING.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update Three.js golden screenshot with 3D cube 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add explicit permissions to CI workflow Set minimal read-only permissions for security best practices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix test.setTimeout() to be inside describe blocks Playwright requires setTimeout to be called within a test or describe block. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Simplify E2E tests - remove excessive timeouts - Remove explicit setTimeout calls (default 30s is sufficient) - Replace waitForTimeout(5000/6000) with proper waitForAppLoad() - Wait for inner iframe visibility instead of fixed delays - Keep only 500ms stabilization for screenshot animations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Auto-generate missing snapshots in CI Use updateSnapshots: 'missing' in CI to handle cross-platform screenshot differences (macOS vs Linux). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Use platform-agnostic golden screenshots - Remove -chromium-darwin suffix from snapshot filenames - Configure snapshotPathTemplate for cross-platform compatibility - Increase tolerance to 5% for rendering differences between platforms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Refactor tests to use forEach instead of for-of loop May help with Playwright test discovery issues in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Use npm ci and list reporter in CI - Use npm ci for exact package versions - Use --reporter=list instead of html to avoid potential re-evaluation issues - Only upload test-results on failure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Exclude e2e tests from bun test Run bun test only on src/ directory to avoid running Playwright tests with Bun's test runner. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix prettier formatting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add interaction tests for basic server apps Test that clicking buttons in the MCP App triggers the corresponding host callbacks: - Send Message → host logs "Message from MCP App" - Send Log → host logs "Log message from MCP App" - Open Link → host logs "Open link request" Tests both React and Vanilla JS implementations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Mask dynamic content in E2E screenshot tests Add Playwright mask option to handle servers with dynamic/random content: - basic-react/vanillajs: mask server time display - system-monitor: mask CPU chart, memory stats, uptime - cohort-heatmap: mask heatmap grid (random data) - customer-segmentation: mask scatter chart (random data) This addresses PR feedback about handling examples with non-deterministic output. Masking replaces the previous 10% tolerance with proper exclusion of dynamic elements, allowing tighter 1% tolerance for the rest of the UI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add wiki-explorer to E2E tests with default URL param - Add default URL "https://en.wikipedia.org/wiki/Model_Context_Protocol" to wiki-explorer-server's get-first-degree-links tool inputSchema - Update basic-host to automatically populate input field with tool defaults extracted from inputSchema.properties - Add wiki-explorer-server to E2E test suite with dynamic masking for the force-directed graph 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Use .default() for threejs tool schema instead of .optional() This exposes the default code and height values in the JSON Schema, allowing basic-host to auto-populate the input field with defaults. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Enable parallel E2E tests with timeouts and canvas masking - Enable fullyParallel with 4 workers locally (2 in CI) - Add 30s timeout per test - Mask threejs canvas for stable screenshots - Update snapshots to reflect default values in input fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Add pre-commit check for private registry URLs in package-lock.json Same check as in CI to catch issues before push. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Increase screenshot diff tolerance to 6% for cross-platform rendering Font rendering differs between macOS and Linux, causing ~5% pixel differences. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Revert pre-commit artifactory check (moved to separate PR #133) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Format threejs server.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update CONTRIBUTING.md to reflect platform-agnostic screenshots 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(e2e): use factory pattern for MCP servers to support parallel connections McpServer only supports one transport at a time. When multiple browser contexts connected in parallel, calling server.connect(transport) for each session overwrote the previous transport's callbacks, causing connection corruption and test timeouts. Changes: - server-utils.ts: accept factory function instead of single instance - All 9 example servers: wrap server creation in createServer() function - playwright.config.ts: re-enable parallel execution (4 workers) - servers.spec.ts: add toBeEnabled wait for server connection - Update screenshot baselines for basic-vanillajs and cohort-heatmap 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 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;
|
|
|
|
/** Session state: transport + its dedicated server instance */
|
|
interface Session {
|
|
transport: Transport;
|
|
server: McpServer;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @param createServer - Factory function that creates a new McpServer instance per session.
|
|
* Each session needs its own server because McpServer only supports
|
|
* one transport at a time.
|
|
*/
|
|
export async function startServer(
|
|
createServer: () => McpServer,
|
|
options: ServerOptions,
|
|
): Promise<void> {
|
|
const { port, name = "MCP Server" } = options;
|
|
|
|
// Session store: each session has its own transport AND server instance
|
|
const sessions = new Map<string, Session>();
|
|
|
|
// 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 session = sessionId ? sessions.get(sessionId) : undefined;
|
|
|
|
// Session exists but wrong transport type
|
|
if (
|
|
session &&
|
|
!(session.transport instanceof StreamableHTTPServerTransport)
|
|
) {
|
|
return res.status(400).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32000, message: "Session uses different transport" },
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
// New session requires initialize request
|
|
if (!session) {
|
|
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,
|
|
});
|
|
}
|
|
|
|
// Create new server instance for this session (McpServer supports only one transport)
|
|
const serverInstance = createServer();
|
|
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
onsessioninitialized: (id) => {
|
|
sessions.set(id, { transport, server: serverInstance });
|
|
},
|
|
});
|
|
transport.onclose = () => {
|
|
if (transport.sessionId) sessions.delete(transport.sessionId);
|
|
};
|
|
await serverInstance.connect(transport);
|
|
session = { transport, server: serverInstance };
|
|
}
|
|
|
|
await (session.transport as StreamableHTTPServerTransport).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 {
|
|
// Create new server instance for this session (McpServer supports only one transport)
|
|
const serverInstance = createServer();
|
|
const transport = new SSEServerTransport("/messages", res);
|
|
sessions.set(transport.sessionId, { transport, server: serverInstance });
|
|
res.on("close", () => sessions.delete(transport.sessionId));
|
|
await serverInstance.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 session = sessions.get(req.query.sessionId as string);
|
|
if (!session || !(session.transport instanceof SSEServerTransport)) {
|
|
return res.status(404).json({
|
|
jsonrpc: "2.0",
|
|
error: { code: -32001, message: "Session not found" },
|
|
id: null,
|
|
});
|
|
}
|
|
await session.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((session) => session.transport.close().catch(() => {}));
|
|
httpServer.close(() => process.exit(0));
|
|
};
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
});
|
|
}
|