Skip to main content
All posts

MCP App outputSchema: Validate structuredContent for ChatGPT Apps and Claude Connectors (September 2026)

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkoutputSchemastructuredContent
MCP App outputSchema validates the structuredContent contract between a tool, the host, and the rendered app resource.

MCP App outputSchema validates the structuredContent contract between a tool, the host, and the rendered app resource.

An MCP App can render correctly for weeks with no outputSchema. Then a backend field changes, the view still expects the old name, and the user gets an empty panel with a successful tool call behind it.

outputSchema turns that hidden agreement into a protocol contract. It describes the structuredContent your tool returns, so the server can reject bad output before it leaves, clients can validate it, the model can reason about known fields, and the app view can render a stable shape.

TL;DR: Declare outputSchema for every tool that returns structuredContent. Validate the final result on the server because client validation is not guaranteed. Use JSON Schema 2020-12, keep cross-host output inside an object wrapper for now, separate model-visible data from result _meta, and test the contract at the handler, MCP, and rendered-view boundaries.

What outputSchema Guarantees

The current MCP tools specification gives outputSchema two clear jobs:

  • A server that declares it must return structuredContent that conforms to it.
  • A client should validate the structured result against it.

The first rule is a requirement. The second is a recommendation, so a server cannot assume the host will catch a mismatch. Validate before returning the result and fail the tool call with a useful execution error if your own data does not conform.

outputSchema does not validate content, tool-result _meta, authorization, or business rules. It also does not create missing fields or convert strings into numbers. Those remain server responsibilities.

It is also separate from an AI provider’s structured output feature. MCP structuredContent is produced by the server after a tool runs. LLM structured outputs constrain data generated by a model. If a tool calls a model internally, validate the model response, map it to your public result type, then validate that final value against the MCP schema.

inputSchema and outputSchema

Both fields contain JSON Schema, but they protect opposite sides of the call.

ContractDirectionMain questionWho owns validation?
inputSchemaHost to serverWhich arguments may call this tool?Host may prevalidate; server must still validate and authorize
outputSchemaServer to hostWhich JSON value may appear in structuredContent?Server must return conforming data; client should validate

For an MCP App, outputSchema has an extra consumer: the rendered View. The same result may enter model context, cross the host bridge as ui/notifications/tool-result, and become component state. A mismatch can therefore break follow-up reasoning and UI rendering at the same time.

OpenAI’s current Plugin reference tells developers to declare outputSchema for every tool that returns structuredContent. ChatGPT exposes that structured data to both the model and the component.

The 2026 Schema Change

MCP 2026-07-28 expanded tool schemas to full JSON Schema 2020-12. It also changed structuredContent from an object-only field to any JSON value.

Protocol or host surfaceoutputSchema rootstructuredContent root
MCP 2025-11-25 and earlierObjectObject
MCP 2026-07-28Any valid JSON SchemaObject, array, string, number, boolean, or null
Stable MCP Apps 2026-01-26Uses the underlying MCP tool resultCurrently typed as object in the View notification
Current ChatGPT Plugin APISchema for the exact returned objectCurrently documented as object

This creates a temporary compatibility gap. A root array is valid when both sides use MCP 2026-07-28:

{
  "outputSchema": {
    "type": "array",
    "items": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "title": { "type": "string" }
      },
      "required": ["id", "title"]
    }
  }
}

But stable MCP App hosts and older clients may reject or mishandle that result. For a ChatGPT App, Claude Connector, or multi-host MCP App, use an object wrapper until your support matrix proves otherwise:

// Most portable today
structuredContent: { results }

// Valid in MCP 2026-07-28, but not portable to every App host yet
structuredContent: results

An object wrapper also leaves room for query, totalCount, warnings, and future optional fields without changing the root type.

Use JSON Schema 2020-12 Deliberately

MCP 2026-07-28 defaults a schema without $schema to JSON Schema 2020-12. Implementations must support that dialect, and MCP recommends it. You can declare another dialect, but each target client and server must support it.

The current protocol permits $defs, local $ref, oneOf, anyOf, allOf, not, and conditional schemas. Use that power with restraint because host SDKs and older clients may support a smaller subset.

Keep reusable definitions in the same document:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$defs": {
    "searchResult": {
      "type": "object",
      "properties": {
        "id": { "type": "string" },
        "title": { "type": "string" },
        "url": { "type": "string", "format": "uri" }
      },
      "required": ["id", "title", "url"],
      "additionalProperties": false
    }
  },
  "type": "object",
  "properties": {
    "results": {
      "type": "array",
      "items": { "$ref": "#/$defs/searchResult" }
    }
  },
  "required": ["results"],
  "additionalProperties": false
}

Do not rely on a validator fetching an external $ref. MCP implementations must not dereference network references by default because that would create server-side request forgery and resource-exhaustion risks. The protocol also recommends bounds on schema depth, subschema count, and validation time for complex compositions.

A Complete MCP App Example

This tool searches product documentation and returns data for both model reasoning and a rendered View:

import { registerAppTool } from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';

const SearchResult = z.object({
  id: z.string(),
  title: z.string(),
  url: z.string().url(),
  snippet: z.string(),
});

const SearchOutput = z.object({
  query: z.string(),
  results: z.array(SearchResult),
  totalCount: z.number().int().nonnegative(),
});

registerAppTool(
  server,
  'search_docs',
  {
    title: 'Search Docs',
    description: 'Search product documentation.',
    inputSchema: {
      query: z.string().min(1).describe('The search query.'),
    },
    outputSchema: SearchOutput.shape,
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      openWorldHint: false,
    },
    _meta: {
      ui: { resourceUri: 'ui://docs/search.html' },
    },
  },
  async ({ query }) => {
    const records = await searchDocs(query);
    const output = SearchOutput.parse({
      query,
      results: records.map((record) => ({
        id: record.publicId,
        title: record.title,
        url: record.url,
        snippet: record.snippet,
      })),
      totalCount: records.length,
    });

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

The handler validates after mapping the upstream records. This matters because the provider’s response type is not your public MCP contract. Mapping removes unused fields, applies names your View owns, and gives you one place to redact data.

The serialized JSON in content follows MCP’s backwards-compatibility recommendation for tools that return structured data. The stable MCP Apps specification also requires a meaningful content array so the tool remains useful when the host cannot render its View. A host-specific app may choose a short sentence to save tokens, but that gives up the exact serialized fallback.

Keep Result Lanes Separate

outputSchema describes only structuredContent. It should contain stable result data that the model may reason about and the View may render.

ValueResult fieldPart of outputSchema?
Portable text or media fallbackcontentNo
Typed model-visible result datastructuredContentYes
Non-secret View-only cursor or render hintTool-result _metaNo
Tool execution failure markerisErrorNo
Server-only state or credentialsDo not return itNo

ChatGPT documents structuredContent as visible to both the model and component. It sends result _meta only to the component. Even then, _meta is browser-visible, so it is not a secret store. The broader field rules are covered in MCP App Tool Results.

The schema should expose only fields the caller is authorized to receive. Validation can prove that customerEmail is a string; it cannot prove the current user may read that email. Authorize first, project the allowed fields, then validate the result.

Design Stable Output Schemas

A useful output schema reflects every valid state, not only the response you saw in development.

Required, optional, and nullable

These states are different:

const Invoice = z.object({
  id: z.string(),                    // always present
  memo: z.string().optional(),       // may be absent
  paidAt: z.string().nullable(),     // present, but may be null
  lineItems: z.array(LineItem),      // empty array is valid
});

Use an empty array for a valid empty collection. Use null when the field is known but has no value. Use optional only when omission itself has meaning or an older producer may not send the field.

Closed and open objects

additionalProperties: false catches misspelled and accidentally leaked fields. It also means a producer cannot add a field until every strict consumer accepts the new schema. Closed objects work well for internal systems that deploy together. Public tools may need optional extension points or a careful rollout plan.

Enums and unknown values

Enums make UI states explicit, but a new enum member can break an older View. Render a safe unknown state, add the new value to consumers first, then let producers emit it. A discriminated object is easier to evolve than a union based only on shape:

const Result = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('results'), items: z.array(Item) }),
  z.object({ kind: z.literal('empty'), reason: z.string() }),
]);

JSON-safe numbers and dates

JSON has no bigint, Date, undefined, NaN, or Infinity. Return large identifiers as strings, dates as ISO 8601 strings, and money as integer minor units or decimal strings. Parse and enforce format rules on the server because host validators may treat JSON Schema format as an annotation rather than an assertion.

Data size

A valid schema does not make a large result cheap. Keep list results small, include only fields needed for the current answer and View, and paginate. Large structuredContent can consume model context, while large _meta still costs transport time and browser memory.

The Current sunpeak Pattern

sunpeak tool files export the tool descriptor, input shape, output shape, and handler separately. That keeps the file-based convention aligned with the MCP SDK while allowing the same Zod values to drive runtime validation and TypeScript types.

// src/tools/search-docs.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

const SearchResultSchema = z.object({
  id: z.string(),
  title: z.string(),
  url: z.string().url(),
  snippet: z.string(),
});

export const tool: AppToolConfig = {
  resource: 'docs',
  title: 'Search Docs',
  description: 'Search product documentation.',
  annotations: {
    readOnlyHint: true,
    destructiveHint: false,
    openWorldHint: false,
  },
  _meta: {
    ui: { visibility: ['model', 'app'] },
  },
};

export const schema = {
  query: z.string().min(1).describe('The search query.'),
};

export const outputSchema = {
  query: z.string(),
  results: z.array(SearchResultSchema),
  totalCount: z.number().int().nonnegative(),
};

export const SearchDocsOutputSchema = z.object(outputSchema);
export type SearchDocsOutput = z.infer<typeof SearchDocsOutputSchema>;
type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function searchDocsTool(
  args: Args,
  _extra: ToolHandlerExtra
) {
  const records = await searchDocs(args.query);
  const output = SearchDocsOutputSchema.parse({
    query: args.query,
    results: records,
    totalCount: records.length,
  });

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

The View reads that same type through useToolData:

import { SafeArea, useToolData } from 'sunpeak';
import type { SearchDocsOutput } from '../../tools/search-docs';

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 result received.</SafeArea>;

  return (
    <SafeArea className="p-4">
      <h1>Results for {output.query}</h1>
      {output.results.length === 0 ? (
        <p>No matching documentation.</p>
      ) : (
        <ul>
          {output.results.map((result) => (
            <li key={result.id}>{result.title}</li>
          ))}
        </ul>
      )}
    </SafeArea>
  );
}

TypeScript catches mistakes inside code that shares the type. Runtime parsing catches bad database, API, or fixture data. The published outputSchema tells independent clients what crossed the wire. You need all three because none replaces the others.

Version the Contract Without Breaking Old Views

Changing outputSchema can break cached tool descriptors, old conversations, and Views deployed on a different schedule.

Usually compatible:

  • Add an optional field that old consumers ignore.
  • Relax a numeric or string constraint when every consumer can handle the wider value.
  • Add metadata outside structuredContent without changing the render contract.

Usually breaking:

  • Rename or remove a required field.
  • Change a field from a string to an object.
  • Make an optional field required.
  • Add an enum value that old consumers treat as impossible.
  • Switch the root between object and array.

For a breaking change, version the tool name or result shape, publish a new resource URI, and keep the old path available through the migration window. If you keep one tool name, include a schemaVersion discriminator and make the View parse each supported version explicitly. Do not use the version field as an excuse to accept arbitrary objects.

Test the Contract at Four Boundaries

Start with the handler because that is where you can validate the complete result before host normalization:

import { expect, test } from 'vitest';
import handler, { SearchDocsOutputSchema } from './search-docs';

test('handler returns a conforming public result', async () => {
  const result = await handler({ query: 'oauth' }, {} as never);
  const output = SearchDocsOutputSchema.parse(result.structuredContent);

  expect(output.query).toBe('oauth');
  expect(JSON.stringify(output)).not.toMatch(/accessToken|refreshToken/i);
});

Next, confirm that tools/list publishes the schema and tools/call returns the matching data:

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

test('search_docs publishes and follows outputSchema', async ({ mcp }) => {
  const tools = await mcp.listTools();
  const descriptor = tools.find((item) => item.name === 'search_docs');

  expect(descriptor?.outputSchema).toMatchObject({ type: 'object' });

  const result = await mcp.callTool('search_docs', { query: 'oauth' });
  expect(result.isError).toBeFalsy();
  expect(result.structuredContent).toMatchObject({
    query: 'oauth',
    results: expect.any(Array),
  });
});

Then render the tool through the host replica:

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

test('docs View renders empty and populated results', async ({ inspector }) => {
  const result = await inspector.renderTool('search_docs', {
    query: 'oauth',
  });

  await expect(
    result.app().getByRole('heading', { name: 'Results for oauth' })
  ).toBeVisible();
});

Finally, add negative fixtures that try to cross the boundary with:

  • A missing required field
  • null where a string is required
  • An empty array and maximum allowed array size
  • An unknown enum value
  • An extra private field
  • A malformed URL or date
  • A result for the wrong user or tenant
  • A stale schema version

Run the rendered cases in every host replica you support. The sunpeak testing framework runs protocol, browser, and visual checks locally and in CI, including ChatGPT and Claude runtime replicas, without using live-host credits for each edit.

Debug Schema Failures in Order

When structuredContent disappears or the View renders an empty state, follow the data path:

  1. Inspect the handler’s raw return value.
  2. Parse structuredContent with the server’s schema and keep the validation path in the error log.
  3. Call tools/list and confirm the published outputSchema matches the intended version.
  4. Call tools/call and inspect the protocol result before it reaches a host.
  5. Inspect ui/notifications/tool-result at the View bridge.
  6. Validate again at the View boundary and render a clear unsupported-result state.
  7. Check for a cached descriptor or resource from a previous deployment.

OpenAI’s ChatGPT UI guide tells custom Views to treat the incoming tool result as untrusted input. That is a useful rule for every host. A schema advertised by the server is evidence about intent, not proof that each result is safe.

A Shipping Checklist

Before publishing a tool that returns structuredContent, verify:

  • The declared schema matches the exact value returned on every success path.
  • The server validates the mapped public result at runtime.
  • Required, optional, nullable, empty, and maximum-size states are covered.
  • No schema field exposes data outside the caller’s authorization.
  • content provides a useful fallback for clients without the App View.
  • Result _meta contains only non-secret View helpers.
  • Schema changes remain compatible with cached or older Views, or use a new version.
  • Protocol and rendered tests pass in each supported host runtime.

You can inspect the descriptor, call the tool, and render the result in the local sunpeak MCP App inspector, then run the same contract in CI with sunpeak/test. That gives an outputSchema failure a precise test location instead of leaving it as an empty ChatGPT App or Claude Connector panel.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

What is outputSchema in an MCP App?

outputSchema is the JSON Schema a tool publishes for its structuredContent result. It gives the server, client, AI host, app view, and tests one machine-readable contract. Under MCP 2026-07-28, the schema defaults to JSON Schema 2020-12 and may describe any JSON value.

Does every MCP App tool need an outputSchema?

No. A text-only tool that returns content without structuredContent does not need one. Declare outputSchema whenever a tool returns structuredContent. OpenAI explicitly tells ChatGPT Plugin developers to declare it for every tool that returns structuredContent.

Can structuredContent be an array or primitive value?

MCP 2026-07-28 permits objects, arrays, strings, numbers, booleans, and null when they match outputSchema. Stable MCP Apps and current ChatGPT APIs still type structuredContent as an object, so an object wrapper remains the safest cross-host shape.

What happens when structuredContent does not match outputSchema?

The MCP specification requires servers to return conforming structured results and recommends that clients validate them. A client may reject the call, omit the data, or pass it through, so the server should validate before returning and tests should cover invalid, empty, and boundary values.

Is MCP outputSchema the same as LLM structured outputs?

No. MCP structuredContent is data produced by your server after a tool runs, and outputSchema describes that data. LLM structured outputs constrain JSON generated by a model. A tool may use model-generated JSON internally, but it must still validate the final server result against its MCP outputSchema.

Can the model see fields declared in outputSchema?

In ChatGPT, structuredContent is sent to both the model and the component, while tool-result _meta is component-only. Treat outputSchema fields as model-visible and browser-visible. Do not include secrets, broad signed URLs, private notes, or fields the user is not authorized to read.

Should outputSchema use JSON Schema 2020-12?

Yes. MCP 2026-07-28 defaults schemas without a $schema field to JSON Schema 2020-12, requires implementations to support that dialect, and recommends using it. An explicit older dialect is allowed, but every target implementation must support it.

How should I test MCP App outputSchema?

Validate the handler result on the server, inspect outputSchema through tools/list, call the tool over MCP, and render the same result in each host replica. Include negative fixtures, optional and null values, empty arrays, enum boundaries, unknown fields, and redaction checks.