MCP App Model Context: What the AI Can See After UI Clicks

MCP App model context is the narrow channel between what the user does in the iframe and what the AI can reason about later.
The biggest search gap around MCP Apps right now is not “how do I render an iframe.” Most developers can find that. The harder question comes after the UI works:
What does the AI model know after the user clicks something inside the app?
That question shows up under many searches: MCP App model context, ChatGPT App updateModelContext, structuredContent vs app state, sendMessage vs callTool, whether _meta is visible to the model, and why a selected row in the UI does not affect the next answer.
TL;DR: The model does not inspect the iframe DOM. It sees the data you intentionally put in model-readable lanes: content, selected structuredContent, app state, updateModelContext, and messages sent through the host. Keep private UI state in React state or _meta, keep durable records on the server, and test that model-visible context contains only compact facts the model needs.
The Mental Model
An MCP App has three readers:
| Reader | What it can read | Good use |
|---|---|---|
| The model | content, selected structuredContent, app state, messages, context updates | Reasoning and next-turn answers |
| The resource iframe | structuredContent, _meta, host context, app state, local component state | Rendering UI and handling user interactions |
| Your server | Tool inputs, authenticated session, database records, backend state | Durable work and security decisions |
Do not blur those lanes. The model does not need your pagination cursor. The iframe should not hold the only copy of a submitted order. Your server should not trust a model-visible selection without validating it.
The MCP Apps overview describes the core pattern: a tool returns data, the host renders a resource, and the iframe talks to the host through a bridge. OpenAI’s Apps SDK reference maps the same idea into ChatGPT’s app bridge and compatibility APIs. The exact host names differ, but the design problem is the same: decide which state becomes model context.
What the Model Sees by Default
Start with the tool result.
return {
content: [
{
type: 'text',
text: 'Displayed 12 unpaid invoices for April 2026.',
},
],
structuredContent: {
period: '2026-04',
invoices: [
{ id: 'INV-101', customer: 'Acme', totalCents: 42000, status: 'unpaid' },
],
},
_meta: {
nextCursor: 'cursor_abc',
internalAccountId: 'acct_789',
},
};
The model-readable part should be short and useful. content tells the model what happened. structuredContent gives the UI typed data and can also be part of model context depending on the host and app design. _meta is for the resource, not for the model.
That split gives you a clean answer to most data questions:
| Data | Put it in |
|---|---|
| “Displayed 12 invoices” | content |
| Invoice rows the UI renders | structuredContent |
| Pagination cursor | _meta |
| Selected invoice IDs after user clicks | app state or updateModelContext |
| Final saved decision | server tool and database |
If you copy everything into structuredContent, the model sees too much. If you hide everything in _meta, the model cannot answer useful follow-ups. If you leave user selections in React state only, the next assistant turn will not know what the user selected.
UI State Is Not Model Context
A normal React component can keep a selected row in useState:
import { useState } from 'react';
import { useToolData } from 'sunpeak';
export function InvoiceTable() {
const { output } = useToolData<unknown, { invoices: Array<{ id: string; totalCents: number }> }>();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// This updates the UI only. The model does not know selectedIds changed.
}
That is fine for open menus, hover state, local sorting, or a temporary draft while the user is still typing. It is wrong when the user expects the assistant to use the selection later.
For model-visible state, use the MCP App host bridge. In sunpeak, the simplest path is useAppState, which syncs the state through the host:
import { useAppState, useToolData } from 'sunpeak';
type Invoice = {
id: string;
customer: string;
totalCents: number;
};
type InvoiceState = {
selectedIds: string[];
};
export function InvoiceTable() {
const { output } = useToolData<unknown, { invoices: Invoice[] }>();
const [state, setState] = useAppState<InvoiceState>({ selectedIds: [] });
function toggleInvoice(id: string) {
setState((previous) => {
const selected = new Set(previous.selectedIds);
if (selected.has(id)) selected.delete(id);
else selected.add(id);
return { selectedIds: Array.from(selected) };
});
}
return (
<ul>
{output?.invoices.map((invoice) => (
<li key={invoice.id}>
<label>
<input
type="checkbox"
checked={state.selectedIds.includes(invoice.id)}
onChange={() => toggleInvoice(invoice.id)}
/>
{invoice.customer}
</label>
</li>
))}
</ul>
);
}
Now a later user prompt like “summarize the selected invoices” has a path to the selected IDs. The app does not need to duplicate all invoice rows into app state, because the selected IDs are enough to connect the user’s choice to the tool result or the server.
updateModelContext Is for Quiet State
Use updateModelContext when the model should know about a UI change but the app should not send a visible chat message yet.
Good examples:
- The user selected three rows.
- The user changed a date range.
- The user reached step 4 of a wizard.
- The user approved one item and rejected another.
import { useUpdateModelContext } from 'sunpeak';
export function DateRangeControl() {
const updateModelContext = useUpdateModelContext();
async function commitRange(range: { start: string; end: string }) {
await updateModelContext({
structuredContent: {
selectedDateRange: range,
},
});
}
return null;
}
The important word is “commit.” Do not send every keystroke if the model only needs the final date range. Keep local typing state in React state, then update model context when the user applies the filter, leaves the step, or clicks a clear action.
Also send the full compact context you want the model to have. If the user changes from one date range to another, the model should see the current range, not a stream of historical edits unless history matters.
sendMessage Is for User Intent
sendMessage is different. It adds a user-role message to the conversation, so the assistant can respond.
Use it when a button means “ask the assistant to do something with this state.”
import { useAppState, useSendMessage, useToolData } from 'sunpeak';
type ReportState = {
selectedIds: string[];
};
export function ExplainSelectionButton() {
const [state] = useAppState<ReportState>({ selectedIds: [] });
const sendMessage = useSendMessage();
async function explain() {
await sendMessage({
message: `Explain the selected records: ${state.selectedIds.join(', ')}`,
});
}
return (
<button type="button" disabled={state.selectedIds.length === 0} onClick={explain}>
Explain selected
</button>
);
}
That message should be short and explicit. Do not paste full hidden payloads into it. If the model needs a durable record or fresh server data, send IDs and call a server tool.
A good split is:
| User action | Better path |
|---|---|
| Check a row | useAppState |
| Apply a filter | updateModelContext |
| Load next page | app-only server tool |
| Ask for an explanation | sendMessage |
| Save a decision | server tool, then update context with the result |
Do Not Use _meta as a Secret Vault
_meta is UI-only, but that does not make it a place for secrets. The rendered resource can read it. Browser devtools, logs, crash reports, or telemetry could expose it if you are careless.
Use _meta for:
- pagination cursors
- render hints
- short-lived signed asset references
- support IDs
- internal row IDs the UI needs but the model should not reason about
Do not use _meta for:
- API keys
- OAuth refresh tokens
- session cookies
- private conversation transcripts
- raw upstream payloads with fields the UI does not need
If a value is required for a privileged action, keep it on the server and expose an app-only tool. The iframe can ask the host to call that tool, and the server can validate auth, rate limits, ownership, and idempotency before doing the work.
A Concrete Approval Flow
Approval flows are where model context bugs become visible.
The tool renders a review UI:
return {
content: [{ type: 'text', text: 'Displayed 4 deployment changes for review.' }],
structuredContent: {
changes: [
{ id: 'chg_1', file: 'api/routes.ts', risk: 'medium' },
{ id: 'chg_2', file: 'billing/checks.ts', risk: 'high' },
],
},
_meta: {
reviewId: 'rev_123',
},
};
The UI lets the user approve individual changes:
import { useAppState, useCallServerTool, useToolData } from 'sunpeak';
type Change = {
id: string;
file: string;
risk: 'low' | 'medium' | 'high';
};
type ReviewState = {
approvedIds: string[];
rejectedIds: string[];
};
export function ReviewResource() {
const { output } = useToolData<unknown, { changes: Change[] }>();
const [state, setState] = useAppState<ReviewState>({ approvedIds: [], rejectedIds: [] });
const callServerTool = useCallServerTool();
async function submit() {
const result = await callServerTool({
name: 'submit_review_decision',
arguments: {
approvedIds: state.approvedIds,
rejectedIds: state.rejectedIds,
},
});
if (!result.isError) {
setState({ approvedIds: [], rejectedIds: [] });
}
}
return null;
}
The model-visible state is compact: approved IDs and rejected IDs. The server receives those IDs and validates that they belong to the current authenticated review. The model does not need the internal review token. The UI does not own the final write.
That is the pattern to repeat: model context captures user intent, server tools perform durable work, and _meta helps the iframe render without leaking private details into the model.
How to Test Model Context
Test this at the data lane where bugs happen.
First, write a protocol test for the initial tool result:
import { expect, test } from 'sunpeak/test';
test('invoice result keeps private data out of model-visible lanes', async ({ mcp }) => {
const result = await mcp.callTool('show_invoices', { period: '2026-04' });
expect(result.content?.[0]?.text).toContain('Displayed');
expect(result.structuredContent).toEqual(
expect.objectContaining({
period: '2026-04',
invoices: expect.any(Array),
}),
);
const modelVisible = JSON.stringify({
content: result.content,
structuredContent: result.structuredContent,
});
expect(modelVisible).not.toContain('internalAccountId');
expect(JSON.stringify(result._meta)).toContain('nextCursor');
});
Then test the UI state path:
import { expect, test } from 'sunpeak/test';
test('selected invoices become compact model context', async ({ inspector }) => {
const result = await inspector.renderTool('show_invoices', { period: '2026-04' });
const app = result.app();
await app.getByRole('checkbox', { name: /Acme/ }).check();
await app.getByRole('checkbox', { name: /Globex/ }).check();
await expect(result.modelContext()).resolves.toEqual(
expect.objectContaining({
selectedIds: expect.arrayContaining(['INV-101', 'INV-102']),
}),
);
});
The exact assertion helper depends on your test harness, but the behavior should be the same: click the real UI, then inspect the model-visible state. Do not stop at DOM assertions. A checked checkbox can look right while the model context is still empty.
Add negative tests too. Put a harmless sentinel value in _meta, click through the app, and assert that the sentinel never appears in content, structuredContent, model context updates, or sent messages.
The Checklist
Use this checklist when you build or review an MCP App:
contentis short, factual, and useful for the model.structuredContentcontains typed render data, not raw upstream payloads._metacontains UI-only metadata and no secrets.- React state holds only state the model does not need.
useAppStateorupdateModelContextholds compact user decisions and selections.sendMessageis used only when a user action should continue the conversation.- App-only tools handle UI-driven backend work.
- Server tools validate every ID or decision before writing.
- Tests prove private sentinel values never enter model-visible lanes.
With sunpeak, you can test this loop locally in the MCP App inspector: render the tool, click the real resource, inspect app state, toggle host context, and run the same cases in CI. That is the fastest way to catch the bug where the UI looks right but the assistant still cannot reason about what the user did.
Get Started
npx sunpeak newFurther Reading
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- Interactive MCP Apps with useAppState - two-way UI state patterns
- MCP App state persistence - useAppState, widgetState, localStorage, and databases
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- MCP App tool results - content, structuredContent, and _meta
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak docs - updateModelContext
- sunpeak docs - sendMessage
- MCP Apps overview - official Model Context Protocol docs
- OpenAI Apps SDK reference - ChatGPT app bridge and compatibility APIs
Frequently Asked Questions
Can an AI model see everything inside an MCP App iframe?
No. The model does not read arbitrary DOM state from the iframe. It sees the MCP tool result, selected structuredContent, model-visible app state, and messages or context updates the app sends through the host bridge. If a click, filter, form field, or selection should affect a later answer, sync a compact version of that state through app state or updateModelContext.
What is model context in an MCP App?
Model context is the information the host can provide to the AI model after a tool call or app interaction. In MCP Apps, it usually comes from content, structuredContent, selected app state, updateModelContext requests, and user-role messages sent through the host. It is separate from private UI state and tool result _meta.
Should I use structuredContent or updateModelContext for user selections?
Use structuredContent for the data returned by a tool, such as rows, totals, and record summaries. Use updateModelContext or a framework hook such as useAppState for user-selected state after the resource renders, such as selected IDs, approved items, filters, or the current workflow step.
What should go in content for an MCP App tool result?
Put a short, model-readable summary in content. It should tell the model what the tool displayed or did, without duplicating the whole UI payload. For example, "Displayed 12 unpaid invoices for April 2026." Put render data in structuredContent and private UI-only details in _meta.
Is _meta visible to the model in MCP Apps?
_meta is intended for UI-only metadata, not model reasoning. Use it for cursors, internal IDs, render hints, support IDs, or short-lived UI references that the resource needs but the model should not use. Do not put secrets in _meta either, because the iframe can still read it.
When should an MCP App use sendMessage?
Use sendMessage when a user action should continue the conversation and usually trigger an assistant reply, such as "explain these selected rows" or "draft a response from this review." If the user action should quietly update state without a reply, use updateModelContext or useAppState instead.
How do I test MCP App model context?
Write protocol tests for content, structuredContent, and _meta. Then render the resource in a local inspector, click the real controls, and assert that model-visible app state contains only the compact fields the model needs. Add negative tests that sentinel private values never appear in content, structuredContent, updateModelContext payloads, or sent messages.