Your MCP Tool Schema Is Legal and Your Model Still Can't Obey It
The 2026-07-28 MCP spec loosened tool schemas to the full JSON Schema 2020-12 vocabulary, so you can finally write a oneOf, an if/then, a bare array return. The trouble is that the constrained decoding that forces a model to obey a schema supports a much smaller subset.
A team I was helping shipped a new internal MCP server the week the 2026-07-28 spec landed. One of its tools returned a bare array of result rows, which the new revision finally permits, and the author wrote a clean output schema with a oneOf to describe the two shapes a row could take. It validated against the spec's own meta-schema. It passed every server test they had. Then in production the agent calling that tool produced malformed arguments on roughly one call in six, retried, and eventually gave up, on a tool that was, by every check they owned, correct.
The schema was legal. The problem was that the model on the other end could not be constrained to it. MCP 2026-07-28 loosened tool schemas to the full JSON Schema 2020-12 vocabulary, and the structured-output machinery that actually forces a model to obey a schema honors a much smaller subset. The gap between what the spec lets you write and what a decoder can enforce is a new failure surface, and it sits exactly at the boundary you most want to trust: the tool contract.
What the 2026-07-28 spec now lets you write
Until this revision, MCP deliberately boxed in tool schemas. An outputSchema had to be a type: "object", which forced a tool that naturally returns a list to wrap it in a pointless container. The motivation for the change, written plainly in SEP-2106, is a weather tool that should return a bare array of hourly forecasts but had to bury it under a wrapper object. That restriction is gone.
The new rules are broad. The 2026-07-28 changelog says the spec now loosens inputSchema and outputSchema to allow any JSON Schema 2020-12 keywords, and lets structuredContent be any JSON value. In practice that means inputSchema must still be an object at the top but may use everything else, outputSchema fully supports 2020-12 including arrays, primitives, and compositions, and the composition keywords (oneOf, anyOf, allOf, not), the conditional keywords (if, then, else), and the reference keywords ($ref, $defs, $anchor) are all on the table. This is the same spec revision that moved version negotiation to every request, and it has the same flavor: a correct, well-motivated change that quietly hands the client a new job.
The vocabulary itself is not exotic. JSON Schema 2020-12 is the current release, organized into vocabularies for core, applicator, validation, unevaluated, and more. A server author reaching for oneOf to say "this field is one of these two shapes" is writing ordinary, idiomatic schema. The meta-schema accepts it. Your linter accepts it. The spec now accepts it. Everything accepts it except the one component whose whole purpose is to make the model produce it.
What constrained decoding actually honors
The reason native structured output works is constrained decoding. The provider compiles your schema into a grammar and, at each step, masks the tokens that would violate it, so the output is forced to match. It is the strongest guarantee you can get, and it is also the component with the smallest vocabulary.
Read what the providers actually support. OpenAI's structured outputs guide states that while it supports much of JSON Schema, some features are unavailable, and the list is specific: allOf, not, dependentRequired, dependentSchemas, if, then, and else are not supported, on any model. Strict mode also requires additionalProperties: false on every object with every key listed in required, so an optional field has to be modeled as nullable rather than omitted, and it caps a schema at 5000 object properties and 10 levels of nesting. Fine-tuned models drop even more: minLength, maxLength, pattern, format, minimum, maximum, multipleOf, patternProperties, minItems, and maxItems all fall away. Other providers draw the line in different places, which is the real point: the enforceable set is a moving subset, not a constant.
So line the two up. The spec vocabulary for a tool schema is now all of 2020-12. The enforceable vocabulary is a provider-specific and model-specific subset that, on a bad day, excludes composition, conditionals, and every numeric and length bound you wrote. The oneOf from the opening lives in the first set and not the second. There is nothing wrong with the schema. There is a mismatch between the layer that permits it and the layer that has to produce it, and that mismatch is yours to notice, because neither layer will raise.
Where the gap opens
The failure has three shapes, and only one of them is loud.
The first is the quiet weakening. The client sees a schema with keywords the decoder cannot handle, strips them so the call does not error, and constrains against what is left. A oneOf of two shapes collapses to an unconstrained object. The model is now free inside the hole you opened, and it will eventually wander into it. You believe the schema is enforced because you attached it; in truth the enforced schema is a shadow of the one you wrote. This is the most dangerous mode because it looks exactly like success until the sixth call.
The second is the honest fallback that nobody wired up honestly. The client declines to constrain against a schema it cannot fully honor and generates freely, then validates the result after the fact. That is a defensible design if the validation and repair are real. Usually they are not, so the failure surfaces as an intermittent validation error on precisely the keyword that could not be constrained, and the team treats it as model flakiness and turns up retries. This is the same reflex I warned about in matching reasoning effort to tool calling: spending more compute on a problem that was structural, not stochastic.
The third is the silent disappearance. A stricter client rejects a tool whose schema it cannot compile, and the tool drops out of the model's available set with no error anyone sees. The agent simply never calls it and routes around the capability, the same shape of silent loss as a removed protocol method after an upgrade. You find it when someone asks why the agent stopped using a tool that, again, is perfectly correct.
Lint the schema before you register the tool
The fix starts before any model runs. Every tool schema should pass through a gate that projects it against the subset your decoder enforces and reports exactly which keywords will be dropped or will cause a rejection. Run it in CI next to your other checks, so a schema that cannot be constrained fails the build rather than production.
Here is the kind of schema that passes everything and bites. It is valid 2020-12, valid MCP, and unconstrainable on a strict decoder.
{
"type": "object",
"properties": {
"result": {
"oneOf": [
{ "type": "object", "properties": { "temp_c": { "type": "number" } } },
{ "type": "array", "items": { "type": "number" } }
]
}
},
"if": { "properties": { "unit": { "const": "f" } } },
"then": { "required": ["temp_f"] }
}
The linter does not try to be a full JSON Schema engine. It walks the schema, collects the keywords present, and subtracts the set your target model enforces. What remains is the list of silent weakenings waiting to happen.
# Keywords a strict decoder cannot enforce. Keep this per provider/model, read
# from their docs, not from memory: the set moves and differs between models.
UNSUPPORTED = {
"allOf", "not", "if", "then", "else",
"dependentRequired", "dependentSchemas",
"oneOf", # some decoders take anyOf but not oneOf
"patternProperties", "unevaluatedProperties",
}
def lint_tool_schema(schema: dict, path: str = "$") -> list[str]:
problems = []
for key, val in schema.items():
if key in UNSUPPORTED:
problems.append(f"{path}: '{key}' is not enforceable by the decoder")
# objects: every key must be required, extra keys banned
if key == "properties" and isinstance(val, dict):
declared = set(val)
required = set(schema.get("required", []))
if declared - required:
problems.append(f"{path}: optional keys {declared - required} "
f"must be required and nullable instead")
if schema.get("additionalProperties") is not False:
problems.append(f"{path}: needs additionalProperties: false")
for name, sub in val.items():
problems.extend(lint_tool_schema(sub, f"{path}.{name}"))
return problems
Fed the schema above, the gate reports the oneOf, the if/then, and the missing additionalProperties: false in one pass. Now you have a decision to make on purpose, in a pull request, instead of a decoder making it silently in production.
Project the schema, then validate what comes back
The decision is usually not "drop the tool." It is "keep two schemas." Hold the full 2020-12 schema as the contract your server validates structuredContent against, and project a decoder-safe version to hand the model. The projection flattens oneOf into the broadest single shape the decoder accepts, turns optional fields into nullable required ones, and strips bounds the model cannot be held to. Then, crucially, you put the strictness back on your side with a validation gateway that checks the real response against the full schema.
// Constrain with the projection the decoder can honor; validate against the
// true schema. The projection is a hint; the validation is the contract.
async function callTool(tool: Tool, args: unknown, model: Model) {
const safe = projectToEnforceable(tool.inputSchema); // decoder-safe subset
const raw = await model.call({
tool: tool.name,
schema: safe, // what the model is actually constrained to
arguments: args,
});
// The decoder honored `safe`, not `tool.inputSchema`. Re-check the full thing.
const check = validateFull(raw, tool.inputSchema); // oneOf, if/then, bounds
if (!check.ok) {
// Do not retry blind. Feed the exact failure back once, then fail loudly.
return repairOnce(model, tool, raw, check.errors);
}
return raw;
}
This is the validation gateway pattern applied to the tool boundary: one place that owns the call, the constraint, and the check, and that returns a typed result or an explicit failure, never a half-matched object. When repair runs out of budget it raises, which is the same discipline as validating and recovering a tool call rather than looping on a structural failure. The projection is honest only because the validation behind it is strict. Drop the validation and you are back to the quiet weakening, just with more code.
The parts that bite
Two schemas means two schemas to keep in sync. The moment you project a decoder-safe schema and keep a full one for validation, you own the drift between them. A field added to the server's contract but not to the projection is a field the model is never asked for. Generate the projection from the full schema at build time so it cannot fall behind, and fingerprint both, because a tool definition that changes between fetches can change either one.
$ref is a security boundary now, not just ergonomics. SEP-2106 is explicit that implementations must not automatically dereference $ref URIs that are not same-document JSON pointers, that external fetching defaults to off, and that a schema's depth, subschema count, and per-validation time should be bounded so a malicious tool definition cannot burn your CPU. If you flatten a schema for the decoder you are resolving refs yourself, so inherit those limits rather than writing a resolver that trusts the input.
The enforceable subset is per model, so a schema that constrains today weakens on the next model. The subset OpenAI documents is not the one another provider documents, and a fine-tuned variant drops more than its base. Pin the model your projection targets, and re-run the lint gate when you change models the same way you would re-run evals, because a silent downgrade of the constraint will not show up as an error anywhere.
structuredContent as any JSON value breaks old typed consumers. Widening it from object to unknown is source-breaking for SDK code that accessed properties without a guard, and a server returning an array or primitive must also emit a text block with the serialized JSON so older clients do not choke. The flexibility the spec gained on the wire is flexibility your call sites have to actually handle.
Fine-tuned models lower the floor further than you expect. If you fine-tune, the decoder may drop minimum, maximum, pattern, and length bounds on top of the composition keywords. A schema that was safely constrainable on the base model can quietly lose its numeric guards on the tuned one, and the values will look plausible right up until one does not.
The takeaway
The spec loosening is the right change. A tool that returns a list should be allowed to say so, and the full JSON Schema 2020-12 vocabulary is the correct contract language for the side of the boundary your server controls. The mistake is to assume that because the spec now accepts a schema and your meta-schema validates it, the model can be made to produce it. Those are different guarantees from different layers, and the 2026-07-28 revision widened the first without touching the second. The gap between them is where a correct tool starts emitting malformed arguments, and nothing in your stack will raise to tell you it opened.
So treat the tool schema as two things: a rich contract you validate against, and a narrower projection you constrain with, kept in sync and gated in CI. Lint every schema against the subset your decoder actually honors, project deliberately rather than letting a client strip keywords behind your back, and put the strictness back with a validation step that fails loudly. If your agents call MCP tools and you are not sure which of your tool schemas are being quietly weakened before they reach the model, book a consultation call and we can audit the boundary before it audits itself in production.
Questions this essay answers
01Why does a valid MCP tool schema still produce malformed tool calls?
Because validity and enforceability are two different things. The 2026-07-28 spec lets a tool schema use any JSON Schema 2020-12 keyword, so a schema with oneOf, if/then, or allOf passes the spec meta-schema and your server tests. The structured-output decoder that forces the model to emit matching arguments supports a smaller subset. When the schema uses a keyword the decoder cannot enforce, the client either weakens the constraint or stops constraining, and the model is then free to produce arguments that do not match. The schema is correct; the model was never actually held to it.
02Which JSON Schema keywords does constrained decoding not support?
It varies by provider and model, so the only safe answer is to read the current docs for the model you use. As a common shape, strict structured output on major providers rejects allOf, not, if/then/else, and the dependent-schema keywords, requires additionalProperties false with every key listed as required, and caps total properties and nesting depth. Fine-tuned variants often drop numeric and length constraints such as minimum, maximum, pattern, and minLength as well. Treat the enforceable set as a moving subset you detect, not a constant you can assume.
03Should I just avoid the new JSON Schema 2020-12 keywords in MCP tools?
In the schema you hand the model, mostly yes: keep it inside the subset your decoder enforces so the constraint is real. That does not mean you lose the richer vocabulary everywhere. You can keep a full 2020-12 schema as the contract your server validates structuredContent against, and project a decoder-safe version for the model. The two must stay in sync, or you constrain against one shape and validate against another. The spec loosening is genuinely useful for the server side of the contract; the restraint belongs on the side the model has to satisfy.
04Does native structured output guarantee my tool arguments match the schema?
Only for the part of the schema the provider actually enforces, and only on the models where enforcement is hard rather than best effort. Schema constrained decoding makes shape and type failures largely disappear for supported keywords. It says nothing about keywords it had to drop, nothing about whether the values are correct, and nothing about providers where the strict flag is advisory. The durable contract still lives in your code: a validation gateway that checks the response against the full schema and fails loudly when it does not match.
