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

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:
| Boundary | Current protocol | Transport | Initialization model |
|---|---|---|---|
| Host to MCP server | Core MCP 2026-07-28 for updated Hosts and servers | HTTP or another core transport | Stateless requests; no core initialize session |
| View to Host | MCP Apps 2026-01-26 | postMessage through the iframe sandbox | ui/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:
| 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 |
| Partial input | Host | Host sends zero or more preview argument updates | App treats partial data as final |
| Tool input | Host | Host sends complete arguments at most once, before any result | Component expects output too early |
| Tool result | Host | Host sends the completed server result | UI reads the wrong data lane |
| Cancellation | Host | Host sends a terminal cancellation instead of a result | 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 |
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:
- The View sends
ui/initializewith its app identity and capabilities. - The Host returns the negotiated Apps protocol version, Host identity, Host capabilities, and initial Host context.
- The View sends
ui/notifications/initialized. - 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:
| Event | Next state | Data rule |
|---|---|---|
| Connected, no input yet | waiting | Show a stable shell with non-zero height |
| Partial input | loading | Replace the preview, but do not commit actions |
| Complete input | loading | Store final arguments and clear partial input |
Result with isError: false | success | Render structuredContent, including an empty value |
Result with isError: true | error | Render a retry or recovery path |
| Cancellation | cancelled | Stop work for that invocation without treating the View as broken |
| Teardown | closing | Block 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:
| 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.
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:
- 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
connect()resolve before the app called a Host API? - Did the app receive partial input, complete input, a result, or cancellation?
- Is the component reading
structuredContent, notcontent, for UI data? - Did stale async work overwrite a newer lifecycle state?
- Does the Host support the request your button is making?
- Did Host context change after mount?
- 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
npx sunpeak newFurther Reading
- MCP App error handling - loading, error, cancelled, and partial input states
- MCP App host context - theme, locale, viewport, and safe areas
- 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 TypeScript SDK v2 migration for MCP Apps
- MCP App conformance testing across hosts
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - App class
- sunpeak docs - MCP App event handlers
- sunpeak docs - AppProvider
- sunpeak docs - MCP App lifecycle
- sunpeak docs - protocol versions and compatibility
- MCP Apps overview - Model Context Protocol
- MCP Apps App class API reference
- MCP 2026-07-28 specification release
- Claude MCP Apps troubleshooting
- Building cross-platform MCP Apps for Claude and ChatGPT
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.