Testing MCP App Data Flow: content, structuredContent, _meta, and Host Bridge State (August 2026)

Testing content, structuredContent, _meta, and host bridge state in MCP Apps.
Every MCP App has several data contracts, not one. The model creates tool input. The MCP server returns a tool result. The host sends input and result notifications to a sandboxed app. The app can call more server tools, react to host context, and share selected state back with the model.
A test that only checks whether a React component renders misses most of that path. It can pass while the server returns the wrong schema, an internal cursor reaches model context, a partial input triggers a write, or an app-only button calls the wrong tool.
TL;DR: Test every boundary separately. Validate inputSchema and outputSchema at the MCP layer. Keep content useful and compact. Treat structuredContent as model-visible unless a host contract explicitly says otherwise. Use _meta for app-only hints, but never as a secret store. Test partial input, cancellation, host context, app-initiated tool calls, and updateModelContext. Run the same rendered scenarios in ChatGPT and Claude host replicas before you ship.
The MCP App Data Flow
The MCP Apps overview defines a tool linked to a ui:// resource. The host can preload the resource, initialize its bridge, send streaming and complete tool input, deliver the tool result, and accept requests from the app.
That creates these boundaries:
| Boundary | Producer | Consumer | What to test |
|---|---|---|---|
| Tool definition | MCP server | Host and model | Input schema, output schema, UI resource link, visibility |
| Tool input | Model and host | Server and app | Partial versus complete input, validation, cancellation |
content | Server tool | Host and model | Accurate summary, supported content blocks, no internal data |
structuredContent | Server tool | App and possibly model | Schema, size, stable public fields, error shape |
_meta | Server tool | Host and app | App-only hints, opaque handles, no credentials |
| Host context | Host | App | Theme, locale, viewport, safe area, display mode |
| App Context | App | Host and model | Minimal state, overwrite behavior, follow-up prompts |
| Server tool call | App | Host and MCP server | Capability checks, arguments, result, error handling |
Tests should mirror this table. When one test tries to prove every boundary, a failure only says that the page broke. Smaller contract tests tell you which producer or consumer changed.
Start With the Tool Contract
MCP tools can declare both inputSchema and outputSchema. The current MCP specification work uses JSON Schema 2020-12. The 2026-07-28 release candidate also permits unrestricted output schemas and any JSON value in structuredContent.
Host support will not update at the same speed. For a portable MCP App today, an object-shaped structuredContent result remains the least surprising choice. Keep your schema explicit and add newer root shapes only after testing each supported host.
const outputSchema = {
type: 'object',
properties: {
period: { type: 'string' },
invoices: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
customer: { type: 'string' },
total: { type: 'number' },
status: { enum: ['open', 'paid', 'overdue'] },
},
required: ['id', 'customer', 'total', 'status'],
additionalProperties: false,
},
},
},
required: ['period', 'invoices'],
additionalProperties: false,
} as const;
additionalProperties: false makes accidental field spread visible. If someone changes invoices.map(toPublicInvoice) to invoices.map((invoice) => ({ ...invoice })), validation catches database IDs and provider fields before they reach a host.
Split the Tool Result by Reader
A useful result gives each reader only what it needs:
return {
content: [
{
type: 'text',
text: 'Displayed 12 invoices for April 2026: 8 paid, 3 open, and 1 overdue.',
},
],
structuredContent: {
period: '2026-04',
invoices: invoices.map(toPublicInvoice),
},
_meta: {
nextCursor: page.nextCursor,
viewRevision: page.revision,
},
};
content is not limited to text. MCP supports text, image, audio, resource link, and embedded resource blocks. For many UI tools, a short text summary is still the best model-facing result because it tells the model what the app displayed without repeating every row.
structuredContent should contain the public data needed to render and reason about the result. Do not assume that it is private just because the iframe consumes it. Hosts can use structured results in model context, and the data may appear in traces or logs.
_meta is for values that help the app but should not guide model reasoning. Cursors, view revisions, and opaque UI handles fit. API keys, OAuth refresh tokens, cookies, and private conversation data do not. The host transports _meta, so it is an organizational boundary, not an access-control system.
Test the Protocol Result Before the UI
sunpeak’s MCP fixture calls the real MCP server. It works with TypeScript, Python, Go, Rust, and other MCP server implementations because the test runs at the protocol boundary.
import { test, expect } from 'sunpeak/test';
test('list-invoices returns the public contract', async ({ mcp }) => {
const result = await mcp.callTool('list-invoices', {
period: '2026-04',
});
expect(result.isError).toBeFalsy();
expect(result.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'text',
text: expect.stringContaining('April 2026'),
}),
])
);
expect(result.structuredContent).toMatchObject({
period: '2026-04',
invoices: expect.any(Array),
});
});
Add error cases too. A server error should set isError: true, put a useful explanation in content, and avoid returning a success-shaped structuredContent object that the app could mistake for valid data.
Add a Leak Sentinel
A sentinel test proves that internal data did not drift into model-facing lanes. Use a fake, searchable value, never a real credential.
const UI_ONLY_SENTINEL = 'UI_ONLY_SENTINEL_8f31';
test('ui metadata stays out of model-facing output', async () => {
const result = await listInvoices({
period: '2026-04',
testCursor: UI_ONLY_SENTINEL,
});
expect(result._meta?.nextCursor).toBe(UI_ONLY_SENTINEL);
const modelFacing = JSON.stringify({
content: result.content,
structuredContent: result.structuredContent,
});
expect(modelFacing).not.toContain(UI_ONLY_SENTINEL);
expect(modelFacing).not.toMatch(/refreshToken|sessionCookie|internalAccountId/i);
});
Run this close to the handler, where you can inspect all result fields. Then add an eval or live-host smoke test with the same harmless sentinel. That second check verifies host behavior instead of only your server code.
Use Simulation Files for Repeatable UI States
Protocol tests prove what the server returns. Simulation files prove what the app does with each result. A sunpeak simulation can define tool input, the initial tool result, host context, and responses to tools called by the app.
{
"tool": "list-invoices",
"toolInput": { "period": "2026-04" },
"toolResult": {
"content": [{ "type": "text", "text": "Displayed 2 invoices for April 2026." }],
"structuredContent": {
"period": "2026-04",
"invoices": [
{ "id": "inv_001", "customer": "Acme Co", "total": 1200, "status": "paid" },
{ "id": "inv_002", "customer": "Northwind", "total": 840, "status": "open" }
]
},
"_meta": { "nextCursor": "page_2", "viewRevision": "rev_7" }
},
"serverTools": {
"load-more-invoices": {
"content": [{ "type": "text", "text": "Loaded 2 more invoices." }],
"structuredContent": {
"invoices": [
{ "id": "inv_003", "customer": "Contoso", "total": 460, "status": "paid" },
{ "id": "inv_004", "customer": "Globex", "total": 215, "status": "overdue" }
]
}
}
}
}
Save the file as tests/simulations/list-invoices.json. Calling inspector.renderTool('list-invoices') without an input loads that fixture. Passing an input object calls the real server instead, which is useful for a separate integration path.
import { test, expect } from 'sunpeak/test';
test('invoice app renders and loads the next page', async ({ inspector }) => {
const result = await inspector.renderTool('list-invoices');
const app = result.app();
await expect(app.getByRole('heading', { name: 'April 2026 invoices' })).toBeVisible();
await expect(app.getByText('Acme Co')).toBeVisible();
await expect(app.getByText('$1,200')).toBeVisible();
await app.getByRole('button', { name: 'Load more' }).click();
await expect(app.getByText('Contoso')).toBeVisible();
});
sunpeak runs E2E specs in both ChatGPT and Claude host projects. That catches differences in iframe timing, bridge capabilities, theme variables, and display behavior without requiring a paid account for either host.
Test Input as a State Machine
The iframe can exist before the server finishes. The MCP Apps SDK exposes complete input, streaming partial input, result, and cancellation events. The App class reference says to register handlers before connect() so handshake-time notifications are not missed.
app.addEventListener('toolinputpartial', ({ arguments: partial }) => {
showPreview(partial);
});
app.addEventListener('toolinput', ({ arguments: input }) => {
showLoading(input);
});
app.addEventListener('toolresult', (result) => {
showResult(result);
});
app.addEventListener('toolcancelled', ({ reason }) => {
showCancelled(reason);
});
await app.connect();
Partial input is healed JSON. A host may close an unfinished object or array so the app receives valid JSON, but the final string, item, or field may still be missing. Use it to draw a preview. Do not authorize a request, send a payment, save a record, or call another tool until complete validated input arrives.
For React apps built with sunpeak, useToolData wraps these events and exposes inputPartial, input, output, isLoading, isError, isCancelled, and cancelReason. Unit test each transition, then use E2E tests for the states users can see.
Separate App Context From UI State
An MCP App can call updateModelContext when an interaction changes what the model should know next. For example, selecting an invoice can share a public invoice ID and status:
const updateModelContext = useUpdateModelContext();
async function selectInvoice(invoice: Invoice) {
setSelectedId(invoice.id);
await updateModelContext({
structuredContent: {
selectedInvoice: {
id: invoice.id,
status: invoice.status,
},
},
});
}
That state is meant for the model, so keep it small and intentional. Hover state, panel width, cache contents, cursors, and temporary form controls should stay in local React state.
sunpeak’s useAppState provides React-style state and automatically sends the next state through updateModelContext. Use it when the whole state value belongs in model context. Use useUpdateModelContext directly when you need to send a narrower projection.
Follow-up evals should seed the same App Context and ask a prompt that depends on it:
{
name: 'follows up on the selected invoice',
prompt: 'Open the selected invoice',
appContext: {
structuredContent: {
selectedInvoice: { id: 'inv_002', status: 'open' }
}
},
expect: {
tool: 'get-invoice',
args: { invoiceId: 'inv_002' }
}
}
This tests whether the model can use state shared by the app instead of asking the user to select the item again.
Test App-Initiated Tool Calls
Buttons inside the iframe can call MCP server tools through callServerTool. The MCP Apps SDK patterns recommend app-only tools for operations such as paging, polling, validation, and confirmed actions. Mark them with _meta.ui.visibility: ['app'] when the model should not call them directly.
const loadMore = useCallServerTool();
const result = await loadMore({
name: 'load-more-invoices',
arguments: { cursor: 'page_2' },
});
if (result?.isError) {
showError(result.content);
}
Test the capability check, exact arguments, loading state, success result, protocol error, and transport error. In a simulation, serverTools can return one fixed result or conditional results selected by the arguments. That keeps button tests deterministic and avoids real writes.
Host Context Is a Different Lane
Theme, locale, viewport, display mode, safe-area insets, and supported display modes come from the host. They are presentation inputs, not business data.
Test at least:
- Light and dark themes
- Inline and fullscreen modes when supported
- Narrow and wide viewports
- Safe-area padding
- Locale-sensitive formatting
- Missing optional capabilities
- Host context changes after initial render
Do not copy account data or tool results into host context mocks. Keeping host context narrow makes a failed test point to layout or bridge behavior instead of application data.
A Practical Test Matrix
You do not need every value in every test. Cover each boundary at the cheapest useful level:
| Test level | Best use | Example failure caught |
|---|---|---|
| Schema test | Tool definitions and result validation | Internal field added to structuredContent |
| Handler unit test | content, _meta, and sentinel separation | Cursor copied into model-facing text |
| MCP integration test | Real transport and server registration | Wrong tool name or serialized result |
| Hook or component test | Input/result state transitions | Cancellation rendered as success |
| Simulation E2E test | Host bridge and user interaction | Button calls the wrong server tool |
| Cross-host E2E test | ChatGPT and Claude runtime differences | One host misses the first result notification |
| Eval | Model tool choice and App Context | Follow-up prompt ignores selected item |
| Live-host smoke test | Production host behavior | Capability or sandbox policy drift |
Run schema, protocol, unit, simulation, and cross-host tests in CI. Keep live-host tests smaller because they use real accounts and can vary with host availability.
Data-Flow Review Checklist
Before shipping an MCP App, verify:
- Tool input and output schemas reject unknown internal fields.
contenttells the model what the UI showed without copying the full payload.structuredContentcontains only public render and reasoning data._metacontains no credentials and is never treated as an authorization boundary.- A harmless sentinel cannot reach model-facing results or App Context.
- Partial input cannot trigger writes or app-initiated tool calls.
- Error and cancellation states do not reuse stale success data.
updateModelContextsends only facts needed for later turns.- App-only tools are hidden from the model and tested through
serverToolsmocks. - Theme, display mode, locale, viewport, and safe area have rendered coverage.
- The same E2E spec passes in every supported host runtime.
- Follow-up evals can act on state the app shared with the model.
The main goal is traceability. For any value on screen or in model context, you should be able to name where it came from, which boundary moved it, and which test fails when that contract changes. sunpeak’s testing framework gives each of those boundaries a local, repeatable test path, so data-flow bugs can fail in CI instead of after a manual refresh in a production host.
Get Started
npx sunpeak newFurther Reading
- MCP App tool results - content, structuredContent, and _meta
- MCP App outputSchema and structuredContent
- MCP App model context and UI state
- MCP App lifecycle - input, results, cancellation, and teardown
- Testing multi-tool MCP Apps
- MCP App error handling
- sunpeak MCP App testing framework
- sunpeak MCP App inspector
- MCP Apps overview - Model Context Protocol
- MCP Apps SDK App class reference
- MCP Apps SDK patterns
- MCP 2026-07-28 release candidate
Frequently Asked Questions
What is the difference between content, structuredContent, and _meta in an MCP App?
content is the standard MCP result lane for text, images, audio, resource links, and embedded resources. Hosts use it as model-facing result context. structuredContent carries JSON that an MCP App can render and that hosts may also include in model context. _meta carries app-specific result metadata, but it is host-mediated and is not a security boundary. Test each lane independently.
How do I test structuredContent in an MCP App?
Declare an outputSchema, call the tool through the MCP protocol, and assert that structuredContent matches the schema and contains no internal fields. Then render the same scenario from a simulation file and assert the UI. This tests both the server contract and the resource that consumes it.
Should secrets go in structuredContent or _meta?
No. Do not put API keys, refresh tokens, session cookies, or long-lived credentials in any tool result. _meta is useful for app-only hints and opaque handles, but the host transports it, logs may capture it, and host behavior can change. If an app needs authorization, use the host and MCP authorization flow instead of passing secrets through result data.
How do I test that _meta stays out of model-visible fields?
Use a harmless sentinel such as UI_ONLY_SENTINEL in a handler or simulation. Assert that the sentinel appears only in _meta and never in content, structuredContent, updateModelContext payloads, logs intended for the model, or eval App Context. A live-host smoke test can also ask the model to summarize what it received and verify the sentinel is absent.
How should I test partial tool input?
Render partial input as preview data only, then test the transition to complete input, success, error, and cancellation. The MCP Apps SDK describes partial arguments as healed JSON, so the value can be syntactically valid while still incomplete. Never use partial input to authorize, charge, save, or trigger another write.
What is the difference between app state and private UI state?
App state sent with updateModelContext is model-visible context for a later turn, so it should contain only facts the model needs, such as a selected public item ID or filter. Private UI state stays inside the component and includes DOM measurements, cache entries, cursors, draft control state, and tokens.
Can I test MCP App host bridge state in CI?
Yes. sunpeak simulation files define deterministic tool input, tool results, app-initiated server tool responses, and host context. Its Playwright fixtures render those scenarios in local ChatGPT and Claude runtime replicas, so the same E2E test can run across hosts without paid host accounts or model credits.
What data-flow tests should every MCP App have?
At minimum, test tool input and output schemas, content and structuredContent separation, _meta leak sentinels, loading and cancellation states, app-initiated server tool calls, updateModelContext payloads, host context changes, and the rendered UI in every supported host. Add evals for follow-up prompts that depend on App Context.