MCP App outputSchema: Validate structuredContent for ChatGPT Apps and Claude Connectors

MCP App outputSchema validates the structuredContent contract between a tool, the host, and the rendered app resource.
Developers usually learn MCP tool schemas in this order: first inputSchema, then structuredContent, then the UI resource that renders that data. outputSchema often gets skipped because the app seems to work without it.
That works until the first contract mismatch. The tool returns items, the resource reads results, ChatGPT shows an empty widget, and Claude has no clear schema to validate against. The bug is not in React. The contract was never written down.
TL;DR: If an MCP App tool returns structuredContent, declare an outputSchema. It describes the exact JSON object your tool returns, lets clients validate the result, helps hosts reason about follow-up tool calls, and gives your resource component a stable render contract. Pair it with TypeScript types and an integration test so schema drift fails in CI instead of inside ChatGPT or Claude.
Why This Is the Search Gap
The current MCP App search surface is crowded with setup guides: how to register a tool, how to attach a ui:// resource, how to render a ChatGPT App, and how to pass structuredContent into a widget. Those help people get their first app on screen.
The next failure is more specific:
structuredContentworks locally but not in ChatGPT.- The app iframe renders, but
toolOutputis missing a field. - Claude calls the tool correctly, but the UI cannot trust the output shape.
- A model follow-up refers to stale or malformed result data.
- A test passes because TypeScript types exist, but the protocol schema is absent.
Those are outputSchema problems. They sit between the MCP protocol, host validation, and your React component.
outputSchema vs inputSchema
inputSchema and outputSchema both use JSON Schema, but they describe opposite sides of a tool call.
| Schema | Direction | Main reader | Answers |
|---|---|---|---|
inputSchema | Host to server | Model and host | What arguments can the model pass? |
outputSchema | Server to host | Host, model, resource, tests | What JSON object does the tool return? |
For a normal MCP server, outputSchema helps clients parse structured tool results. For an MCP App, it matters even more because the same result also hydrates a UI resource.
The MCP tools specification says tools may provide output schemas for structured results. When they do, servers must return structuredContent that conforms to the schema, and clients should validate it.
OpenAI’s Apps SDK reference is more direct for ChatGPT Apps: declare outputSchema for any tool that returns structuredContent.
The Smallest Useful Example
Here is a simple MCP App tool that returns search results for a rendered resource:
import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
registerAppTool(
server,
'search_docs',
{
title: 'Search Docs',
description: 'Search product documentation.',
inputSchema: {
query: z.string().describe('The search query.'),
},
outputSchema: {
query: z.string(),
results: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().url(),
snippet: z.string(),
}),
),
},
_meta: {
ui: { resourceUri: 'ui://docs/search.html' },
},
},
async ({ query }) => {
const results = await searchDocs(query);
return {
content: [
{
type: 'text',
text: `Found ${results.length} docs for "${query}".`,
},
],
structuredContent: {
query,
results: results.map((result) => ({
id: result.id,
title: result.title,
url: result.url,
snippet: result.snippet,
})),
},
};
},
);
The important part is not the syntax. The important part is that outputSchema and structuredContent describe the same object:
{
"query": "oauth setup",
"results": [
{
"id": "auth-quickstart",
"title": "OAuth quickstart",
"url": "https://example.com/docs/oauth",
"snippet": "Set up OAuth 2.1 with PKCE..."
}
]
}
If the handler later returns items instead of results, the schema test should fail before the app ships.
The sunpeak Version
In a sunpeak project, you normally keep the tool file, output type, and resource component close together.
// src/resources/docs/types.ts
export interface SearchResult {
id: string;
title: string;
url: string;
snippet: string;
}
export interface SearchDocsOutput {
query: string;
results: SearchResult[];
}
// src/tools/search-docs.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
import type { SearchDocsOutput } from '../resources/docs/types';
export const tool: AppToolConfig = {
resource: 'docs',
title: 'Search Docs',
description: 'Search product documentation.',
annotations: { readOnlyHint: true },
outputSchema: z.object({
query: z.string(),
results: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().url(),
snippet: z.string(),
}),
),
}),
};
export const schema = {
query: z.string().describe('The search query.'),
};
type Args = z.infer<z.ZodObject<typeof schema>>;
export default async function (
args: Args,
_extra: ToolHandlerExtra,
): Promise<{ content: Array<{ type: 'text'; text: string }>; structuredContent: SearchDocsOutput }> {
const results = await searchDocs(args.query);
return {
content: [{ type: 'text', text: `Found ${results.length} docs for "${args.query}".` }],
structuredContent: {
query: args.query,
results,
},
};
}
// src/resources/docs/docs.tsx
import { SafeArea, useToolData } from 'sunpeak';
import type { SearchDocsOutput } from './types';
export default function DocsResource() {
const { output, isLoading, isError } = useToolData<{ query: string }, SearchDocsOutput>();
if (isLoading) return <SafeArea>Searching docs...</SafeArea>;
if (isError) return <SafeArea>Search failed.</SafeArea>;
if (!output) return <SafeArea>No search results returned.</SafeArea>;
return (
<SafeArea className="p-4 font-sans">
<h1>Results for {output.query}</h1>
<ul>
{output.results.map((result) => (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
<p>{result.snippet}</p>
</li>
))}
</ul>
</SafeArea>
);
}
This gives you three layers of protection:
- TypeScript checks the handler return value and resource reads.
outputSchematells the MCP host what the result must look like.- Tests can validate the live MCP result against the schema.
TypeScript alone is not enough because hosts do not see your TypeScript types. outputSchema is the contract that crosses the protocol boundary.
What Should Go in outputSchema
Put the stable, public render payload in outputSchema.
Good fields:
- IDs that are safe for the model to mention.
- Labels, titles, dates, statuses, and totals.
- Rows, cards, chart series, and form defaults.
- Public URLs the user can open.
- Small pagination fields when the model should know about them.
Avoid fields that do not belong in structuredContent:
- Access tokens, refresh tokens, session cookies, or signed URLs with broad access.
- Internal database IDs that the model does not need.
- Large raw API responses with dozens of unused fields.
- UI-only view hints when
_metais available. - Cache keys that should never appear in model context.
The reason is simple: outputSchema describes structuredContent, and structuredContent is not a private storage lane. In ChatGPT Apps, OpenAI documents window.openai.toolOutput as the structured content and says the model reads those fields verbatim. For cross-host apps, treat structuredContent as model-visible unless your exact target host documents a stricter separation.
For the data-lane split, use this rule:
| Data | Field |
|---|---|
| Short model-readable summary | content |
| Stable JSON the resource renders and the model may reason about | structuredContent plus outputSchema |
| UI-only helper values when supported | tool result _meta |
The longer guide to these lanes is MCP App Tool Results: content, structuredContent, and _meta.
Required, Optional, and Nullable Fields
Schema drift usually starts with required fields.
If a field can be absent, make it optional in the schema and handle that path in the resource:
outputSchema: z.object({
title: z.string(),
description: z.string().optional(),
dueDate: z.string().nullable(),
});
Then render all three states explicitly:
{output.description ? <p>{output.description}</p> : null}
{output.dueDate ? <time>{output.dueDate}</time> : <span>No due date</span>}
Do not mark fields required because they usually exist in the happy path. Required means the tool must return the field every time. If an API can omit it, your schema should say so.
Arrays need the same care. If a search can return no results, results: [] is better than omitting results, because the resource can render an empty state from a stable shape.
Versioning outputSchema
Changing outputSchema is a contract change.
Safe changes:
- Add an optional field.
- Add a new enum value if the resource handles unknown values safely.
- Add a nullable field with a clear fallback.
Risky changes:
- Rename a field.
- Remove a field.
- Change a field type from string to object.
- Make an optional field required.
- Change an enum without a fallback.
For public apps, treat risky changes like API version changes. Keep the old field for a while, add the new field, update the resource, then remove the old field after you know every host cache and deployed resource has moved forward.
This matters for ChatGPT Apps because tool descriptors and resources can be cached. A new resource may render against an old tool schema, or an old chat may still have a previous tool shape. Backward-compatible output changes make that rollout less fragile.
Test outputSchema Before UI Tests
The first test should call the tool through the MCP layer and validate structuredContent directly.
import { describe, expect, test } from 'vitest';
import { z } from 'zod';
import handler, { tool } from '../../src/tools/search-docs';
const SearchDocsOutput = z.object({
query: z.string(),
results: z.array(
z.object({
id: z.string(),
title: z.string(),
url: z.string().url(),
snippet: z.string(),
}),
),
});
test('search_docs returns structuredContent that matches outputSchema', async () => {
const result = await handler({ query: 'oauth setup' }, {} as never);
expect(result.structuredContent).toEqual(SearchDocsOutput.parse(result.structuredContent));
expect(tool.outputSchema).toBeDefined();
});
If your framework exposes the MCP client fixture, test through that layer too:
import { expect, test } from 'sunpeak/test';
test('search_docs protocol result matches output schema', async ({ mcp }) => {
const result = await mcp.callTool('search_docs', { query: 'oauth setup' });
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
query: 'oauth setup',
results: expect.any(Array),
});
});
Then render the same tool in the inspector:
import { expect, test } from 'sunpeak/test';
test('docs resource renders outputSchema fields', async ({ inspector }) => {
const result = await inspector.renderTool('search_docs', { query: 'oauth setup' });
const app = result.app();
await expect(app.getByRole('heading', { name: /Results for oauth setup/i })).toBeVisible();
await expect(app.getByRole('listitem').first()).toBeVisible();
});
That order matters. Schema tests tell you whether the server contract is valid. Inspector tests tell you whether the UI can render that valid contract.
Common outputSchema Bugs
The tool returns an array at the top level. structuredContent should be a JSON object. Wrap arrays in an object:
structuredContent: { results }
The schema describes the API response, not the app payload. Your API may return 80 fields. Your app may render 8. Make outputSchema describe the trimmed payload your tool returns, not the upstream response.
The schema omits fields the resource reads. If the resource reads output.results[0].snippet, the schema should include snippet. Otherwise, the resource has a hidden dependency.
The schema includes secrets because the UI needs them. If the UI needs sensitive access, prefer an app-only server tool. If you must pass a token to the iframe, scope it tightly, expire it quickly, and do not pretend outputSchema makes it private.
The schema has no empty state. Search, list, and report tools should represent empty results as valid data. An empty array is usually better than a missing field.
The TypeScript type and outputSchema drift apart. Generate both from one Zod schema when you can, or test them together when you cannot.
Where sunpeak Helps
sunpeak does not make outputSchema optional. It makes it easier to keep the schema, tool handler, resource component, and tests close enough that drift is obvious.
Use the sunpeak MCP App framework to:
- Put tool files and resource files in predictable locations.
- Type
structuredContentfrom the handler touseToolData. - Render the resource locally in ChatGPT and Claude-like runtimes.
- Test the tool result and rendered iframe in CI without a paid host account.
The universal rule still applies if you use another framework: outputSchema is the host-visible contract for structuredContent. Write it, keep it narrow, and test it before you debug the UI.
Get Started
npx sunpeak newFurther Reading
- MCP App tool results - content, structuredContent, and _meta
- End-to-end TypeScript types in MCP Apps - type the tool-to-resource contract
- Testing MCP App data flow - validate structuredContent and rendered UI
- MCP App conformance testing - verify tool descriptors and resource links
- Claude Connector data access patterns - structure tool results for Claude and MCP App UIs
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- MCP tools specification - outputSchema and structuredContent
- OpenAI Apps SDK reference - outputSchema for structuredContent
Frequently Asked Questions
What is outputSchema in an MCP App?
outputSchema is the JSON Schema a tool declares for the JSON object it returns in structuredContent. In an MCP App, that schema defines the data contract between the server tool, the AI host, and the rendered resource component. If a tool declares outputSchema, its structuredContent should match that schema.
Do MCP Apps need outputSchema for every tool?
No. Declare outputSchema when the tool returns structuredContent, especially when a UI resource reads that data. A text-only tool that returns only content does not need outputSchema. For ChatGPT Apps, OpenAI recommends declaring outputSchema for any tool that returns structuredContent.
What happens if structuredContent does not match outputSchema?
The MCP tools specification says clients should validate structured results against outputSchema when one is provided. Host behavior can vary, but a mismatch can lead to rejected tool results, missing app data, model confusion, or a rendered resource that fails because required fields are absent. Test the schema mismatch locally instead of waiting for the host to surface it.
Is outputSchema the same as inputSchema?
No. inputSchema describes the arguments a model may pass when it calls a tool. outputSchema describes the structuredContent object the tool returns after it runs. In an MCP App, inputSchema helps the model call the tool correctly, while outputSchema helps the host, model, and UI resource understand the returned data.
Can ChatGPT see structuredContent that matches outputSchema?
Yes. OpenAI documents window.openai.toolOutput as the tool structuredContent and says the model reads it verbatim. Treat structuredContent as model-visible in ChatGPT Apps. Put only concise, safe, public result data in structuredContent, and keep UI-only helper values in _meta when the host supports it.
Should outputSchema include UI-only fields like cursors or view hints?
Only include fields that belong in structuredContent. If a cursor or view hint is safe for the model and useful for follow-up reasoning, include it in structuredContent and outputSchema. If it is only for a Next button or a UI rendering hint, put it in tool result _meta when your target host supports UI-only metadata.
How do I test outputSchema in MCP Apps?
Call the tool through the MCP layer, validate result.structuredContent against the same schema the tool declares, and render the resource in the local inspector with that result. This catches server contract bugs and resource rendering bugs before a real ChatGPT or Claude session sees them.