All posts

MCP App Lifecycle: connect(), Tool Input, Results, and Teardown

Abe Wheeler
MCP Apps MCP App Framework MCP App Testing ChatGPT Apps ChatGPT App Framework ChatGPT App Testing Claude Connectors Claude Connector Framework Host Bridge App Class
The MCP App lifecycle starts with host initialization, then moves through tool input, tool results, bidirectional requests, and teardown.

The MCP App lifecycle starts with host initialization, then moves through tool input, tool results, bidirectional requests, and teardown.

Most MCP App bugs make more sense when you trace the lifecycle instead of staring at the React component.

The host does more than render an iframe. It discovers your UI tool, fetches your ui:// resource, initializes a bridge, sends tool input before the server result is ready, pushes the result or cancellation, listens for app requests, updates host context, and eventually tears the iframe down.

If you know that order, you can debug missing data, stuck loading states, ignored button clicks, stale display modes, and cleanup bugs much faster.

TL;DR: An MCP App resource is a sandboxed iframe that connects to the host with App.connect(). Register event handlers before connecting. Treat partial input as preview-only, complete input as the final arguments, tool result as server output, and cancellation as a separate state. Feature-detect host capabilities before using optional bridge requests. Clean up during teardown. In sunpeak, AppProvider, useToolData, useCallServerTool, useHostContext, and useTeardown wrap the low-level bridge so most React code does not need to call the App class directly.

The Lifecycle in One Table

The MCP Apps overview describes the core pattern: a tool points at a UI resource, the host renders that resource in a sandboxed iframe, and the app communicates with the host over a postMessage bridge.

Here is the practical lifecycle developers end up debugging:

PhaseWho starts itWhat happensCommon bug
Tool discoveryHostHost calls tools/list and sees _meta.ui.resourceUriTool has no resource URI
Resource fetchHostHost reads the ui:// resource and loads HTMLResource URI mismatch
Iframe renderHostHost mounts sandboxed HTMLCSP blocks assets
Bridge connectAppApp calls connect() and performs ui/initializeHandlers registered too late
Tool inputHostHost sends final tool argumentsComponent expects output too early
Partial inputHostHost streams preview argumentsApp treats partial data as final
Tool resultHostHost sends server result or errorUI reads the wrong data lane
CancellationHostHost tells the app the call stoppedApp shows an error instead of a stopped state
Interactive requestsAppApp calls server tools, opens links, updates model context, or requests display modesApp assumes every host supports every request
Context changesHostTheme, locale, viewport, and display mode changeUI reads context only once
TeardownHostHost asks app to clean up before unmountTimers or sockets keep running

You do not need to write every step by hand. A framework can wrap the bridge. But you still need to know the lifecycle because it defines which data is available at each point.

connect() Starts the Host Bridge

The low-level MCP Apps SDK exposes an App class:

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

const app = new App(
  { name: 'invoice-viewer', version: '1.0.0' },
  {},
  { autoResize: true },
);

connect() establishes the postMessage transport to the parent host and performs the initialization handshake:

await app.connect();

During that call, the app sends its name, version, and capabilities. The host returns host capabilities and initial context, such as theme, locale, viewport, display mode, safe area, and supported display modes. With autoResize: true, the SDK also starts reporting size changes with ResizeObserver.

One rule matters more than the rest:

Register handlers before connect().

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

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

app.ontoolinput = (params) => {
  renderInput(params.arguments);
};

app.ontoolresult = (result) => {
  renderResult(result.structuredContent);
};

app.ontoolcancelled = ({ reason }) => {
  renderStopped(reason);
};

await app.connect();

The official App class docs call this out because some notifications can arrive during or right after the handshake. If you call connect() first and attach handlers later, your app can miss the first update and sit in an empty state.

Prefer addEventListener for Shared Code

The on* properties are easy to read, but they replace previous handlers:

app.ontoolresult = renderResult;
app.ontoolresult = trackResult; // renderResult is gone

For shared utilities, use addEventListener so multiple listeners can coexist and clean up:

function attachLifecycleLogging(app: App) {
  const onResult = (result: unknown) => {
    console.debug('tool result', result);
  };

  app.addEventListener('toolresult', onResult);

  return () => {
    app.removeEventListener('toolresult', onResult);
  };
}

Use the direct on* setters for small apps where one owner controls the resource. Use event listeners when a design system, analytics adapter, state library, or test harness also needs lifecycle events.

The React Version in sunpeak

In a sunpeak project, you usually do not instantiate App yourself. The framework adds AppProvider during dev and build, and the React hooks read from that context.

The manual version looks like this:

import { AppProvider } from 'sunpeak';

<AppProvider appInfo={{ name: 'invoice-viewer', version: '1.0.0' }}>
  <InvoiceResource />
</AppProvider>;

The framework version is automatic. Your resource component can go straight to hooks:

import { SafeArea, useToolData } from 'sunpeak';

interface InvoiceInput {
  period: string;
}

interface InvoiceOutput {
  invoices: Array<{ id: string; customer: string; total: string }>;
}

export function InvoiceResource() {
  const { input, inputPartial, output, isLoading, isError, isCancelled } =
    useToolData<InvoiceInput, InvoiceOutput>();

  if (isLoading) {
    return (
      <SafeArea className="p-5 font-sans">
        <p>Loading {inputPartial?.period ?? 'invoices'}...</p>
      </SafeArea>
    );
  }

  if (isError) {
    return (
      <SafeArea className="p-5 font-sans">
        <p>Could not load invoices.</p>
      </SafeArea>
    );
  }

  if (isCancelled) {
    return (
      <SafeArea className="p-5 font-sans">
        <p>Stopped.</p>
      </SafeArea>
    );
  }

  if (!output) return null;

  return (
    <SafeArea className="p-5 font-sans">
      <h1>Invoices for {input?.period}</h1>
      <ul>
        {output.invoices.map((invoice) => (
          <li key={invoice.id}>
            {invoice.customer}: {invoice.total}
          </li>
        ))}
      </ul>
    </SafeArea>
  );
}

useToolData wraps ontoolinput, ontoolinputpartial, ontoolresult, and ontoolcancelled. That gives React one state object instead of four bridge callbacks.

Tool Input Is Not Tool Output

MCP App hosts can render the iframe before the server-side tool finishes. That means the app can receive input first, then output later.

Use input for:

  • Showing what the tool is working on
  • Rendering a loading state with real labels
  • Preparing UI structure before data arrives

Use output for:

  • Rendering server results
  • Showing calculated fields
  • Displaying records fetched by the tool handler

For example, an invoice tool input might be { period: '2026-05' }, while the output contains invoice rows. If your component expects rows in input, it will fail. If it waits for rows before showing any UI, it will feel blank while the server works.

The split is useful because it lets the app show progress before the data exists.

Partial Input Is Preview-Only

ontoolinputpartial is one of the most useful lifecycle events for polished MCP Apps. It lets the host stream arguments while the model is still generating them.

app.ontoolinputpartial = (params) => {
  const query = params.arguments?.query;
  if (typeof query === 'string') {
    showSearchPreview(query);
  }
};

There is a catch: partial arguments are healed JSON. The host closes brackets and braces so you get a valid object, but the final field or item may be incomplete.

That means partial input is good for:

  • Search previews
  • Skeleton labels
  • Draft titles
  • “Looking up…” copy
  • Showing which item the model appears to be targeting

It is not safe for:

  • Writes
  • Payments
  • Auth decisions
  • Deleting data
  • Sending messages
  • Any action that needs validated complete input

Wait for complete tool-input and, for server-backed actions, wait for the actual tool result.

Tool Result Carries the Server Contract

When the server-side tool finishes, the host sends the result to the app.

The important fields are:

FieldPrimary use
contentText the model can read
structuredContentData the UI renders
isErrorError flag
_metaProtocol metadata and, where supported, UI-only details

Most MCP App UIs should render from structuredContent. Keep content short and factual so the model knows what happened without reading a full UI payload.

return {
  content: [{ type: 'text', text: 'Displayed 14 invoices for May 2026.' }],
  structuredContent: {
    period: '2026-05',
    invoices,
  },
};

If the result has isError: true, render an error state. If the host sends tool-cancelled, render a stopped state. Do not collapse both into the same UI because they mean different things. An error says the tool failed. A cancellation usually means the user stopped the run.

Interactive Requests Go Back Through the Host

After the result renders, the app can keep interacting with the host. Common requests include:

RequestUse it for
callServerToolPagination, refresh, submit actions, validation
updateModelContextTell the model about user selections
requestDisplayModeAsk for fullscreen or picture-in-picture
openLinkOpen an external URL safely
sendMessageAdd a message to the conversation
downloadFileAsk the host to download content
sendLogSend debug logs to host tooling

In React, these are usually hooks. A “load more” button might call an app-only server tool:

import { useCallServerTool, useToolData } from 'sunpeak';

interface InvoiceOutput {
  invoices: Array<{ id: string; customer: string; total: string }>;
  nextCursor?: string;
}

export function InvoiceList() {
  const { output } = useToolData<unknown, InvoiceOutput>();
  const callServerTool = useCallServerTool();

  if (!output) return null;

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

    await callServerTool({
      name: 'load-more-invoices',
      arguments: { cursor: output.nextCursor },
    });
  }

  return (
    <button onClick={loadMore} disabled={!output.nextCursor}>
      Load more
    </button>
  );
}

This request still goes through the host bridge. The iframe does not need direct credentials for your MCP server. The host proxies the tool call to the originating server.

Feature-Detect Before Optional Requests

Not every host supports every bridge request. Some hosts may support server tool calls but not downloads. Some may support fullscreen but not picture-in-picture. Some may block external links.

At the low level, read capabilities after connection:

const capabilities = app.getHostCapabilities();

if (capabilities?.serverTools) {
  await app.callServerTool({
    name: 'refresh-dashboard',
    arguments: {},
  });
}

At the framework level, use hooks that expose host state and supported modes:

import { useRequestDisplayMode } from 'sunpeak';

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

  if (!availableModes?.includes('fullscreen')) return null;

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

Feature detection matters for cross-host MCP Apps because a ChatGPT App and an interactive Claude Connector can share the same resource while still running in different host environments.

Host Context Can Change After Mount

The initial handshake gives the app a host context, but that context is not fixed. Theme can change. The viewport can resize. The display mode can move from inline to fullscreen. Locale or device capability can update.

Low-level apps can handle onhostcontextchanged:

app.onhostcontextchanged = (ctx) => {
  if (ctx.theme) {
    document.documentElement.dataset.theme = ctx.theme;
  }

  if (ctx.displayMode) {
    document.body.dataset.displayMode = ctx.displayMode;
  }
};

sunpeak hooks keep this reactive:

import { useDisplayMode, useTheme, useViewport } from 'sunpeak';

function Layout() {
  const displayMode = useDisplayMode();
  const theme = useTheme();
  const viewport = useViewport();

  return (
    <main data-mode={displayMode} data-theme={theme}>
      {viewport.width > 720 ? <WideLayout /> : <CompactLayout />}
    </main>
  );
}

The common bug is reading host context once during module initialization. Read it through the bridge or a reactive hook so the UI updates when the host changes.

Teardown Is Part of the Contract

Hosts can ask an app to shut down before removing the iframe. Handle that request if your resource starts background work.

Examples of work to clean up:

  • Polling intervals
  • WebSocket connections
  • Event listeners on window
  • In-flight fetch() requests
  • Audio, video, camera, or microphone streams
  • Temporary state that should be saved before unmount

Low-level teardown:

let pollTimer: number | undefined;
const abortController = new AbortController();

pollTimer = window.setInterval(refreshData, 5000);

app.onteardown = async () => {
  if (pollTimer) window.clearInterval(pollTimer);
  abortController.abort();
  return {};
};

React teardown usually lives in hooks, but host teardown is still useful when cleanup needs to happen before the host removes the iframe:

import { useTeardown } from 'sunpeak';

function LiveDashboard() {
  useTeardown(async () => {
    await saveDraftState();
    return {};
  });

  return <Dashboard />;
}

If your app is read-only and has no background work, teardown may not need much code. If your app opens long-running connections, treat teardown as required.

What to Test

Lifecycle bugs are easy to miss in manual testing because they depend on timing. Add tests for the state transitions that matter.

Start with these cases:

  • Resource renders before output exists
  • Partial input updates the loading UI
  • Complete input replaces partial input
  • Successful result renders structuredContent
  • Error result shows an error state
  • Cancellation shows a stopped state
  • App-only button calls the expected server tool
  • Display mode button is hidden when unsupported
  • Host context changes update layout or theme
  • Teardown stops polling or aborts in-flight work

With sunpeak, use simulation files for the tool states and Playwright for interactions:

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

test('invoice resource loads more rows through the host bridge', async ({ inspector }) => {
  const result = await inspector.renderTool('show-invoices', {
    input: { period: '2026-05' },
    output: {
      structuredContent: {
        invoices: [{ id: 'inv_001', customer: 'Acme Co', total: '$320' }],
        nextCursor: 'cursor_2',
      },
    },
  });

  await result.app().getByRole('button', { name: 'Load more' }).click();

  await expect(result.lastToolCall()).resolves.toMatchObject({
    name: 'load-more-invoices',
    args: { cursor: 'cursor_2' },
  });
});

This catches the bridge contract, not just the rendered DOM. The user clicked a button inside the iframe, the app sent a host request, and the host observed the right tool call.

A Practical Debugging Checklist

When an MCP App fails to render or gets stuck, walk the lifecycle in order:

  1. Does tools/list include the tool?
  2. Does the tool metadata point at the right ui:// resource?
  3. Can the host read that resource?
  4. Does CSP allow the scripts, styles, images, and API calls your UI needs?
  5. Does the iframe call connect()?
  6. Were handlers registered before connect()?
  7. Did the app receive input, partial input, result, or cancellation?
  8. Is the component reading structuredContent, not content, for UI data?
  9. Does the host support the request your button is making?
  10. Did host context change after mount?
  11. Did teardown stop background work?

This order avoids a common debugging trap: changing React state logic when the actual issue is a missing resource URI, a late event handler, or a host capability that was never available.

Where sunpeak Fits

sunpeak wraps the lifecycle without hiding it.

The framework handles AppProvider, resource bundling, resource URI generation, the local inspector, HMR, simulation files, and host-specific runtime differences. Hooks like useToolData, useHostContext, useCallServerTool, useRequestDisplayMode, useUpdateModelContext, and useTeardown let you write normal React code while still following the MCP Apps lifecycle.

That matters most in testing. You can render the same app locally in replicated ChatGPT and Claude host runtimes, drive the iframe with Playwright, and assert bridge events in CI before you connect a real host.

If you are building an MCP App by hand, keep the lifecycle table close. If you are building with sunpeak, the same table explains what the hooks are doing and what to test when the app behaves differently across hosts.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is the MCP App lifecycle?

The MCP App lifecycle is the sequence an interactive MCP resource follows inside a host: the host discovers a UI tool, fetches the resource, renders it in a sandboxed iframe, the app connects with App.connect(), the host sends tool input and tool results, the app can make view-to-host requests, and the host eventually sends teardown before unmounting the iframe.

What does App.connect() do in an MCP App?

App.connect() establishes the postMessage transport to the parent host, sends the ui/initialize request with app info and capabilities, receives host capabilities and context, sends the initialized notification, and starts automatic size reporting when autoResize is enabled. Register event handlers before connect() so you do not miss handshake-time notifications.

What is the difference between tool input and tool result in MCP Apps?

Tool input is the argument object the model generated for the tool call. It can arrive as streaming partial input first, then complete input. Tool result is the MCP tool response after the server-side tool finishes. The result usually contains content for model context and structuredContent for the app UI.

When should I use ontoolinputpartial?

Use ontoolinputpartial for preview-only UI while the host is still generating tool arguments. Partial input is healed JSON, so it is syntactically valid but may contain truncated values. Do not use partial input for writes, irreversible actions, billing, auth decisions, or anything that requires complete validated arguments.

How does an MCP App call another server tool from the iframe?

An MCP App calls another server tool through the host bridge with callServerTool, or through a framework hook such as sunpeak useCallServerTool. The host proxies the request back to the originating MCP server, which keeps the iframe sandboxed and avoids direct access to your server credentials.

How do I detect host capabilities in an MCP App?

After connection, call getHostCapabilities() on the App instance or use a framework hook that wraps host capabilities. Check for support before calling optional host features such as server tool calls, downloads, external link opening, display mode changes, sampling, or message sending.

What should happen during MCP App teardown?

During teardown, stop timers, close sockets, abort in-flight fetches, save any state the host needs, and return an empty result when cleanup finishes. Teardown is your last chance to prevent background work from continuing after the host removes the iframe.

How do I test the MCP App lifecycle?

Test lifecycle behavior at three levels: unit test hooks and event adapters, render simulation files in a local inspector for loading, success, error, and cancelled states, and add E2E tests that click UI controls which call server tools or request display mode changes. Run those tests in CI so host bridge regressions are caught before deployment.