Skip to main content
All posts

MCP App Capability Detection: Host Features, Fallbacks, and Tests (September 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingHost CapabilitiesFeature DetectionGraceful Degradation
MCP App capability detection keeps optional host features behind tested fallbacks.

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

An MCP App can open inside a host and still lack an optional feature that your UI expects. The current surface may not proxy server tool calls, allow fullscreen, open external links, download a file, or grant microphone access. Those differences can come from the host, account policy, device, or the user’s choice.

Capability detection is how the app asks the runtime what works now. A good implementation also plans for the answer to change and gives the user another way to finish the task.

TL;DR: Register handlers before connecting, await App.connect(), then read app.getHostCapabilities() and app.getHostContext(). Gate each optional action on the narrowest signal available. Treat permissions and mode changes as requests that can fail. Preserve user intent in the fallback, and test unknown, supported, denied, changed, cancelled, and failed states.

Capability detection happens at four layers

Developers often treat capability detection as one boolean. MCP Apps have four separate layers, and an action can pass one layer but fail at the next.

LayerQuestionExample signal
Client and server discoveryCan this MCP client receive an app UI?MCP Apps extension capability and supported MIME type
App and host handshakeWhich bridge operations does the host support?app.getHostCapabilities() after ui/initialize
Current host contextDoes the current surface fit the interaction?availableDisplayModes, container size, platform, safe areas
Runtime permission and resultDid policy, the user, and the operation allow it?Permission promise resolves and the requested action succeeds

This separation explains several common bugs. A server can return a UI resource to a client that supports MCP Apps, while the rendered app still cannot call another server tool. A host can advertise fullscreen, then decline a specific request. Resource metadata can ask for microphone access, while the user denies the browser prompt.

The safest decision order for an optional action is:

  1. Wait until capability state is known.
  2. Check the exact host capability or current-context value.
  3. Ask for permission or a host-controlled transition after a user action.
  4. Use the result returned by the host or browser as the source of truth.
  5. Keep the user’s work and show the fallback if any step fails.

Read capabilities after the handshake

The MCP Apps SDK performs a separate UI handshake. The app sends ui/initialize with its own information and capabilities. The host responds with its protocol version, implementation information, host capabilities, and host context. App.connect() completes that exchange.

Register handlers before you connect so the app does not miss early tool input or context notifications:

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

const app = new App(
  { name: 'order-review', version: '1.0.0' },
  { availableDisplayModes: ['inline', 'fullscreen'] }
);

app.addEventListener('toolinput', ({ arguments: input }) => {
  loadDraft(input);
});

app.addEventListener('hostcontextchanged', (context) => {
  applyHostContext(context);
});

await app.connect();

const capabilities = app.getHostCapabilities();
const context = app.getHostContext();

getHostCapabilities() and getHostContext() return undefined before connection. That is an unknown state, not proof that a feature is missing. Render a neutral loading state until connect() settles, then decide which controls to show.

The current SDK exposes host capability groups such as:

  • serverTools for calls proxied to the MCP server
  • serverResources for resource reads proxied to the server
  • openLinks and downloadFile for host-owned browser actions
  • logging for structured messages sent to the host
  • sampling for requests that use the host’s model connection
  • sandbox.permissions for permissions the host granted to the app iframe

The list can grow as the protocol adds features. Check the field your action needs instead of turning the whole object into one canUseBridge flag.

Keep capabilities separate from context

Capabilities describe operations. Host context describes the environment around the iframe. Context includes fields such as the applied display mode, host-supported display modes, theme, locale, container dimensions, safe area insets, platform, device traits, and tool information.

Context can change while the app is open. A user can expand the app, rotate a device, resize a panel, or switch theme. The SDK updates getHostContext() when it receives ui/notifications/host-context-changed, so recalculate derived UI state when that event arrives.

function canRequestFullscreen(app: App) {
  return app.getHostContext()?.availableDisplayModes?.includes('fullscreen') ?? false;
}

let showExpand = canRequestFullscreen(app);

app.addEventListener('hostcontextchanged', () => {
  showExpand = canRequestFullscreen(app);
  renderToolbar({ showExpand });
});

Do not use getHostVersion().name as a feature flag. The same host can expose different features by plan, policy, version, surface, or device. Host information helps with logs and support reports, while capability and context fields should control the UI.

Model more states than supported and missing

A boolean works for a static field after initialization, but many product states need more detail:

StateWhat it meansUI response
UnknownThe app has not finished connectingDisable the action or show a small loading state
AvailableThe host advertised the needed featureShow the action
UnavailableThe capability or context value is absentShow the fallback path
DeniedUser or policy rejected the requestExplain what was denied and keep their work
CancelledThe host or user stopped an active operationReturn to a safe retry state
FailedThe supported operation returned an errorOffer retry or fallback without claiming no support
ChangedContext or permission changed after first renderRecalculate controls and layout

This distinction matters for telemetry too. If a supported download fails because the server returned an expired URL, logging it as download capability missing sends the team in the wrong direction.

Display modes need three checks

Display modes are host-controlled. A reliable expand button checks what the app declared, what the host currently offers, and what mode the host actually applied.

import { useDisplayMode, useRequestDisplayMode } from 'sunpeak';

export function ExpandButton() {
  const mode = useDisplayMode();
  const { availableModes, requestDisplayMode } = useRequestDisplayMode();
  const canExpand = availableModes?.includes('fullscreen') ?? false;

  if (!canExpand || mode === 'fullscreen') return null;

  async function expand() {
    try {
      await requestDisplayMode('fullscreen');
    } catch {
      showInlineNotice('Fullscreen is unavailable. Your work is still here.');
    }
  }

  return (
    <button type="button" onClick={expand}>
      Expand
    </button>
  );
}

Do not set local state to fullscreen when the request starts. The returned context is the applied state because the host owns the container. Keep inline mode complete enough to finish the main task, even if fullscreen is the better view.

Server tool calls need a model-visible fallback

App-initiated tools/call requests support pagination, refresh, saves, and confirmed actions without leaving the UI. Gate those controls on serverTools, then handle errors from the call itself. A negotiated capability means the host understands the request, not that every tool is authorized or every call will succeed.

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

export function Results() {
  const { output } = useToolData<unknown, { nextCursor?: string }>();
  const { hostCapabilities } = useHostInfo();
  const callServerTool = useCallServerTool();
  const canCall = Boolean(hostCapabilities?.serverTools);

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

    try {
      await callServerTool({
        name: 'load_more_results',
        arguments: { cursor: output.nextCursor },
      });
    } catch {
      showChatFallback('Ask to load the next page.');
    }
  }

  return canCall ? (
    <button type="button" onClick={loadMore}>
      Load more
    </button>
  ) : (
    <p>Ask in chat to load the next page.</p>
  );
}

The normal MCP tool should still return useful text content for a client that cannot render its UI. The MCP Apps specification recommends text fallbacks, and this also gives the model enough information to continue when the interactive path is unavailable.

Input has its own timing edge. Some hosts require confirmation before they release tool arguments. Treat missing early input as pending until you receive ui/notifications/tool-input, rather than deciding that the host does not support the tool.

Opening a URL and starting a download cross the iframe boundary, so let the host own those actions. Check openLinks before app.openLink() and downloadFile before app.downloadFile(). Catch rejection even after a positive capability check.

User goalPreferred pathFallback
Open a reportHost openLink() requestDisplay a labeled, copyable HTTPS URL
Download an exportHost downloadFile() requestGenerate a short-lived server download link
Import a fileHost file extension when availableSigned upload or server-side import flow
Open account settingsHost link requestGive exact navigation steps and preserve the draft

Do not try to escape the sandbox with popups, hidden iframes, or window.top.location. Those paths break host security rules and still fail under common browser policies.

File handling may rely on a host extension rather than the shared MCP Apps methods. OpenAI’s current UI guide recommends starting with the shared MCP Apps protocol and feature-detecting each optional ChatGPT extension. Put a host extension behind a small adapter so the rest of the app can use one interface:

type FilePicker = {
  pick(): Promise<Array<{ id: string; name: string }>>;
};

function getFilePicker(): FilePicker | undefined {
  const pick = window.openai?.selectFiles;
  if (typeof pick !== 'function') return undefined;

  return { pick: () => pick() };
}

If no picker exists, show the server import path. Do not branch on the product name because an extension can differ across accounts and surfaces.

Permissions have three gates

Camera, microphone, geolocation, and clipboard access need three positive answers:

  1. Resource metadata requests the permission so the host can configure the iframe sandbox.
  2. The browser exposes the required API in that sandbox.
  3. Host policy and the user allow the specific request.
async function startRecording() {
  if (!navigator.mediaDevices?.getUserMedia) {
    return showUploadFallback('Recording is unavailable here. Upload an audio file instead.');
  }

  try {
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    beginRecording(stream);
  } catch (error) {
    showUploadFallback('Microphone access was denied. Upload an audio file instead.');
    recordPermissionOutcome(error);
  }
}

Permission metadata is a request, not a grant. Ask for browser access after a clear user action, handle denial without a reload, and stop active streams during teardown. The same pattern applies to camera, microphone, geolocation, and clipboard permissions.

Put optional host extensions behind one adapter

The shared MCP Apps protocol should be the default path. A host may also expose useful methods that have not reached the shared spec. Keep those calls in one adapter with three rules:

  • feature-detect the exact method before calling it
  • return the same result shape as the portable implementation
  • expose a fallback that does not depend on the extension

This makes migration simpler because app components do not need host-name checks or global-object checks. It also gives tests one seam where they can simulate available, absent, rejected, and malformed host behavior.

Write a capability contract for each optional feature

Before building a control, write a short capability contract. It can live beside the component or in its test fixture.

export const exportCsvContract = {
  requirement: 'hostCapabilities.downloadFile',
  primaryPath: 'download the generated CSV through the host',
  fallback: 'show a short-lived HTTPS download link',
  preserves: ['active filters', 'selected columns'],
  tests: ['unknown', 'success', 'missing', 'rejected', 'expired-link'],
};

The contract prevents a common failure where a developer hides a broken button but removes the only way to finish the task. The preserves list matters because a fallback that clears a long form or selection is technically available but still poor product behavior.

Test the state transitions, not only the final branches

A useful capability test matrix includes timing and failure states:

ScenarioWhat to simulateWhat to assert
Before initializationCapabilities and context are unknownNo unsupported action fires
Supported successCapability present, request resolvesResult becomes the source of truth
Supported rejectionCapability present, host rejectsUser input remains and fallback appears
Missing capabilityCapability absentPrimary control is hidden or replaced
Permission deniedBrowser rejects permission promiseClear denial copy and alternate input appear
Context changesHost sends a context updateControls and layout recalculate
Tool cancelledHost sends cancellationPending UI clears without fake success
Runtime failureServer or link operation failsRetry or portable path remains available

Unit tests can mock hooks and bridge methods. Integration tests should run the same resource through several host-context fixtures and assert the rendered UI. Browser tests should also inspect the console and failed requests because a fallback can look correct while an unsupported bridge call still fires in the background.

Log capability names, protocol and app versions, state transitions, and error classes. Avoid logging tool input, full host context, file contents, tokens, or other private data. The goal is to learn which branch failed without copying the user’s work into telemetry.

Test capability branches locally with sunpeak

sunpeak is an open-source, server-agnostic MCP App framework and testing tool. You can inspect an existing server locally, pin host state in fixtures, and run protocol, browser, visual, live, and eval tests without changing the server implementation.

Start the Inspector against a running MCP server:

npx sunpeak inspect --server http://localhost:3000/mcp

Then create a test setup for that server:

npx sunpeak test init --server http://localhost:3000/mcp

Use separate simulations for inline-only context, server tools missing, touch input, permission denial, and a failed host action. Keep the universal fallback logic in app code and use the local Inspector to prove each branch works before you deploy it to a host.

Capability detection is complete when every optional action has a narrow check, a useful fallback, and a test for the transition between them. Start with the MCP App framework, or use sunpeak to inspect and test the server you already have.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is MCP App capability detection?

MCP App capability detection is the process of reading the features a host negotiated during ui/initialize, then checking current host context and runtime permissions before using an optional feature. It replaces assumptions about a host name or device with direct checks for server tools, resources, links, downloads, display modes, sampling, and browser permissions.

How do I check host capabilities in an MCP App?

Register event handlers first, await app.connect(), then call app.getHostCapabilities(). The method returns undefined before the connection handshake finishes. Read changing UI state with app.getHostContext() and listen for hostcontextchanged events because theme, display mode, container size, safe areas, and other context can change while the app is open.

What is the difference between host capabilities and host context?

Host capabilities describe operations the host supports, such as serverTools, serverResources, openLinks, downloadFile, logging, or sampling. Host context describes the current environment, such as display mode, available display modes, theme, locale, platform, safe area, and container dimensions. A feature may require both a negotiated capability and suitable current context.

Why should an MCP App avoid host-name checks?

A host name does not tell you whether a feature works for this account, policy, app version, device, or surface. Check the negotiated capability or API directly. Keep host information for diagnostics and analytics, not as the main switch that enables an action.

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

Give the user another way to complete the task. For example, keep a useful inline layout when fullscreen is unavailable, let the model call a tool when app-initiated tool calls are unavailable, show a copyable URL when links cannot open, or offer a server import flow when a native file picker is missing.

Do resource permissions guarantee camera, microphone, or geolocation access?

No. Resource metadata asks the host to configure its sandbox, but the browser, host policy, operating system, or user can still deny access. Check that the browser API exists, request access after a user action, handle rejection, and keep a fallback for later denial or revocation.

How should I test MCP App capability fallbacks?

Test more than supported and missing. Cover the pre-initialization state, supported success, host rejection, policy denial, context changes, cancelled work, and runtime failure. Assert that every failed branch keeps user input and offers a useful next action instead of leaving a dead control.