← ResultsHow it works

Architecture: Host / Runner / TestSuite

How the conformance harness is factored so any host — a web chat client today, a desktop app like VSCode or Goose tomorrow — can run the same suite. All the platform-specific code lives behind one Host interface.

Three objects

TestSuite

Runs in the iframe. Owns the test definitions and the MCP-app communication (the ext-apps App). A test emits typed capability requests, awaits the results, and asserts.

Runner

Platform-agnostic. Lists tests, then pumps each request the suite parks to the Host and feeds the result back. A generic dispatcher — no per-test logic.

Host

The only platform-specific piece. Opens the app, prompts the agent so the suite renders, exposes one method per capability. BrowserHost (Playwright) today; desktop hosts drop in later.

                 in the iframe            external process (a "Host")
              ┌────────────────────┐      ┌──────────────────────────────┐
   asserts →  │      TestSuite      │      │           Runner             │
              │  owns test defs +   │      │  generic dispatch loop:      │
              │  MCP-app comms      │      │  poll → dispatch → resolve   │
              │                     │      │            │                 │
              │  window             │◀─────┤  SuiteBridge (frame.evaluate │
              │  .__mcpConformance  │─────▶│    for a browser host)       │
              │  listTests/start/   │ poll │            │                 │
              │  poll/resolve       │      │            ▼                 │
              └─────────┬───────────┘      │   Host capability methods    │
                        │                  │  clickTrigger / confirmDialog│
              ext-apps App               │  conversationContains /      │
              (PostMessage bridge)         │  toggleTheme / …             │
                        │                  └──────────────┬───────────────┘
                        ▼                                 │ real clicks, DOM,
              ┌────────────────────┐                      │ conversation API
              │  the chat host      │◀─────────────────────┘
              │  (ChatGPT / Claude  │
              │   / a desktop app)  │
              └─────────┬───────────┘
                        │ MCP
                        ▼
              ┌────────────────────┐
              │   MCP server        │
              └────────────────────┘

The typed capability protocol

The protocol is deliberately primitive — one variant per thing a host can physically do. The test carries the meaning (e.g. "the tool must stay hidden" is a test that negates a conversationContains result, not a dedicated request kind).

// shared/protocol.ts — the one contract both sides import as source
export type CapabilityRequest =
  | { kind: "clickTrigger"; commitDraftedMessage?: boolean }   // real cross-origin click (user activation)
  | { kind: "confirmDialog"; dialog: "open-link" | "download" | "sampling" }
  | { kind: "conversationContains"; marker: string; timeoutMs: number }
  | { kind: "toggleTheme"; to: "light" | "dark" }
  | { kind: "readModelToolList" }        // optional desktop-host affordance
  | { kind: "resetIsolation" };          // suite emits this before each manual test

export interface CapabilityResult {
  ok: boolean; value?: unknown; error?: string;
  unsupported?: boolean;                 // host lacks the capability → test skips / falls back
}

Pull model: the suite pulls, the Runner polls

A sandboxed, nested, cross-origin iframe can't reliably push a message out (nested cross-origin postMessage drops), but the Runner can always reach in via frame.evaluate. So the direction is fixed: a test awaits t.host(req), which parks req in a single pending slot; the Runner poll()s that slot, services the request against the Host, and calls resolve(result) to unblock the test.

The suite installs one object at window.__mcpConformance with listTests() · start(filter?) · poll() · resolve(result). A desktop host swaps frame.evaluate for its own transport (IPC / a WebSocket to the app process) behind the same SuiteBridge interface — that is the entire drop-in seam. The same pending slot also backs the in-iframe yes/no/skip buttons, so the suite runs with no driver at all (a human answers).

Optional capabilities & fallback

Only setup() and teardown() are mandatory on a Host; every capability method is optional. An absent method resolves to { unsupported: true }. In the suite:

// visibility/app-tool-hidden — capability-or-fallback
const direct = await t.hostOptional({ kind: "readModelToolList" });
if (!direct.unsupported) {               // a desktop host that can introspect the model's tools
  t.assert(!(direct.value as string[]).includes("conformance_probe"), "hidden tool leaked");
  return;
}
// browser hosts don't expose that — fall back to asking the agent and scanning the conversation
t.bindTrigger(() => t.app.sendMessage({ role: "user", content: [{ type: "text", text: ASK }] }));
await t.host({ kind: "clickTrigger", commitDraftedMessage: true });
const r = await t.host({ kind: "conversationContains", marker: "conformance_probe", timeoutMs: 45_000 });
t.assert(!r.ok, "hidden tool name surfaced in the conversation");

Pluggable hosts

The three chat products differ in behavior — how you enter a prompt, dismiss a modal, verify a conversation turn, commit a drafted message — and in which capabilities they support. So they are subclasses of a shared BrowserHost, not config rows:

HostPrompt entryVerify a turnNotes
ChatGPTBrowserHost#prompt-textarea, mention pickerbackend conversation APIsends ui/message directly
ClaudeBrowserHostProseMirror contenteditablescan transcript textdrafts ui/message → Send
AlpicPlaygroundBrowserHosttextarea[name=message]scan transcript textno login

VSCodeHost / GooseHost would be peer Host implementations — not BrowserHost subclasses — providing their own setup()/SuiteBridge transport and whichever capabilities they can (a desktop host may implement readModelToolList that browser hosts can't). Anything a host can't do is simply unsupported, and those tests skip. Where the browser driver clicks real product UI, it necessarily overfits per-host DOM — see How it works.