E2E Testing MCP Apps, ChatGPT Apps, and Claude Connectors (July 2026)

E2E testing MCP Apps across ChatGPT and Claude hosts with the inspector fixture.
Unit tests tell you whether your React component behaves with mocked hooks. Integration tests tell you whether your MCP server returns the right tool result. Neither tells you whether the app works inside the host iframe that users actually see.
End-to-end tests fill that gap. They render your MCP App through a host runtime, with the same tool result shape, iframe boundary, display mode, theme, safe area, and bridge behavior that ChatGPT Apps and Claude Connectors depend on.
TL;DR: Use the inspector fixture from sunpeak/test to render tools in simulated ChatGPT and Claude runtimes with inspector.renderTool(). Assert against the app iframe with Playwright through result.app(). Use simulation files for deterministic data, run the same tests across hosts, themes, and display modes, then reserve live host tests for release confidence.
What changed since the April version
MCP Apps have more written standards and host docs now. The official MCP Apps overview defines the basic shape: MCP servers expose tools and ui:// resources, tools link to UI through metadata such as _meta.ui.resourceUri, and hosts render those resources in sandboxed iframes. OpenAI’s Apps SDK reference maps ChatGPT Apps onto the same model with resource templates, widget metadata, tool result _meta, and the window.openai bridge. Claude’s custom connector guide uses remote MCP servers for Claude Connectors.
That means an E2E test for an MCP App should not stop at “did the component mount?” It should prove that the server result, resource metadata, host iframe, bridge state, and rendered UI agree with each other.
For sunpeak, the testing surface has also expanded. A new sunpeak project includes local inspector E2E tests, unit tests, simulation files, visual regression support, live host test scaffolding, and eval scaffolding. Existing MCP servers can add the same test stack with npx sunpeak test init --server URL, even when the server itself is written in Python, Go, Rust, or another stack.
What E2E tests catch
MCP Apps have a longer render path than normal web apps:
- The model or test harness calls an MCP tool.
- The server returns
content,structuredContent, and sometimes_meta. - The tool result points the host at a resource, usually through metadata such as
_meta.ui.resourceUri. - The host loads that resource into a sandboxed iframe.
- The app reads tool data, host context, display mode, theme, safe-area values, and bridge state.
- The user clicks, types, filters, expands, closes, or triggers more tool calls.
Every step can break in a way unit tests will miss. Good E2E tests catch:
- Tool result and UI mismatches, such as
structuredContentchanging shape while the component still expects the old field. - Resource metadata mistakes, including missing
ui://resource links, wrong MIME type handling, CSP blocks, or a resource that works in one host and fails in another. - Iframe-only bugs, including focus traps, blocked network calls, broken CSS isolation, and code that assumes direct access to the parent window.
- Display mode bugs, such as content that fits inline but overflows in picture-in-picture, or fullscreen layouts that hide the primary action below host chrome.
- Theme and safe-area bugs, especially hardcoded text colors, fixed heights, and bottom controls clipped by the host.
- Bridge and interaction bugs, including
useAppState,callServerTool,sendMessage, and host context updates.
That last point matters more in July 2026 because developers are no longer only building static cards. The useful MCP Apps are interactive: dashboards, file browsers, editors, approval flows, checkout-like forms, and data tools. Their tests need to click through those flows.
The inspector fixture
The inspector fixture from sunpeak/test renders a tool result inside a local host replica. It starts the dev server when needed, opens the inspector, selects the configured host runtime, calls the tool or loads a simulation, and returns handles you can use in Playwright assertions.
import { test, expect } from 'sunpeak/test';
test('dashboard renders revenue chart', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', {
quarter: 'Q2',
year: 2026,
});
const app = result.app();
await expect(app.getByRole('heading', { name: 'Q2 2026' })).toBeVisible();
await expect(app.getByTestId('revenue-chart')).toBeVisible();
});
inspector.renderTool() takes:
- The tool name, matching a tool exposed by your MCP server.
- Optional tool input. If you omit it, use a simulation file for deterministic data.
- Optional render settings such as
displayMode,theme,prodResources, and timeout values.
The result gives you:
result.app(), a Playwright frame locator scoped to your resource iframe.result.structuredContent, the structured data the app received.result.isError, which is useful for server error cases.result.screenshot(), which feeds visual regression tests.
Use result.app() for UI assertions. Use result.structuredContent sparingly, usually to make sure a failure is in the renderer and not the server result. If all your E2E test does is inspect JSON, it belongs in the integration-test layer.
Start with a small host matrix
The easiest way to make MCP App E2E tests noisy is to multiply every state by every host, display mode, theme, viewport, and data case. You want coverage, but you also want a suite developers will run before pushing.
Start with this matrix:
| Test target | Minimum coverage |
|---|---|
| Hosts | ChatGPT and Claude runtime replicas |
| Themes | Light and dark for the main resource |
| Display modes | Inline and fullscreen, plus picture-in-picture when the host supports it |
| Viewports | One desktop size and one narrow mobile-ish size |
| Data states | Happy path, empty state, error state, high-volume state |
| Interactions | One complete flow per resource |
Do not apply the full matrix to every edge case. Pick one or two canonical states for broad matrix coverage, then test narrow logic in unit tests.
With sunpeak, host coverage comes from defineConfig():
// playwright.config.ts
import { defineConfig } from 'sunpeak/test/config';
export default defineConfig();
That config creates separate Playwright projects for the configured host runtimes. Your test code stays host-neutral:
import { test, expect } from 'sunpeak/test';
test('primary action is available', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard');
const app = result.app();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
When the test fails on one host and passes on another, the report tells you which runtime failed. That is much more useful than a manual note saying “check Claude later.”
Test display modes by behavior
ChatGPT Apps and MCP Apps can render in different display modes depending on host support and user context. Treat display modes as layout contracts. The test should check what changes for the user, not only that the root element exists.
import { test, expect } from 'sunpeak/test';
test('inline mode shows the compact summary', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'inline',
});
const app = result.app();
await expect(app.getByTestId('summary-strip')).toBeVisible();
await expect(app.getByRole('button', { name: 'Open details' })).toBeVisible();
await expect(app.getByTestId('full-data-table')).not.toBeVisible();
});
test('fullscreen mode exposes analysis controls', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await expect(app.getByTestId('full-data-table')).toBeVisible();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
For picture-in-picture, write the test around the actual compact behavior. A good PiP test checks that the user can still read the main value, close or expand the surface, and avoid hidden controls. Do not assume the same host supports the same modes forever. Feature-detect where your app logic depends on it, and test the fallback.
Test themes, safe areas, and host context together
Theme bugs are rarely just “dark mode is wrong.” They often combine with host context: a dark host, a narrow viewport, a bottom safe area, and a sticky footer. E2E tests can cover that combination directly.
const themes = ['light', 'dark'] as const;
for (const theme of themes) {
test(`dashboard works in ${theme} theme`, async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
theme,
displayMode: 'fullscreen',
});
const app = result.app();
await expect(app.getByTestId('dashboard-root')).toBeVisible();
await expect(app.getByRole('button', { name: 'Export' })).toBeVisible();
});
}
Prefer user-facing assertions over brittle CSS checks. Checking background-color: rgb(32, 33, 35) is useful only when that exact token is the contract. Most of the time, assert that text is visible, controls are reachable, content does not overflow, and keyboard focus can reach the footer action.
When you do need CSS assertions, connect them to a known contract, such as your own component token or a documented host variable. See the host context and safe area guide for the context values worth testing.
Use simulation files for deterministic states
Simulation files are the difference between a stable E2E test and a slow manual reproduction. They describe the tool call and result that the host should render:
{
"tool": "show-dashboard",
"userMessage": "Show the Q2 revenue dashboard",
"toolInput": {
"arguments": { "quarter": "Q2", "year": 2026 }
},
"toolResult": {
"content": [{ "type": "text", "text": "Dashboard loaded." }],
"structuredContent": {
"quarter": "Q2 2026",
"revenue": 142500,
"orders": 1203,
"topProducts": [
{ "name": "Wireless Headphones", "unitsSold": 412, "revenue": 32960 },
{ "name": "USB-C Hub", "unitsSold": 287, "revenue": 14350 }
]
}
}
}
Then your test can render the state without calling a production API:
test('dashboard handles a high-volume result', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard');
const app = result.app();
await expect(app.getByText('Q2 2026')).toBeVisible();
await expect(app.getByTestId('product-row')).toHaveCount(2);
});
Create simulations for:
- Normal data, with a representative result.
- Empty data, with no records but valid structure.
- Error data, with
isErroror your app’s error payload. - Permission-limited data, where the user can see some content but not all actions.
- High-volume data, where scroll, virtualization, and truncation matter.
Keep simulations close to real tool outputs. If your production server returns structuredContent.orders.items, do not test a simplified fixture named orders. The point is to catch contract drift before users do.
Test interactions through the iframe
Interactive MCP Apps need browser tests that click through real flows. Use Playwright locators on result.app(), because that keeps assertions scoped to the resource iframe.
test('filters products and opens detail panel', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await app.getByRole('textbox', { name: 'Search products' }).fill('headphones');
await expect(app.getByText('Wireless Headphones')).toBeVisible();
await expect(app.getByText('USB-C Hub')).not.toBeVisible();
await app.getByRole('button', { name: 'Wireless Headphones' }).click();
await expect(app.getByRole('dialog', { name: 'Product details' })).toBeVisible();
});
A good interaction test proves a user can complete a task:
- Change tabs, filters, sort order, or selected records.
- Submit a form and see the success or validation state.
- Trigger an app-only server tool and render the response.
- Expand or close the app when the host supports display mode transitions.
- Recover after an error without reloading the whole host.
Do not test implementation details like a state hook being called with a particular object unless that object is the contract. Test the behavior the user sees.
Add accessibility checks where the host boundary matters
Accessibility testing belongs in E2E when the host iframe changes the outcome. Keyboard focus, modal boundaries, color contrast, scroll regions, and screen reader labels are all easy to break in an embedded app.
Start with Playwright assertions:
test('keyboard users can reach the primary action', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
});
const app = result.app();
await app.getByRole('button', { name: 'Export' }).focus();
await expect(app.getByRole('button', { name: 'Export' })).toBeFocused();
});
Then add an automated accessibility scan for one or two representative states. Run it against both light and dark themes. The accessibility testing guide covers deeper checks, but the practical rule is simple: do not let a host iframe become an excuse for inaccessible UI.
Add visual regression for host drift
Visual regression tests are E2E tests with screenshot baselines. They are especially useful for MCP Apps because small host changes can move your iframe, change available height, or alter the safe area. A locator test may still pass while the UI looks broken.
test('dashboard fullscreen screenshot is stable', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
theme: 'dark',
});
await result.screenshot('dashboard-fullscreen-dark');
});
Use screenshots for stable states:
- Main resource in fullscreen.
- Inline compact view.
- Dark theme.
- Empty state.
- Error state.
Avoid screenshotting data that changes every run. Freeze dates, random IDs, animation states, timestamps, and remote images. If a screenshot changes every time, it will train the team to ignore failures.
Test production resources before release
Dev resources can hide build-time problems. A component may render with HMR but fail after bundling because an import path changes, CSS loads in a different order, or a package relies on dev-only behavior.
Add at least one production-resource smoke test:
test('production resource bundle renders', async ({ inspector }) => {
const result = await inspector.renderTool('show-dashboard', undefined, {
displayMode: 'fullscreen',
prodResources: true,
});
await expect(result.app().getByTestId('dashboard-root')).toBeVisible();
});
Run this in CI after the build step. It is cheap insurance against shipping a resource that only works on localhost.
Keep live tests separate from local E2E tests
Local E2E tests and live host tests answer different questions.
Local E2E tests ask:
- Does the app render correctly for known tool states?
- Does it behave across simulated hosts, themes, display modes, and viewports?
- Does it work without paid host accounts or API credits?
- Can CI run it on every pull request?
Live tests ask:
- Does the deployed server connect to the real host?
- Does auth work with the host’s current flow?
- Does the real model select the expected tool for a normal prompt?
- Does the real host load the iframe and pass data as expected?
Keep local E2E broad and live tests narrow. A practical release gate is 20 to 80 local E2E tests, plus 2 to 5 live tests for the most important user paths. Use live testing after your deterministic suite is already green.
CI gating that developers will tolerate
A useful MCP App CI pipeline usually has three lanes:
| Lane | When it runs | What it catches |
|---|---|---|
| Fast checks | Every push | Type errors, unit tests, protocol integration issues |
| Inspector E2E | Pull requests | iframe rendering, host matrix, interactions, accessibility smoke checks |
| Release checks | Main branch or manual release | production resources, visual regression, live host tests, evals |
For GitHub Actions, keep secrets out of the default E2E path. Local inspector tests should not need host credentials. Put live tests and evals behind a scheduled job, manual workflow, or protected environment. The MCP App CI/CD guide has the full setup.
Debugging failures
When an E2E test fails, first identify which layer broke:
- Did the tool call fail? Check
result.isErrorand the raw tool result. - Did the resource fail to load? Check resource metadata, CSP, network errors, and console errors.
- Did the iframe render but the UI assertion fail? Use Playwright’s trace viewer and inspect the frame.
- Did only one host fail? Look at display mode, safe area, host CSS variables, and feature detection.
- Did only CI fail? Check viewport size, timing, missing fonts, remote images, and production resource build output.
Use the Playwright UI runner when you need to see the iframe:
pnpm test:e2e -- --ui
Use trace capture when the failure is intermittent:
pnpm test:e2e -- --trace on
If the bug is host-specific, reproduce it in the local inspector first. If it reproduces only in the real host, promote it to a live test after the fix so it does not regress silently.
Where E2E fits in the full testing strategy
E2E tests are the right layer for host rendering and user flows. They should not replace every other test type.
- Unit tests: component logic, formatter functions, validation, small state transitions.
- Integration tests: MCP tool handlers, protocol shape, auth branches, server errors.
- E2E inspector tests: iframe rendering, host context, display modes, themes, user interactions.
- Visual regression tests: pixel-level layout drift in known states.
- Live tests: real host connection, auth, model tool selection, deployed iframe loading.
- Evals: whether models call the right tools with the right arguments.
If you are starting from zero, write one E2E test for each resource’s happy path, then add empty, error, and high-volume simulations. After that, add the display mode and theme matrix for the resource that matters most to the product.
Get started
New sunpeak projects come with E2E testing scaffolding:
npx sunpeak new my-app
cd my-app
pnpm test:e2e
For an existing MCP server:
npx sunpeak test init --server http://localhost:8000/mcp
pnpm test:e2e
The test framework works with any MCP server. Use the testing docs for setup, E2E reference for the inspector fixture, and simulation docs for deterministic states.
If you already have a ChatGPT App or Claude Connector, start by turning one manual smoke test into an inspector E2E test. Pick the state you most often check by hand, freeze it as a simulation, assert the rendered UI, and run it in CI. That one test usually pays for itself the next time a resource metadata, display mode, or host theme change would have reached production.
Get Started
npx sunpeak newFurther Reading
- sunpeak testing framework documentation
- sunpeak E2E testing reference
- sunpeak simulation files reference
- MCP App CI/CD - run your tests in GitHub Actions
- MCP App testing strategy - which tests to write first
- Cross-host compatibility testing for MCP Apps
- Live testing for Claude Connectors and ChatGPT Apps
- Accessibility testing for MCP Apps
- Visual regression testing MCP Apps
- MCP App resource metadata, CSP, permissions, and widget fields
- MCP Apps overview
- OpenAI Apps SDK reference
- Claude custom connectors with remote MCP
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
Frequently Asked Questions
How do I E2E test an MCP App?
Use the inspector fixture from sunpeak/test. Call inspector.renderTool("tool-name", input, options) to render your tool in a simulated MCP host runtime, then assert against the rendered iframe with Playwright through result.app(). Cover the primary tool result states, display modes, themes, and host-specific behavior before you run live tests against real ChatGPT or Claude.
What should an MCP App E2E test verify?
A useful MCP App E2E test verifies that the tool result renders inside the iframe, the main content is visible, the layout works in the requested display mode, the UI responds to user interaction, theme and safe-area values are respected, and any host bridge action behaves correctly. It should not duplicate every unit-test edge case.
Do I need a ChatGPT or Claude subscription to run MCP App E2E tests?
No. Local E2E tests run against the sunpeak inspector, which replicates ChatGPT and Claude MCP App runtimes on localhost. They do not need paid host accounts, API keys, or AI credits. Use live tests only for a smaller pre-release check against real hosts.
How do I test MCP Apps across ChatGPT and Claude automatically?
Use defineConfig() from sunpeak/test/config. It creates Playwright projects for the configured host runtimes, so the same test file can run once for ChatGPT and once for Claude. When a test fails, the Playwright report shows which host failed.
How do I test display modes in MCP App E2E tests?
Pass displayMode to inspector.renderTool(), for example { displayMode: "fullscreen" }. Test inline, picture-in-picture, and fullscreen behavior where the host supports them, and assert against stable UI outcomes such as visible controls, scroll boundaries, and non-overflowing content instead of only checking the root node.
What are simulation files in MCP App testing?
Simulation files are JSON fixtures that define deterministic tool-call states: tool input, tool result content, structuredContent, optional _meta, and the user message shown in the host. They let E2E tests render known states without calling production services or depending on an LLM to choose the same tool every time.
How do E2E tests differ from live host tests?
E2E tests run against a local host replica. They are fast, deterministic, and appropriate for pull requests and CI. Live host tests run through real ChatGPT or Claude sessions with real host auth, model routing, and iframe behavior. Keep live tests smaller and run them before release or on a schedule.
Where do accessibility and visual regression tests fit?
Accessibility and visual checks build on the same E2E foundation. Use Playwright locators for keyboard and focus behavior, axe-style checks for common accessibility regressions, and screenshot baselines for theme, viewport, and host layout drift. They should run against representative states, not every test case.