Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App TestingMCP App FrameworkChatGPT AppsChatGPT App TestingChatGPT App FrameworkClaude ConnectorsClaude Connector TestingClaude Connector FrameworkTDDTest-Driven Development
Test-driven development for MCP Apps: write simulations and tests first, then build.

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.

That matters more in July 2026 than it did when this post first went live. MCP Apps are now an official MCP extension, ChatGPT now documents MCP Apps compatibility and recommends the standard bridge for new app UI, and Claude has continued expanding MCP support across Claude products and the API. The testing problem is no longer “can this work?” The problem is “can I prove this tool, UI state, and host behavior still work after the next change?”

TL;DR: Start TDD with the tool contract, not the component. Write the tool input schema, output shape, and simulation file first. Then write a failing unit, integration, or inspector test. Build the smallest tool handler or resource component that makes it pass. Add cross-host and visual checks once the contract is stable.

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:

  1. What tool will the model call?
  2. What arguments does the tool accept?
  3. What structuredContent shape does the UI render?
  4. What data stays hidden in _meta?
  5. What resource URI should the host render?
  6. 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.

OpenAI’s current Apps SDK docs recommend the same direction for new ChatGPT Apps: use the MCP Apps bridge by default, then add ChatGPT-specific extensions only when you need them. That means the contract you test should use standard MCP Apps ideas 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.

  1. Write the tool contract.
  2. Write a simulation file that pins one complete host state.
  3. Write a failing test against that state.
  4. Build the minimum handler or resource component.
  5. 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-order accepts an orderId.
  • It returns structuredContent with the fields above.
  • It renders ui://order-review.
  • The UI can call confirm-order after the user clicks a button.

You now have enough to write the first simulation before the implementation exists.

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.

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 { 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: React.ReactNode }) => <div>{children}</div>,
  useToolData: () => mockToolData,
}));

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 />);

    expect(screen.getByText('Wireless Headphones')).toBeDefined();
    expect(screen.getByText('$79.00')).toBeDefined();
    expect(screen.getByRole('button', { name: /confirm/i })).toBeDefined();
  });
});

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:

// src/resources/order-review/order-review.tsx
import { SafeArea, 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<OrderReviewOutput>();

  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: {output.status}</p>
      {output.status === 'pending' ? <button type="button">Confirm order</button> : null}
    </SafeArea>
  );
}

Run the unit test. It should pass. Now you can improve the markup, styling, and state handling while the contract stays fixed.

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 { 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 result = await handler({ orderId: 'ord_123' });

    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 { getOrder } from '../lib/orders';

export async function handler(input: { orderId: string }) {
  const order = await getOrder(input.orderId);

  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,
    },
  };
}

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 result = await mcp.callTool('review-order', { orderId: 'ord_123' });

  expect(result.isError).toBeFalsy();
  expect(result.structuredContent).toMatchObject({
    orderId: 'ord_123',
    status: 'pending',
  });
});

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.
  • structuredContent no longer matches outputSchema.
  • 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.

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.

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');
  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.

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.

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/message where that is the right UX.
  • The resource updates model-visible context through ui/update-model-context only 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 hidden _meta.
  • 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 />);
  expect(screen.getByText('Could not load this order.')).toBeDefined();
});

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');
  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:

import { expect, test } from 'sunpeak/test';

test('review order fits in narrow inline mode', async ({ inspector }) => {
  const result = await inspector.renderTool('review-order', {
    host: 'chatgpt',
    displayMode: 'inline',
    width: 390,
  });

  await expect(result.app().getByText('Wireless Headphones')).toBeVisible();
});

Then run the same user flow in the Claude runtime. You are looking for differences such as clipped buttons, missing safe-area padding, colors that only work in one theme, and assumptions about display mode transitions.

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:

  • OpenAI’s Apps SDK docs say apps are submitted and published as plugins.
  • Developer mode is the normal way to test a custom app in ChatGPT before submission.
  • ChatGPT now documents full MCP Apps compatibility in the Apps SDK changelog.
  • New UI should use the MCP Apps bridge by default, with OpenAI extensions reserved for ChatGPT-specific capabilities.

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:

  1. Write or update the tool input schema.
  2. Define the structuredContent type and output schema.
  3. Add one happy-path simulation.
  4. Write a failing handler unit test or integration test.
  5. Build the handler.
  6. Write a failing resource unit test with the same data.
  7. Build the resource.
  8. Add simulations for empty, error, cancelled, and large states.
  9. Add inspector tests for the main user flow.
  10. Add cross-host checks for ChatGPT and Claude.
  11. Add visual snapshots for the final layout.
  12. 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 structuredContent type.
  • 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 test suite as a side effect of building 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

Documentation →
npx sunpeak new

Further Reading

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. Because the boundary is explicit, you can write the data contract first and test the UI against a fixed simulation before the real backend exists.

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. They make a host state reproducible, so the local inspector and Playwright tests can render the same UI every time. In TDD, write the simulation first to define the state you want, then write the failing test against that state.

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?

Test the tool contract first: input validation, outputSchema, structuredContent shape, and error results. Then test the resource component with mocked tool data or a simulation. Add inspector E2E tests for user flows, display modes, themes, viewport sizes, and server tool calls once the contract is stable.

How does TDD help with cross-host MCP App support?

A TDD workflow forces you to encode host assumptions as tests. Instead of manually checking ChatGPT and Claude after every change, you run inspector tests across host modes, themes, widths, safe areas, display modes, and app states. That catches many iframe, CSS variable, and bridge-message issues before live host testing.

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.