Skip to main content
All posts

MCP App Tool Results: content, structuredContent, and _meta (September 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkstructuredContentTool 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 empty MCP App screens start with a tool result problem, not a rendering problem. The server returns data in the wrong field, the host applies a different visibility rule than the developer expected, or the view reads a shape that does not match the tool’s declared output.

The result envelope has four fields worth testing independently: content, structuredContent, _meta, and isError. They overlap just enough to cause mistakes, but each one has a different job.

TL;DR: Put a meaningful portable fallback in content. Put typed result data in structuredContent. Put non-secret, app-only runtime data in result _meta. Use isError for a completed tool call that failed. Declare an outputSchema, keep the result small, and test the handler, MCP transport, and rendered view separately.

The Result Envelope and Its Readers

The MCP tools specification defines the base result. The MCP Apps specification adds the host-to-view delivery path for interactive resources.

FieldTypical readersPut this hereKeep this out
contentModels, users, text clients, app viewsText, images, audio, resource links, embedded resources, and a portable fallbackHidden identifiers, private UI state, a needlessly large copy of the response
structuredContentModels and app views in hosts such as ChatGPTTyped JSON that matches outputSchema and supports follow-up reasoning or renderingSecrets, raw provider responses, data the model should never receive
_metaApp view when the host forwards itCursors, view IDs, cache versions, and other non-secret UI helpersCredentials, private notes, durable authorization, anything needed to answer the user
isErrorHost, model, app viewtrue when the tool call completed with an application-level failureProtocol and transport errors that should be JSON-RPC errors

Visibility is a host decision, so check the exact hosts you support. OpenAI’s current Plugin reference is explicit: content and structuredContent reach both the model and component, while tool-result _meta reaches the component but stays out of model context.

That does not make _meta secret. The component runs in a browser. Its code, network activity, and received messages can be inspected by the person using that browser. If disclosure would hurt, keep the value on your server.

Three Different _meta Locations

MCP Apps use the same _meta spelling in several places. The location changes the meaning.

  1. Tool descriptor _meta configures a tool before any call. It can connect the tool to a UI resource and describe host behavior.
  2. Resource _meta travels with a registered or returned resource. It describes how the host should frame, permit, or present that UI.
  3. Tool result _meta belongs to one call. It carries runtime values for the app view without putting them in model-visible structuredContent on hosts that support this split.

A resource URI belongs to the descriptor or resource contract. A next-page cursor produced by one request belongs to the result. Mixing these locations can make the app work in one host while failing silently in another.

Build a Portable Result

Consider an invoice tool. A result might look like this:

const structuredContent = {
  period: '2026-08',
  invoices: invoices.map((invoice) => ({
    id: invoice.publicId,
    customer: invoice.customerName,
    totalCents: invoice.totalCents,
    status: invoice.status,
    dueDate: invoice.dueDate,
  })),
};

return {
  content: [
    {
      type: 'text',
      text: JSON.stringify(structuredContent),
    },
  ],
  structuredContent,
  _meta: {
    nextCursor,
    viewId: 'invoice-list',
  },
};

The JSON text block follows the core MCP recommendation for backwards compatibility. A client that does not support structuredContent can still read the result. The object gives modern hosts and the app a stable render payload. The cursor stays out of ChatGPT’s model context but remains available to its component.

You may prefer a short sentence in content, such as Displayed 12 invoices for August 2026, because it uses fewer tokens and reads better. That can be a good host-specific choice, but it is not the same compatibility guarantee as serializing the structured result. Decide which clients you support, document the choice, and test it.

The stable MCP Apps specification also requires tools with UI to return meaningful content so the call still works when a host cannot render the view. An empty text block is not a useful fallback.

content Is More Than Text

Tool result content is an array of content blocks. Current MCP can carry text, images, audio, resource links, and embedded resources. A text summary is common because models and basic clients handle it well, but it is not the only option.

Use content for information that should survive without the app:

  • The result the user asked for, or a compact JSON representation of it
  • A readable error message and recovery instructions
  • A citation or resource the client should display
  • Media that is itself the result of the tool

Do not make the text claim more than structuredContent proves. If the text says 12 invoices were returned while the object contains 11, the model and the view can tell different stories. Derive both fields from the same validated value.

structuredContent Is the Typed Contract

structuredContent is server-produced JSON. It is separate from an LLM provider’s feature called structured output. If the tool declares outputSchema, the returned JSON must match it, and clients should validate the result.

The 2026-07-28 MCP tools specification permits any JSON value at the root, including an object, array, string, number, boolean, or null. There is a practical compatibility catch: the stable MCP Apps 2026-01-26 notification and current ChatGPT compatibility reference still type structuredContent as an object.

For a cross-host MCP App, use a named object wrapper:

// Portable across current MCP App hosts
structuredContent: { invoices }

// Valid in MCP 2026-07-28, but older app hosts may reject or mishandle it
structuredContent: invoices

The wrapper also gives the schema room to grow. You can later add period, totalCount, or warnings without changing the root type.

Good structured data has a few traits:

  • Stable names and types that the view can validate
  • Public identifiers when the user or model needs to refer to a record
  • Only the fields needed for this result and likely follow-up calls
  • A declared schema that matches production output, including empty states

Raw upstream API responses usually fail this test. They are large, change without notice, and often include internal fields. Map them into your own result type at the server boundary.

Result _meta Is UI-Only, Not Private

Use result _meta when the app needs a helper value that should not consume model context. Common examples include:

  • A short-lived pagination cursor used by a Next button
  • A mounted view or request identifier used for correlation
  • A cache version used to decide whether the view should refetch
  • A presentation hint that has no bearing on the answer

Do not send access tokens, refresh tokens, session cookies, database keys, or unredacted private records. Do not put the only copy of authoritative state in _meta, either. A page number, selected tab, or temporary sort order is view state. Whether an invoice is paid is business state and should come from the server on each relevant action.

OpenAI also uses namespaced result metadata for host features. For example, an authentication failure can include _meta['mcp/www_authenticate']. Treat host-reserved keys as protocol integration points, not as a place for arbitrary app data.

isError Separates Tool Failure From Protocol Failure

A request can reach the correct tool, run, and still fail. A missing invoice, rejected update, or upstream timeout is normally an application-level tool result:

return {
  isError: true,
  content: [
    {
      type: 'text',
      text: 'Invoice inv_42 could not be loaded. Try again or choose another invoice.',
    },
  ],
  structuredContent: {
    code: 'INVOICE_UNAVAILABLE',
    retryable: true,
  },
};

The host can show the failure, the model can respond accurately, and the app can render a recovery action. Use a JSON-RPC error for protocol failures such as an invalid request, unknown method, or broken transport. Keeping those paths distinct makes logs, retries, and tests much easier to read.

An error result must follow the same disclosure rules as a successful result. Stack traces, provider responses, SQL fragments, and internal account IDs do not belong in content or structuredContent.

Declare the Contract in sunpeak

sunpeak maps a file-based tool to its app resource, schema, handler, and tests. A current tool file can declare the render contract like this:

import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  resource: 'invoices',
  title: 'List Invoices',
  description: 'List invoices for a billing period.',
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    openWorldHint: false,
  },
  _meta: {
    ui: { visibility: ['model', 'app'] },
  },
};

export const schema = {
  period: z.string().describe('Billing period in YYYY-MM format'),
};

export const outputSchema = {
  period: z.string(),
  invoices: z.array(
    z.object({
      id: z.string(),
      customer: z.string(),
      totalCents: z.number().int(),
      status: z.enum(['open', 'paid', 'overdue']),
      dueDate: z.string(),
    })
  ),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function listInvoices(
  args: Args,
  _extra: ToolHandlerExtra
) {
  const { invoices, nextCursor } = await loadInvoices(args.period);
  const output = { period: args.period, invoices };

  return {
    content: [{ type: 'text' as const, text: JSON.stringify(output) }],
    structuredContent: output,
    _meta: { nextCursor },
  };
}

The app can consume the same object through useToolData:

import { SafeArea, useToolData } from 'sunpeak';

type InvoiceOutput = {
  period: string;
  invoices: Array<{
    id: string;
    customer: string;
    totalCents: number;
    status: 'open' | 'paid' | 'overdue';
    dueDate: string;
  }>;
};

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 invoice result received.</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 current hook treats structuredContent as its primary output, falling back to content when structured data is absent. It also exposes loading, error, cancellation, input, and partial-input state. A view should still validate untrusted values at its boundary, because the data crossed a host bridge and may come from an older server or a test fixture.

How Hosts Deliver the Result

The standard MCP Apps path sends a ui/notifications/tool-result JSON-RPC notification to the view. Its result can include content, structuredContent, _meta, and isError. Framework hooks subscribe to that bridge and turn the notification into app state.

ChatGPT also provides a compatibility API:

ChatGPT propertyCurrent meaning
window.openai.toolOutputThe tool’s structuredContent
window.openai.toolResponseMetadataStatus plus copies of the full MCP result envelope, including hidden result _meta
window.openai.toolInputThe arguments for the current tool call
window.openai.widgetStateState persisted for that widget, separate from the tool result

New cross-host apps should use the MCP Apps bridge directly or a framework adapter. Add window.openai code only for ChatGPT-specific behavior. This keeps the main data path portable while still allowing host features where they add real value.

Keep Large Data on the Server

Moving a large payload from structuredContent to _meta does not make it cheap. It may reduce model-context use in ChatGPT, but the host still transports it and the browser still parses it. Large result envelopes slow the call, raise memory use, and make every test fixture harder to manage.

Use a small initial result, then fetch more when the user asks:

  1. Return the records needed for the first view and a total count.
  2. Put a short-lived next cursor in result _meta when only the app needs it.
  3. Expose an app-only helper tool for later pages when the host supports app-only visibility.
  4. Keep filtering, authorization, and durable state on the server.

The official MCP Apps patterns show app-only tools and chunked loading for this purpose. App-only tools still need authorization and input validation. Hiding a tool from the model is a routing choice, not a security boundary.

Test Every Boundary

A single rendered happy-path test cannot tell you which layer broke. Split coverage by boundary.

First, unit test the handler. This is the simplest place to inspect result _meta, validate redaction, and exercise branches that your MCP fixture may intentionally normalize:

import { expect, test } from 'vitest';
import listInvoices from './list-invoices';

test('keeps the cursor out of model-visible fields', async () => {
  const result = await listInvoices(
    { period: '2026-08' },
    {} as never
  );

  expect(result.structuredContent).toMatchObject({ period: '2026-08' });
  expect(result._meta).toHaveProperty('nextCursor');
  expect(JSON.stringify(result.content)).not.toContain('nextCursor');
  expect(JSON.stringify(result.structuredContent)).not.toContain('nextCursor');
});

Next, call the tool through MCP with sunpeak/test. The current fixture exposes the protocol fields most callers use: content, structuredContent, and isError.

import { expect, test } from 'sunpeak/test';

test('list_invoices returns a valid protocol result', async ({ mcp }) => {
  const result = await mcp.callTool('list_invoices', {
    period: '2026-08',
  });

  expect(result.isError).toBeFalsy();
  expect(result.content?.[0]).toMatchObject({ type: 'text' });
  expect(result.structuredContent).toMatchObject({
    period: '2026-08',
    invoices: expect.any(Array),
  });
});

Finally, render the tool in the local host replica:

import { expect, test } from 'sunpeak/test';

test('invoice app renders the structured result', async ({ inspector }) => {
  const result = await inspector.renderTool('list_invoices', {
    period: '2026-08',
  });

  await expect(
    result.app().getByText('Invoices for 2026-08')
  ).toBeVisible();
});

Repeat the rendered test across the host replicas you support. sunpeak can run these flows locally and in CI for ChatGPT and Claude, so you can catch host-specific bridge behavior without spending live-host credits on every code change.

Add explicit redaction assertions for token-like strings, internal record fields, stack traces, and provider error bodies. Test success, empty, partial, error, cancelled, and paginated results. The schema contract matters most at its edges.

Debug in Delivery Order

When a view is blank or stale, inspect the path in the order data moves:

  1. Call the handler and inspect the full returned object.
  2. Call the tool over MCP and compare the protocol result.
  3. Validate structuredContent against outputSchema.
  4. Confirm the tool descriptor points to the expected app resource.
  5. Inspect the ui/notifications/tool-result payload at the bridge boundary.
  6. Confirm the view reads structuredContent rather than a host-specific field by mistake.
  7. Check loading, error, cancellation, and empty states before changing component state logic.

This sequence separates a server bug from a transport bug, a host mapping bug, and a rendering bug. Changing React code cannot repair data that never reached the iframe.

A Field Selection Rule

Ask who must read the value and what happens if it leaks:

  • A model, user, or plain MCP client needs it: put it in content.
  • The app needs typed data and it is safe for model context: put it in structuredContent.
  • Only the app needs it and browser disclosure is acceptable: put it in result _meta.
  • The host needs it to configure the tool or resource: put it in the correct descriptor or resource _meta.
  • Only the server needs it: do not return it.
  • It is a credential or secret: do not put it in any result field.

Then test that decision at the handler, MCP, bridge, and rendered-view boundaries. The sunpeak MCP App inspector gives you the local host view, and sunpeak/test carries the same contract into CI. That makes result bugs reproducible before they reach 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 an array of MCP content blocks that clients can display and models can read. structuredContent is JSON data for programmatic use, including rendering an MCP App view. When a tool declares outputSchema, its structuredContent must match that schema.

Can structuredContent be an array or primitive value?

The MCP 2026-07-28 tools specification permits any JSON value, including arrays and primitives. The stable MCP Apps specification and current ChatGPT compatibility APIs still describe structuredContent as an object, so wrapping data in a named object is the most portable choice.

Can the model see structuredContent and _meta?

ChatGPT exposes content and structuredContent to the model and component, while tool-result _meta goes only to the component. Treat structuredContent as model-visible. Treat _meta as browser-visible rather than secret, because app code and browser inspection can read it.

Does an MCP App need content when it returns structuredContent?

Yes. The stable MCP Apps specification requires meaningful content for graceful degradation, and the core MCP tools specification recommends a serialized JSON TextContent fallback when returning structured content. This keeps the tool useful in clients that do not render its app.

What is the difference between tool descriptor _meta and tool result _meta?

Tool descriptor _meta configures the tool and its host integration, such as linking a tool to an app resource. Tool result _meta is runtime data returned by one tool call for the app view. Resource _meta is a third location used when registering or reading the UI resource.

What should go in tool result _meta?

Use result _meta for non-secret data the app needs but the model does not, such as a short-lived cursor, view identifier, cache version, or presentation hint. Keep credentials, authorization tokens, private notes, and authoritative business state on the server.

How should an MCP tool report an application error?

Return a normal tool result with isError set to true and useful content when the tool ran but the requested operation failed. Reserve JSON-RPC protocol errors for malformed requests, unknown methods, and transport-level failures. Some hosts also use result _meta for authentication challenges.

How do I test content, structuredContent, _meta, and isError?

Unit test the handler to inspect the full return object, call the tool through the MCP layer to test its protocol-visible content, structuredContent, and isError, then render it in a host replica to test the app. Add redaction assertions so secrets cannot enter any result lane.