All posts

MCP App Tool Results: content, structuredContent, and _meta

Abe Wheeler
MCP Apps MCP App Framework MCP App Testing ChatGPT Apps ChatGPT App Framework ChatGPT App Testing Claude Connectors Claude Connector Framework structuredContent Tool Results
MCP App tool results carry separate data lanes for the model, the rendered app, and UI-only metadata.

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.toolOutput is null because 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:

  • structuredContent not reaching the widget
  • _meta missing from ui/notifications/tool-result
  • content versus structuredContent
  • window.openai.toolOutput versus toolResponseMetadata
  • 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.

FieldMain readerUse it forAvoid
contentModel, user, non-app clientsShort text summary, status, citations, compact fallbackFull UI payloads, hidden IDs, private UI state
structuredContentApp resource, often model contextTyped JSON rows, cards, charts, form state, public result dataSecrets, huge hidden payloads, UI-only hints
_metaApp resource when supportedCursors, cache keys, view hints, non-secret internal UI dataAnything 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 fieldMaps toNotes
window.openai.toolOutputstructuredContentUse for the main render payload in ChatGPT-specific code
window.openai.toolResponseMetadatametadata for the full MCP resultIncludes widget-only metadata in current ChatGPT docs
window.openai.toolInputtool inputMay arrive after approval for gated tools
window.openai.widgetStatepersisted widget stateNot 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 _meta only 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.

  1. Call the tool directly through an MCP inspector or integration test.
  2. Confirm the tool returns content and structuredContent at the top level of the result.
  3. Confirm structuredContent matches outputSchema.
  4. Confirm the UI-capable tool descriptor points at the resource with _meta.ui.resourceUri.
  5. Confirm the resource reads the right field, such as output from useToolData.
  6. If you use ChatGPT-specific code, log whether window.openai.toolOutput and window.openai.toolResponseMetadata are 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

Documentation →
npx sunpeak new

Further Reading

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.