MCP Tool Output Schema: Stop Trusting Agent Prose

Trusting the agent's narration confirmed 3 refunds that never happened. Validating the declared shape confirmed zero.

MCP Tool Output Schema: Stop Trusting Agent Prose

Saturday morning, 9:40. Nobody is at the dashboard. A headless agent works the support queue against your desk’s MCP server, and one tool is process_refund(order_id, amount_cents). The batch runs twelve calls. At noon the run summary reads like a clean shift: nine refunds confirmed, three declined with reasons quoted. It goes into the weekly report.

Three of the nine are false. Calls 10, 11, and 12 came back as a timeout the SDK swallowed into {}, an empty 200 OK body, and a 502 that died mid-write. No money moved on any of the three, and the agent narrated all of them as processed anyway. It had no better move available.

Here is the claim, derived row by row from twelve responses you can check with a pencil. Gate the batch on the agent’s narration and you confirm 3 refunds that never happened. Gate the same twelve responses on a declared, validated output schema and you confirm zero false ones. Same calls, same bytes. The whole difference is one declared shape and one check at the boundary.

This blog has already gated the front of a tool call: what destructive calls may run, what credentials they run with, what the agent proposes to do. None of that touches the moment after an allowed call returns, when something must decide whether the effect it reports is real. Today that decision belongs to a language model writing the most plausible next sentence. This piece moves the decision into a shape.

Thirty seconds on your own server settles it.

Open the definition of the most consequential tool you expose, the one that refunds, deletes, charges, or deploys, and search it for outputSchema. Then open whatever renders its result and find what the success state branches on: structuredContent, or a read of content with a string match on “done” or “success”.

A declared schema on the consequential tools plus a UI branching on structuredContent: you already made this move, close the tab. Either half missing means the gap below is live in your system today.

Either a shape gets validated at the boundary, or a sentence gets believed in the UI. One of those two is already deciding your successes.

One procedure, two indistinguishable outcomes

Section titled “One procedure, two indistinguishable outcomes”

Be precise about why the narration cannot be trusted, because the reason decides the fix.

A model does not have two procedures, one for narrating successes and one for narrating failures. It has exactly one procedure for turning tool bytes into a sentence: predict the most plausible next token given everything in the conversation so far. When the tool returns a clean result with refunded: true and an amount, the plausible continuation is “Done - refunded $42.00.” When the tool returns a truncated 502 body, or {} from a swallowed timeout, or an empty 200 OK, the plausible continuation is also “Done”, because nothing in those bytes screams failure and the whole conversation was pointed at a refund. The narration is optimized for coherence with the conversation, and the conversation expected success.

The usual objection: modern models are good, and a flatly invented success is rare. Mostly true, and it aims at the wrong case. You are defending the expensive tail, the rare call where an upstream service half-died and handed back bytes that carry no signal either way. On exactly those calls the model has nothing to be right with, because no amount of model quality recovers information that never arrived. Prompting the model to be careful fails for the same reason: a prompt can buy you a hedge (“I believe this succeeded”), and a hedge on a false success is still a false success.

This stops being a curiosity at the boundary. A read that returns garbage costs a wasted turn. A refund that reports success while silently failing is a lie your customer acts on. The batch above is twelve calls so you can hold it in your head; a real Saturday batch is hundreds, and the malformed tail lands precisely on the runs with no human in the loop.

The narration cannot carry the success distinction, because a clean success and a swallowed failure come out of the identical procedure: predict the plausible next sentence.

Fix the example now and keep it. One tool: process_refund(order_id, amount_cents) on a support desk’s MCP server. One batch: twelve calls, headless, unattended. The payment backend is the ground truth, computed the obvious way from what really happened: six refunds moved, six did not.

Here are all twelve raw responses, with the narration each produced:

#What the server returnedisErrorThe agent narratedMoney moved
1Valid result: refunded, rf_101, $42.00no”Done - refunded $42.00.”yes
2Valid result: refunded, rf_102, $15.00no”Done - refunded $15.00.”yes
3Valid result: refunded, rf_103, $80.00no”Done - refunded $80.00.”yes
4Valid result: refunded, rf_104, $29.99no”Done - refunded $29.99.”yes
5Valid result: refunded, rf_105, $6.50no”Done - refunded $6.50.”yes
6Valid result: refunded, rf_106, $120.00no”Done - refunded $120.00.”yes
7Error text: “insufficient balance on the source account”yes”Failed - insufficient balance on the source account.”no
8Error text: “duplicate refund request for this order”yes”Failed - duplicate refund request for this order.”no
9Error text: “order was never charged, nothing to refund”yes”Failed - order was never charged, nothing to refund.”no
10Swallowed timeout: {}no”Refund processed.”no
11Empty 200 OK bodyno”Looks like that went through.”no
12Truncated 502 body: refund_id onlyno”Refund complete.”no

The twelve fall into three classes. Six clean successes: valid payloads, money moved. Three explicit failures: the server flagged isError and quoted a reason, and the narration passed it through faithfully. Three malformed responses: no error flag, a broken payload, no money moved. Rows 10 through 12 are the expensive tail, made countable.

One ground rule, declared openly: this server is not lying. Every response above is exactly what the payment backend really did, and the only corruption is the ordinary kind, a timeout, an empty body, a truncated write. A server that fabricates a schema-valid lie is a different failure, and the honesty section takes it up separately. This toy isolates one gap: what the model does with ambiguous bytes.

Six refunds moved. The narration reports nine. Those two numbers are the whole subject of this piece.

Compute the answer the obvious way first. The obvious way is the one almost every setup uses today: whatever the agent says about the call, that is the record. Call it the narration gate, gating success on the agent’s prose.

In mechanism terms the narration gate reduces to one rule, visible on every row: confirm unless the server set isError. Rows 1 through 6 confirm, correctly. Rows 7 through 9 fail, correctly: the server said so and the narration read the flag. Rows 10 through 12 confirm, wrongly: the server set no flag, nothing in the bytes argued either way, and the plausible continuation won.

Count it. Nine confirmations, six real refunds, and the gap is exactly 3: the narration gate produces 3 false positives on this batch, call ids 10, 11, and 12. Hold onto 3. It produces no false negatives anywhere; it never reports failure on a refund that happened. The errors run in one direction, toward success, the worst direction, because false confirmations flow outward into reports, receipts, and replies to customers.

The narration gate fails in a fixed direction, on a fixed class of responses, every time that class appears.

Now the fix, and by this point it should feel forced. The model cannot extract a success signal from bytes that do not contain one, so put the signal where a machine can check it: in the shape of the response.

Since the 2025-06-18 revision, an MCP tool can declare an outputSchema beside its input schema. The contract for process_refund:

PROCESS_REFUND = {
"name": "process_refund",
"description": "Refund a charged order to its original payment method.",
"inputSchema": {...}, # order_id, amount_cents: inputs are not this piece's subject
"outputSchema": {
"type": "object",
"properties": {
"refunded": {"type": "boolean"},
"refund_id": {"type": "string"},
"amount_cents": {"type": "integer"},
},
"required": ["refunded", "refund_id", "amount_cents"],
},
}

Three fields, all required, and the first one is the whole point: refunded is the literal definition of “this worked”, written as a boolean. The spec puts two duties behind the declaration. The server “MUST provide structured results that conform to this schema”, returned in the structuredContent field. And “Clients SHOULD validate structured results against this schema.” Note the SHOULD: the wire does not enforce the check, you write it.

So write it. The schema gate is one rule: confirm only if structuredContent is present, has all three required fields, and refunded is literally true. Run it over the same twelve responses:

#CategoryMoney movedNarration confirmsRight?Schema confirmsRight?
1Clean successTrueTrueyesTrueyes
2Clean successTrueTrueyesTrueyes
3Clean successTrueTrueyesTrueyes
4Clean successTrueTrueyesTrueyes
5Clean successTrueTrueyesTrueyes
6Clean successTrueTrueyesTrueyes
7Explicit failureFalseFalseyesFalseyes
8Explicit failureFalseFalseyesFalseyes
9Explicit failureFalseFalseyesFalseyes
10MalformedFalseTruenoFalseyes
11MalformedFalseTruenoFalseyes
12MalformedFalseTruenoFalseyes

Row by row on the rejects. Row 10’s {} is missing all three required fields. Row 11 has no structuredContent at all. Row 12 salvaged one field of three. All three fail for the boring reason that they do not have the shape, and the shape is all the gate looks at.

Pay off the plant. Narration gate: 3 false positives, ids 10, 11, 12. Schema gate: zero, twelve matches out of twelve, and no false negatives either. Both gates read the identical bytes; this ledger was computed by a script from the twelve raw responses and re-run before publishing, and every cell follows from the last section’s table by inspection.

Roll the twelve rows up by category:

CategoryCallsRefunds that happenedNarration confirmsSchema confirms
Clean success6666
Explicit failure (isError)3000
Malformed / ambiguous3030
Total12696

Both gates read the same twelve responses. One confirms nine, one confirms six, and six is the number the bank knows.

Look once more at where the narration gate’s three false positives came from. The explicit failures were never the problem: the server flagged those, and the narration passed the flag through. All three false positives came from responses where nothing in the bytes screamed failure, so the narration defaulted to the plausible continuation of a refund batch, which is “processed”.

The schema gate never read those three responses for meaning. It rejected them because they lack the declared shape. That is the trick, and it deserves a name: the boundary boolean, the single validated field, structuredContent.refunded === true, that your UI gates on instead of a sentence.

The swap is smaller than it looks and more radical. A sentence is a vote about what plausibly happened; a validated boolean is a fact about what shape arrived. The vote can be wrong on ambiguous bytes by design, because plausibility is all it ever measured. The boolean cannot be wrong about which shape arrived, because the shape is the only thing it checks. The fix replaces the judge with a reader of shapes, on the one question where judging was the wrong instrument.

A vote replaced by a boolean: that is the whole mechanism, and everything left is wiring.

Three pieces, and the first is written: the outputSchema above. The second is enforcement at the boundary, the half teams skip: anything that fails the schema gets rejected before it reaches the model or the UI:

from jsonschema import validate, ValidationError
def finish_tool(name, raw_result, schema):
try:
validate(instance=raw_result, schema=schema["outputSchema"])
except ValidationError as e:
# Never hand a half-shaped result up as "probably fine".
return {"isError": True,
"content": f"Output failed schema at {list(e.absolute_path)}: {e.message}"}
return {"isError": False, "structuredContent": raw_result}

A swallowed timeout that yields {} fails required right here, loud, at the boundary, instead of becoming a green checkmark three layers up. One spec note for honesty: under the current revision (2026-07-28), tool execution errors are supposed to surface as isError: true. Rows 10 through 12 model a server that skips that, which is precisely the realistic case the gate exists for. The spec says servers should flag failures; the gate catches the ones that arrive unflagged.

The third piece is the UI, one line of intent:

{result.structuredContent?.refunded === true
? <Refunded id={result.structuredContent.refund_id}
cents={result.structuredContent.amount_cents} />
: <NotRefunded detail={result.content} />}

The success state now has exactly one path in, through the validated boolean. A shape mismatch cannot render as success, because no other render branch exists.

The objection that would end this piece if it went unanswered: doesn’t structured output defeat the purpose of MCP? GitHub Discussion #1121, “Structured Output defeats the purpose of MCP - Turn off by default”, opened 2025-07-26 and still open, argues that structured output “hurts LLM response quality while making the tools/server ecosystem less interoperable” and “creates tight coupling between clients and servers, defeating MCP’s main advantage of self-orchestration”. The objection is right about over-typing and wrong about the consequential tools. Typing all of a server’s read tools couples your contract to every client, for safety nobody needs. Typing process_refund couples a client to exactly the three fields that define “this worked”, and that coupling is the product. On a refund tool, rigidity is the point.

The model still narrates, and that is fine. Let it talk. The narration has just stopped being the thing anyone believes.

A schema checks shape, not truth. The toy declared an honest server, and that rule was load-bearing. A buggy or compromised server can return a perfectly valid {refunded: true, refund_id: "rf_118", amount_cents: 4200} while the row sits untouched. The gate passes, the UI renders green, and you are holding a typed lie. Validation closes the gap where the model invents success from ambiguous bytes. A server that is itself wrong is a different job, and it lives on the server: derive refunded from the real effect (rows_affected == 1), and read the postcondition back before reporting it. The spec’s own wording is honest about the split: the server “MUST provide structured results that conform to this schema”, and nothing on the wire enforces that MUST. The spec requires conformance. It cannot make the server honest.

The result carries the payload twice, and clients disagree about which half to forward. For backwards compatibility, a tool returning structured content SHOULD also return the serialized JSON in a TextContent block, wording unchanged from the 2025-06-18 revision through 2026-07-28. The payload exists twice: typed in structuredContent, stringified in content. SEP-1624, “Clarify structuredContent vs content Usage Guidance” (filed 2025-10-08, still an open proposal), exists because real hosts have handled the two fields inconsistently, and proposes the split explicitly: content is “model-oriented output optimized for readability and token efficiency”, while structuredContent is “machine-oriented output for programmatic tool use”. Two consequences. If your UI falls back to reading content whenever structuredContent is missing, you have reintroduced prose-parsing on exactly the malformed responses you built the gate for; treat a missing structuredContent as a failure to surface, never as a cue to read the string. And the deeper consequence is the one the close comes back to: the field you gate the screen on is, by this proposal’s own framing, not reliably the field the agent reads back for its own next decision.

When not to use this. Reads do not need it, and typing every tool on the server is the over-reach Discussion #1121 is right to push back on. A schema tuned too tight converts valid-but-changed payloads into false failures: mark every field required, and one added field turns a real refund into an “action failed” your operators learn to click past. A dismissed error boundary is no boundary. Keep required scoped to the literal definition of “this worked”, and type the consequential tools first: refund, delete, charge, deploy, notify.

Two dated facts, so the samples cannot read as unaware of the current spec. The 2025-06-18 revision that introduced these fields restricted structuredContent to a JSON object; the current 2026-07-28 revision says it “can be any JSON value (object, array, string, number, boolean, or null)”. The object-shaped samples here remain valid, and an object is the shape a gating boolean actually uses. And the same two fields now have a second, currently larger use: the MCP Apps extension (io.modelcontextprotocol/ui) renders UI widgets from structured tool results. That is a different job sharing the same plumbing. This piece covers the success/failure boundary; widget rendering is a separate subject.

Validation buys a trustworthy channel from the server to the screen. It cannot buy an honest server, and it does not yet buy a grounded agent.

CategoryCallsRefunds that happenedNarration confirmsSchema confirms
Clean success6666
Explicit failure (isError)3000
Malformed / ambiguous3030
Total12696

Row by row, one last time. The clean-success row teaches only that neither gate breaks honest traffic. The explicit-failure row corrects the easy charge that the model narrates blindly: given a flagged error, it passed the reason through on all three. The malformed row is the entire piece. Three responses with no flag and no payload: three confirmations that never happened under the narration gate, zero under the schema gate. Twelve calls, nine or six confirmed, and the difference is one declared shape and one validation step.

One loose end, and this fix created it. The boundary boolean protects the human’s screen: no malformed response can render as a green refund. The agent’s own next move is a different matter. The conversation the model continues from still contains content, the string field, because that is the field the model reads. SEP-1624’s own framing is that content is model-oriented and structuredContent is machine-oriented, which means the bytes your gate rejected and the bytes your agent reasons from can be two different copies. On call 10, the screen now says “not confirmed” while the model still holds “Refund processed.” in its window as it drafts the customer’s reply. The gap this piece just closed for the human’s view may still be open one hop later, in the agent’s own reasoning, and nobody has shipped that fix yet.

Twelve calls, 3 false confirmations or 0. One declared shape and one check decide which column you live in, and for now they decide it only for your screen.


About the numbers. The Saturday batch is a toy: the amounts, the failure texts, the narration lines, and the ground truth (six refunds moved, six did not) are invented for traceability, so every verdict in the ledger can be checked by inspection. Both gates and every table cell were computed by a script from the twelve raw responses and re-run before publishing: the narration gate matches ground truth on 9 of 12, with exactly 3 false positives (call ids 10, 11, 12) and no false negatives, and the schema gate matches on 12 of 12. Every dated claim is quoted from a named primary source, fetched 2026-08-16: the MCP specification’s 2025-06-18 and 2026-07-28 revisions for the outputSchema duties, the backwards-compatibility wording, and the isError rule; GitHub Discussion #1121 (opened 2025-07-26) and SEP-1624 (filed 2025-10-08) for the community positions. The last two were open and unresolved at that fetch; treat them as live debate, and re-check their status before you cite them.

Where the neighbors sit: Elicitation plus tool annotations gates the call before it runs, where this piece gates what the call claims after it returns. The server is the trust boundary holds the credentials a call may use, an input-side authority question, where this asks whether a returned effect is real, an output-side truth question. The confused deputy server is a server-architecture problem, one server holding two trust levels, where this is a per-tool contract problem. Make a hook validate the agent’s output checks the agent’s proposed action with a host-side hook before it runs; this checks the tool’s returned result with the spec’s own outputSchema after. Make the tool ask instead of fail elicits a missing input the agent could not have had, where this refuses to believe an untrusted output the agent already received. Three tag-family neighbors with no argument overlap: One server, not ten integrations, Retrieval vs generation failures, and Instructions are not data.

For the per-tool mechanics, see MCP servers for declaring an outputSchema on each consequential tool, Permissions for the gate on the way in that this piece extends to the way back, and Headless & CI for the unattended batch where the gap bites hardest.