Skip to main content
All posts

Snapshot Testing MCP Apps, ChatGPT Apps, and Claude Connectors (September 2026)

Abe Wheeler
MCP AppsMCP App TestingMCP App FrameworkChatGPT AppsChatGPT App TestingChatGPT App FrameworkClaude ConnectorsClaude Connector TestingClaude Connector Framework
Snapshot testing MCP App resource components and tool output.

Snapshot testing MCP App resource components and tool output.

MCP App resources may use React, Vue, Svelte, Preact, Solid, or plain JavaScript, but the thing you are testing is bigger than a component tree. A tool returns content, structuredContent, and sometimes app-facing _meta. Tool metadata points at a ui:// resource. The host renders that resource in an iframe, then sends tool input, results, theme, display mode, safe area, and other host context through the bridge.

Snapshot testing is useful because those contracts are easy to break with a small refactor. A renamed field in structuredContent, a missing _meta.ui.resourceUri, or an accidental loading-state change can keep an MCP App, ChatGPT App, or Claude Connector from rendering the data the user expects.

TL;DR: Use toMatchInlineSnapshot() for small tool and metadata contracts, focused DOM snapshots for meaningful component structure, accessibility snapshots for roles and names, and Playwright image snapshots for layout. Normalize unstable fields, pin the browser environment, and inspect every baseline update. Use ordinary assertions, MCP protocol tests, Inspector E2E tests, live-host checks, and evals for behavior snapshots cannot prove.

Snapshot the Contract You Own

The core snapshot idea is simple: capture stable output, save it, and fail when that output changes. The hard part is choosing a boundary that belongs to your app.

The MCP Apps specification defines the shared UI layer used by compatible hosts, including ChatGPT and Claude. The View-to-Host protocol stays at 2026-01-26 while SDK packages continue to change. In September 2026, the upstream MCP Apps TypeScript SDK 2.x uses split MCP packages, while sunpeak 0.20.x uses @modelcontextprotocol/ext-apps 1.7.5 and @modelcontextprotocol/sdk 1.30. Their View protocol remains wire-compatible, so snapshots should protect your contract instead of serializing every field an SDK happens to expose.

Useful snapshots protect the boundaries between those layers:

  • The backend tool contract your model and UI consume.
  • The resource metadata that tells the host which UI to render.
  • The React resource markup for each meaningful state.
  • The host-state branches for display mode, theme, safe area, and host capabilities.
  • The semantic roles, names, and states users need to complete the workflow.

Avoid snapshots of a full initialize response, complete SDK object, generated bundle, or host-owned UI. SDK upgrades can add fields, reorder output, or change error text without changing your app. Pick the fields your server and View promise to each other, then pair the snapshot with schema and behavior assertions.

What to Snapshot in an MCP App

For most MCP Apps, snapshot testing works best as a small set of contract tests instead of one giant render snapshot. Start with these layers.

LayerSnapshotWhy it matters
Tool outputstructuredContent and selected _metaCatches data-shape changes before the resource breaks
Tool definitioninputSchema, outputSchema, annotations, _meta.ui.resourceUriCatches broken host wiring and missing resource links
Resource metadataURI, MIME type, CSP, permissions, and domainCatches iframe and host bridge config drift
Resource componentFocused HTML subtreeCatches markup changes that affect the UI contract
Accessibility treeRoles, names, values, and expanded stateCatches semantic UI regressions without pixel noise
Host statedisplay mode, theme, safe area, capabilitiesCatches branches that only render in specific hosts or modes

Do not snapshot everything just because you can. A snapshot should help a reviewer answer one question: “Did the contract change in a way we meant to change?”

Snapshot Testing Resource Components

Start with a focused resource component snapshot. Mock the sunpeak hooks, render the component, and snapshot the smallest meaningful subtree.

import { render } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DashboardResource } from './dashboard';

let mockToolOutput: Record<string, unknown> = {};
let mockDisplayMode: 'inline' | 'fullscreen' | 'pip' = 'inline';

vi.mock('sunpeak', () => ({
  useToolData: () => ({
    output: mockToolOutput,
    input: null,
    inputPartial: null,
    isError: false,
    isLoading: false,
    isCancelled: false,
    cancelReason: null,
  }),
  useAppState: () => [{}, vi.fn()],
  useDisplayMode: () => mockDisplayMode,
  useRequestDisplayMode: () => ({
    availableModes: ['inline', 'fullscreen', 'pip'],
    requestDisplayMode: vi.fn(),
  }),
  useHostInfo: () => ({
    hostVersion: undefined,
    hostCapabilities: { serverTools: true },
  }),
  SafeArea: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));

describe('DashboardResource snapshots', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    mockDisplayMode = 'inline';
    mockToolOutput = {
      quarter: 'Q2',
      year: 2026,
      revenue: 142000,
      deals: 47,
      topProduct: 'Enterprise Plan',
    };
  });

  it('renders dashboard summary', () => {
    const { container } = render(<DashboardResource />);
    const summary = container.querySelector('[data-testid="dashboard-summary"]');

    expect(summary).toMatchSnapshot();
  });

  it('renders empty state', () => {
    mockToolOutput = {
      quarter: 'Q2',
      year: 2026,
      revenue: 0,
      deals: 0,
      topProduct: null,
    };

    const { container } = render(<DashboardResource />);
    expect(container.querySelector('[data-testid="empty-state"]')).toMatchSnapshot();
  });
});

The first run writes a .snap file next to the test:

src/resources/dashboard/
  dashboard.tsx
  dashboard.test.tsx
  __snapshots__/
    dashboard.test.tsx.snap

On later runs, Vitest compares the current output to the saved snapshot. If someone changes a class, removes an element, or changes the copy, the test fails with a text diff.

- Snapshot  - 1
+ Received  + 1

  <section data-testid="dashboard-summary">
    <h2>Q2 2026</h2>
-   <span class="revenue">$142,000</span>
+   <span class="revenue-amount">$142,000</span>
    <p>47 deals</p>
  </section>

That kind of diff is useful. It tells you exactly what changed without launching a browser.

Snapshot Tool Results, Not Just Markup

The most valuable MCP App snapshots often live on the backend side. Your resource component depends on structuredContent. The host and model depend on content. The View may depend on selected result _meta. If those fields drift, a component snapshot will not save you.

Snapshot the tool result after removing fields that should change on every run:

import { describe, expect, it, vi } from 'vitest';
import handler from '../../src/tools/show-dashboard';

vi.mock('../../src/lib/api', () => ({
  getDashboardData: vi.fn().mockResolvedValue({
    generatedAt: '2026-06-17T12:05:02.331Z',
    revenue: 142000,
    deals: 47,
    topProduct: 'Enterprise Plan',
  }),
}));

function stableToolResult(result: Awaited<ReturnType<typeof handler>>) {
  return {
    content: result.content,
    structuredContent: {
      ...result.structuredContent,
      generatedAt: '<iso timestamp>',
    },
  };
}

describe('show-dashboard tool result', () => {
  it('returns the UI contract', async () => {
    const result = await handler({ quarter: 'Q2', year: 2026 }, {} as any);

    expect(stableToolResult(result)).toMatchInlineSnapshot(`
      {
        "content": [
          {
            "text": "Dashboard for Q2 2026: $142,000 revenue across 47 deals.",
            "type": "text",
          },
        ],
        "structuredContent": {
          "deals": 47,
          "generatedAt": "<iso timestamp>",
          "quarter": "Q2",
          "revenue": 142000,
          "topProduct": "Enterprise Plan",
          "year": 2026,
        },
      }
    `);
  });
});

This catches changes that matter to MCP Apps:

  • structuredContent.revenue was renamed to amount.
  • The text fallback disappeared, so non-UI clients get a blank result.
  • App-facing result _meta changed shape and the View lost data it needs.

Pair this with a schema assertion when the tool declares an outputSchema. The schema tells you whether the value is valid. The snapshot tells you whether the reviewed example contract changed.

it('matches output schema and reviewed snapshot', async () => {
  const result = await handler({ quarter: 'Q2', year: 2026 }, {} as any);

  expect(() => DashboardOutput.parse(result.structuredContent)).not.toThrow();
  expect(result.structuredContent).toMatchInlineSnapshot(`
    {
      "deals": 47,
      "quarter": "Q2",
      "revenue": 142000,
      "topProduct": "Enterprise Plan",
      "year": 2026,
    }
  `);
});

The tool result is only one side of the contract. A UI-capable MCP tool also needs to point at the resource the host should render. In portable MCP Apps, that usually means _meta.ui.resourceUri on the tool definition and text/html;profile=mcp-app on the resource.

Snapshot the small metadata object that wires the pieces together. A sunpeak protocol test can inspect the server’s real tools/list and resources/list responses:

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

test('dashboard MCP App wiring stays stable', async ({ mcp }) => {
  const tools = await mcp.listTools();
  const resources = await mcp.listResources();
  const tool = tools.find((item) => item.name === 'show-dashboard');
  const resource = resources.find((item) => item.uri === 'ui://dashboard');

  expect({
    tool: tool && {
      name: tool.name,
      title: tool.title,
      annotations: tool.annotations,
      outputSchema: tool.outputSchema,
      ui: tool._meta?.ui,
    },
    resource: resource && {
      uri: resource.uri,
      mimeType: resource.mimeType,
      ui: resource._meta?.ui,
    },
  }).toMatchInlineSnapshot(`
    {
      "resource": {
        "mimeType": "text/html;profile=mcp-app",
        "ui": {
          "csp": {
            "connectDomains": [
              "https://api.example.com",
            ],
            "resourceDomains": [
              "https://cdn.example.com",
            ],
          },
          "domain": "https://dashboard.example.com",
        },
        "uri": "ui://dashboard",
      },
      "tool": {
        "annotations": {
          "destructiveHint": false,
          "openWorldHint": false,
          "readOnlyHint": true,
        },
        "name": "show-dashboard",
        "outputSchema": {
          "type": "object",
        },
        "title": "Show dashboard",
        "ui": {
          "resourceUri": "ui://dashboard",
          "visibility": [
            "model",
            "app",
          ],
        },
      },
    }
  `);
});

Keep tool and resource fields in their correct places:

FieldOwner
_meta.ui.resourceUriTool metadata
_meta.ui.visibilityTool metadata, defaulting to ['model', 'app']
_meta.ui.cspUI resource metadata
_meta.ui.permissionsUI resource metadata
text/html;profile=mcp-appUI resource MIME type

This is also a good place to protect tool annotations. Host approval UI and review can depend on hints such as readOnlyHint, destructiveHint, and openWorldHint, so accidental changes should be visible in review. Keep secrets and user data out of snapshots. Result _meta reaches the View even when the model does not see it, so snapshot only selected, non-sensitive fields that your UI treats as a contract.

Snapshot Display Modes and Host State

MCP Apps can render inline and may offer fullscreen or picture-in-picture. They also react to theme, safe-area insets, container dimensions, locale, platform, and negotiated capabilities. A component may show a compact summary inline and a richer table in fullscreen. Snapshot branches that change your DOM.

const cases = [
  { theme: 'light', displayMode: 'inline', serverTools: true },
  { theme: 'dark', displayMode: 'fullscreen', serverTools: true },
  { theme: 'light', displayMode: 'inline', serverTools: false },
  { theme: 'dark', displayMode: 'pip', serverTools: false },
] as const;

it.each(cases)('renders %o', ({ theme, displayMode, serverTools }) => {
  mockHostInfo = {
    hostVersion: { name: 'test-host', version: '1.0.0' },
    hostCapabilities: { serverTools },
  };
  mockTheme = theme;
  mockDisplayMode = displayMode;

  const { container } = render(<DashboardResource />);
  expect(container.querySelector('[data-testid="dashboard-shell"]')).toMatchSnapshot();
});

Keep the matrix intentional. You do not need to snapshot every permutation if most combinations produce the same markup. Use unit snapshots for branches that change the DOM. Use browser tests for CSS-only differences, safe-area layout, real focus behavior, and iframe sizing.

With sunpeak, the same state matrix can move into E2E tests through the Inspector. Host selection belongs to the Playwright project, so the same spec can run against separate ChatGPT and Claude replicas without hardcoding a host name inside the component.

Add Accessibility Snapshots for Semantic UI

A DOM snapshot can stay green after a role, accessible name, or expanded state breaks. Playwright accessibility snapshots focus on the semantic tree instead of CSS classes or pixels:

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

test('dashboard exposes the expected controls', async ({ inspector }) => {
  const result = await inspector.renderTool('show-dashboard');
  const app = result.app();

  await expect(app).toMatchAriaSnapshot(`
    - heading "Q2 2026 dashboard" [level=1]
    - text: "$142,000 revenue across 47 deals"
    - button "Open filters"
    - button "View fullscreen"
  `);
});

Keep this snapshot focused on roles, names, values, and states that users need. Pair it with direct accessibility assertions for keyboard focus, error associations, live regions, and disabled controls. An ARIA snapshot does not prove color contrast, focus visibility, reading order in a complex layout, or that the control works when clicked.

Snapshot Loading, Partial, Error, Empty, and Cancelled States

Non-happy paths are easy to miss because they often require exact tool or host timing. Snapshot them once so they cannot disappear quietly.

import { useToolData } from 'sunpeak';

it('renders loading state', () => {
  vi.mocked(useToolData).mockReturnValue({
    output: null,
    input: null,
    inputPartial: null,
    isError: false,
    isLoading: true,
    isCancelled: false,
    cancelReason: null,
  });

  const { container } = render(<DashboardResource />);
  expect(container.querySelector('[data-testid="loading"]')).toMatchSnapshot();
});

it('renders cancelled state', () => {
  vi.mocked(useToolData).mockReturnValue({
    output: null,
    input: null,
    inputPartial: null,
    isError: false,
    isLoading: false,
    isCancelled: true,
    cancelReason: 'User cancelled the request',
  });

  const { container } = render(<DashboardResource />);
  expect(container.querySelector('[data-testid="cancelled"]')).toMatchSnapshot();
});

it('renders partial input as a preview', () => {
  vi.mocked(useToolData).mockReturnValue({
    output: null,
    input: null,
    inputPartial: { quarter: 'Q' },
    isError: false,
    isLoading: true,
    isCancelled: false,
    cancelReason: null,
  });

  const { container } = render(<DashboardResource />);
  expect(container.querySelector('[data-testid="input-preview"]')).toMatchSnapshot();
});

These tests are small, but they protect real user experience. Partial input can be missing, truncated, or replaced while the model is still producing arguments, so use it only for a preview. Wait for complete input before saving data or starting an action. A spinner, empty table, auth error, cancellation, or late result is still part of the app contract.

Normalize Nondeterministic Values

Snapshots fail when any serialized value changes. That is useful for reviewed output and painful for unstable output. Normalize or remove values that are expected to change.

Good candidates for normalization:

  • ISO timestamps.
  • Random IDs.
  • Request IDs and trace IDs.
  • Build hashes.
  • OAuth tokens, session IDs, and user-specific private data.
  • Relative ordering from APIs that do not guarantee order.

One pattern is to make a small serializer for the exact value you are snapshotting.

function stableDashboardSnapshot(result: DashboardResult) {
  return {
    ...result,
    generatedAt: '<iso timestamp>',
    requestId: '<request id>',
    rows: [...result.rows].sort((a, b) => a.id.localeCompare(b.id)),
  };
}

expect(stableDashboardSnapshot(result)).toMatchInlineSnapshot();

Vitest also supports property matchers when a field should have a type but not an exact value. For large HTML, SVG, CSS, or generated text, toMatchFileSnapshot() keeps the baseline in its native file format instead of escaping it inside a .snap serializer.

Do not hide real instability with too much normalization. If order matters to the resource component, do not sort it away. If an ID appears in the DOM and a click handler depends on it, test the stable contract that the UI actually needs. Never replace private values with placeholders after writing the raw snapshot to disk; remove or redact them before the assertion runs.

Use Browser Snapshots for What Users See

Text snapshots do not run layout, fonts, media queries, safe areas, or iframe sizing. Use Playwright image comparisons for states where appearance is part of correctness:

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

test('dashboard fits in dark fullscreen mode', async ({ inspector }) => {
  const result = await inspector.renderTool(
    'show-dashboard',
    undefined,
    { theme: 'dark', displayMode: 'fullscreen' }
  );

  await expect(result.app().getByRole('heading', { name: 'Q2 2026 dashboard' })).toBeVisible();
  await result.screenshot('dashboard-dark-fullscreen');
});

sunpeak’s result.screenshot() captures the app content inside the Inspector’s double iframe, not the Inspector sidebars. Normal E2E runs skip screenshot comparisons. pnpm test:visual enables them and runs the E2E layer against the configured Playwright host projects.

Pin the operating system, browser version, fonts, locale, timezone, viewport, theme, display mode, animations, and test data for image baselines. Playwright warns that rendering can vary with the environment, so generate and compare a given baseline in the same container or CI image. Use snapshotPathTemplate with {projectName} when ChatGPT and Claude replicas need different expected images.

When Snapshots Help

Snapshots are a good fit when the output is structured, reviewed, and cheap to serialize.

  • Complex resource markup with tables, nested cards, filters, or grouped data.
  • Tool handlers that return structuredContent used by the UI.
  • Metadata that links tools, resources, domains, CSP, and host permissions.
  • Display-mode branches where the DOM changes.
  • Loading, partial-input, error, empty, and cancelled states.
  • Accessibility roles, names, and expanded or disabled states.
  • Tool annotations and other app-owned discovery fields.

They are less useful for tiny components. A component that renders one label is better covered by a normal assertion:

expect(screen.getByText('No results')).toBeInTheDocument();

They are also the wrong tool for visual bugs. A CSS change can break the layout while the HTML snapshot stays exactly the same. For that, use visual regression tests.

How Snapshots Fit in a sunpeak Test Suite

sunpeak gives you several test layers for MCP Apps, ChatGPT Apps, and Claude Connectors:

  • Unit tests for pure functions, tools, hooks, and resource components.
  • Snapshot tests for reviewed HTML, structuredContent, and metadata contracts.
  • Accessibility snapshots for semantic roles, names, and states.
  • Inspector E2E tests for user behavior in replicated ChatGPT and Claude runtimes.
  • Visual regression tests for screenshots across hosts, themes, display modes, and viewport sizes.
  • Live host tests for the narrow real-host paths supported by your adapter. sunpeak 0.20.x packages a ChatGPT live adapter; use an external harness for other live hosts.
  • Multi-model evals when you need to check whether models choose and call tools correctly.

Use snapshots early in that stack. They fail fast, often in milliseconds, and tell you whether a contract changed before a browser opens. Then use the Inspector and Playwright tests for behavior the snapshot cannot prove.

For example, a dashboard resource might use this split:

TestWhat it proves
Tool result snapshotstructuredContent still matches the reviewed UI contract
Resource metadata snapshotThe tool still points at ui://dashboard with the right CSP
Component snapshotThe summary DOM still has the expected structure
Accessibility snapshotControls keep their roles, names, and states
Inspector E2E testA user can open fullscreen and filter rows
Visual testThe table still fits in inline mode and dark theme
Live testThe deployed app still loads in the real host

That division keeps each test honest. If a text snapshot starts checking layout, it will miss the bug. If a browser test starts checking every JSON field, it becomes slow and hard to review.

Managing Snapshots in Practice

The long-term problem with snapshots is review discipline. They only help if people read the diff.

Update intentionally. When a snapshot fails, read the diff before running -u. If the change was expected, update the snapshot. If the change was accidental, fix the code.

Commit snapshots with the code change. A snapshot update should sit next to the tool, metadata, or resource change that caused it. Reviewers need both sides to judge the change.

Keep snapshots focused. Prefer the table, summary, or metadata object over the whole page. Smaller snapshots are easier to review and less likely to churn.

Delete stale snapshots. Vitest reports obsolete entries when a test is removed or renamed, and CI fails instead of rewriting them by default. Remove obsolete baselines as part of the same reviewed change.

Name test states like a reviewer. renders dashboard is vague. renders inline empty state without actions tells the reviewer what contract the snapshot protects.

Running Snapshot Tests

Snapshot tests run as part of your unit test suite.

# Run all unit tests, including snapshots
pnpm test:unit

# Update snapshots after an intentional change
pnpm test:unit -- -u

# Compare Playwright image baselines
pnpm test:visual

# Update Playwright image baselines after review
pnpm test:visual -- --update

In CI/CD, run unit and protocol tests before slower E2E and visual tests. Never pass an update flag in CI, because a mismatch should fail and produce a reviewable diff or image artifact. The MCP App CI/CD guide covers the full pipeline.

Snapshot testing is one part of an MCP App testing strategy. It is a fast contract layer for structuredContent, selected metadata, semantic UI, and focused markup. Use it with sunpeak simulation fixtures, MCP protocol assertions, Inspector E2E tests, visual comparisons, narrow live-host checks, and model evals, so each test protects the behavior it can actually observe.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is snapshot testing for MCP Apps?

Snapshot testing serializes the output of your MCP App resource component, tool handler, resource metadata, or rendered HTML and compares it to a saved baseline. In MCP Apps, snapshots are useful because a small change to structuredContent, _meta.ui.resourceUri, display mode handling, or iframe markup can break the UI even when the tool still returns data.

What should I snapshot test in an MCP App?

Start with the smallest tool result, resource metadata, DOM subtree, or accessibility tree that protects an app-owned contract. Add snapshots for loading, partial input, error, empty, cancelled, inline, fullscreen, picture-in-picture, light theme, and dark theme only when those states produce meaningful differences.

Should MCP App snapshots include structuredContent or _meta?

Snapshot structuredContent when the View depends on its exact shape. Snapshot only selected _meta fields that form your app contract, such as a UI resource link or app-facing result metadata. Resource CSP and permissions belong on resource metadata, while tool visibility and resourceUri belong on tool metadata. Never snapshot secrets, OAuth tokens, user records, request IDs, or raw timestamps.

How do I keep MCP App snapshots from becoming noisy?

Snapshot the smallest output that proves the contract, normalize nondeterministic fields before asserting, and avoid whole-page or whole-protocol snapshots. Keep browser baselines on one pinned OS, browser, font set, viewport, host project, theme, and display mode. A useful snapshot tells a reviewer what changed and why.

What is the difference between snapshot testing and visual regression testing for MCP Apps?

Data and DOM snapshots compare serialized values. Accessibility snapshots compare the role, name, and state structure exposed to assistive technology. Visual regression tests compare rendered pixels. Use each for its own contract: data shape, semantic UI, or layout and styling.

Can I snapshot test ChatGPT Apps and Claude Connectors the same way?

Yes. ChatGPT and Claude support MCP Apps, but optional capabilities and host chrome differ. Keep unit and protocol snapshots host-neutral. Use separate Playwright projects and baseline paths for host-specific browser snapshots, then snapshot only states where the View changes because of a negotiated capability, theme, display mode, safe area, or container size.

How do I update snapshots after an intentional MCP App change?

For Vitest snapshots, run pnpm test:unit -- -u. For sunpeak visual baselines, run pnpm test:visual -- --update. Inspect every text or image diff before committing it, and never let CI rewrite baselines automatically. Commit the implementation and reviewed baseline changes together.

Do snapshot tests replace MCP App E2E tests?

No. A snapshot proves that one captured value stayed the same. It does not prove that a user can complete a workflow, the iframe bridge works, schemas accept real values, authorization holds, or models choose the right tool. Pair focused snapshots with assertions, MCP protocol tests, Inspector E2E tests, visual tests, narrow live-host checks, and tool-calling evals.