tests: add Playwright E2E tests with screenshot golden testing (+ fix examples session handling) (#115)

* 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>
This commit is contained in:
Olivier Chafik 2025-12-13 20:57:27 +00:00 • committed by GitHub
commit c9e37204de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 3045 additions and 1457 deletions

View file

@ -1,9 +1,30 @@
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo } from "./implementation";
import styles from "./index.module.css";
/**
* Extract default values from a tool's JSON Schema inputSchema.
* Returns a formatted JSON string with defaults, or "{}" if none found.
*/
function getToolDefaults(tool: Tool | undefined): string {
if (!tool?.inputSchema?.properties) return "{}";
const defaults: Record<string, unknown> = {};
for (const [key, prop] of Object.entries(tool.inputSchema.properties)) {
if (prop && typeof prop === "object" && "default" in prop) {
defaults[key] = prop.default;
}
}
return Object.keys(defaults).length > 0
? JSON.stringify(defaults, null, 2)
: "{}";
}
// Host passes serversPromise to CallToolPanel
interface HostProps {
serversPromise: Promise<ServerInfo[]>;
@ -74,6 +95,14 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
setSelectedServer(server);
const [firstTool] = server.tools.keys();
setSelectedTool(firstTool ?? "");
// Set input JSON to tool defaults (if any)
setInputJson(getToolDefaults(server.tools.get(firstTool ?? "")));
};
const handleToolSelect = (toolName: string) => {
setSelectedTool(toolName);
// Set input JSON to tool defaults (if any)
setInputJson(getToolDefaults(selectedServer?.tools.get(toolName)));
};
const handleSubmit = () => {
@ -96,7 +125,7 @@ function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) {
<select
className={styles.toolSelect}
value={selectedTool}
onChange={(e) => setSelectedTool(e.target.value)}
onChange={(e) => handleToolSelect(e.target.value)}
>
{selectedServer && toolNames.map((name) => (
<option key={name} value={name}>{name}</option>

View file

@ -7,25 +7,28 @@ import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
import { startServer } from "../shared/server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
const RESOURCE_URI = "ui://get-time/mcp-app.html";
const server = new McpServer({
name: "Basic MCP App Server (React-based)",
version: "1.0.0",
});
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
// resource (the UI it renders). The `_meta` field on the tool links to the
// resource URI, telling hosts which UI to display when the tool executes.
{
const resourceUri = "ui://get-time/mcp-app.html";
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Basic MCP App Server (React-based)",
version: "1.0.0",
});
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
// resource (the UI it renders). The `_meta` field on the tool links to the
// resource URI, telling hosts which UI to display when the tool executes.
server.registerTool(
"get-time",
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async (): Promise<CallToolResult> => {
const time = new Date().toISOString();
@ -36,8 +39,8 @@ const server = new McpServer({
);
server.registerResource(
resourceUri,
resourceUri,
RESOURCE_URI,
RESOURCE_URI,
{},
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
@ -46,19 +49,21 @@ const server = new McpServer({
contents: [
// Per the MCP App specification, "text/html;profile=mcp-app" signals
// to the Host that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3101", 10);
await startServer(server, { port, name: "Basic MCP App Server (React-based)" });
await startServer(createServer, { port, name: "Basic MCP App Server (React-based)" });
}
}

View file

@ -7,25 +7,28 @@ import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
import { startServer } from "../shared/server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
const RESOURCE_URI = "ui://get-time/mcp-app.html";
const server = new McpServer({
name: "Basic MCP App Server (Vanilla JS)",
version: "1.0.0",
});
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
// resource (the UI it renders). The `_meta` field on the tool links to the
// resource URI, telling hosts which UI to display when the tool executes.
{
const resourceUri = "ui://get-time/mcp-app.html";
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Basic MCP App Server (Vanilla JS)",
version: "1.0.0",
});
// MCP Apps require two-part registration: a tool (what the LLM calls) and a
// resource (the UI it renders). The `_meta` field on the tool links to the
// resource URI, telling hosts which UI to display when the tool executes.
server.registerTool(
"get-time",
{
title: "Get Time",
description: "Returns the current server time as an ISO 8601 string.",
inputSchema: {},
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async (): Promise<CallToolResult> => {
const time = new Date().toISOString();
@ -36,8 +39,8 @@ const server = new McpServer({
);
server.registerResource(
resourceUri,
resourceUri,
RESOURCE_URI,
RESOURCE_URI,
{},
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
@ -46,19 +49,21 @@ const server = new McpServer({
contents: [
// Per the MCP App specification, "text/html;profile=mcp-app" signals
// to the Host that this resource is indeed for an MCP App UI.
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3102", 10);
await startServer(server, { port, name: "Basic MCP App Server (Vanilla JS)" });
await startServer(createServer, { port, name: "Basic MCP App Server (Vanilla JS)" });
}
}

View file

@ -223,71 +223,79 @@ function generateHistory(
// MCP Server Setup
// ---------------------------------------------------------------------------
const server = new McpServer({
name: "Budget Allocator Server",
version: "1.0.0",
});
const resourceUri = "ui://budget-allocator/mcp-app.html";
server.registerTool(
"get-budget-data",
{
title: "Get Budget Data",
description:
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
inputSchema: {},
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (): Promise<CallToolResult> => {
const response: BudgetDataResponse = {
config: {
categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({
id,
name,
color,
defaultPercent,
})),
presetBudgets: [50000, 100000, 250000, 500000],
defaultBudget: 100000,
currency: "USD",
currencySymbol: "$",
},
analytics: {
history: generateHistory(CATEGORIES),
benchmarks: BENCHMARKS,
stages: ["Seed", "Series A", "Series B", "Growth"],
defaultStage: "Series A",
},
};
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Budget Allocator Server",
version: "1.0.0",
});
return {
content: [
{
type: "text",
text: JSON.stringify(response),
server.registerTool(
"get-budget-data",
{
title: "Get Budget Data",
description:
"Returns budget configuration with 24 months of historical allocations and industry benchmarks by company stage",
inputSchema: {},
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (): Promise<CallToolResult> => {
const response: BudgetDataResponse = {
config: {
categories: CATEGORIES.map(({ id, name, color, defaultPercent }) => ({
id,
name,
color,
defaultPercent,
})),
presetBudgets: [50000, 100000, 250000, 500000],
defaultBudget: 100000,
currency: "USD",
currencySymbol: "$",
},
],
};
},
);
analytics: {
history: generateHistory(CATEGORIES),
benchmarks: BENCHMARKS,
stages: ["Seed", "Series A", "Series B", "Growth"],
defaultStage: "Series A",
},
};
server.registerResource(
resourceUri,
resourceUri,
{ description: "Interactive Budget Allocator UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return {
content: [
{
type: "text",
text: JSON.stringify(response),
},
],
};
},
);
server.registerResource(
resourceUri,
resourceUri,
{ description: "Interactive Budget Allocator UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
return server;
}
// ---------------------------------------------------------------------------
// Server Startup
@ -295,10 +303,10 @@ server.registerResource(
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3103", 10);
await startServer(server, { port, name: "Budget Allocator Server" });
await startServer(createServer, { port, name: "Budget Allocator Server" });
}
}

View file

@ -147,13 +147,13 @@ function generateCohortData(
};
}
const server = new McpServer({
name: "Cohort Heatmap Server",
version: "1.0.0",
});
function createServer(): McpServer {
const server = new McpServer({
name: "Cohort Heatmap Server",
version: "1.0.0",
});
// Register tool and resource
{
// Register tool and resource
const resourceUri = "ui://get-cohort-data/mcp-app.html";
server.registerTool(
@ -200,14 +200,16 @@ const server = new McpServer({
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3104", 10);
await startServer(server, { port, name: "Cohort Heatmap Server" });
await startServer(createServer, { port, name: "Cohort Heatmap Server" });
}
}

View file

@ -51,62 +51,73 @@ function getCustomerData(segmentFilter?: string): {
};
}
const server = new McpServer({
name: "Customer Segmentation Server",
version: "1.0.0",
});
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Customer Segmentation Server",
version: "1.0.0",
});
// Register the get-customer-data tool and its associated UI resource
{
const resourceUri = "ui://customer-segmentation/mcp-app.html";
// Register the get-customer-data tool and its associated UI resource
{
const resourceUri = "ui://customer-segmentation/mcp-app.html";
server.registerTool(
"get-customer-data",
{
title: "Get Customer Data",
description:
"Returns customer data with segment information for visualization. Optionally filter by segment.",
inputSchema: GetCustomerDataInputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ segment }): Promise<CallToolResult> => {
const data = getCustomerData(segment);
server.registerTool(
"get-customer-data",
{
title: "Get Customer Data",
description:
"Returns customer data with segment information for visualization. Optionally filter by segment.",
inputSchema: GetCustomerDataInputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async ({ segment }): Promise<CallToolResult> => {
const data = getCustomerData(segment);
return {
content: [{ type: "text", text: JSON.stringify(data) }],
};
},
);
return {
content: [{ type: "text", text: JSON.stringify(data) }],
};
},
);
server.registerResource(
resourceUri,
resourceUri,
{ description: "Customer Segmentation Explorer UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
server.registerResource(
resourceUri,
resourceUri,
{ description: "Customer Segmentation Explorer UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
},
],
};
},
);
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
},
],
};
},
);
}
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3105", 10);
await startServer(server, { port, name: "Customer Segmentation Server" });
await startServer(createServer, {
port,
name: "Customer Segmentation Server",
});
}
}

View file

@ -242,67 +242,75 @@ const DEFAULT_INPUTS: ScenarioInputs = {
// MCP Server
// ============================================================================
const server = new McpServer({
name: "SaaS Scenario Modeler",
version: "1.0.0",
});
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "SaaS Scenario Modeler",
version: "1.0.0",
});
// Register tool and resource
{
const resourceUri = "ui://scenario-modeler/mcp-app.html";
// Register tool and resource
{
const resourceUri = "ui://scenario-modeler/mcp-app.html";
server.registerTool(
"get-scenario-data",
{
title: "Get Scenario Data",
description:
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
inputSchema: GetScenarioDataInputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (args: {
customInputs?: ScenarioInputs;
}): Promise<CallToolResult> => {
const customScenario = args.customInputs
? calculateScenario(args.customInputs)
: undefined;
server.registerTool(
"get-scenario-data",
{
title: "Get Scenario Data",
description:
"Returns SaaS scenario templates and optionally computes custom projections for given inputs",
inputSchema: GetScenarioDataInputSchema.shape,
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
async (args: {
customInputs?: ScenarioInputs;
}): Promise<CallToolResult> => {
const customScenario = args.customInputs
? calculateScenario(args.customInputs)
: undefined;
return {
content: [
{
type: "text",
text: JSON.stringify({
templates: SCENARIO_TEMPLATES,
defaultInputs: DEFAULT_INPUTS,
customProjections: customScenario?.projections,
customSummary: customScenario?.summary,
}),
},
],
};
},
);
return {
content: [
{
type: "text",
text: JSON.stringify({
templates: SCENARIO_TEMPLATES,
defaultInputs: DEFAULT_INPUTS,
customProjections: customScenario?.projections,
customSummary: customScenario?.summary,
}),
},
],
};
},
);
server.registerResource(
resourceUri,
resourceUri,
{ description: "SaaS Scenario Modeler UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
},
],
};
},
);
server.registerResource(
resourceUri,
resourceUri,
{ description: "SaaS Scenario Modeler UI" },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
{
uri: resourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
},
],
};
},
);
}
return server;
}
// ============================================================================
@ -311,10 +319,13 @@ const server = new McpServer({
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3106", 10);
await startServer(server, { port, name: "SaaS Scenario Modeler Server" });
await startServer(createServer, {
port,
name: "SaaS Scenario Modeler Server",
});
}
}

View file

@ -24,21 +24,31 @@ export interface ServerOptions {
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(
server: McpServer,
createServer: () => McpServer,
options: ServerOptions,
): Promise<void> {
const { port, name = "MCP Server" } = options;
// Unified session store for both transport types
const sessions = new Map<string, Transport>();
// 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" });
@ -52,12 +62,13 @@ export async function startServer(
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;
let session = sessionId ? sessions.get(sessionId) : undefined;
// Session exists but wrong transport type
if (sessionId && sessions.has(sessionId) && !transport) {
if (
session &&
!(session.transport instanceof StreamableHTTPServerTransport)
) {
return res.status(400).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Session uses different transport" },
@ -66,7 +77,7 @@ export async function startServer(
}
// New session requires initialize request
if (!transport) {
if (!session) {
if (req.method !== "POST" || !isInitializeRequest(req.body)) {
return res.status(400).json({
jsonrpc: "2.0",
@ -75,20 +86,27 @@ export async function startServer(
});
}
transport = new StreamableHTTPServerTransport({
// 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!);
sessions.set(id, { transport, server: serverInstance });
},
});
const t = transport;
t.onclose = () => {
if (t.sessionId) sessions.delete(t.sessionId);
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
await server.connect(transport);
await serverInstance.connect(transport);
session = { transport, server: serverInstance };
}
await transport.handleRequest(req, res, req.body);
await (session.transport as StreamableHTTPServerTransport).handleRequest(
req,
res,
req.body,
);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
@ -104,10 +122,12 @@ export async function startServer(
// 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);
sessions.set(transport.sessionId, { transport, server: serverInstance });
res.on("close", () => sessions.delete(transport.sessionId));
await server.connect(transport);
await serverInstance.connect(transport);
} catch (error) {
console.error("SSE error:", error);
if (!res.headersSent) res.status(500).end();
@ -116,15 +136,15 @@ export async function startServer(
app.post("/messages", async (req: Request, res: Response) => {
try {
const transport = sessions.get(req.query.sessionId as string);
if (!(transport instanceof SSEServerTransport)) {
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 transport.handlePostMessage(req, res, req.body);
await session.transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error("Message error:", error);
if (!res.headersSent) {
@ -151,7 +171,7 @@ export async function startServer(
const shutdown = () => {
console.log("\nShutting down...");
sessions.forEach((t) => t.close().catch(() => {}));
sessions.forEach((session) => session.transport.close().catch(() => {}));
httpServer.close(() => process.exit(0));
};

View file

@ -102,13 +102,13 @@ async function getMemoryStats(): Promise<MemoryStats> {
};
}
const server = new McpServer({
name: "System Monitor Server",
version: "1.0.0",
});
function createServer(): McpServer {
const server = new McpServer({
name: "System Monitor Server",
version: "1.0.0",
});
// Register the get-system-stats tool and its associated UI resource
{
// Register the get-system-stats tool and its associated UI resource
const resourceUri = "ui://system-monitor/mcp-app.html";
server.registerTool(
@ -170,14 +170,16 @@ const server = new McpServer({
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3107", 10);
await startServer(server, { port, name: "System Monitor Server" });
await startServer(createServer, { port, name: "System Monitor Server" });
}
}

View file

@ -14,6 +14,32 @@ import { startServer } from "../shared/server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
// Default code example for the Three.js widget
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(width, height);
renderer.setClearColor(0x1a1a2e);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x00ff88 })
);
scene.add(cube);
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
camera.position.z = 3;
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();`;
const THREEJS_DOCUMENTATION = `# Three.js Widget Documentation
## Available Globals
@ -103,14 +129,17 @@ animate();
- For animations, use \`requestAnimationFrame\`
`;
const server = new McpServer({
name: "Three.js Server",
version: "1.0.0",
});
const resourceUri = "ui://threejs/mcp-app.html";
// Register tool and resource
{
const resourceUri = "ui://threejs/mcp-app.html";
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
function createServer(): McpServer {
const server = new McpServer({
name: "Three.js Server",
version: "1.0.0",
});
// Tool 1: show_threejs_scene
server.registerTool(
@ -120,13 +149,16 @@ const server = new McpServer({
description:
"Render an interactive 3D scene with custom Three.js code. Available globals: THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, height.",
inputSchema: {
code: z.string().describe("JavaScript code to render the 3D scene"),
code: z
.string()
.default(DEFAULT_THREEJS_CODE)
.describe("JavaScript code to render the 3D scene"),
height: z
.number()
.int()
.positive()
.optional()
.describe("Height in pixels (default: 400)"),
.default(400)
.describe("Height in pixels"),
},
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
@ -135,7 +167,7 @@ const server = new McpServer({
content: [
{
type: "text",
text: JSON.stringify({ code, height: height || 400 }),
text: JSON.stringify({ code, height }),
},
],
};
@ -180,14 +212,16 @@ const server = new McpServer({
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3108", 10);
await startServer(server, { port, name: "Three.js Server" });
await startServer(createServer, { port, name: "Three.js Server" });
}
}

View file

@ -17,6 +17,32 @@ interface ThreeJSToolInput {
height?: number;
}
// Default demo code shown when no code is provided
const DEFAULT_THREEJS_CODE = `const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(width, height);
renderer.setClearColor(0x1a1a2e);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0x00ff88 })
);
scene.add(cube);
scene.add(new THREE.DirectionalLight(0xffffff, 1));
scene.add(new THREE.AmbientLight(0x404040));
camera.position.z = 3;
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();`;
type ThreeJSAppProps = WidgetProps<ThreeJSToolInput>;
const SHIMMER_STYLE = `
@ -125,7 +151,7 @@ export default function ThreeJSApp({
const containerRef = useRef<HTMLDivElement>(null);
const height = toolInputs?.height ?? toolInputsPartial?.height ?? 400;
const code = toolInputs?.code;
const code = toolInputs?.code || DEFAULT_THREEJS_CODE;
const partialCode = toolInputsPartial?.code;
const isStreaming = !toolInputs && !!toolInputsPartial;

View file

@ -67,13 +67,13 @@ function extractWikiLinks(pageUrl: URL, html: string): PageInfo[] {
}));
}
const server = new McpServer({
name: "Wiki Explorer",
version: "1.0.0",
});
function createServer(): McpServer {
const server = new McpServer({
name: "Wiki Explorer",
version: "1.0.0",
});
// Register the get-first-degree-links tool and its associated UI resource
{
// Register the get-first-degree-links tool and its associated UI resource
const resourceUri = "ui://wiki-explorer/mcp-app.html";
server.registerTool(
@ -83,7 +83,11 @@ const server = new McpServer({
description:
"Returns all Wikipedia pages that the given page links to directly.",
inputSchema: z.object({
url: z.string().url().describe("Wikipedia page URL"),
url: z
.string()
.url()
.default("https://en.wikipedia.org/wiki/Model_Context_Protocol")
.describe("Wikipedia page URL"),
}),
_meta: { [RESOURCE_URI_META_KEY]: resourceUri },
},
@ -137,14 +141,16 @@ const server = new McpServer({
};
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await server.connect(new StdioServerTransport());
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3109", 10);
await startServer(server, { port, name: "Wiki Explorer" });
await startServer(createServer, { port, name: "Wiki Explorer" });
}
}