Test-Driven Development for MCP Apps, ChatGPT Apps, and Claude Connectors (October 2026)

Test-driven development for MCP Apps: write simulations and tests first, then build.
MCP Apps are a good fit for test-driven development because the app has a clear contract boundary. The server tool receives input and returns content, structuredContent, and optional _meta. The UI resource renders inside a host iframe and receives that data through the MCP Apps bridge. If you can describe that contract before you build the UI, you can test-drive the app.
The MCP Apps overview now describes the shared tool-to-UI resource link, iframe lifecycle, bridge, and host context. ChatGPT’s UI guide recommends that same bridge for new UI, with optional host extensions layered on later. Those shared parts give you concrete boundaries to test before trying an app in a live conversation.
TL;DR: Start with the tool contract and a simulation for one UI state. Write a failing test, build the handler and resource, then prove a real MCP call returns the same shape. In sunpeak, renderTool(fixtureName) uses a simulation, while renderTool(toolName, nonEmptyInput) calls the real server. Run both paths before cross-host and visual checks.
The TDD Unit Is the Tool Contract
In a normal React app, TDD often starts with a component. In an MCP App, start one layer earlier:
- What tool will the model call?
- What arguments does the tool accept?
- What
structuredContentshape does the UI render? - What data belongs in tool result
_metafor the UI? It reaches the client, so it cannot hold secrets. - What resource URI should the host render?
- What host actions can the UI request later?
That contract gives you a spec before you write the UI. The official MCP Apps docs describe the basic flow: a tool declares a ui:// resource, the model calls the tool, the host renders the resource in a sandboxed iframe, and the UI communicates with the host over JSON-RPC via postMessage.
ChatGPT’s current UI guide recommends the MCP Apps bridge for new UI, with ChatGPT-specific extensions only where the shared specification lacks a feature. That means the contract you test should use standard MCP Apps messages first, such as ui/notifications/tool-input, ui/notifications/tool-result, tools/call, ui/message, and ui/update-model-context.
Red, Green, Refactor for MCP Apps
The MCP App version of red-green-refactor has one extra first step: write the simulation.
- Write the tool contract.
- Write a simulation file that pins one complete host state.
- Write a failing test against that state.
- Build the minimum handler or resource component.
- Refactor with the simulation and tests still green.
Use a small order review app as the example. The tool displays a pending order and lets the user confirm or cancel it from the UI.
Step 1: Define the Contract
Start with the data the UI needs:
// src/resources/order-review/types.ts
export interface OrderReviewOutput {
orderId: string;
itemName: string;
quantity: number;
totalUsd: number;
status: 'pending' | 'confirmed' | 'cancelled';
}
Then define the tool behavior in plain terms:
review-orderaccepts anorderId.- It returns
structuredContentwith the fields above. - It renders
ui://order-review. - The UI can call
confirm-orderafter the user clicks a button.
You now have enough to write the first simulation before the implementation exists. The server should also return useful text in content for a client that does not render the UI; the iframe is an optional way to complete the same task.
Step 2: Write the Simulation First
Create tests/simulations/review-order-pending.json:
{
"tool": "review-order",
"userMessage": "Review order ord_123",
"toolInput": {
"orderId": "ord_123"
},
"toolResult": {
"structuredContent": {
"orderId": "ord_123",
"itemName": "Wireless Headphones",
"quantity": 1,
"totalUsd": 79,
"status": "pending"
}
},
"serverTools": {
"confirm-order": [
{
"when": { "orderId": "ord_123", "confirmed": true },
"result": {
"structuredContent": {
"orderId": "ord_123",
"status": "confirmed"
}
}
},
{
"when": { "orderId": "ord_123", "confirmed": false },
"result": {
"structuredContent": {
"orderId": "ord_123",
"status": "cancelled"
}
}
}
]
}
}
This file is more than mock data. It is a durable state you can load in the inspector, share in a bug report, and reuse in Playwright. sunpeak simulations load from tests/simulations/, so the same fixture works for manual inspection and automated tests. Keep example IDs and data in fixtures; tests that must prove access control should call the real server with records owned by different test users.
Step 3: Write the Failing UI Test
For fast feedback, start with a unit test that mocks the runtime hook your component reads from:
// tests/unit/order-review.test.tsx
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import OrderReview from '../../src/resources/order-review/order-review';
let mockToolData: Record<string, unknown> = {};
vi.mock('sunpeak', () => ({
SafeArea: ({ children }: { children: ReactNode }) => <div>{children}</div>,
useToolData: () => mockToolData,
useCallServerTool: () => vi.fn(),
}));
describe('OrderReview resource', () => {
beforeEach(() => {
mockToolData = {
output: {
orderId: 'ord_123',
itemName: 'Wireless Headphones',
quantity: 1,
totalUsd: 79,
status: 'pending',
},
isLoading: false,
isError: false,
isCancelled: false,
};
});
it('renders the pending order', () => {
render(<OrderReview />);
screen.getByText('Wireless Headphones');
screen.getByText('$79.00');
screen.getByRole('button', { name: /confirm/i });
});
});
This fails because the component does not exist yet. That is the point. The test states the behavior you want before implementation details start shaping the code.
Step 4: Build the Smallest Resource
Now write only enough component code to pass. The example also wires the decision to the server tool so the later interaction test has a real behavior to check:
// src/resources/order-review/order-review.tsx
import { useState } from 'react';
import { SafeArea, useCallServerTool, useToolData } from 'sunpeak';
import type { ResourceConfig } from 'sunpeak';
import type { OrderReviewOutput } from './types';
export const resource: ResourceConfig = {
description: 'Review an order before confirmation',
};
export default function OrderReview() {
const { output, isLoading, isError, isCancelled } = useToolData<unknown, OrderReviewOutput>();
const callServerTool = useCallServerTool();
const [decision, setDecision] = useState<'confirmed' | 'cancelled' | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
async function decide(confirmed: boolean) {
if (!output || pending) return;
setError('');
setPending(true);
try {
const result = await callServerTool({
name: 'confirm-order',
arguments: { orderId: output.orderId, confirmed },
});
const next = result?.structuredContent as { status?: string } | undefined;
if (result?.isError || next?.status !== (confirmed ? 'confirmed' : 'cancelled')) {
throw new Error('The order was not updated');
}
setDecision(confirmed ? 'confirmed' : 'cancelled');
} catch {
setDecision(null);
setError('Could not update this order. Try again.');
} finally {
setPending(false);
}
}
if (isLoading) return <SafeArea>Loading order...</SafeArea>;
if (isCancelled) return <SafeArea>Order review stopped.</SafeArea>;
if (isError || !output) return <SafeArea>Could not load this order.</SafeArea>;
return (
<SafeArea>
<h2>{output.itemName}</h2>
<p>${output.totalUsd.toFixed(2)}</p>
<p>Status: {decision ?? output.status}</p>
{error ? <p role="alert">{error}</p> : null}
{output.status === 'pending' && !decision ? (
<>
<button type="button" disabled={pending} onClick={() => decide(true)}>Confirm order</button>
<button type="button" disabled={pending} onClick={() => decide(false)}>Cancel order</button>
</>
) : null}
</SafeArea>
);
}
Run the unit test. It should pass. Then add click tests that mock useCallServerTool and check the confirm-order arguments, returned status, and error message. Keep the server’s permission check authoritative; a button state is no substitute for one.
Add the Tool Handler Test Before the Handler
The component test proves the UI can render known data. It does not prove the server can produce that data.
Write a handler test next:
// tests/unit/review-order-handler.test.ts
import { describe, expect, it, vi } from 'vitest';
import type { ToolHandlerExtra } from 'sunpeak/mcp';
import handler from '../../src/tools/review-order';
vi.mock('../../src/lib/orders', () => ({
getOrder: vi.fn().mockResolvedValue({
id: 'ord_123',
itemName: 'Wireless Headphones',
quantity: 1,
totalUsd: 79,
status: 'pending',
}),
}));
describe('review-order handler', () => {
it('returns structuredContent for the order review UI', async () => {
const extra = { http: { authInfo: { token: 'test-user' } } } as ToolHandlerExtra;
const result = await handler({ orderId: 'ord_123' }, extra);
expect(result.structuredContent).toEqual({
orderId: 'ord_123',
itemName: 'Wireless Headphones',
quantity: 1,
totalUsd: 79,
status: 'pending',
});
});
});
Then build the handler:
// src/tools/review-order.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
import { getOrder } from '../lib/orders';
export const tool: AppToolConfig = {
title: 'Review Order',
description: 'Review an order before confirming it',
resource: 'order-review',
};
export const schema = { orderId: z.string().min(1) };
export const outputSchema = {
orderId: z.string(),
itemName: z.string(),
quantity: z.number(),
totalUsd: z.number(),
status: z.enum(['pending', 'confirmed', 'cancelled']),
};
export default async function handler(input: { orderId: string }, extra: ToolHandlerExtra) {
const order = await getOrder(input.orderId, extra.http?.authInfo);
return {
content: [{ type: 'text' as const, text: `Review order ${order.id}` }],
structuredContent: {
orderId: order.id,
itemName: order.itemName,
quantity: order.quantity,
totalUsd: order.totalUsd,
status: order.status,
},
};
}
This follows sunpeak’s tool-file convention: tool links the View, schema validates input, outputSchema describes structured output, and the default export handles the call. Make getOrder reject missing auth and records the caller cannot access. Add handler tests for unknown and other-user IDs; the example above covers only the valid result.
The practical rule: if your resource component expects a field, one test should prove the handler returns it and another test should prove the UI renders it.
Use Integration Tests for the Protocol Boundary
Unit tests can lie because they import functions directly. MCP hosts do not import your handler. They call tools through the protocol.
Add an integration test when the handler shape matters:
import { expect, test } from 'sunpeak/test';
test('review-order returns the UI data contract', async ({ mcp }) => {
const tool = (await mcp.listTools()).find((entry) => entry.name === 'review-order');
expect(tool?.inputSchema).toBeDefined();
expect(tool?.outputSchema).toBeDefined();
expect(tool?._meta?.ui?.resourceUri).toMatch(/^ui:\/\//);
const result = await mcp.callTool('review-order', { orderId: 'ord_123' });
expect(result.isError).toBeFalsy();
expect(result.content).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'text' })]),
);
expect(result.structuredContent).toMatchObject({
orderId: 'ord_123',
status: 'pending',
});
});
Seed ord_123 for this test’s authenticated user. Add protocol tests that reject an empty ID and a valid ID from another user. A passing unit test with a mocked getOrder cannot prove either boundary.
This catches bugs that a direct unit test can miss:
- The tool was registered under the wrong name.
- The input schema rejects a valid host request.
- The handler returns a non-serializable value.
structuredContentno longer matchesoutputSchema.- The tool returns user-visible text but no UI data.
For many MCP servers, this is the highest-return test layer. It is fast, deterministic, and close to how real hosts call your server. Add MCP App conformance checks when you need to verify resource linkage, bridge lifecycle, and protocol versions beyond one tool call.
Use Inspector E2E Tests for the Rendered App
Once the contract and handler pass, test the app in a host runtime. The local inspector gives you a browser, iframe, host context, bridge messages, and rendered resources without a live ChatGPT or Claude session. The MCP App E2E testing guide covers setup and fixtures in more detail.
import { expect, test } from 'sunpeak/test';
test('review order can be confirmed from the app UI', async ({ inspector }) => {
const result = await inspector.renderTool('review-order-pending');
const app = result.app();
await expect(app.getByText('Wireless Headphones')).toBeVisible();
await app.getByRole('button', { name: /confirm/i }).click();
await expect(app.getByText(/confirmed/i)).toBeVisible();
});
This test should run after you already know the handler works. Keep the loop layered:
- Unit tests for component states and pure handler logic.
- Integration tests for MCP tool registration and protocol results.
- Inspector E2E tests for iframe rendering and user flows.
- Visual regression tests for layout and host styling.
- Live host tests for final ChatGPT or Claude behavior before submission.
The first test selects tests/simulations/review-order-pending.json by its filename. The fixture targets the review-order tool, but sunpeak keys simulations by filename, so renderTool('review-order-pending') is the right call. It proves that the View renders the fixture and can call its mocked confirm-order tool. It does not prove that the real review-order handler returns the same data. Add a second browser test with non-empty input:
test('real review-order result renders in the app', async ({ inspector }) => {
const result = await inspector.renderTool('review-order', { orderId: 'ord_123' });
await expect(result.app().getByText('Wireless Headphones')).toBeVisible();
});
Seed that order for the test user. In sunpeak, renderTool(toolName, input) calls the real server when input is non-empty; renderTool(fixtureName) uses the named simulation. Keep both tests, because a hand-edited fixture can pass after the server contract has broken. Display options such as theme and mode go in the third argument.
sunpeak’s current testing framework can scaffold this around an existing server with:
npx sunpeak test init --server http://localhost:8000/mcp
For manual inspection, use:
npx sunpeak inspect --server http://localhost:8000/mcp
That opens a local inspector where you can switch ChatGPT and Claude host modes, themes, widths, display modes, safe areas, locale, platform, tool input, tool result, and simulation state. The local runtime catches many UI bugs, while final behavior in a live host still needs a live check.
Test the Standard Bridge First
The MCP App ecosystem has moved toward a shared UI contract. That should change how you write tests.
For new app UI, test standard MCP Apps bridge behavior first:
- The resource receives tool input through
ui/notifications/tool-input. - The resource receives tool results through
ui/notifications/tool-result. - The resource calls server tools through
tools/call. - The resource sends user intent through
ui/messagewhere that is the right UX. - The resource updates model-visible context through
ui/update-model-contextonly when the model needs that state.
Then add host-specific tests for host-specific behavior. For ChatGPT, that may include plugin submission requirements, ChatGPT-specific metadata, widget presentation, or compatibility with Apps SDK window.openai APIs. For Claude, that may include connector auth, tool allowlists, and the exact connector surface you plan to support.
The split keeps your app portable. It also makes failures easier to read. A standard bridge test failing means the app contract broke. A host-specific test failing means one host needs an adapter, fallback, or metadata change.
TDD Edge Cases Before They Become Bugs
Most MCP App bugs show up in states developers did not keep around locally. TDD makes those states first-class fixtures.
Write simulations for:
- Empty data.
- Null optional fields.
- API timeout.
- Auth required.
- Partial success.
- Large data sets.
- Loading state.
- Cancelled state.
- Tool result with UI-only
_metathat contains no secrets. - Host width too narrow for your layout.
- Dark mode and high-contrast combinations.
For each state, decide which layer should fail first.
If the tool should never return invalid data, write a handler or integration test. If the tool can return the state and the UI must handle it, write a component or inspector test. If the state is visual, add a screenshot test once the UI is stable.
Example error-state simulation:
{
"tool": "review-order",
"userMessage": "Review order ord_missing",
"toolInput": {
"orderId": "ord_missing"
},
"toolResult": {
"content": [{ "type": "text", "text": "Order not found." }],
"structuredContent": null,
"isError": true
}
}
Then write the failing test:
it('shows an error state when the order cannot be loaded', () => {
mockToolData = {
output: null,
isLoading: false,
isError: true,
isCancelled: false,
};
render(<OrderReview />);
screen.getByText('Could not load this order.');
});
This is boring, which is good. The best TDD tests turn expensive manual checks into simple facts the build can repeat.
TDD for Server Tool Calls from the UI
Interactive MCP Apps often need more than one tool call. A user filters a chart, saves a record, confirms an order, or starts a long-running job from the iframe. That means your UI is no longer just rendering structuredContent. It is driving more server work.
Test that with server tool mocks in the simulation, then an inspector test. Keep the contract clear:
- What arguments does the UI send?
- What result does the server tool return?
- Does the model need to see the updated state?
- Does the UI need to call another tool or only update local app state?
- What happens if the call fails or the user cancels?
For a confirmation flow, the simulation above already defines confirm-order. The E2E test clicks the button and checks the result. You can add a second test for the cancel path before building the cancel button:
test('declining an order shows cancelled status', async ({ inspector }) => {
const result = await inspector.renderTool('review-order-pending');
const app = result.app();
await app.getByRole('button', { name: /cancel/i }).click();
await expect(app.getByText(/cancelled/i)).toBeVisible();
});
That failing test forces you to implement the path deliberately instead of treating it as secondary UI.
Cross-Host TDD
MCP Apps can run in more than one host, but “uses MCP” is not the same as “looks and behaves the same everywhere.” Hosts differ in presentation, iframe constraints, CSS variables, display modes, permission prompts, and which optional features they expose.
Add cross-host checks after the main state passes. Run this test in separate ChatGPT and Claude Playwright projects. The sunpeak fixture selects the host from the project. Resize the Playwright page for a narrow viewport, and pass display settings as the third argument:
import { expect, test } from 'sunpeak/test';
test('review order has no horizontal overflow on a phone', async ({ inspector }) => {
await inspector.page.setViewportSize({ width: 390, height: 844 });
const result = await inspector.renderTool('review-order-pending', undefined, {
displayMode: 'inline',
theme: 'dark',
});
const app = result.app();
await expect(app.getByText('Wireless Headphones')).toBeVisible();
const overflows = await app.locator('body').evaluate(
(body) => body.scrollWidth > body.clientWidth,
);
expect(overflows).toBe(false);
});
You are looking for clipped buttons, missing safe-area padding, colors that only work in one theme, and assumptions about display mode transitions. Only request modes that the View declares and the host supports; a host may decline an optional mode. See the cross-host testing guide for a broader scenario matrix.
Use live host testing later, not for the inner TDD loop. Real ChatGPT and Claude checks are still useful before publishing because they catch review, auth, account, permission, and product behavior the local inspector cannot guarantee. They are too slow and variable to be the first place you discover a typo.
Where ChatGPT Apps and Claude Connectors Differ
The shared MCP Apps path covers a lot, but you should test the differences directly.
For ChatGPT Apps:
- ChatGPT’s current UI guide starts with the MCP Apps bridge and keeps host extensions optional.
- Test the linked
ui://resource and the tool’s text response, since a client without UI must still be able to complete the task. - Test ChatGPT-specific presentation, auth, and approval behavior in the live host before publishing.
For Claude Connectors:
- Data-only connectors still need strong tool schema, auth, and integration tests.
- Interactive connectors need rendered resource tests in a Claude-like host runtime.
- Anthropic’s MCP connector for the Messages API lets API requests connect to remote MCP servers without building a separate MCP client, which makes protocol-level tests even more valuable.
The TDD advice is the same in both cases: write the portable MCP contract first, then write host-specific tests where the host has real differences.
A Practical Test Order
If you are starting a feature today, use this order:
- Write or update the tool input schema.
- Define the
structuredContenttype and output schema. - Add one happy-path simulation.
- Write a failing handler unit test or integration test.
- Build the handler.
- Write a failing resource unit test with the same data.
- Build the resource.
- Add simulations for empty, error, cancelled, and large states.
- Add inspector tests for the main user flow.
- Add cross-host checks for ChatGPT and Claude.
- Add visual snapshots for the final layout.
- Run live host checks before plugin submission or connector launch.
This order keeps the fastest tests closest to the code that changes most. It also stops UI work from drifting away from the actual tool result shape.
When TDD Is Too Much
Do not force TDD when the unknown is still product shape. If you are exploring a chart layout, trying a new workflow, or seeing what a third-party API returns, spike it first. Hardcode data if that helps. Use the inspector to see the UI quickly.
Once the shape settles, turn what you learned into:
- A real
structuredContenttype. - A simulation file.
- A unit test for the component.
- An integration test for the tool.
- An inspector test for the user flow.
That gives you most of the value without pretending every design choice can be known before you start.
Putting It Together
TDD for MCP Apps is not about test purity. It is about making host state repeatable.
Write the contract first. Save the state as a simulation. Prove the handler returns the shape. Prove the resource renders it. Prove the user flow works in a local host runtime. Then prove the same flow still works across ChatGPT, Claude, themes, widths, and display modes.
That workflow gives you a repeatable test suite as you build the app. It also gives future developers and agents concrete examples of what the app is supposed to do.
If you are building from scratch, start with npx sunpeak new. If you already have an MCP server, start with:
npx sunpeak inspect --server http://localhost:8000/mcp
npx sunpeak test init --server http://localhost:8000/mcp
Then write the first simulation before the first component. The rest of the TDD loop gets much easier once the host state is no longer trapped in a live chat session.
Get Started
npx sunpeak newFurther Reading
- MCP App testing strategy - choose the right tests first
- How to unit test MCP Apps - component and handler tests
- Integration testing MCP Apps - protocol-boundary tests
- E2E testing MCP Apps - Playwright tests with the inspector
- Mocking and stubbing MCP App tests - simulations and fixtures
- Visual regression testing MCP Apps - screenshots across hosts
- Cross-host testing MCP Apps - ChatGPT, Claude, and more
- MCP App conformance testing - protocol, UI, and host checks
- MCP App testing framework
- sunpeak Inspector documentation
- Official MCP Apps overview and UI lifecycle
- ChatGPT guide to MCP Apps UI and compatibility
- Anthropic MCP connector docs
Frequently Asked Questions
What is test-driven development for MCP Apps?
Test-driven development for MCP Apps means defining the tool contract, simulation state, and test expectations before building the resource UI or tool handler. The contract usually starts with the tool input schema, outputSchema, structuredContent, and the UI resource that renders that data. You then write a failing unit, integration, or inspector test and build only enough code to make it pass.
Why does TDD fit MCP Apps better than many web apps?
MCP Apps separate server tools from rendered UI resources. A tool produces content, structuredContent, and optional _meta. A resource runs in a host iframe and renders that data. You can test the UI against a fixed simulation before the backend exists, then test the real MCP call because a fixture alone cannot prove the server contract.
How do simulation files support TDD in MCP App development?
Simulation files are JSON fixtures that pin a tool name, user message, tool input, tool result, and optional server tool mocks. In sunpeak, inspector.renderTool(fixtureName) selects a simulation by filename, while inspector.renderTool(toolName, nonEmptyInput) calls the real server. Test both paths so a good fixture cannot hide a broken handler.
Can I practice TDD for ChatGPT Apps without a paid ChatGPT account?
Yes. Use a local inspector and automated tests for the main TDD loop. sunpeak can inspect an existing MCP server with npx sunpeak inspect --server URL and can scaffold test infrastructure with npx sunpeak test init --server URL. Use real ChatGPT developer mode later for final compatibility checks and plugin submission readiness.
Can I use TDD for Claude Connectors?
Yes. For data-only Claude Connectors, start with tool schemas, auth behavior, tool results, and integration tests. For interactive Claude Connectors that render MCP App UI, add inspector and Playwright tests for the rendered resource. Use Claude-specific live checks only for behavior the local runtime cannot prove.
What should I test first in an MCP App?
Start with one valid tool request and its expected text and structuredContent, then add invalid input and an unauthorized record. Check the registered input and output schemas through the MCP protocol. Next, test the UI against a simulation and run one inspector test with non-empty input to exercise the real server.
How does TDD help with cross-host MCP App support?
TDD turns host assumptions into repeatable tests. Run a small set of inspector cases for the main flow in each configured host, then add theme, width, safe-area, and display-mode cases that match the View’s declared capabilities. Finish with live host checks for auth, approvals, and behavior the local runtime cannot prove.
When should I skip TDD for MCP Apps?
Skip strict TDD when you are still discovering the product shape, exploring a third-party API response, or doing purely visual polish. In those cases, spike the idea first, then turn the chosen data shape and UI states into simulations and tests before you keep building.