MCP App Tool Results: content, structuredContent, and _meta
MCP App tool results carry separate data lanes for the model, the rendered app, and UI-only metadata.
Most MCP App rendering bugs are data-lane bugs.
The tool returns a result. The host sends that result to the conversation, the model, and the sandboxed app resource. The fields look close enough that many first builds put everything in one place, usually structuredContent, then run into one of these problems:
- The app renders, but the model has no useful summary for the next turn.
- The model sees internal IDs or cursors that were meant only for the UI.
window.openai.toolOutputisnullbecause the tool result was shaped like an older example.- A non-app MCP client cannot display a useful result because the response only works inside an iframe.
- A large result burns context tokens because the whole UI payload is model-visible.
The fix is to treat content, structuredContent, and _meta as separate contracts.
TL;DR: Put a concise model-readable summary in content. Put typed render data in structuredContent. Put UI-only metadata in _meta when your target host supports it. Declare outputSchema for structured results, keep structuredContent safe for model context, and test each lane before debugging React.
Why This Is the Biggest Search Gap
Developers searching for MCP Apps, ChatGPT Apps, and Claude Connectors quickly find setup guides for ui:// resources and _meta.ui.resourceUri. The next failure is harder to search for because it has many names:
structuredContentnot reaching the widget_metamissing fromui/notifications/tool-resultcontentversusstructuredContentwindow.openai.toolOutputversustoolResponseMetadata- MCP App result not visible to the model
- ChatGPT App iframe renders empty
Those are all tool-result questions. They sit below framework choice and above UI debugging, which means they block real builds.
The Three Result Fields
The MCP tools specification defines tool results as content plus optional structured content. MCP Apps add a rendered resource on top of that result, but the base fields still matter.
| Field | Main reader | Use it for | Avoid |
|---|---|---|---|
content | Model, user, non-app clients | Short text summary, status, citations, compact fallback | Full UI payloads, hidden IDs, private UI state |
structuredContent | App resource, often model context | Typed JSON rows, cards, charts, form state, public result data | Secrets, huge hidden payloads, UI-only hints |
_meta | App resource when supported | Cursors, cache keys, view hints, non-secret internal UI data | Anything required for the answer, long-lived secrets |
The split is simple in principle. content explains the result. structuredContent carries the data. _meta helps the UI operate without making every helper value part of the model context.
A Good Tool Result Shape
Here is a result shape for an invoice viewer:
return {
content: [
{
type: 'text',
text: 'Displayed 12 invoices for April 2026.',
},
],
structuredContent: {
period: '2026-04',
invoices: invoices.map((invoice) => ({
id: invoice.publicId,
customer: invoice.customerName,
totalCents: invoice.totalCents,
status: invoice.status,
dueDate: invoice.dueDate,
})),
},
_meta: {
nextCursor,
viewId,
},
};
This gives each reader what it needs.
The model can say what the app displayed. The resource can render a table from stable fields. The UI can keep a cursor for a Next button without asking the model to reason about that cursor.
Add an outputSchema
If a tool returns structuredContent, declare an outputSchema.
OpenAI’s Apps SDK reference says to declare outputSchema for tools that return structuredContent, and the MCP tools spec says structured results must conform when an output schema is provided.
In a sunpeak-style tool, the same idea usually means typing both the server result and the resource render path:
import { z } from 'zod';
const InvoiceSchema = z.object({
id: z.string(),
customer: z.string(),
totalCents: z.number(),
status: z.enum(['open', 'paid', 'overdue']),
dueDate: z.string(),
});
export const tool = {
name: 'list_invoices',
description: 'List invoices for a billing period.',
inputSchema: z.object({
period: z.string(),
}),
outputSchema: z.object({
period: z.string(),
invoices: z.array(InvoiceSchema),
}),
_meta: {
ui: { resourceUri: 'ui://invoices/app.html' },
},
};
Then the resource should read that same shape:
import { SafeArea, useToolData } from 'sunpeak';
type Invoice = {
id: string;
customer: string;
totalCents: number;
status: 'open' | 'paid' | 'overdue';
dueDate: string;
};
type InvoiceOutput = {
period: string;
invoices: Invoice[];
};
export function InvoiceResource() {
const { output, isLoading, isError } = useToolData<unknown, InvoiceOutput>();
if (isLoading) return <SafeArea>Loading invoices...</SafeArea>;
if (isError) return <SafeArea>Could not load invoices.</SafeArea>;
if (!output) return <SafeArea>No invoices returned.</SafeArea>;
return (
<SafeArea className="p-4">
<h1>Invoices for {output.period}</h1>
<ul>
{output.invoices.map((invoice) => (
<li key={invoice.id}>
{invoice.customer}: ${(invoice.totalCents / 100).toFixed(2)}
</li>
))}
</ul>
</SafeArea>
);
}
The exact helper names vary by framework, but the rule does not. The server and resource should agree on the structured output shape.
What content Should Say
content is not a trash can for the full UI payload. It should be the shortest useful answer a non-app client can show and the shortest useful context the model can use later.
Good content:
{
"type": "text",
"text": "Displayed 12 invoices for April 2026. Three are overdue."
}
Weak content:
{
"type": "text",
"text": ""
}
Risky content:
{
"type": "text",
"text": "{ \"invoices\": [thousands of rows], \"internalAccountId\": \"acct_123\" }"
}
The MCP spec says servers that return structured content should also return serialized JSON in a text block for backwards compatibility. In practice, MCP App developers often use a concise text summary because it gives non-app clients and the model useful context without duplicating the full render payload. Test this against the hosts and clients you plan to support.
What structuredContent Should Contain
structuredContent should contain the stable public data the resource needs to render.
Good candidates:
- Rows for a table
- Cards for a carousel
- Chart series and labels
- Form defaults
- Validation results
- Public IDs the user can see
- Status values the model may need for follow-up turns
Bad candidates:
- OAuth tokens
- Refresh tokens
- Raw session cookies
- Private notes the model should not read
- Huge raw API responses
- UI-only cursors that the model should ignore
- Cache keys with internal structure
OpenAI’s MCP server guide describes ChatGPT’s model as reading structuredContent to narrate what happened. That is the safest default for cross-host design too: assume structuredContent can become model context.
What _meta Should Contain
Use _meta for UI-only values, but treat it as host-mediated data, not as a secure vault.
Good _meta:
_meta: {
nextCursor: 'cursor_abc',
defaultSort: 'dueDate',
viewId: 'invoices-april',
}
Bad _meta:
_meta: {
refreshToken: process.env.REFRESH_TOKEN,
databasePassword: process.env.DB_PASSWORD,
}
If a value would cause harm if exposed in a browser session, do not send it in a tool result. The app runs in an iframe, but it is still client-side code. Use short-lived, scoped, revocable tokens only when a UI truly needs direct access, and prefer server tools for sensitive operations.
How ChatGPT Maps These Fields
ChatGPT supports the standard MCP Apps bridge: JSON-RPC messages over postMessage, including ui/notifications/tool-result. The OpenAI Apps SDK reference documents that this notification includes structuredContent, content, and _meta.
ChatGPT also has the window.openai compatibility layer:
| ChatGPT field | Maps to | Notes |
|---|---|---|
window.openai.toolOutput | structuredContent | Use for the main render payload in ChatGPT-specific code |
window.openai.toolResponseMetadata | metadata for the full MCP result | Includes widget-only metadata in current ChatGPT docs |
window.openai.toolInput | tool input | May arrive after approval for gated tools |
window.openai.widgetState | persisted widget state | Not the same thing as tool output |
For new portable apps, prefer the standard MCP Apps bridge or a framework hook like useToolData. Use window.openai for ChatGPT-specific features after the portable path works.
How Claude Connectors Fit
Interactive Claude Connectors use the same MCP Apps pattern: a tool returns data, a resource renders it, and the host mediates communication through the app bridge.
For Claude-oriented tools, the same split works:
- Put the connector answer summary in
content. - Put renderable rows, charts, forms, or cards in
structuredContent. - Put UI-only helper values in
_metaonly when your target host path supports them.
If the connector also needs to work in plain MCP clients, content matters even more because those clients may never render your app resource.
Debugging Empty or Missing Data
When the iframe renders empty, check the result lanes before changing UI code.
- Call the tool directly through an MCP inspector or integration test.
- Confirm the tool returns
contentandstructuredContentat the top level of the result. - Confirm
structuredContentmatchesoutputSchema. - Confirm the UI-capable tool descriptor points at the resource with
_meta.ui.resourceUri. - Confirm the resource reads the right field, such as
outputfromuseToolData. - If you use ChatGPT-specific code, log whether
window.openai.toolOutputandwindow.openai.toolResponseMetadataare present.
Do this before debugging React state. If the raw tool result is wrong, the UI can only render the wrong thing more politely.
Test the Contract
Add a protocol-level test for the result:
import { expect, test } from 'sunpeak/test';
test('list_invoices returns clean result lanes', async ({ mcp }) => {
const result = await mcp.callTool('list_invoices', {
period: '2026-04',
});
expect(result.isError).toBeFalsy();
expect(result.content?.[0]).toMatchObject({
type: 'text',
text: expect.stringContaining('Displayed'),
});
expect(result.structuredContent).toMatchObject({
period: '2026-04',
});
expect(result.structuredContent).not.toHaveProperty('nextCursor');
expect(result._meta).toHaveProperty('nextCursor');
});
Then add a rendered test:
import { expect, test } from 'sunpeak/test';
test('invoice app renders structured content', async ({ inspector }) => {
const result = await inspector.renderTool('list_invoices', {
period: '2026-04',
});
await expect(result.app().getByText('Invoices for 2026-04')).toBeVisible();
await expect(result.app().getByText(/overdue/i)).toBeVisible();
});
The first test catches server contract bugs. The second catches resource bugs. Together they cover the data lane that most MCP App searches are really asking about.
A Practical Rule
When you are unsure where a value belongs, ask who needs to read it:
- The model or non-app client needs it:
content. - The app UI needs it and it is safe for model context:
structuredContent. - The app UI needs it and the model should not reason about it:
_meta. - Only the server needs it: keep it on the server.
- It is a secret: do not put it in any tool result.
That rule keeps ChatGPT Apps, Claude Connectors, and cross-host MCP Apps easier to debug. It also makes the next layer of testing simpler because each field has one job.
If you are using sunpeak, start by rendering this contract in the local MCP App inspector, then promote the same result shapes into automated tests with sunpeak/test. You can catch broken result fields locally before burning time in a live ChatGPT or Claude session.
Get Started
npx sunpeak new
Further Reading
- Testing MCP App data flow - content, structuredContent, _meta, and bridge state
- MCP App error handling - tool-result, loading, error, and cancelled states
- MCP App lifecycle - host bridge notifications and tool results
- MCP App tool metadata - resourceUri, visibility, and app-only tools
- MCP App TypeScript types - type the tool-to-resource contract
- Claude Connector data access patterns - content, structuredContent, _meta, and pagination
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak useToolData hook reference
- MCP tools specification - tool results and structured content
- MCP Apps overview - official Model Context Protocol docs
- OpenAI Apps SDK reference
- OpenAI Apps SDK MCP server guide
Frequently Asked Questions
What is the difference between content and structuredContent in an MCP App tool result?
content is the normal MCP tool result field for human-readable or media content, usually text the model and user can understand. structuredContent is JSON data that should match the tool outputSchema when one is declared. In MCP Apps, the rendered resource commonly uses structuredContent as its typed render payload, while content gives the model a concise explanation of what happened.
What should go in _meta in an MCP App tool result?
_meta is for UI-only tool result data when the host supports delivering it to the app resource. Use it for cursors, cache keys, transient view hints, non-secret internal IDs, or data the UI needs but the model should not reason about. Do not put secrets, refresh tokens, customer-private notes, or anything required for the model answer in _meta.
Can the model see structuredContent in ChatGPT Apps?
Yes. OpenAI documents ChatGPT as using structuredContent for model narration, and window.openai.toolOutput maps to structuredContent in the widget runtime. Treat structuredContent as model-visible unless the exact host you target documents otherwise. Keep it concise, typed, and safe for follow-up reasoning.
Does an MCP App need content if it already returns structuredContent?
Usually yes. The MCP tools specification says servers that return structured content should also return serialized JSON in a TextContent block for backwards compatibility. For MCP Apps, you can often return a short text summary instead of duplicating the full JSON payload, as long as the host and client behavior you target accept that shape.
How does a ChatGPT App resource receive tool results?
ChatGPT supports the MCP Apps UI bridge, which sends tool results to the iframe through ui/notifications/tool-result over JSON-RPC postMessage. The ChatGPT compatibility layer also exposes window.openai.toolOutput for structuredContent and window.openai.toolResponseMetadata for metadata about the full MCP result envelope.
Should I put pagination cursors in structuredContent or _meta?
Put cursors in structuredContent when the model needs to discuss or choose the next page. Put cursors in _meta when only the UI needs them for a Next button or background fetch. If the cursor grants access to data, treat it like a short-lived capability and scope it tightly.
How do I test content, structuredContent, and _meta in an MCP App?
Call the tool through the MCP layer and assert each field separately. Verify content is concise, structuredContent matches the declared outputSchema, and _meta contains only UI-only values. Then render the resource in a local inspector and assert the UI uses structuredContent and _meta without copying private values into model-visible text.