Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
70c1215
Add Playwright E2E tests with screenshot golden testing
ochafik Dec 9, 2025
b790f3d
Add default demo code for Three.js example
ochafik Dec 9, 2025
3469766
Add E2E testing documentation to CONTRIBUTING.md
ochafik Dec 9, 2025
ca62dfa
Update Three.js golden screenshot with 3D cube
ochafik Dec 9, 2025
585b4d2
Add explicit permissions to CI workflow
ochafik Dec 9, 2025
6db453a
Fix test.setTimeout() to be inside describe blocks
ochafik Dec 9, 2025
0623088
Simplify E2E tests - remove excessive timeouts
ochafik Dec 9, 2025
fd790a1
Auto-generate missing snapshots in CI
ochafik Dec 9, 2025
42665c8
Use platform-agnostic golden screenshots
ochafik Dec 9, 2025
0d05cf8
Refactor tests to use forEach instead of for-of loop
ochafik Dec 9, 2025
f62e4f4
Use npm ci and list reporter in CI
ochafik Dec 9, 2025
95aa92f
Exclude e2e tests from bun test
ochafik Dec 9, 2025
9aa7727
Fix prettier formatting
ochafik Dec 9, 2025
20ef214
Add interaction tests for basic server apps
ochafik Dec 10, 2025
6cd4d1a
Mask dynamic content in E2E screenshot tests
ochafik Dec 10, 2025
13dde97
Merge origin/main into ochafik/e2e-tests
ochafik Dec 11, 2025
4b73b5f
Add wiki-explorer to E2E tests with default URL param
ochafik Dec 11, 2025
d3ff6da
Use .default() for threejs tool schema instead of .optional()
ochafik Dec 11, 2025
e6e6e7a
Enable parallel E2E tests with timeouts and canvas masking
ochafik Dec 11, 2025
da89aa9
Add pre-commit check for private registry URLs in package-lock.json
ochafik Dec 11, 2025
6c44f8e
Increase screenshot diff tolerance to 6% for cross-platform rendering
ochafik Dec 11, 2025
277a004
Revert pre-commit artifactory check (moved to separate PR #133)
ochafik Dec 11, 2025
4a0c3bc
Format threejs server.ts
ochafik Dec 11, 2025
53012ae
Update CONTRIBUTING.md to reflect platform-agnostic screenshots
ochafik Dec 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -36,3 +39,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 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ bun.lockb
.vscode/
docs/api/
tmp/
intermediate-findings/

# Playwright
playwright-report/
test-results/
39 changes: 39 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion examples/basic-host/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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[]>;
Expand Down Expand Up @@ -50,6 +71,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 = () => {
Expand All @@ -72,7 +101,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>
Expand Down
37 changes: 33 additions & 4 deletions examples/threejs-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,32 @@ import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "../../dist/src/app";
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3001;
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
Expand Down Expand Up @@ -123,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 },
},
Expand All @@ -138,7 +167,7 @@ const server = new McpServer({
content: [
{
type: "text",
text: JSON.stringify({ code, height: height || 400 }),
text: JSON.stringify({ code, height }),
},
],
};
Expand Down
28 changes: 27 additions & 1 deletion examples/threejs-server/src/threejs-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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;

Expand Down
6 changes: 5 additions & 1 deletion examples/wiki-explorer-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,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 },
},
Expand Down
Loading
Loading