MCP structured output is a contract, and most servers keep half of it
18 August 2026
MCP Structured Output Validator The contract only the server has to keep. Runs in your browser.Declaring an outputSchema on an MCP tool is not documentation and it is not a
hint. It is a promise, and the specification uses the strongest word it has:
If an output schema is provided:
- Servers MUST provide structured results that conform to this schema.
- Clients SHOULD validate structured results against this schema.
That asymmetry is the whole subject. The server is required to conform, and the client is only advised to check. So a server that declares a schema and drifts from it produces no error anywhere: the client passes the result along, the model reads something shaped differently from what the schema promised, and the failure surfaces much later as a program doing the wrong thing with a field it thought it understood.
The three parts, and what each is for
A tool result can carry two things at once, and the names are easy to confuse.
content is the unstructured half, and it is always present. It is an array
of blocks: text, image, audio, resource links, embedded resources. This is what
the model reads.
structuredContent is the structured half. It is JSON returned in its own
field, so that something downstream of the model can rely on the shape of an
answer rather than parsing prose.
outputSchema is the JSON Schema on the tool definition describing what
structuredContent will look like. Note the exact relationship, because it is
the thing people get backwards: outputSchema describes structuredContent,
not the result. It says nothing about content, about isError, or about the
envelope around them.
One more distinction the specification calls out explicitly, because the naming collides with a different feature entirely:
structuredContentis server-produced result data and is unrelated to LLM “structured outputs” (schema-constrained model generation).
If you arrived here looking for how to make a model emit JSON matching a schema, this is not that. This is a server describing its own return value.
The half nobody implements
Here is the rule most implementations skip:
For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.
It is a SHOULD rather than a MUST, so nothing is broken by ignoring it, which is
exactly why it gets ignored. The consequence is not subtle. A client that does
not read structuredContent, because it was written against an earlier revision
or simply never implemented it, sees a tool result whose content array is empty
or nearly so. The tool ran, the data came back, and the model was handed nothing.
The specification’s own example does it properly, and it is worth copying literally:
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"content": [
{
"type": "text",
"text": "{\"temperature\": 22.5, \"conditions\": \"Partly cloudy\", \"humidity\": 65}"
}
],
"structuredContent": {
"temperature": 22.5,
"conditions": "Partly cloudy",
"humidity": 65
}
}
}
The same data twice, deliberately. Once for a client that understands the structured field, once for everything else.
isError is not a protocol error, and the difference matters
MCP has two ways to report that something went wrong, and choosing the wrong one produces an agent that gives up when it should retry, or loops when it should stop.
| Protocol error | Tool execution error | |
|---|---|---|
| Shape | JSON-RPC error object | Normal result with isError: true |
| Means | The request was malformed or unroutable | The tool ran and the operation failed |
| Examples | Unknown tool, invalid arguments | Rate limited, record not found, business rule rejected it |
| Reaches the model | No | Yes, as content it can read and act on |
That last row is why this matters. A protocol error is handled by the client and the model usually never learns why. A tool execution error is delivered to the model as content, so it can read “the rate limit resets in 30 seconds” and behave accordingly.
The practical rule: if the tool did its job and the answer is “no”, that is
isError: true with an explanation in content. Reserve JSON-RPC errors for
requests you could not process at all. A server returning a protocol error for a
not-found record has taken the information away from the only participant who
could have used it.
Worth noting for anyone matching codes: the specification’s example for an
unknown tool uses -32602, InvalidParams, rather than MethodNotFound. The
method was tools/call and it exists. The tool name inside it was wrong, and
that is a parameter problem.
How this shows up
Rarely as an exception. The reports look like this:
- A workflow reads
result.structuredContent.items[0].idand getsundefinedintermittently, because the server omits the field when a list is empty while the schema marks it required. - A tool works perfectly in one client and returns “nothing” in another, which is the missing TextContent block.
- An agent retries a permanent failure forever, because the server returned a protocol error and the model was told only that the call failed.
- A field changes from number to string in a patch release and nothing fails until a comparison starts behaving oddly.
Every one of those is a contract violation nobody is enforcing, because the enforcement is a SHOULD on the client and the client did not.
Where the ecosystem actually is
Structured output arrived with the 2025-06-18 revision, which is recent enough that “the specification says so” and “your stack does it” are different claims. Three things are worth knowing before you design around it.
Client and framework support is uneven. Support for reading
structuredContent and validating against outputSchema has been requested and
tracked in mainstream agent frameworks well after the revision shipped, for
example in the Vercel AI SDK. If
your agent runs through a framework rather than a raw SDK, check what that
framework does with the field before assuming the contract is enforced anywhere.
The guidance itself is still being clarified. There is an open specification
issue asking exactly when a server should use structuredContent versus
content, SEP-1624,
which is a fair signal that the answer is not obvious from the text alone. If
you have read the spec twice and remain unsure whether your case wants both, you
are not misreading it.
There is a real design argument against using it by default. A
discussion in the specification repository
makes the case that structured output pulls MCP back toward being an API
protocol, where the point of returning text was that a model could read it and
adapt. That argument is worth understanding rather than dismissing, because it
predicts the failure mode: a tool whose output is rigidly typed and whose
content block is empty is useful to your code and useless to the model that
has to decide what to do next.
The practical reading of all three: declare an output schema when a program consumes the fields, keep the text block for the model, and do not assume any particular client is checking your conformance for you.
What to do about it
If you write servers, three things:
- Only declare
outputSchemaif you intend to conform to it. A tool with no output schema and honest text is more useful than one with a schema it drifts from, because the second invites downstream code to trust a shape. - Validate your own output against the schema you published, in your own tests. The client is not obliged to catch this and mostly will not.
- Emit the TextContent block as well. It costs a
JSON.stringifyand it is the difference between working everywhere and working in the clients you happened to test.
If you write clients or agents, two:
- Validate
structuredContentagainst the declared schema, and treat a mismatch as a server defect rather than as data. The specification says you SHOULD, and the cost of skipping it lands a long way from the cause. - Do not assume
structuredContentexists. It appears only when the tool declares a schema, so the path where it is absent is the normal one.
The structured output validator runs the
checklist above against a result you paste, including the conformance check the
client is only advised to do. The tool schema validator
covers the neighbouring problem on the input side: which JSON Schema keywords a client
actually enforces when it hands a schema to a model. Constraints such as
minimum, pattern and maxLength are frequently dropped rather than applied,
so a schema can be perfectly valid and still constrain nothing. The same instinct
applies on the output side. A schema is a description, and something has to
enforce it.
A checklist you can run against a real tool
Take one tool that declares an outputSchema, call it, and check these in
order. Every one of them is a defect that produces no error message.
- Is
structuredContentpresent at all? If the schema is declared and the field is missing, the server is already outside the MUST. - Does it validate against the declared schema? Run the actual result through a JSON Schema validator, not by eye. Required fields that are omitted when a collection is empty are the most common miss.
- Are the types stable across cases? Call it with a result, with an empty result, and with an error. A field that is a number normally and a string in the empty case will pass a casual read and break a comparison later.
- Is
contentnon-empty? If the only place the data appears isstructuredContent, any client that does not read that field gets nothing. - Does the text block contain the same data? Not a summary of it. The
backwards-compatibility rule is about the serialized JSON, so a client
reading only
contentsees the same values. - Is a failed operation
isError: truerather than a JSON-RPC error? Force one: request a record that does not exist and look at which shape comes back. - Does the schema describe the structured field only? A schema that tries to
describe the whole result, with
contentandisErrorin it, is describing the envelope rather than the payload and will never match.
When this advice is wrong
If your tool returns prose, do not invent a schema for it. A summariser, a
search tool that returns snippets, an explainer: these have text as their actual
output, and wrapping it in {"result": "..."} adds a contract without adding
structure. outputSchema earns its place when a program downstream of the model
consumes the fields.
If you control both ends, the backwards-compatibility block is optional. The
argument for duplicating the JSON into content is interoperability with clients
you did not write. In a closed system where you ship the server and the client
together and pin both, it is duplicated tokens on every call for a compatibility
case that cannot arise. Skip it deliberately, and write down that you did,
because the day somebody else connects to that server it becomes a bug.
And if you are on a client that predates structured content, none of this helps today. Check what your client actually reads before designing around a field it ignores, and the fastest way to find out is to return both and see which one reaches the model.