Skip to main content
All posts

Performance Testing 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 FrameworkPerformance TestingLoad Testing
Performance testing MCP App tool latency and resource rendering speed.

Performance testing MCP App tool latency and resource rendering speed.

MCP App performance is broader than normal web performance. Your server handles an MCP tool call, the host decides how to present the response, your resource loads inside a sandboxed iframe, and the user is already waiting in a chat thread. A slow handler makes the assistant look stuck. A slow resource makes the answer look half-finished. A slow host bridge action makes the app feel broken even when the UI already rendered.

TL;DR: Track tool latency, cold starts, resource bundle size, first useful render, host bridge actions, and external dependency time. Run the checks against deterministic simulation data, include the display modes and themes you support, and keep a small CI performance suite with budgets. sunpeak helps by running those tests against replicated ChatGPT and Claude runtimes without a live host account in the default loop.

The MCP App performance model

An MCP App has two performance paths that users experience as one wait:

  1. The MCP path: the host calls your tool, your server validates input, your handler does the work, and the server returns content, structured data, metadata, or linked resources.
  2. The UI path: the host loads the resource iframe, injects host context, applies sandbox and CSP rules, and your component renders the tool result.

That split matters because a single “slow app” report can come from several places:

  • The tool handler is slow because it waits on a database, SaaS API, vector search, file store, or OAuth refresh.
  • The MCP transport adds overhead because the server is cold, far from the host, or moving large payloads.
  • The resource bundle is too large for an iframe that should appear quickly inside a conversation.
  • The UI renders too much at once, often from tables, charts, maps, Markdown, or long lists.
  • The app waits on a host bridge action, such as a follow-up tool call, state update, or display mode request.
  • External assets fail or stall because CSP, CORS, fonts, image domains, or map tiles were not tested as part of performance.

The official MCP Apps overview, OpenAI Apps SDK, and Claude custom connector docs all point to the same practical constraint: your app is a protocol-backed server plus a host-rendered UI. Performance testing needs to cover both halves.

Start with a metric budget

Do not begin with “make it fast.” Start with a table that names the wait you care about and who owns it.

MetricWhat it measuresStarting budget
Warm tool latencymcp.callTool() after dependencies are ready200-500ms for simple reads
Cold tool latencyFirst call after server start or idleUnder 2s unless the host flow can tolerate more
External dependency timeDatabase, API, search, file store, or auth waitSeparate budget per dependency
Serialized result sizeTool output sent through MCPKeep large data paged or summarized
Resource bundle sizeJavaScript and CSS loaded by the iframeStart under 100KB gzipped per route
First useful renderTime until the main UI state is visibleUnder 1s after data is available
Large state renderRendering with realistic maximum dataUnder 2s, or virtualize/paginate
Host bridge actionFollow-up tool calls, model context updates, display mode requestsBudget by action type

These numbers are starting points, not universal rules. A finance export may need 2 seconds. A typeahead search should not. The point is to make the tradeoff explicit before CI starts failing.

Benchmark MCP tool latency

The mcp fixture from sunpeak/test gives you protocol-level access to your tool handlers. Time the whole call first because that is closest to what a real host waits on:

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

test('search-products responds within the warm latency budget', async ({ mcp }) => {
  const start = performance.now();

  const result = await mcp.callTool('search-products', {
    query: 'wireless headphones',
    limit: 10,
  });

  const elapsed = performance.now() - start;

  expect(result.isError).toBeFalsy();
  expect(elapsed).toBeLessThan(500);
});

This measures JSON serialization, MCP routing, your handler logic, and response serialization. If this test gets slower, the user waits longer before the host can render anything.

Then add an isolated version with mocked dependencies. This tells you whether the slowdown lives in your code or outside it:

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

vi.mock('../../src/lib/catalog-api', () => ({
  searchProducts: vi.fn().mockResolvedValue({
    items: [{ id: 'sku_1', name: 'Wireless headphones', price: 79 }],
  }),
}));

test('search-products handler logic stays cheap without network calls', async ({ mcp }) => {
  const start = performance.now();

  const result = await mcp.callTool('search-products', {
    query: 'headphones',
    limit: 10,
  });

  const elapsed = performance.now() - start;

  expect(result.isError).toBeFalsy();
  expect(elapsed).toBeLessThan(50);
});

Keep both tests. The realistic test protects the user experience. The mocked test protects the handler from slow validation, expensive transforms, accidental N+1 loops, and payload bloat.

Track cold starts separately

Cold start latency is the first tool call after your process starts or a serverless worker wakes up. It includes module loading, schema setup, auth client initialization, database connection setup, and any expensive top-level imports.

Use the first call in a fresh worker or test file as your cold measurement, then compare it to a warm call:

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

test('list-categories cold and warm calls stay within budget', async ({ mcp }) => {
  const coldStart = performance.now();
  const coldResult = await mcp.callTool('list-categories', {});
  const coldElapsed = performance.now() - coldStart;

  const warmStart = performance.now();
  const warmResult = await mcp.callTool('list-categories', {});
  const warmElapsed = performance.now() - warmStart;

  expect(coldResult.isError).toBeFalsy();
  expect(warmResult.isError).toBeFalsy();
  expect(coldElapsed).toBeLessThan(2000);
  expect(warmElapsed).toBeLessThan(500);
});

If cold starts regress, inspect top-level imports first. SDKs, database clients, chart renderers, Markdown processors, and large JSON files are common causes. Lazy-load what the tool does not need on every call:

let analyticsClient: AnalyticsClient | null = null;

async function getAnalyticsClient() {
  if (!analyticsClient) {
    const { AnalyticsClient } = await import('./analytics-client');
    analyticsClient = new AnalyticsClient();
  }

  return analyticsClient;
}

This does not make the dependency free. It moves the cost to the tools that need it, which is often the right tradeoff for MCP servers with many tools.

Measure result size and serialization cost

MCP tool results often include content, structuredContent, _meta, or links to resources. Large results slow down serialization, increase memory pressure, and can make the model or host work through data the UI does not need.

Add a test that catches oversized responses before they become normal:

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

const MAX_RESULT_BYTES = 75_000;

test('search-products result stays small enough for host rendering', async ({ mcp }) => {
  const result = await mcp.callTool('search-products', {
    query: 'all',
    limit: 50,
  });

  const bytes = Buffer.byteLength(JSON.stringify(result), 'utf8');

  expect(result.isError).toBeFalsy();
  expect(bytes).toBeLessThan(MAX_RESULT_BYTES);
});

When this fails, do not blindly raise the limit. Ask what the host and model actually need. Many apps should return a compact summary in content, structured rows for the UI, and a pagination cursor instead of a giant result.

Check resource bundle size

Resource bundles load inside host iframes. That means a separate document, isolated JavaScript, CSS, fonts, images, and any boot code your framework ships. Large bundles hurt especially in inline and picture-in-picture modes because the user expects the UI to feel lightweight.

Add a gzipped bundle check after your production build:

import { readFileSync } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { gzipSync } from 'node:zlib';
import { expect, test } from 'vitest';

const BUILD_DIR = join(process.cwd(), 'dist/assets');
const MAX_BUNDLE_SIZE_KB = 100;

test('resource JavaScript bundles stay under the size budget', async () => {
  const files = await readdir(BUILD_DIR);
  const jsFiles = files.filter((file) => file.endsWith('.js'));

  for (const file of jsFiles) {
    const filePath = join(BUILD_DIR, file);
    const fileStats = await stat(filePath);

    if (!fileStats.isFile()) {
      continue;
    }

    const gzipped = gzipSync(readFileSync(filePath));
    const sizeKB = gzipped.length / 1024;

    expect(
      sizeKB,
      `${file} is ${sizeKB.toFixed(1)}KB gzipped, limit is ${MAX_BUNDLE_SIZE_KB}KB`
    ).toBeLessThan(MAX_BUNDLE_SIZE_KB);
  }
});

The most common fixes are boring and effective:

  • Import only the functions you use.
  • Split heavy charts, maps, editors, and Markdown renderers into resource routes that need them.
  • Prefer host CSS variables and plain CSS over a large runtime styling library.
  • Replace large client transforms with server-side shaping when the data is static for the render.
  • Paginate or virtualize tables and lists before they become a UI problem.

Bundle checks are not a replacement for browser tests because a small bundle can still render slowly. Use both.

Test first useful render in a host runtime

Tool latency ends when the host receives the result. The user waits until the resource actually shows useful UI.

With sunpeak, the inspector fixture renders your tool result inside replicated host runtimes. That gives you a browser-level performance test without driving a live ChatGPT or Claude session:

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

test('dashboard reaches first useful render within 1s', async ({ inspector }) => {
  const start = performance.now();

  const result = await inspector.renderTool('show-dashboard', {
    accountId: 'acct_perf_fixture',
  });

  await expect(result.app().getByTestId('dashboard-ready')).toBeVisible();

  const elapsed = performance.now() - start;
  expect(elapsed).toBeLessThan(1000);
});

Choose a selector that means the user can act, not a spinner or wrapper. A heading is sometimes enough. For dashboards, tables, maps, or forms, wait for the first meaningful control or result row.

For rendering-heavy components, record browser performance marks inside the resource component and read them in the test:

// In the resource component, after the first meaningful state is painted:
performance.mark('dashboard-ready');
import { expect, test } from 'sunpeak/test';

test('dashboard component marks ready quickly', async ({ inspector }) => {
  const result = await inspector.renderTool('show-dashboard', {
    accountId: 'acct_perf_fixture',
  });

  await expect(result.app().getByTestId('dashboard-ready')).toBeVisible();

  const readyMark = await result.app().evaluate(() => {
    return performance.getEntriesByName('dashboard-ready')[0]?.startTime ?? -1;
  });

  expect(readyMark).toBeGreaterThan(0);
  expect(readyMark).toBeLessThan(800);
});

Use this for components where the outer test includes host setup time and you need a cleaner component-level number.

Include display modes, themes, and viewport constraints

Display modes change performance because they change layout pressure:

  • Inline mode is usually narrow and should render quickly with limited vertical space.
  • Fullscreen mode exposes wide layouts, large charts, and more visible data.
  • Picture-in-picture mode can reveal overflow, expensive responsive recalculation, and hidden controls that still render.

Test the modes you request:

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

test('orders resource stays fast in picture-in-picture mode', async ({ inspector }) => {
  const start = performance.now();

  const result = await inspector.renderTool(
    'show-orders',
    { customerId: 'cus_perf_fixture' },
    { displayMode: 'pip', theme: 'dark' }
  );

  await expect(result.app().getByTestId('orders-ready')).toBeVisible();

  const elapsed = performance.now() - start;
  expect(elapsed).toBeLessThan(1000);
});

Use the same idea for themes. Dark mode can load different assets. High-contrast states can change text wrapping. Host-specific CSS variables can expose layout issues that never appear in your standalone browser preview. This is where visual regression testing and performance testing work well together.

Test large data and empty data

Most MCP App performance bugs show up with data sizes the developer did not test. A list with 10 rows is not proof that 500 rows will work inside an iframe.

Create simulation fixtures for these states:

  • Small happy path: the default local development state.
  • Realistic maximum: the largest result you expect a normal user to hit.
  • Empty state: no rows, no matches, no uploaded files, or no permissions.
  • Error state: failed external API, expired auth, or partial data.
  • Slow dependency state: mocked API delay, timeout, or retry.

Then write one large-state render test:

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

test('product list renders the large fixture without blocking', async ({ inspector }) => {
  const products = Array.from({ length: 500 }, (_, index) => ({
    id: `product_${index}`,
    name: `Product ${index}`,
    price: 10 + index,
  }));

  const start = performance.now();

  const result = await inspector.renderTool('search-products', {
    query: 'all',
    _mockOutput: {
      results: products,
      total: products.length,
    },
  });

  await expect(result.app().getByTestId('product-list-ready')).toBeVisible();

  const elapsed = performance.now() - start;
  expect(elapsed).toBeLessThan(2000);
});

If it fails, the fix is usually pagination, virtualization, server-side aggregation, or a simpler first paint with details loaded on demand.

Measure host bridge actions

Interactive MCP Apps can do more after the first render. A UI might call another tool, update app state, send a message, request a display mode, or update model context. Those actions are user-facing latency too.

Do not hide them inside generic E2E tests. Give important bridge actions their own budget:

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

test('save preference action completes within 400ms', async ({ inspector }) => {
  const result = await inspector.renderTool('show-preferences', {
    userId: 'usr_perf_fixture',
  });

  const start = performance.now();

  await result.app().getByRole('button', { name: 'Save' }).click();
  await expect(result.app().getByText('Saved')).toBeVisible();

  const elapsed = performance.now() - start;
  expect(elapsed).toBeLessThan(400);
});

This catches problems that tool-only benchmarks miss, such as a slow client-side validation step, a blocked state update, or a follow-up tool call that repeats work the first tool already did.

Watch CSP, CORS, fonts, and external assets

Performance bugs often look like rendering bugs in MCP Apps. A missing CSP domain can make images disappear. A slow font can shift the layout. A map tile server can turn a useful resource into a blank square. A CORS failure can force the UI into a retry loop.

For resource performance tests, record these checks:

  • No failed network requests in the iframe.
  • No unexpected remote domains.
  • Fonts either load quickly or have a stable fallback.
  • Images, map tiles, and scripts are allowed by the resource CSP.
  • Large media is lazy-loaded below the first useful render.

For deeper background, read the focused guide on MCP App iframe, sandbox, origins, and CORS. Treat CSP as part of performance because blocked or retried assets waste the same user wait.

Run performance tests in CI without making them brittle

Performance tests are useful only if the team trusts them. Shared CI runners vary, so do not copy local laptop thresholds into CI without headroom.

Use a separate CI job for performance budgets:

name: MCP App performance

on:
  pull_request:
  push:
    branches: [main]

jobs:
  perf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm test:e2e -- --grep @perf
      - run: pnpm test:visual

Keep the suite small:

  1. One warm latency test per high-use tool.
  2. One cold start test for the slowest route or deployment mode.
  3. One bundle size check.
  4. One first useful render test for each major resource type.
  5. One large fixture render test for the most data-heavy UI.
  6. One bridge action test for any user action that calls back into the host.

Log timings even when tests pass. If a tool moves from 220ms to 470ms over a month, the test may still be green, but the trend is telling you where to look next.

What to optimize first

If the app feels slow, measure before changing code. Then fix the largest wait that the user actually feels.

Tool call latency is often first because the assistant cannot render your result until the tool returns. Profile the handler, separate internal time from dependency time, and cache or batch calls where it is safe.

Cold start comes next for serverless deployments and low-traffic tools. Reduce top-level imports, defer heavy clients, and avoid startup work that only one tool needs.

Resource bundle size matters when the iframe opens slowly. Split heavy resources by route, remove accidental dependencies, and avoid shipping admin-only UI into every user-facing resource.

Rendering speed matters when data is large. Virtualize long lists, summarize first, defer details, and use server-shaped data so the client does less work before the first useful paint.

Host bridge latency matters after the UI appears. Budget actions like save, refresh, follow-up tool calls, and display mode requests because users experience those as app speed too.

Get started with sunpeak

sunpeak is an open-source MCP App framework and testing framework for MCP Apps, ChatGPT Apps, and Claude Connectors. It gives you a local inspector with replicated ChatGPT and Claude runtimes, simulation fixtures for deterministic states, and test commands for unit, E2E, visual, live host, and multi-model eval workflows.

For a new app:

npx sunpeak new
pnpm test:e2e
pnpm test:visual

For an existing MCP server:

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

Start with one latency test, one bundle size check, and one first useful render test. Then add large data, display modes, and host bridge actions as the app grows. Use the testing framework for the current sunpeak workflow, or read the complete guide to testing ChatGPT Apps and MCP Apps to place performance tests beside unit tests, E2E tests, visual regression, live host checks, and evals.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is performance testing for MCP Apps?

Performance testing for MCP Apps measures how fast MCP tools respond, how quickly resource UIs load inside host iframes, how large the resource bundles are, and how the app behaves across display modes, themes, and realistic data sizes. MCP App performance includes the server, the MCP transport, tool result serialization, iframe loading, browser rendering, and any host bridge actions your UI calls.

What MCP App performance metrics should I track first?

Track tool call latency, cold start time, resource bundle size, first useful render, host bridge action latency, and external dependency time. Those metrics map to what users feel: the assistant waits on the tool, the iframe waits on the resource bundle, and the UI waits on rendering or follow-up calls.

How do I measure tool call latency in an MCP App?

Use the mcp fixture from sunpeak/test and time mcp.callTool() with performance.now(). Measure both realistic calls with real dependencies and isolated calls with mocked dependencies. The first test tells you what users feel. The second tells you whether the slowdown is inside your handler or outside your process.

What is a good response time for an MCP App tool?

Simple read-only tools should usually finish in 200-500ms when dependencies are warm. Tools that call external APIs or write to a database may need a larger budget, but anything over 1 second should be intentional and monitored. Cold starts can be slower, so track cold and warm timings separately.

How do I performance test a ChatGPT App UI?

Render the resource through a host-like inspector, wait for the first useful element, and measure how long it takes to appear after the tool result is available. Also test the display modes your ChatGPT App requests, including inline, fullscreen, and picture-in-picture when relevant, because each mode changes viewport pressure and layout cost.

How do I performance test a Claude Connector?

Use the same MCP server and resource performance checks, then run them against a Claude-style host runtime. Claude Connectors are remote MCP integrations, so test tool latency, OAuth or session setup where relevant, iframe rendering, theme handling, and large result states before you rely on a live Claude session.

Can I run MCP App performance tests in CI?

Yes. Keep a small performance suite in CI with latency budgets, bundle size checks, and browser rendering checks against deterministic simulation fixtures. Use wider thresholds than local tests because shared CI runners vary, and upload traces or timing logs when a budget fails.

How does sunpeak help with MCP App performance testing?

sunpeak provides a local inspector with replicated ChatGPT and Claude runtimes, simulation fixtures for deterministic tool states, and a testing framework for unit, E2E, visual, live host, and multi-model eval tests. That lets you test performance locally and in CI without paying for host accounts or burning AI credits in the default loop.