Skip to main content
All posts

Accessibility Testing 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 FrameworkAccessibility TestingAccessibility
Accessibility testing MCP App components across hosts, themes, and display modes.

Accessibility testing MCP App components across hosts, themes, and display modes.

Accessibility testing for MCP Apps is web accessibility testing with a few extra moving parts. Your UI still needs semantic HTML, labels, keyboard support, readable contrast, and clear error messages. It also runs inside a host-controlled iframe, receives state through tool results, changes size through display modes, and inherits theme values from ChatGPT, Claude, or another MCP host.

That host layer is where many accessibility bugs hide. A component can pass a normal React unit test, then fail when ChatGPT renders it inline in a narrow iframe. A button can be reachable in fullscreen, then disappear from the tab order in picture-in-picture. A status update can look obvious on screen but never reach a screen reader because the tool result changed without an announcement.

TL;DR: Use WCAG 2.2 AA as the baseline, run @axe-core/playwright against your rendered app frame, and add Playwright tests for keyboard flows, display mode changes, live regions, themes, and error states. Run the same tests across ChatGPT and Claude host modes with sunpeak so accessibility regressions fail in CI before they reach users.

What Makes MCP App Accessibility Different

MCP Apps are embedded web apps. The host owns the outer page and gives your app an iframe, a runtime bridge, resource metadata, theme values, and display mode behavior. Your app owns the UI inside that iframe.

That split changes what you need to test:

  • Focus enters and leaves through an iframe boundary that your app does not fully control.
  • Display modes can resize the iframe, change scroll behavior, and swap compact controls for full controls.
  • Tool results can update the UI after the initial render, so loading, success, error, and cancelled states need announcements.
  • Host CSS variables can change between ChatGPT and Claude, between light and dark themes, and between future host versions.
  • Resource metadata, content security policy, and sandbox rules can block fonts, images, or scripts that your UI depends on.
  • Host-specific APIs need accessible fallback paths when the app runs somewhere else.

The practical rule is simple: test the app as it will run in the host. Component tests are useful, but they do not cover the iframe, bridge, display mode, or theme context that real users get.

Use WCAG 2.2 AA as the Baseline

Most teams should use WCAG 2.2 AA as their target. It covers the basics that matter in MCP Apps: keyboard operation, focus visibility, labels, contrast, headings, target size, error identification, and consistent interaction.

For MCP Apps, pay special attention to these areas:

  • Keyboard: Every action must work without a mouse. That includes buttons, menus, tabs, filters, charts with selectable data, and display mode controls.
  • Focus visible: The active element needs a clear focus indicator in every theme and display mode.
  • Accessible names: Icon-only buttons need labels. Form controls need labels. Chart controls need names that make sense without the visual chart.
  • Status messages: Tool loading, success, validation errors, auth errors, and cancelled states need role="status" or role="alert" where appropriate.
  • Target size: Compact inline and PiP layouts still need touch targets that users can hit.
  • Reduced motion: Animations, auto-scroll, and loading effects need to respect prefers-reduced-motion.

Do not treat WCAG as only a scanner setting. Automated checks catch a lot, but workflow checks catch the bugs that make an app hard to use.

Set Up axe-core With Playwright

axe-core is the standard automated accessibility engine, and @axe-core/playwright works well with sunpeak’s browser-based inspector tests.

Install it:

pnpm add -D @axe-core/playwright

Then scan a rendered MCP resource:

import AxeBuilder from '@axe-core/playwright';
import { expect, test } from 'sunpeak/test';

test('dashboard resource passes automated accessibility checks @a11y', async ({
  inspector,
}) => {
  const result = await inspector.renderTool('get-dashboard', {
    input: { userId: 'test-user' },
    output: {
      metrics: [
        { label: 'Active users', value: '2,341' },
        { label: 'Revenue', value: '$48,200' },
      ],
    },
  });

  const results = await new AxeBuilder({ page: result.app() })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze();

  expect(results.violations).toEqual([]);
});

An axe failure usually tells you the selector, rule, impact level, and suggested fix. The most common first fixes are missing button labels, invalid ARIA attributes, duplicate IDs, skipped heading levels, and low-contrast custom colors.

Build a Host Accessibility Matrix

A single accessibility scan is useful, but it is not enough for an MCP App. Run the checks across the combinations that change layout or semantics.

import AxeBuilder from '@axe-core/playwright';
import { expect, test } from 'sunpeak/test';

const themes = ['light', 'dark'] as const;
const displayModes = ['inline', 'fullscreen', 'pip'] as const;

for (const theme of themes) {
  for (const displayMode of displayModes) {
    test(`a11y scan in ${theme} ${displayMode} @a11y`, async ({
      inspector,
    }) => {
      const result = await inspector.renderTool('show-report', {
        input: { reportId: 'q2' },
        output: { title: 'Q2 report', status: 'ready', items: [] },
        theme,
        displayMode,
      });

      const results = await new AxeBuilder({ page: result.app() })
        .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
        .analyze();

      expect(results.violations).toEqual([]);
    });
  }
}

In a sunpeak project, defineConfig() from sunpeak/test/config can create separate Playwright projects for the replicated ChatGPT and Claude runtimes. That means the loop above can run once per host without hand-writing host setup in every test.

Focus on combinations that can break:

  • ChatGPT inline in light mode for the default embedded experience
  • ChatGPT fullscreen in dark mode for dense workflows
  • Claude inline in dark mode because host theme values and spacing differ
  • PiP or compact modes for apps with secondary controls
  • Mobile-width viewports for overflow, hidden controls, and touch targets

This is also where visual regression testing helps. axe can tell you a label is missing. A screenshot diff can tell you a focus ring is clipped, a button wraps badly, or an error message overlaps a form.

Test Keyboard Flows, Not Just Elements

Keyboard testing should cover complete workflows. Counting focusable elements is a start, but it does not prove that the app is usable.

test('search workflow works without a mouse @a11y', async ({ inspector }) => {
  const result = await inspector.renderTool('search-docs', {
    input: {},
    output: { results: [] },
  });

  const app = result.app();

  await app.keyboard.press('Tab');
  await expect(app.getByRole('textbox', { name: /search/i })).toBeFocused();

  await app.keyboard.type('oauth');
  await app.keyboard.press('Tab');
  await expect(app.getByRole('button', { name: /run search/i })).toBeFocused();

  await app.keyboard.press('Enter');
  await expect(app.getByRole('status')).toContainText('Search complete');
});

Write separate tests for the paths users rely on most:

  • Open the app and reach the first meaningful control.
  • Move through the main controls with Tab and Shift+Tab.
  • Activate controls with Enter or Space.
  • Close popovers, menus, and dialogs with Escape.
  • Move through tabs, lists, and menus with arrow keys when you build custom widgets.
  • Submit a form, handle validation errors, and return focus to the first error.

Use native HTML wherever you can. A native <button> already supports focus, Enter, Space, role, disabled state, and accessible name calculation. A clickable <div> makes you rebuild all of that by hand, and most apps do it incompletely.

Test Focus Across Display Mode Changes

Display mode changes are one of the most MCP-specific accessibility risks. The host may move your resource from an inline iframe into a larger surface. Your component may switch layout. Controls may move, collapse, or hide.

The test should verify that focus lands somewhere logical after the transition:

test('focus remains logical after fullscreen transition @a11y', async ({
  inspector,
}) => {
  const result = await inspector.renderTool('edit-note', {
    input: { noteId: 'n1' },
    output: { title: 'Release notes', body: 'Draft' },
    displayMode: 'inline',
  });

  const app = result.app();
  const title = app.getByRole('textbox', { name: /title/i });

  await title.focus();
  await result.setDisplayMode('fullscreen');

  await expect(app.getByRole('textbox', { name: /title/i })).toBeFocused();
});

If the focused element no longer exists after a layout change, move focus to the closest useful replacement. For example, if a compact “Edit” button expands into a full editor, focus the editor heading or the first input. Do not let focus fall to <body> unless the user has intentionally closed the app.

Test Live Regions for Tool Result State

MCP Apps often update after a tool finishes. The visual UI changes from loading to ready, from draft to saved, or from pending to failed. Screen reader users need those changes announced.

Use role="status" for non-urgent updates and role="alert" for errors that need immediate attention:

function SaveStatus({ state }: { state: 'idle' | 'saving' | 'saved' | 'error' }) {
  if (state === 'idle') return null;

  if (state === 'error') {
    return <p role="alert">Could not save. Check the highlighted fields.</p>;
  }

  return (
    <p role="status" aria-live="polite">
      {state === 'saving' ? 'Saving changes' : 'Changes saved'}
    </p>
  );
}

Then test the message that users need:

test('announces save completion @a11y', async ({ inspector }) => {
  const result = await inspector.renderTool('edit-note', {
    input: { noteId: 'n1' },
    output: { saveState: 'saved' },
  });

  const app = result.app();
  await expect(app.getByRole('status')).toContainText('Changes saved');
});

Keep live regions small. Do not wrap the whole app in aria-live, because every render can become an announcement.

Test Color Contrast and Host CSS Variables

The safest default is to use host CSS variables for text, backgrounds, borders, surfaces, and status colors. Those variables are how the host makes your app feel native in light and dark mode. They also reduce the risk of custom colors failing contrast when the host theme changes.

Hardcoded colors need extra tests:

test('status badges meet contrast requirements in dark mode @a11y', async ({
  inspector,
}) => {
  const result = await inspector.renderTool('show-status', {
    input: {},
    output: {
      items: [
        { name: 'API', status: 'healthy' },
        { name: 'Database', status: 'degraded' },
      ],
    },
    theme: 'dark',
  });

  const results = await new AxeBuilder({ page: result.app() })
    .withTags(['wcag2aa', 'wcag22aa'])
    .analyze();

  expect(results.violations).toEqual([]);
});

axe cannot fully inspect canvas charts, bitmap images, or some SVG visualization patterns. For charts, do three things:

  • Do not encode meaning with color alone.
  • Provide a table, list, or textual summary for the same data.
  • Run visual tests in light and dark mode so color and label regressions are visible.

The MCP App styling guide goes deeper on host CSS variables and theme-safe styling.

Test Error, Empty, Auth, and Cancelled States

Accessibility bugs often sit outside the happy path. MCP Apps need state coverage because tool calls can load slowly, fail validation, hit auth, or get cancelled by the host.

Add tests for:

  • Loading: The state has role="status" and does not steal focus.
  • Empty: The message explains what happened and keeps the next action reachable.
  • Validation errors: Each field error is associated with its input through aria-describedby.
  • Auth required: The sign-in action has a clear accessible name and a non-mouse fallback.
  • Cancelled: The app explains that the action stopped and lets the user retry.
  • Partial data: The UI avoids broken tables, unlabeled placeholders, and focus targets with no action.

This is where MCP App error handling and accessibility testing overlap. A good error state is not only a nice message. It has focus behavior, a programmatic relationship to the failed control, and a clear next step.

Check Resource Metadata and External Assets

Accessibility can fail before your React component runs. If a resource depends on a font, image, script, or API call that is blocked by resource metadata or CSP, users may see missing icons, broken charts, invisible labels, or unstyled controls.

When you register the app resource, review the metadata documented by the OpenAI Apps SDK reference and the MCP Apps extension docs. In practice, test these cases:

  • The app still has readable text if a custom font fails.
  • Icon buttons still have accessible names when SVG icons fail.
  • External images have useful alt text or are marked decorative.
  • CSP allowlists include only the asset and API domains the app needs.
  • prefers-reduced-motion changes animation behavior in the app frame.
  • The app does not rely on the host shell to explain controls inside the iframe.

Use git diff --check, e2e tests, and visual tests together before you publish. Accessibility bugs often come from the join between content, CSS, resource metadata, and runtime data.

A Complete Accessibility Test File

This is a compact test file you can adapt:

import AxeBuilder from '@axe-core/playwright';
import { expect, test } from 'sunpeak/test';

const themes = ['light', 'dark'] as const;
const displayModes = ['inline', 'fullscreen'] as const;

const fixture = {
  input: { userId: 'test-user' },
  output: {
    metrics: [
      { label: 'Active users', value: '2,341' },
      { label: 'Revenue', value: '$48,200' },
    ],
    saveState: 'saved',
  },
};

for (const theme of themes) {
  for (const displayMode of displayModes) {
    test(`automated WCAG scan ${theme}/${displayMode} @a11y`, async ({
      inspector,
    }) => {
      const result = await inspector.renderTool('get-dashboard', {
        ...fixture,
        theme,
        displayMode,
      });

      const results = await new AxeBuilder({ page: result.app() })
        .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
        .analyze();

      expect(results.violations).toEqual([]);
    });
  }
}

test('primary workflow is keyboard reachable @a11y', async ({ inspector }) => {
  const result = await inspector.renderTool('get-dashboard', fixture);
  const app = result.app();

  await app.keyboard.press('Tab');
  await expect(app.getByRole('button', { name: /refresh dashboard/i })).toBeFocused();

  await app.keyboard.press('Enter');
  await expect(app.getByRole('status')).toContainText('Dashboard refreshed');
});

test('focus survives display mode transition @a11y', async ({ inspector }) => {
  const result = await inspector.renderTool('get-dashboard', {
    ...fixture,
    displayMode: 'inline',
  });

  const app = result.app();
  const refresh = app.getByRole('button', { name: /refresh dashboard/i });

  await refresh.focus();
  await result.setDisplayMode('fullscreen');

  await expect(app.getByRole('button', { name: /refresh dashboard/i })).toBeFocused();
});

test('tool result state is announced @a11y', async ({ inspector }) => {
  const result = await inspector.renderTool('get-dashboard', fixture);
  const app = result.app();

  await expect(app.getByRole('status')).toContainText('Changes saved');
});

In a sunpeak project, keep this in your Playwright suite and run it with:

pnpm test:e2e --grep @a11y

Or add a dedicated script:

{
  "scripts": {
    "test:a11y": "playwright test --grep @a11y"
  }
}

Manual Checks Still Matter

Automated tests are the right default for CI, but they do not replace a short manual pass. Before you publish a public MCP App, run through the main workflow with:

  • Keyboard only
  • VoiceOver, NVDA, or another screen reader
  • Light and dark mode
  • Inline and fullscreen display modes
  • One slow or failed tool response
  • One long-label or edge-case data fixture

You do not need hours of manual testing on every change. You do need one focused pass before publishing and automated coverage for the regressions you can predict.

Where sunpeak Helps

sunpeak is useful here because accessibility depends on host state. You can render the same MCP App in replicated ChatGPT and Claude runtimes, switch themes and display modes, pin tool outputs with simulation data, and run Playwright tests in CI. That means you can test the states that usually require paid host accounts, manual refreshes, or live AI runs.

Start with axe and keyboard tests for the main workflow. Add display mode and theme coverage next. Then add visual tests for the layouts that are likely to clip text, hide controls, or break focus indicators. That gives you practical accessibility coverage without turning every release into a manual host-by-host review.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Do MCP Apps need accessibility testing?

Yes. MCP Apps render web UI inside host-controlled iframes, so they need the same accessibility checks as other web apps plus extra checks for iframe focus boundaries, display mode changes, host themes, and dynamic tool result updates. Use WCAG 2.2 AA as the practical baseline for most public MCP Apps, ChatGPT Apps, and Claude Connectors.

How do I run accessibility tests for MCP Apps in CI/CD?

Install @axe-core/playwright, render your resource through the sunpeak inspector fixture, and run axe against the rendered app frame. Add keyboard navigation assertions for Tab, Shift+Tab, Enter, Escape, and arrow-key behavior. Run the suite with pnpm test:e2e or a dedicated test:a11y script in CI so every host, theme, and display mode you support is checked before release.

Can axe-core test everything in an MCP App?

No. axe-core catches many automated issues, including missing labels, invalid ARIA, duplicate IDs, poor contrast, and heading problems. It cannot prove that a workflow makes sense to a screen reader user, that a display mode transition preserves context, or that canvas and chart colors meet contrast rules. Pair axe with Playwright role assertions, keyboard tests, visual checks, and a small manual screen reader pass before publishing.

How do I test keyboard navigation in an MCP App?

Use Playwright to press Tab, Shift+Tab, Enter, Escape, Space, and arrow keys inside the rendered app frame. Assert that every interactive control is reachable, focus order follows the visual workflow, buttons and links can be activated without a mouse, and non-modal components never trap focus. Test inline and fullscreen layouts because responsive changes can reorder or hide controls.

How do I test screen reader announcements for tool results?

Add aria-live regions for meaningful state changes such as loading completion, save success, validation errors, and cancelled tool runs. In tests, locate the live region by role, usually status or alert, and assert that the final user-facing message appears when the tool result state changes. Avoid putting the whole app inside a live region because that creates noisy announcements.

What accessibility issues are unique to MCP Apps?

The host owns the outer document, iframe boundary, display mode shell, and part of the runtime context. Your app must handle focus entering the iframe, focus after display mode changes, host-provided CSS variables, safe areas, sandboxed resource limits, and live updates from tool results. These are normal web accessibility concerns, but MCP Apps combine them in ways that standard component tests often miss.

How does display mode affect MCP App accessibility?

Inline, fullscreen, and picture-in-picture modes change available width, scrolling, focus targets, and sometimes which controls should be visible. Test that controls remain reachable, names remain visible, focus moves to a logical element after transitions, and the app does not depend on hover-only UI. On smaller screens, test the fallback path for modes that are unavailable or constrained by the host.

Does sunpeak support accessibility testing for ChatGPT Apps and Claude Connectors?

Yes. sunpeak provides local inspector and Playwright-based testing utilities that render MCP Apps in replicated ChatGPT and Claude runtimes. That lets you run axe-core scans, keyboard tests, display mode checks, theme checks, and visual tests locally and in CI without spending host credits or manually refreshing a live ChatGPT or Claude session on every change.