All posts

MCP App Capability Detection: Host Features, Fallbacks, and Tests

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkHost CapabilitiesFeature Detection
MCP App capability detection keeps optional host features behind tested fallbacks.

MCP App capability detection keeps optional host features behind tested fallbacks.

Most MCP App tutorials stop at the happy path: the tool runs, the iframe renders, and the component reads structuredContent.

The next set of developer searches is more specific:

  • Can this host call a server tool from the iframe?
  • Does this ChatGPT App support fullscreen here?
  • Can this Claude Connector open a link without another prompt?
  • Is a file picker available, or do I need a server-side upload flow?
  • Why does microphone access work in one host but fail in another?

Those are capability questions. They need runtime checks, not guesses.

TL;DR: Treat every optional MCP App feature as something the host may or may not support. Read host capabilities after App.connect(), use focused React hooks for display modes and device traits, declare resource permissions in metadata, and still feature-detect browser APIs at runtime. Hide unavailable controls or replace them with real fallbacks. Test the missing-capability path as carefully as the supported path.

Why This Search Gap Matters

MCP Apps have a shared base: tools, resources, sandboxed iframes, ui:// resources, and a JSON-RPC bridge. That base is portable across hosts.

The optional edges are where apps break:

  • Display mode requests.
  • App-initiated server tool calls.
  • Message sending and model context updates.
  • External links and downloads.
  • File upload and file picker APIs.
  • Browser permissions such as clipboard, camera, microphone, and geolocation.
  • Host context fields such as platform, safe area, and device capabilities.

If you assume all of those exist everywhere, the UI becomes brittle. A button appears but does nothing. A mobile layout shows a hover-only menu. A file upload flow only works in one host. A fullscreen button appears in an inline-only runtime.

Capability detection keeps the UI honest.

Capability Detection Means Asking the Runtime

At the low level, an MCP App connects to the host, then reads the host capabilities:

import { App } from '@modelcontextprotocol/ext-apps';

const app = new App({ name: 'customer-review', version: '1.0.0' });

await app.connect();

const capabilities = app.getHostCapabilities();

if (capabilities?.serverTools) {
  enableServerToolButtons();
} else {
  renderReadOnlyFallback();
}

The exact capability object can grow as the MCP Apps spec and host implementations add features. That is the point. Your app should read what the host says now instead of hardcoding what one host did during development.

In React with sunpeak, most code should use framework hooks instead of holding the App instance:

import { useHostInfo } from 'sunpeak';

export function CapabilityBadge() {
  const { hostCapabilities } = useHostInfo();

  return (
    <p>
      Server tools are {hostCapabilities?.serverTools ? 'available' : 'not available'} in this
      host.
    </p>
  );
}

Use useHostInfo() when you need the broad capability snapshot. Use focused hooks when the feature has its own API.

Prefer Feature Checks Over Host Checks

This is the wrong shape:

const canUpload = hostName === 'chatgpt';

It fails as soon as:

  • the host changes behavior by plan, workspace policy, device, or app version
  • another host adds the same feature
  • one surface supports a feature but mobile does not
  • a beta feature exists for some accounts but not others

Prefer this shape:

const canUpload = Boolean(fileApi?.selectFiles);

or this:

const canFullscreen = availableModes?.includes('fullscreen') ?? false;

Host identity is still useful for analytics, bug reports, support copy, and last-resort fallbacks. It should not be the first thing deciding whether a button works.

Display Modes Need Their Own Gate

Display modes are the most common capability check because they affect visible UI.

In sunpeak, read the current mode and the modes the host says are available:

import { useDisplayMode, useRequestDisplayMode } from 'sunpeak';

export function ExpandButton() {
  const mode = useDisplayMode();
  const { requestDisplayMode, availableModes } = useRequestDisplayMode();

  const canFullscreen = availableModes?.includes('fullscreen') ?? false;

  if (!canFullscreen || mode === 'fullscreen') {
    return null;
  }

  return (
    <button type="button" onClick={() => requestDisplayMode('fullscreen')}>
      Expand
    </button>
  );
}

Three rules keep this path reliable:

  1. Hide controls for unavailable modes.
  2. Request mode changes from user actions, not during render.
  3. Keep the layout valid even when the host returns a different mode than requested.

That last rule matters. A host owns the container. Your app can ask for fullscreen or picture-in-picture, but the host decides what happens.

Server Tool Calls Should Have a Read-Only Fallback

callServerTool lets a rendered resource ask the host to call another tool on the same MCP server. It is useful for pagination, validation, refresh buttons, draft saves, and confirmed actions.

But not every host or surface will expose the same action bridge at the same time. Gate the control:

import { useCallServerTool, useHostInfo, useToolData } from 'sunpeak';

interface OrdersOutput {
  orders: Array<{ id: string; total: string }>;
  nextCursor?: string;
}

export function OrdersResource() {
  const { output } = useToolData<unknown, OrdersOutput>();
  const { hostCapabilities } = useHostInfo();
  const callServerTool = useCallServerTool();

  const canCallServerTool = Boolean(hostCapabilities?.serverTools);
  const canLoadMore = canCallServerTool && Boolean(output?.nextCursor);

  async function loadMore() {
    if (!output?.nextCursor || !canCallServerTool) return;

    await callServerTool({
      name: 'load_more_orders',
      arguments: { cursor: output.nextCursor },
    });
  }

  return (
    <section>
      {/* render orders */}
      {canCallServerTool ? (
        <button type="button" disabled={!canLoadMore} onClick={loadMore}>
          Load more
        </button>
      ) : (
        <p>Ask for the next page in chat to load more orders.</p>
      )}
    </section>
  );
}

The fallback is simple, but it is real. The user still has a path forward because the model can call the normal model-visible tool.

If the UI action should never be model-visible, make it an app-only tool, then test the host path where app-only tool calls are available.

Opening a link from inside a host iframe is a host-owned action. Some hosts may ask for confirmation. Some may require allowed link metadata. Some enterprise environments may block links by policy.

The same is true for downloads. A report export can feel like a normal web download when the host supports it, but the fallback should still be useful:

Desired actionCapability pathFallback
Open a dashboardHost open-link requestShow a copyable HTTPS URL
Download a CSVHost download requestGenerate a server-side link
Open a support ticketHost open-link requestSend a chat message with the ticket ID
Launch an OAuth flowHost-supported auth path or server routeShow connected-account state and retry

The pattern is the same: ask the host to do the host-owned thing. If it cannot, give the user a path that does not depend on iframe privileges.

Do not use window.top.location, popup hacks, or hidden iframes to bypass the host. MCP App iframes are sandboxed because the host owns the conversation surface.

File APIs Are Optional, So Design the Portable Path First

File handling is one of the clearest examples of capability detection.

Some host runtimes expose native file selection or upload APIs. Others do not. Even when a host has a native picker, it may only exist on specific surfaces or account types.

Build the portable path first:

  1. Accept existing file references from connected systems.
  2. Use a normal server-side import flow where possible.
  3. Use signed upload URLs when the user must upload to your backend.
  4. Add host-native file controls behind feature detection.

The UI can branch:

export function ImportPanel({ nativeFiles }: { nativeFiles?: { selectFiles?: () => void } }) {
  if (nativeFiles?.selectFiles) {
    return (
      <button type="button" onClick={() => nativeFiles.selectFiles?.()}>
        Select file
      </button>
    );
  }

  return (
    <form>
      <label htmlFor="import-url">Import from URL</label>
      <input id="import-url" name="url" type="url" />
      <button type="submit">Import</button>
    </form>
  );
}

The details differ by framework and host-specific API, but the rule is stable: the native path is an upgrade. The server-side path is the fallback.

Browser Permissions Are Requests, Not Guarantees

Resource metadata can ask for browser permissions:

import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Record a short voice note',
  _meta: {
    ui: {
      permissions: {
        microphone: {},
      },
    },
  },
};

That metadata lets the host prepare its sandbox and permission prompts. It does not guarantee that the user, browser, host, or workspace policy will grant access.

Your component still needs browser feature detection:

async function startRecording() {
  if (!navigator.mediaDevices?.getUserMedia) {
    setError('Microphone access is not available here.');
    return;
  }

  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    beginRecording(stream);
  } catch {
    setError('Microphone access was denied.');
  }
}

Do the same for geolocation, clipboard, camera, and any browser API that depends on sandbox flags or user permission. Metadata says what the app wants. Runtime checks say what the app got.

Host Context Is Capability Data Too

Some capability checks are not named permissions. They come from host context.

For example:

  • deviceCapabilities.touch decides whether small hover controls are usable.
  • platform can hint at mobile, desktop, or web behavior.
  • safeAreaInsets tells you whether host chrome cuts into the viewport.
  • availableDisplayModes decides whether a layout mode is possible.
  • containerDimensions decides whether a table should become a list.

Use host context for layout and interaction decisions:

import { useDeviceCapabilities, useViewport } from 'sunpeak';

export function Toolbar() {
  const { touch = false, hover = true } = useDeviceCapabilities();
  const viewport = useViewport();

  const compact = (viewport?.width ?? 0) < 420;
  const largeTargets = touch || !hover;

  return (
    <div className={compact ? 'grid gap-2' : 'flex gap-2'}>
      <button className={largeTargets ? 'min-h-11 px-4' : 'min-h-8 px-3'} type="button">
        Filter
      </button>
      <button className={largeTargets ? 'min-h-11 px-4' : 'min-h-8 px-3'} type="button">
        Export
      </button>
    </div>
  );
}

This is better than assuming mobile from a user agent string. The host is telling you the constraints it has placed around the iframe.

Design Fallbacks as Product States

A fallback should not feel like an exception path. It should be one of the states your app is designed to render.

Use this checklist:

  • If fullscreen is unavailable, does inline mode still show the main task?
  • If server tool calls are unavailable, can the user ask the model for the same action?
  • If native file selection is unavailable, is there a server-side import path?
  • If a link cannot open directly, can the user copy it?
  • If a browser permission is denied, does the UI say what happened?
  • If _meta is unavailable in a host path, can the component render from structuredContent?
  • If safe area data is missing, do default paddings still work?

The fallback does not need to match the best path. It needs to be usable and honest.

Test Capabilities as a Matrix

Capability bugs often survive because local development only exercises the “everything supported” state.

Add tests for at least these branches:

CapabilitySupported testMissing test
Fullscreen modeExpand button requests fullscreenButton is hidden in inline-only host
Server tool callsPagination calls app-only toolUI shows chat fallback
Touch inputButtons use large tap targetsHover-only affordances are hidden
File pickerNative picker button appearsServer import form appears
Permission requestRecorder can startDenied state renders
External linkHost open-link request firesCopyable URL appears

For unit tests, mock the hooks:

import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { OrdersResource } from './orders';

vi.mock('sunpeak', async () => {
  const actual = await vi.importActual<typeof import('sunpeak')>('sunpeak');

  return {
    ...actual,
    useToolData: () => ({
      output: { orders: [], nextCursor: 'page-2' },
      isLoading: false,
      isError: false,
      isCancelled: false,
    }),
    useHostInfo: () => ({
      hostCapabilities: { serverTools: false },
    }),
    useCallServerTool: () => vi.fn(),
  };
});

test('shows chat fallback when server tool calls are unavailable', () => {
  render(<OrdersResource />);
  expect(screen.getByText(/Ask for the next page/)).toBeInTheDocument();
});

For E2E tests, render the same simulation in a local inspector and vary host, display mode, theme, viewport, and mocked server tool responses. That catches the layout and iframe behavior unit tests cannot see.

Use sunpeak to Keep the Checks Local

You can write capability checks with the low-level MCP Apps SDK, but the repetitive work is testing them. Each feature branch needs a host runtime, a tool result, host context, display mode, theme, and sometimes app-only server tool mocks.

sunpeak gives you that loop locally. The Inspector can render the same resource in replicated host runtimes, while the test fixtures can pin tool results, host state, server tool responses, and display modes. That means you can verify “fullscreen missing,” “server tools unavailable,” and “touch device layout” without spending each iteration in a real ChatGPT or Claude session.

Start with the portable path:

npx sunpeak new

Then add capability tests before the first host-specific feature ships. The app will feel less surprising because every optional control has a tested path for “yes” and “not here.”

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is MCP App capability detection?

MCP App capability detection means checking what the current host supports before using optional bridge features, display modes, browser permissions, file APIs, downloads, external links, or server tool calls. The app reads host capabilities and host context after the initialization handshake, then renders only the controls that can work in that host.

How do I check host capabilities in an MCP App?

With the low-level MCP Apps SDK, call app.getHostCapabilities() after app.connect() completes. In React with sunpeak, use useHostInfo for host capabilities and use focused hooks such as useRequestDisplayMode, useDeviceCapabilities, useDisplayMode, and useHostContext for feature-specific checks.

Why should MCP Apps use feature detection instead of host-name checks?

Host-name checks age quickly because hosts add features, remove beta behavior, and differ across web, desktop, mobile, and enterprise environments. Feature detection asks the direct question: can this runtime perform the action right now? Use host-name checks only as a last fallback when no capability signal exists.

What should an MCP App do when a host capability is missing?

A missing capability should produce a real fallback, not a broken button. For example, hide unavailable fullscreen controls, use a server-side import flow when native file upload is missing, show a copyable URL when open-link is not available, or keep a read-only layout when server tool calls are disabled.

Are display modes part of MCP App capability detection?

Yes. Display modes are host-controlled. Read the current display mode and available display modes before showing expand, pop out, or close controls. A host may support inline only, may support fullscreen but not picture-in-picture, or may deny a requested mode.

How do browser permissions relate to MCP App capabilities?

Browser permissions such as microphone, camera, geolocation, and clipboard write are declared in resource metadata, but the host and browser can still deny them. Treat permission metadata as a request. At runtime, use normal browser feature detection and render denial states.

How do I test MCP App fallbacks?

Test fallback branches directly. Mock capability hooks in unit tests, render the same resource in the sunpeak inspector with different host, display mode, theme, and device settings, and add E2E assertions that unavailable features are hidden or replaced with useful alternatives.