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:
parent
cfd1d1dc43
commit
c9e37204de
29 changed files with 3045 additions and 1457 deletions
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -6,3 +6,8 @@ bun.lockb
|
|||
.vscode/
|
||||
docs/api/
|
||||
tmp/
|
||||
intermediate-findings/
|
||||
|
||||
# Playwright
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
2435
package-lock.json
generated
File diff suppressed because it is too large
Load diff
10
package.json
10
package.json
|
|
@ -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
46
playwright.config.ts
Normal 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
182
tests/e2e/servers.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
BIN
tests/e2e/servers.spec.ts-snapshots/basic-react.png
Normal file
BIN
tests/e2e/servers.spec.ts-snapshots/basic-react.png
Normal file
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
Loading…
Reference in a new issue