Skip to main content
All posts

Integration Testing for MCP Apps, ChatGPT Apps, and Claude Connectors (June 2026)

Abe Wheeler
MCP AppsMCP App TestingMCP App FrameworkChatGPT AppsChatGPT App TestingChatGPT App FrameworkClaude ConnectorsClaude Connector TestingClaude Connector FrameworkIntegration Testing
Integration testing MCP App tool handlers and resource components together.

Integration testing MCP App tool handlers and resource components together.

Unit tests tell you whether a function works. Browser tests tell you whether a user can see the app. Integration tests cover the layer between them: the MCP server contract.

For MCP Apps, ChatGPT Apps, and Claude Connectors, that contract is where a lot of real bugs start. Your tool can compile, your component can render with mock data, and the live app can still break because structuredContent changed shape, a resource URI points at stale UI, or _meta carries data the component expects but the tool no longer returns.

TL;DR: Write integration tests that call real tools through the running MCP server. Verify registration, input schemas, outputSchema, structuredContent, _meta, resource links, annotations, errors, and multi-tool workflows before you render the app in an inspector or connect it to ChatGPT or Claude.

What Integration Tests Prove

An MCP App has more contracts than a normal web app:

  • The host calls your MCP tool with JSON input.
  • Your server validates that input and runs the handler.
  • The handler returns content, structuredContent, and optional _meta.
  • The host shows model-visible data to the model and component-private data to the resource.
  • The resource iframe reads the result and renders the UI.
  • App-specific bridge calls, such as ChatGPT window.openai.callTool, can call more server tools from inside the component.

Unit tests usually skip most of that. They import the handler and call it with a JavaScript object. E2E tests cover the full rendered experience, but they are slower and harder to debug because a failure might live in the handler, protocol response, resource metadata, iframe sandbox, CSS, auth, or host settings.

Integration tests keep the fast feedback loop while exercising the real MCP boundary. They are the right place to catch bugs like these:

  • A tool returns { items: [...] }, but the resource reads output.results.
  • outputSchema promises results[].id, but structuredContent returns productId.
  • A field arrives as a string from the host, but your schema only accepts a number.
  • Tool annotations are missing readOnlyHint or destructiveHint.
  • _meta includes component data in one code path but not the error path.
  • A resource link points at an old URI after a build.
  • A second tool expects the output shape of the first tool, but the first tool changed.

Those are not theoretical edge cases. They are the normal failure mode for app-backed MCP servers.

The Basic Shape

In sunpeak projects, integration tests use the mcp fixture from sunpeak/test. The fixture starts or connects to the MCP server, then lets the test call tools through the protocol.

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

test('search tool returns the component contract', async ({ mcp }) => {
  const result = await mcp.callTool('search-products', {
    query: 'headphones',
  });

  expect(result.isError).toBeFalsy();
  expect(result.structuredContent.results).toBeInstanceOf(Array);
  expect(result.structuredContent.results[0]).toMatchObject({
    id: expect.any(String),
    name: expect.any(String),
    price: expect.any(Number),
  });
});

This does not render a browser. It proves that a real tool call through your MCP server returns the data shape your resource needs.

For an existing server that was not built with sunpeak, the workflow is the same: point the inspector and test setup at the server endpoint.

npx sunpeak test init --server http://localhost:8000/mcp
pnpm test:e2e

That matters for teams with Python, Go, Rust, or custom TypeScript MCP servers. The test target is the MCP contract, not the framework that produced it.

Start With Tool Registration

Before you test behavior, prove the tools exist and have the right public contract.

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

test('tools are registered with schemas and annotations', async ({ mcp }) => {
  const tools = await mcp.listTools();
  const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));

  expect(byName['search-products']).toBeDefined();
  expect(byName['product-detail']).toBeDefined();
  expect(byName['create-order']).toBeDefined();

  expect(byName['search-products'].annotations?.readOnlyHint).toBe(true);
  expect(byName['create-order'].annotations?.destructiveHint).toBe(true);

  expect(byName['search-products'].inputSchema.properties.query.type).toBe('string');
  expect(byName['search-products'].inputSchema.required).toContain('query');
});

This catches missing exports, incorrect names, invalid schemas, and weak annotations before the host has to guess what the tool does.

For Claude Connectors, annotations matter because they help the host reason about tool safety. For ChatGPT Apps, they also help keep model planning aligned with the UI path you expect.

Test outputSchema Against structuredContent

The current MCP tools spec gives tool results three important places to put data:

FieldWho uses itWhat to test
contentModel and transcriptHuman-readable fallback text or media
structuredContentModel and componentData that should match outputSchema
_metaComponent and host internalsComponent-private data, hints, cursors, opaque IDs

When a tool declares outputSchema, integration tests should assert that structuredContent actually follows it. TypeScript catches compile-time drift inside your codebase. Integration tests catch runtime drift at the MCP boundary.

test('search output matches the schema promised to hosts', async ({ mcp }) => {
  const result = await mcp.callTool('search-products', {
    query: 'headphones',
  });

  expect(result.isError).toBeFalsy();

  const output = result.structuredContent;
  expect(output).toEqual({
    results: expect.any(Array),
    total: expect.any(Number),
    nextCursor: expect.any(String),
  });

  for (const item of output.results) {
    expect(item).toMatchObject({
      id: expect.any(String),
      name: expect.any(String),
      price: expect.any(Number),
      inStock: expect.any(Boolean),
    });
  }
});

Avoid only checking that the call succeeds. A successful response with the wrong shape is still a broken app.

Test _meta Separately

_meta is useful for component-only data, but it can become a dumping ground. Keep it intentional.

test('_meta contains only component-private fields', async ({ mcp }) => {
  const result = await mcp.callTool('search-products', {
    query: 'headphones',
  });

  expect(result._meta).toMatchObject({
    selectedFilters: expect.any(Object),
    debugTraceId: expect.any(String),
  });

  expect(result._meta).not.toHaveProperty('apiKey');
  expect(result._meta).not.toHaveProperty('accessToken');
  expect(result._meta).not.toHaveProperty('rawCustomerRecord');
});

Do not treat _meta as a secret store. It is hidden from the model, but it still moves through host infrastructure and reaches your component. Put only the data your component needs.

Good _meta integration tests usually check three things:

  • The component data exists on successful results.
  • Error results still include enough metadata for recovery, if the component expects it.
  • Sensitive upstream data is not present.

Interactive MCP Apps do not stop at the first tool result. A tool often returns a resource link that tells the host which UI to render. The UI may then call app-only tools from inside the iframe.

Your integration tests should prove both contracts.

test('dashboard tool links to the expected app resource', async ({ mcp }) => {
  const result = await mcp.callTool('show-dashboard', {
    accountId: 'acct_test',
  });

  expect(result.isError).toBeFalsy();

  const resourceLink = result.content.find((item) => item.type === 'resource_link');
  expect(resourceLink).toMatchObject({
    uri: 'ui://dashboard',
    name: 'dashboard',
  });
});

If your app has component-triggered tools, test those tools through MCP too. You want to know they validate input and return stable data even before a browser test clicks the button.

test('app-only refresh tool returns the same dashboard contract', async ({ mcp }) => {
  const result = await mcp.callTool('refresh-dashboard', {
    accountId: 'acct_test',
    range: '30d',
  });

  expect(result.isError).toBeFalsy();
  expect(result.structuredContent.cards).toBeInstanceOf(Array);
});

Then use an E2E test to prove the actual bridge call works from the rendered component. Keep the integration test focused on the server contract.

Test Error Results Like Product Features

Error handling is part of the protocol contract. If an upstream API fails, the host and component still need a response they can work with.

test('invalid product id returns a useful MCP error', async ({ mcp }) => {
  const result = await mcp.callTool('product-detail', {
    productId: 'missing-product',
  });

  expect(result.isError).toBe(true);
  expect(result.content[0]).toMatchObject({
    type: 'text',
    text: expect.stringContaining('Product not found'),
  });
});

Also test validation failures. Host-generated input can be messy, especially when a model fills optional fields.

test('invalid input fails before handler side effects run', async ({ mcp }) => {
  const result = await mcp.callTool('create-order', {
    productId: 'sku_123',
    quantity: -5,
  });

  expect(result.isError).toBe(true);
  expect(result.content[0].text).toContain('quantity');
});

For destructive tools, pair this with a mock or test database assertion that no write happened.

Test Multi-Tool Workflows

Many MCP Apps use a workflow, not a single tool. A search tool finds items. A detail tool opens one item. An action tool changes state. Integration tests should run that chain with real tool calls.

test('search to detail to order workflow keeps IDs compatible', async ({ mcp }) => {
  const search = await mcp.callTool('search-products', {
    query: 'headphones',
  });

  expect(search.isError).toBeFalsy();

  const firstProduct = search.structuredContent.results[0];
  expect(firstProduct.id).toBeTruthy();

  const detail = await mcp.callTool('product-detail', {
    productId: firstProduct.id,
  });

  expect(detail.isError).toBeFalsy();
  expect(detail.structuredContent.id).toBe(firstProduct.id);

  const order = await mcp.callTool('create-order', {
    productId: detail.structuredContent.id,
    quantity: 1,
  });

  expect(order.isError).toBeFalsy();
  expect(order.structuredContent.orderId).toEqual(expect.any(String));
});

This catches contract drift that isolated tests miss. If the search tool renames id to productId, the detail step fails immediately.

Mock External Dependencies at the Right Boundary

Integration tests should exercise your MCP server and handler code. They do not need to exercise every upstream service on every CI run.

Mock at a boundary you own:

vi.mock('../../src/lib/catalog-api', () => ({
  searchProducts: vi.fn().mockResolvedValue({
    results: [
      {
        id: 'sku_123',
        name: 'Wireless Headphones',
        price: 79.99,
        inStock: true,
      },
    ],
    total: 1,
    nextCursor: '',
  }),
}));

That keeps the real handler logic in the test while removing network flake. It also lets you test awkward cases that are hard to reproduce against production APIs: empty results, rate limits, partial records, malformed data, and slow responses.

Use real dependencies in a smaller staging suite when you need to prove:

  • OAuth tokens and refresh flows work.
  • Pagination matches the upstream API.
  • Rate limits and retry logic behave as expected.
  • Database queries match production indexes and constraints.

Where Integration Tests Sit in the Suite

A practical MCP App test suite usually looks like this:

LayerRuntimeMain job
UnitVitest or similarPure functions, schema helpers, component logic
IntegrationMCP serverTools, schemas, structuredContent, _meta, workflows
E2ELocal inspector with PlaywrightRendered resource UI, iframe behavior, host state
VisualInspector screenshotsLayout changes across themes, display modes, and viewports
LiveReal ChatGPT or ClaudeAccount setup, auth, connector install, final host behavior

Integration tests and rendered E2E tests may both run under pnpm test:e2e. The difference is the fixture. Tests with the mcp fixture are protocol-level integration tests. Tests with the inspector fixture are rendered app tests.

One clean folder layout is:

tests/
  e2e/
    integration/
      tools.spec.ts
      workflows.spec.ts
      errors.spec.ts
    rendering/
      dashboard.spec.ts
      themes.spec.ts

Run the protocol-level tests when you change tool handlers. Run the rendering tests when you change resource components. Run both in CI.

pnpm test:e2e tests/e2e/integration/
pnpm test:e2e tests/e2e/rendering/

A Complete Integration Test Checklist

Use this checklist when you add a new MCP App tool:

  • listTools() includes the tool name.
  • Input schema accepts the values the host will send.
  • Tool annotations match the tool’s behavior.
  • Success results include useful content.
  • structuredContent matches outputSchema.
  • _meta contains only component-private data.
  • Resource links point at the current app resource URI.
  • Error paths return isError: true and clear text.
  • Destructive paths do not run when validation fails.
  • Outputs work as inputs to downstream tools.

You do not need every assertion in one giant test. Smaller tests are easier to debug and faster to update when the contract changes.

Get Started

If you already use sunpeak, the mcp fixture is available from sunpeak/test. Add one integration test for tool registration, then add one contract test for the tool your main resource depends on most.

If you have an existing MCP server, scaffold tests around the server you already have:

npx sunpeak test init --server http://localhost:8000/mcp
pnpm test:e2e

Then run browser tests in the sunpeak MCP App Inspector for the rendered UI. The split is simple: integration tests prove the MCP contract, inspector tests prove the user experience, and live host tests prove the final ChatGPT or Claude connection.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is integration testing for MCP Apps?

Integration testing for MCP Apps verifies that tool handlers, MCP protocol behavior, resource links, output schemas, structuredContent, _meta, and host-facing tool annotations work together. It calls tools through a running MCP server instead of importing handler functions directly, so it catches bugs that unit tests miss without needing a full browser render.

How do I integration test an MCP App tool handler?

Use a protocol-level test fixture such as sunpeak/test. Call mcp.callTool("tool-name", input), then assert on isError, content, structuredContent, _meta, and any fields your resource component reads. This exercises JSON-RPC serialization, input validation, handler logic, and MCP result formatting together.

What should an MCP App integration test assert?

Assert that the tool is registered, the input schema accepts valid host input, outputSchema matches the structuredContent shape, resource metadata points at the expected UI, _meta contains only component-private data, and errors return useful content instead of raw exceptions. For app tools, also test annotations and app-only visibility rules.

What is the difference between unit, integration, and e2e tests for MCP Apps?

Unit tests call functions or components in isolation. Integration tests call real MCP tools through the running server and validate protocol-level contracts. E2E tests render the app resource in a host-like browser runtime, usually through an inspector. You need all three because they catch different classes of bugs.

Can I integration test ChatGPT Apps without a paid ChatGPT account?

Yes. Local integration tests run against your MCP server and do not require ChatGPT, Claude, a tunnel, or host credits. Use live ChatGPT or Claude testing near release time to verify account settings, auth, connector installation, and final host behavior.

How do I test structuredContent and _meta in MCP tool results?

Call the tool through the MCP fixture and assert that structuredContent contains the model-visible data promised by outputSchema. Then assert that _meta contains only component-private data such as resource hints, cursor state, or internal IDs. Do not put secrets in _meta, because it still travels through the host.

Should integration tests mock external APIs?

Most CI integration tests should mock APIs at the network or client boundary so the MCP contract remains deterministic. Add a smaller staging suite that uses real APIs when you need to prove auth, pagination, rate limits, or upstream data contracts.

How do I test multi-tool MCP workflows?

Call the first tool, use its structuredContent as input to the second tool, and continue through the workflow. This proves that tool outputs are usable by downstream tools and catches field name changes that isolated handler tests often miss.