MCP App Lifecycle: connect(), Tool Input, Results, 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:
| Phase | Who starts it | What happens | Common bug |
|---|---|---|---|
| Tool discovery | Host | Host calls tools/list and sees _meta.ui.resourceUri | Tool has no resource URI |
| Resource fetch | Host | Host reads the ui:// resource and loads HTML | Resource URI mismatch |
| Iframe render | Host | Host mounts sandboxed HTML | CSP blocks assets |
| Bridge connect | App | App calls connect() and performs ui/initialize | Handlers registered too late |
| Tool input | Host | Host sends final tool arguments | Component expects output too early |
| Partial input | Host | Host streams preview arguments | App treats partial data as final |
| Tool result | Host | Host sends server result or error | UI reads the wrong data lane |
| Cancellation | Host | Host tells the app the call stopped | App shows an error instead of a stopped state |
| Interactive requests | App | App calls server tools, opens links, updates model context, or requests display modes | App assumes every host supports every request |
| Context changes | Host | Theme, locale, viewport, and display mode change | UI reads context only once |
| Teardown | Host | Host asks app to clean up before unmount | Timers 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:
| Field | Primary use |
|---|---|
content | Text the model can read |
structuredContent | Data the UI renders |
isError | Error flag |
_meta | Protocol 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:
| Request | Use it for |
|---|---|
callServerTool | Pagination, refresh, submit actions, validation |
updateModelContext | Tell the model about user selections |
requestDisplayMode | Ask for fullscreen or picture-in-picture |
openLink | Open an external URL safely |
sendMessage | Add a message to the conversation |
downloadFile | Ask the host to download content |
sendLog | Send 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:
- Does
tools/listinclude the tool? - Does the tool metadata point at the right
ui://resource? - Can the host read that resource?
- Does CSP allow the scripts, styles, images, and API calls your UI needs?
- Does the iframe call
connect()? - Were handlers registered before
connect()? - Did the app receive input, partial input, result, or cancellation?
- Is the component reading
structuredContent, notcontent, for UI data? - Does the host support the request your button is making?
- Did host context change after mount?
- 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
npx sunpeak new
Further Reading
- MCP App error handling - loading, error, cancelled, and partial input states
- Testing MCP App data flow - content, structuredContent, _meta, and bridge state
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- Requesting display mode transitions in MCP Apps
- Interactive MCP Apps with useAppState
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - App class
- sunpeak docs - MCP App event handlers
- sunpeak docs - AppProvider
- MCP Apps overview - Model Context Protocol
- MCP Apps App class API reference
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.