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
29 changed files with 3045 additions and 1457 deletions

View file

@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@ -35,3 +38,32 @@ jobs:
- run: npm test
- run: npm run prettier
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --reporter=list
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: test-results/
retention-days: 7

5
.gitignore vendored
View file

@ -6,3 +6,8 @@ bun.lockb
.vscode/
docs/api/
tmp/
intermediate-findings/
# Playwright
playwright-report/
test-results/

View file

@ -40,6 +40,45 @@ Or build and run examples:
npm run examples:start
```
## Testing
### Unit Tests
Run unit tests with Bun:
```bash
npm test
```
### E2E Tests
E2E tests use Playwright to verify all example servers work correctly with screenshot comparisons.
```bash
# Run all E2E tests
npm run test:e2e
# Run a specific server's tests
npm run test:e2e -- --grep "Budget Allocator"
# Run tests in interactive UI mode
npm run test:e2e:ui
```
### Updating Golden Screenshots
When UI changes are intentional, update the golden screenshots:
```bash
# Update all screenshots
npm run test:e2e:update
# Update screenshots for a specific server
npm run test:e2e:update -- --grep "Three.js"
```
**Note**: Golden screenshots are platform-agnostic. Tests use canvas masking and tolerance thresholds to handle minor cross-platform rendering differences.
## Code of Conduct
This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing.

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

2435
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,10 @@
"build": "npm run generate:schemas && bun build.bun.ts",
"prepack": "npm run build",
"build:all": "npm run build && npm run examples:build",
"test": "bun test",
"test": "bun test src",
"test:e2e": "playwright test",
"test:e2e:update": "playwright test --update-snapshots",
"test:e2e:ui": "playwright test --ui",
"examples:build": "bun examples/run-all.ts build",
"examples:start": "NODE_ENV=development npm run build && bun examples/run-all.ts start",
"examples:dev": "NODE_ENV=development bun examples/run-all.ts dev",
@ -50,10 +53,11 @@
},
"author": "Olivier Chafik",
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/bun": "^1.3.2",
"bun": "^1.3.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"bun": "^1.3.2",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"esbuild": "^0.25.12",
@ -78,8 +82,8 @@
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.53.3",
"@rollup/rollup-darwin-x64": "^4.53.3",
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
"@rollup/rollup-linux-arm64-gnu": "^4.53.3",
"@rollup/rollup-linux-x64-gnu": "^4.53.3",
"@rollup/rollup-win32-x64-msvc": "^4.53.3"
}
}

46
playwright.config.ts Normal file
View file

@ -0,0 +1,46 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : 4, // Parallel execution now works with factory pattern
timeout: 30000, // 30s per test
reporter: process.env.CI ? "list" : "html",
// Use platform-agnostic snapshot names (no -darwin/-linux suffix)
snapshotPathTemplate:
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}",
use: {
baseURL: "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
launchOptions: {
// Use system Chrome on macOS for stability, default chromium in CI
...(process.platform === "darwin" ? { channel: "chrome" } : {}),
},
},
},
],
// Run examples server before tests
webServer: {
command: "npm run examples:start",
url: "http://localhost:8080",
// Always start fresh servers to avoid stale state issues
reuseExistingServer: false,
timeout: 120000,
},
// Snapshot configuration
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.06,
animations: "disabled",
},
},
});

182
tests/e2e/servers.spec.ts Normal file
View file

@ -0,0 +1,182 @@
import { test, expect, type Page, type ConsoleMessage } from "@playwright/test";
// Dynamic element selectors to mask for screenshot comparison
// Note: CSS modules generate unique class names, so we use attribute selectors
// with partial matches (e.g., [class*="heatmapWrapper"]) for those components
const DYNAMIC_MASKS: Record<string, string[]> = {
"basic-react": ["code"], // Server time display
"basic-vanillajs": ["#server-time"], // Server time display
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
"system-monitor": [
".chart-container", // CPU chart (highly dynamic)
"#status-text", // Current timestamp
"#memory-percent", // Memory percentage
"#memory-detail", // Memory usage details
"#memory-bar-fill", // Memory bar fill level
"#info-uptime", // System uptime
],
"wiki-explorer": ["#graph"], // Force-directed graph (dynamic layout)
threejs: ["canvas"], // 3D render canvas (dynamic animation)
};
// Server configurations
const SERVERS = [
{ key: "basic-react", index: 0, name: "Basic MCP App Server (React-based)" },
{
key: "basic-vanillajs",
index: 1,
name: "Basic MCP App Server (Vanilla JS)",
},
{ key: "budget-allocator", index: 2, name: "Budget Allocator Server" },
{ key: "cohort-heatmap", index: 3, name: "Cohort Heatmap Server" },
{
key: "customer-segmentation",
index: 4,
name: "Customer Segmentation Server",
},
{ key: "scenario-modeler", index: 5, name: "SaaS Scenario Modeler" },
{ key: "system-monitor", index: 6, name: "System Monitor Server" },
{ key: "threejs", index: 7, name: "Three.js Server" },
{ key: "wiki-explorer", index: 8, name: "Wiki Explorer" },
];
/**
* Helper to get the app frame locator (nested: sandbox > app)
*/
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
}
/**
* Collect console messages with [HOST] prefix
*/
function captureHostLogs(page: Page): string[] {
const logs: string[] = [];
page.on("console", (msg: ConsoleMessage) => {
const text = msg.text();
if (text.includes("[HOST]")) {
logs.push(text);
}
});
return logs;
}
/**
* Wait for the MCP App to load inside nested iframes.
* Structure: page > iframe (sandbox) > iframe (app)
*/
async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").first();
await expect(outerFrame.locator("iframe")).toBeVisible();
}
/**
* Load a server by selecting it and clicking Call Tool
*/
async function loadServer(page: Page, serverIndex: number) {
await page.goto("/");
// Wait for servers to connect (select becomes enabled when servers are ready)
await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
await page.locator("select").first().selectOption({ index: serverIndex });
await page.click('button:has-text("Call Tool")');
await waitForAppLoad(page);
}
/**
* Get mask locators for dynamic elements inside the nested app iframe.
*/
function getMaskLocators(page: Page, serverKey: string) {
const selectors = DYNAMIC_MASKS[serverKey];
if (!selectors) return [];
const appFrame = getAppFrame(page);
return selectors.map((selector) => appFrame.locator(selector));
}
test.describe("Host UI", () => {
test("initial state shows controls", async ({ page }) => {
await page.goto("/");
await expect(page.locator("label:has-text('Server')")).toBeVisible();
await expect(page.locator("label:has-text('Tool')")).toBeVisible();
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
});
test("screenshot of initial state", async ({ page }) => {
await page.goto("/");
await expect(page.locator('button:has-text("Call Tool")')).toBeVisible();
await expect(page).toHaveScreenshot("host-initial.png");
});
});
// Define tests for each server using forEach to avoid for-loop issues
SERVERS.forEach((server) => {
test.describe(server.name, () => {
test("loads app UI", async ({ page }) => {
await loadServer(page, server.index);
});
test("screenshot matches golden", async ({ page }) => {
await loadServer(page, server.index);
await page.waitForTimeout(500); // Brief stabilization
// Get mask locators for dynamic content (timestamps, charts, etc.)
const mask = getMaskLocators(page, server.key);
await expect(page).toHaveScreenshot(`${server.key}.png`, {
mask,
maxDiffPixelRatio: 0.06,
});
});
});
});
// Interaction tests for basic servers (React and Vanilla JS have identical UIs)
const BASIC_SERVERS = SERVERS.filter(
(s) => s.key === "basic-react" || s.key === "basic-vanillajs",
);
BASIC_SERVERS.forEach((server) => {
test.describe(`${server.name} - Interactions`, () => {
test("Send Message button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Send Message")').click();
// Wait for the async message to be processed
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Message from MCP App"))).toBe(
true,
);
});
test("Send Log button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Send Log")').click();
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Log message from MCP App"))).toBe(
true,
);
});
test("Open Link button triggers host callback", async ({ page }) => {
const logs = captureHostLogs(page);
await loadServer(page, server.index);
const appFrame = getAppFrame(page);
await appFrame.locator('button:has-text("Open Link")').click();
await page.waitForTimeout(500);
expect(logs.some((log) => log.includes("Open link request"))).toBe(true);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Some files were not shown because too many files have changed in this diff Show more