Skip to main content
All posts

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

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkHost BridgeApp Class
The MCP App View lifecycle starts with Host initialization, then moves through tool input, tool results, bidirectional requests, and teardown.

The MCP App View 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 handlers before connecting. The Host may send zero or more partial inputs, then complete input before a result; cancellation can arrive instead. Treat those as explicit state transitions, feature-detect optional Host requests, and clean up during both protocol teardown and normal unmount. MCP 2026-07-28 removed the core Host-to-server handshake, but it did not remove the separate View-to-Host ui/initialize handshake. In sunpeak, AppProvider, useToolData, useCallServerTool, useHostContext, and useTeardown wrap the low-level bridge.

MCP Apps Have Two Separate Lifecycles

The phrase “MCP lifecycle” now needs a qualifier because an MCP App uses two protocol connections:

BoundaryCurrent protocolTransportInitialization model
Host to MCP serverCore MCP 2026-07-28 for updated Hosts and serversHTTP or another core transportStateless requests; no core initialize session
View to HostMCP Apps 2026-01-26postMessage through the iframe sandboxui/initialize, response, then ui/notifications/initialized

The MCP 2026-07-28 release removed the core initialize/initialized exchange and Mcp-Session-Id. It did not remove App.connect(). The Apps handshake is an independent protocol between the View and Host, so it still negotiates Host capabilities, View capabilities, Host context, and the Apps protocol version.

As of August 2026, sunpeak 0.20.x uses core MCP 2025-11-25 through MCP SDK v1, so its Host-to-server connection still performs the older core handshake. Its View bridge uses the stable Apps 2026-01-26 protocol. sunpeak’s --stateless option creates a fresh v1 server for each HTTP request, but it does not enable the 2026-07-28 wire protocol. The protocol compatibility guide tracks both boundaries.

This distinction fixes two common wrong assumptions:

  • One iframe does not equal one core MCP transport session.
  • A stateless MCP server does not make the View bridge stateless.

If an app needs state across server calls, return an explicit identifier such as draftId or cursor and pass it to later tools. Do not hide app state in a core transport session. Inside the iframe, keep UI state local or persist it through a supported Host API, and assume the Host can remount the View.

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
Partial inputHostHost sends zero or more preview argument updatesApp treats partial data as final
Tool inputHostHost sends complete arguments at most once, before any resultComponent expects output too early
Tool resultHostHost sends the completed server resultUI reads the wrong data lane
CancellationHostHost sends a terminal cancellation instead of a resultApp 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

The stable MCP Apps specification gives this ordering more precision. A Host must wait for ui/notifications/initialized before sending requests or notifications to the View. It may send partial input zero or more times, must send complete input at most once, and must send complete input before tool-result. A displayed View then receives either a result or a cancellation for the invocation.

You do not need to write every step by hand. A framework can wrap the bridge. You still need to know the lifecycle because it defines which data is available and which transitions your UI must tolerate.

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.

What the Handshake Guarantees

The Host cannot send lifecycle events just because the iframe exists. The View must finish this sequence first:

  1. The View sends ui/initialize with its app identity and capabilities.
  2. The Host returns the negotiated Apps protocol version, Host identity, Host capabilities, and initial Host context.
  3. The View sends ui/notifications/initialized.
  4. The Host can now send tool data, context changes, ping requests, or teardown.

Do not call getHostCapabilities() or a Host-mediated method before connect() resolves. After it resolves, capabilities are still feature gates, not a promise that every Host supports the same behavior.

The Apps specification also permits teardown at any point after initialization. A View can therefore receive teardown while it is waiting for input, while the server tool is running, or after the user has interacted with the result.

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.

Model Lifecycle Events as State Transitions

Avoid deriving lifecycle state from truthy data checks. An empty result can be valid, and cancellation is not an error. Use explicit states or a hook that keeps them for you:

EventNext stateData rule
Connected, no input yetwaitingShow a stable shell with non-zero height
Partial inputloadingReplace the preview, but do not commit actions
Complete inputloadingStore final arguments and clear partial input
Result with isError: falsesuccessRender structuredContent, including an empty value
Result with isError: trueerrorRender a retry or recovery path
CancellationcancelledStop work for that invocation without treating the View as broken
TeardownclosingBlock new work and release resources

Cancellation ends the current tool invocation. It does not prove the iframe has unmounted, so keep the cancelled UI stable until a new supported action or teardown occurs. Likewise, a result does not end the View. Buttons, pagination, display mode requests, and context updates can continue after the initial server tool completes.

Async work started by a lifecycle callback can finish out of order. Guard long preview or rendering work with a monotonically increasing revision:

let revision = 0;

app.ontoolinputpartial = async ({ arguments: partial }) => {
  const current = ++revision;
  const preview = await buildPreview(partial);

  if (current === revision) renderPreview(preview);
};

app.ontoolinput = ({ arguments: input }) => {
  revision += 1; // Invalidates any slower partial-input work.
  renderFinalInput(input);
};

This prevents a slow preview from overwriting the complete input that arrived later.

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.

callServerTool() returns that helper tool’s result to the calling code. It does not replace the original useToolData().output automatically, so read the returned structuredContent and update local UI state when a refresh, pagination, or form action succeeds. Keep a request revision or disable the control while a call is pending so an older response cannot overwrite newer user input.

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. The request can arrive any time after initialization, so stop accepting new work as soon as teardown begins.

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.

Use two cleanup layers:

  • Protocol teardown for async work the Host should wait for, such as saving a draft or closing a server-side lease.
  • Normal component cleanup for timers, observers, listeners, sockets, and abort controllers.

A crashed renderer, closed process, or lost connection cannot guarantee a graceful ui/resource-teardown round trip. Cleanup that only runs in onteardown can therefore leak while the page remains alive. Keep teardown callbacks short, idempotent, and safe to run after normal cleanup already started.

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
  • A slow partial-input render cannot overwrite complete input
  • Successful result renders structuredContent
  • Error result shows an error state
  • Cancellation shows a stopped state
  • Cancellation does not trigger result-only side effects
  • App-only button calls the expected server tool
  • Two rapid app-only calls ignore an older response that finishes last
  • Display mode button is hidden when unsupported
  • Host context changes update layout or theme
  • Teardown during loading stops polling or aborts in-flight work
  • Normal unmount performs synchronous cleanup even without protocol teardown

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. Add a second test that delays two mocked results in reverse order if the UI permits repeated refresh or search actions.

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 connect() resolve before the app called a Host API?
  8. Did the app receive partial input, complete input, a result, or cancellation?
  9. Is the component reading structuredContent, not content, for UI data?
  10. Did stale async work overwrite a newer lifecycle state?
  11. Does the Host support the request your button is making?
  12. Did Host context change after mount?
  13. Did protocol teardown and normal unmount 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: discovery, resource fetch, sandboxed rendering, the App.connect() handshake, complete or partial tool input, a result or cancellation, interactive View-to-Host requests, context updates, and teardown. This View lifecycle is separate from the core MCP connection between the Host and server.

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.

Did MCP 2026-07-28 remove App.connect() and ui/initialize?

No. MCP 2026-07-28 removed the core Host-to-server initialize handshake and protocol session. An MCP App View still uses the separate 2026-01-26 Apps protocol with ui/initialize and ui/notifications/initialized over postMessage. App.connect() handles that View-to-Host handshake.

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.

What should happen during MCP App teardown?

During teardown, stop timers, close sockets, abort in-flight fetches, remove global listeners, and save only state that must persist. Return an empty result when async cleanup finishes. Also keep normal component or page cleanup because a browser process or host crash cannot guarantee a graceful teardown request.

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.