Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ jobs:
- budget-allocator-server
- cohort-heatmap-server
- customer-segmentation-server
- conformance-server
- debug-server
- lazy-auth-server
- map-server
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
./examples/budget-allocator-server \
./examples/cohort-heatmap-server \
./examples/customer-segmentation-server \
./examples/conformance-server \
./examples/debug-server \
./examples/lazy-auth-server \
./examples/map-server \
Expand Down
11 changes: 11 additions & 0 deletions build.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,15 @@ await Promise.all([
outdir: "dist/src/server",
external: PEER_EXTERNALS,
}),
// Conformance: an isomorphic protocol (browser-safe, imported by the app View)
// and the Node runner (Playwright kept external — an optional peer dep).
buildJs("src/conformance/protocol.ts", {
outdir: "dist/src/conformance",
external: PEER_EXTERNALS,
}),
buildJs("src/conformance/index.ts", {
outdir: "dist/src/conformance",
target: "node",
external: ["playwright", ...PEER_EXTERNALS],
}),
]);
2 changes: 2 additions & 0 deletions examples/conformance-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
90 changes: 90 additions & 0 deletions examples/conformance-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Conformance server

![MCP Apps Conformance screenshot](screenshot.png)

A **host-conformance test harness** for the MCP Apps spec ([SEP-1865 · `2026-01-26`](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx), extension id `io.modelcontextprotocol/ui`), modeled on [web-platform-tests](https://web-platform-tests.org).

It has two parts:

- **The conformance app** (this server) — a single `ui://` **TestSuite** that renders inside the host's sandboxed iframe, drives the `postMessage`/JSON-RPC bridge, and asserts the host's behaviour against the spec.
- **A reusable runner** — importable from the SDK at [`@modelcontextprotocol/ext-apps/conformance`](../../src/conformance): a `Host` interface, an abstract Playwright `BrowserHost`, and a `Runner` that drives the app against a real host and returns structured `SubtestResult[]`. Use it (dev-only) to conformance-test your own host in CI.

> The host is the browser. The `ui://` page is the WPT test. The bridge is the harness.

## Architecture: Host / Runner / TestSuite

- **TestSuite** (this app, in the iframe) — owns the tests and the MCP-app communication. A test emits a typed `CapabilityRequest` and awaits the result.
- **Runner** (external, platform-agnostic) — lists the tests, pumps each request to the Host, feeds the result back. No per-test logic.
- **Host** (platform-specific) — opens the app, prompts the agent so the TestSuite renders, and answers capability requests. `BrowserHost` (Playwright) is provided; `VSCodeHost`/`Goose`-style hosts are drop-in peers.

The TestSuite installs one control seam at `window.__mcpConformance` (`listTests`/`start`/`poll`/`resolve`); the Runner reaches it (a browser host does so via `frame.evaluate`). The TestSuite **pulls** (a test awaits a request) and the Runner **polls** — the iframe never has to push out (nested cross-origin `postMessage` is unreliable). A test's **vantage** says where a requirement is observable: `in-view` (asserted from inside the iframe), `host` (only from the host surface — DOM, conversation, console — which the Runner inspects), or `server`.

## Run it against a host — by hand

Connect a host to this server's `/mcp` endpoint, prompt the host to call the `run_conformance` tool, and click **Run**. From the monorepo root:

```bash
npm install
EXAMPLE=conformance-server npm run examples:start # serves http://localhost:31xx/mcp (port printed)
```

Automatic `in-view` checks run on click. A `· manual` check parks an **action card**: the trigger button fires the gesture-gated call (open a link, send a message…), and **It worked / It didn't / Skip** records the verdict — so the suite is usable with no driver at all.

## Automate it — the importable runner

Add `playwright` to your devDependencies (it's an **optional peer** of the SDK), subclass `BrowserHost` for your host, and run:

```ts
import type { Page } from "playwright";
import {
BrowserHost,
Runner,
type SubtestResult,
} from "@modelcontextprotocol/ext-apps/conformance";

// Fill in the three hooks for YOUR host's UI; launch, the bridge, real
// cross-origin clicks, dialog handling, and per-test isolation are all shared.
class MyHost extends BrowserHost {
readonly name = "my-host";
readonly url = "https://my-host.example/"; // a host that already has the conformance app connected
readonly widgetSelector = 'iframe[src*="conformance"]';

protected async sendPrompt(page: Page, appName: string) {
await page.fill("#composer", `run ${appName}`);
await page.keyboard.press("Enter");
}
protected async dismissModal(_page: Page) {} // dismiss cookie/onboarding modals if any
protected async verifyConversation(page: Page, marker: string) {
return (await page.textContent("body"))?.includes(marker) ?? false;
}
}

const results: SubtestResult[] = await new Runner(new MyHost(), {
appName: "Conformance",
profileDir: ".profile", // a persistent profile so a login is reused across runs
}).run();
```

The conformance app must **already be connected** to the host you point at — self-host this example (or any deployment of it) and connect it in the host you're testing. Every `Host` capability method is optional: an absent one is reported `unsupported`, and the affected test skips or falls back (e.g. `readModelToolList` → conversation scan).

## Starter suite

A small, high-signal set spanning every mechanism — extend it with the reserved requirements as you implement them:

| Test | Clause | Vantage | What it proves |
| ------------------------------------ | -------- | ------- | ------------------------------------------------------------------------------------- |
| `lifecycle/tool-input` | MUST | in-view | host delivers the tool arguments to the view |
| `security/csp-no-loosening` | MUST NOT | in-view | an undeclared origin stays blocked under a declared CSP |
| `security/sandbox-proxy-required` | MUST | in-view | the view is wrapped in an intermediate sandbox proxy (`window.parent !== window.top`) |
| `security/iframe-sandboxed` | MUST | host | the Runner reads the host's `<iframe sandbox>` attribute |
| `visibility/app-tool-hidden` | MUST NOT | host | an app-only tool (`conformance_probe`) stays hidden from the model |
| `messages/add-to-conversation` | SHOULD | host | a `ui/message` reaches the conversation |
| `links/open-external` | SHOULD | host | `ui/open-link` opens the requested URL (the Runner verifies the tab) |
| `model-context/provide-future-turns` | SHOULD | host | `ui/update-model-context` reaches the model next turn |

## Layout

- `server.ts` — the MCP server: one `ui://` TestSuite resource + fixture tools (`run_conformance` launcher, app-only `conformance_probe`, model-only `model_only_probe`).
- `main.ts` — Streamable HTTP / stdio entry point.
- `mcp-app.html` + `src/` — the React TestSuite: `mcp-app.tsx` (UI) + `harness/` (`assert` · `host-gateway` · `registry` engine · `channel`) + `tests.ts` (the suite) + `catalogue.json` (per-test spec links).
- The runner that drives this app lives in the SDK: [`src/conformance`](../../src/conformance).
76 changes: 76 additions & 0 deletions examples/conformance-server/catalogue.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[
{
"id": "lifecycle/tool-input",
"clause": "MUST",
"vantage": "in-view",
"spec": "2026-01-26",
"line": 1106,
"implemented": true,
"bucket": "automatic"
},
{
"id": "security/csp-no-loosening",
"clause": "MUST NOT",
"vantage": "in-view",
"spec": "2026-01-26",
"line": 286,
"implemented": true,
"bucket": "automatic"
},
{
"id": "security/sandbox-proxy-required",
"clause": "MUST",
"vantage": "in-view",
"spec": "2026-01-26",
"line": 472,
"implemented": true,
"bucket": "automatic"
},
{
"id": "security/iframe-sandboxed",
"clause": "MUST",
"vantage": "host",
"spec": "2026-01-26",
"line": 1698,
"implemented": true,
"bucket": "operator-verify"
},
{
"id": "visibility/app-tool-hidden",
"clause": "MUST NOT",
"vantage": "host",
"spec": "2026-01-26",
"line": 400,
"implemented": true,
"bucket": "operator-verify",
"askAgent": true
},
{
"id": "messages/add-to-conversation",
"clause": "SHOULD",
"vantage": "host",
"spec": "2026-01-26",
"line": 1033,
"implemented": true,
"bucket": "operator-verify"
},
{
"id": "links/open-external",
"clause": "SHOULD",
"vantage": "host",
"spec": "2026-01-26",
"line": 996,
"implemented": true,
"bucket": "operator-verify"
},
{
"id": "model-context/provide-future-turns",
"clause": "SHOULD",
"vantage": "host",
"spec": "2026-01-26",
"line": 1097,
"implemented": true,
"bucket": "operator-verify",
"askAgent": true
}
]
Binary file added examples/conformance-server/grid-cell.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
93 changes: 93 additions & 0 deletions examples/conformance-server/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Entry point for running the MCP server.
* Run with: npx @modelcontextprotocol/server-basic-react
* Or: node dist/index.js [--stdio]
*/

import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
import { createServer } from "./server.js";

/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
*/
export async function startStreamableHTTPServer(
createServer: () => McpServer,
): Promise<void> {
const port = parseInt(process.env.PORT ?? "3001", 10);

const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());

app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});

try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});

const httpServer = app.listen(port, (err) => {
if (err) {
console.error("Failed to start server:", err);
process.exit(1);
}
console.log(`MCP server listening on http://localhost:${port}/mcp`);
});

const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};

process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}

/**
* Starts an MCP server with stdio transport.
*
* @param createServer - Factory function that creates a new McpServer instance.
*/
export async function startStdioServer(
createServer: () => McpServer,
): Promise<void> {
await createServer().connect(new StdioServerTransport());
}

async function main() {
if (process.argv.includes("--stdio")) {
await startStdioServer(createServer);
} else {
await startStreamableHTTPServer(createServer);
}
}

main().catch((e) => {
console.error(e);
process.exit(1);
});
13 changes: 13 additions & 0 deletions examples/conformance-server/mcp-app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>MCP Apps Conformance Runner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/mcp-app.tsx"></script>
</body>
</html>
56 changes: 56 additions & 0 deletions examples/conformance-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@modelcontextprotocol/server-conformance",
"version": "1.0.0",
"type": "module",
"description": "Host-conformance test server for the MCP Apps spec: ships a ui:// runner that asserts host behaviour and reports PASS/FAIL inside the iframe",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/ext-apps",
"directory": "examples/conformance-server"
},
"license": "MIT",
"main": "dist/server.js",
"types": "dist/server.d.ts",
"bin": {
"mcp-server-conformance": "dist/index.js"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/server.d.ts",
"default": "./dist/server.js"
}
},
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve": "bun --watch main.ts",
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
"dev": "cross-env NODE_ENV=development concurrently \"npm run watch\" \"npm run serve\"",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/sdk": "^1.24.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "22.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}
Binary file added examples/conformance-server/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading