Your Agent Isn't Confused. Your Schema Lied to It.

TL;DR: Most teams debugging a flaky agent are tuning prompts and trimming tool counts. The defect is usually one layer down: a field published in tools/list that the API answers with a 400. A person reads the error and moves on; an agent starts guessing. The fix is structural: the schema you offer must be smaller than the schema you accept, and a compiler check can guarantee it.There's a genre of post going around: teams build an MCP server with 50-plus tools, watch their agents stumble through tool selection, and conclude the protocol is broken. One AWS Heroes write-up(opens in new tab) opens on exactly that scene, and the effect is measurable: production telemetry shared by Mayank Khandelwal(opens in new tab) puts Claude Haiku 4.5 at 91% tool-selection accuracy with 10 tools and 87% with 15. There's academic work on the same question(opens in new tab), asking how many tools an agent should see at all.
The usual remedies are tool count and prompt wording. We shipped an MCP server for Clipwright(opens in new tab), our video generation API, and hit a failure that neither remedy touches, because it isn't about how many tools you expose. It's about which fields you show, and whether you meant them.
The caller can't read your docs, and that changes the contract
A REST API assumes a human integrator. They hit a confusing field, open the docs, read a paragraph, and form a theory about what it does. The docs are load-bearing: they carry meaning the schema does not.
An agent reads tools/list. That's the entire surface. Two consequences follow, and the second one is where the bugs live.
Anything not in the schema does not exist. Not "is undocumented". Does not exist. A capability you shipped but never described is one the agent will never call.
Anything in the schema is a promise. The agent can't discount a field or read hedging in your tone. If it's offered, it's real, and the agent will reach for it when the task seems to call for it.
So when a field is published but always rejected, you've created a trap that only springs for non-human callers. Show a person a field and refuse it: they read the error, shrug, move on. Show an agent the same field and it sees the 400 as a puzzle. Maybe the value was malformed. Maybe it needs a companion field. It retries variations, because "this field is offered but never accepted" is not a sentence any schema can express.
The offered schema is smaller than the accepted schema
Our stack derives everything from one set of Zod schemas: the REST route validates with it, the SDK types come from it, and the CLI and MCP server both consume the SDK. One source, four surfaces.
What was wrong was assuming all four should offer the same schema.
REST accepts the full input schema. The MCP server offers a deliberately smaller one: the full schema minus every field we answer with a 400. Our server's comment puts the reasoning plainly: Taken further, the same reasoning explains why there is no OpenAPI document at all.
fields with a rejected disposition must not be offered to a caller we are going to punish with a 400 for using themThe offered set is derived from a registry of decisions rather than hand-maintained, so changing our mind about a field moves it between surfaces in one line instead of three.
The tool description has to stay in line with the schema too, for a reason specific to LLM callers: they read the description before the schema. A description promising "1080p 9:16" while the schema says otherwise is the same defect one layer up, offering something you won't honor, just in prose.
Three dispositions, and the compiler checks you picked one
Every input field carries exactly one recorded decision.
| Disposition | Meaning | Where the caller finds out |
|---|---|---|
implemented | reaches the vendor; a test proves it | it works |
rejected | 400 at the edge, before a run exists | synchronously, on the call |
warned | not honored, but refusing would be worse | in warnings[] on the run |
The mechanism that keeps this honest is one line of TypeScript. The table is declared as const satisfies Record<keyof typeof makeUgcInputShape, Disposition>, so a new input field with no recorded decision does not compile. The comment in our codebase: a new field without a decision does not get through, no matter who added it, including the plan itself.
That's the part worth stealing even if you never write an MCP server. The usual guard against "field silently ignored" is a test per field, which holds until someone adds a field and forgets the test, exactly when you needed it. Making the absence of a decision a build error moves the guard from discipline to mechanics.
One subtlety you'll hit if you try this: a naive "field present → warn" rule fires on every happy path, because fields declared with .default() are always present after parsing. Our trigger has two modes: warn when the field is present, or only when it differs from the default.
Warn or refuse? The answer is about channels, not severity
warned and rejected look like a severity dial. They aren't. The deciding question has nothing to do with how bad the problem is: on which channel will the caller learn about it?
Two fields make the distinction concrete.
An ignored look (visual styling) degrades the output. The caller gets a video, it isn't styled as asked, and a warning on the run explains why. That warning lands on a channel they're already reading, because they're polling for the video anyway.
An ignored webhook_url breaks the integration outright. From our disposition registry:
An ignoredlookruins the frame — the user sees the result. An ignoredwebhook_urlbreaks the integration: the agent subscribed and STOPPED polling, whilewarnings[]are delivered throughget_run— the very channel the client opted out of. A warning on a channel nobody is listening to is not a disclosure.
That last sentence is the rule, and I haven't seen it stated anywhere else. A warning only counts as disclosure if it reaches someone. Warn about the webhook and the disclosure goes to a channel the caller has, by definition, stopped reading. So webhook_url is refused synchronously instead, with a message naming the alternative: poll get_run.
A refusal that doesn't say what to do next is, for an autonomous caller, indistinguishable from a broken API. It's the same lesson we hit building a kill switch before shipping an agent: the machine needs the exit named, because it won't infer one.
Long operations: put the next instruction in the response
Video generation takes minutes; MCP calls don't. So make_ugc returns a run_id immediately and the agent polls get_run.
The polling loop is where LLM callers diverge from code, and the fix was one field. A real in-progress response:
1{
2 "status": "IN_PROGRESS",
3 "run_id": "run_…",
4 "state": "rendering",
5 "video_url": null,
6 "warnings": [],
7 "next_action": "Video is NOT ready. Call get_run with run_id=run_… again in ~5 seconds. Repeat until state is 'succeeded' or 'failed'. Do NOT tell the user the video is done until you have a video_url."
8}
next_action is an imperative in the body, not in the docs. That last clause was written against an observed behavior: a model receiving a successful-looking response and telling the user the video is ready, because the call succeeded. The run hadn't.
Two more details earn their place. warnings appears in every branch, including failure. Discrepancies never go quiet, even when the run dies for an unrelated reason. And failures expand into fields, not prose. A 402 means "stop and tell the human these numbers." A 429 means "wait this long, repeat the same call." Opposite instructions, and an agent can only separate them in free text by guessing, so the failure carries retryable, a next_action, and the numbers.
That last part isn't only our conclusion. A developer writing up MCP error design independently(opens in new tab) landed on the same shape: errors as sentences naming the field, the reason, and the next move, including an explicit "do not retry until resolved." Two teams converging on the same rule from different products is worth more than either team's opinion.
What we didn't find
We don't have an incident where REST failed and MCP saved us. MCP is the primary surface because the product's intended caller is an agent, not because REST fell over. If you want a war story justifying the protocol, we can't sell you one.
The real friction was duller. A Zod schema that's been through .refine() becomes a ZodEffects and stops exposing .shape, so the raw shape lives separately and the refined schema gets rebuilt from it. Miss that and your two definitions drift apart quietly.
Five things to do this week
- List every input field and write one decision each: honored, refused, or warned. The exercise finds bugs on its own. Ours surfaced a field that was read inside the task and looked fine, but never reached the vendor.
- Make a missing decision a build error.
satisfies Record<keyof Shape, Disposition>costs one line and replaces a test-per-field convention nobody maintains. - Subtract refused fields from what you offer. Offered and accepted are different objects; the difference is the whole point.
- For each warning, name the channel that carries it. If the caller stops listening to that channel because of the very thing you're warning about, refuse instead.
- Put the next instruction in the response body. Your docs aren't in the agent's context window. Your JSON is.
Open your own tools/list output and read it as an agent would: every field there is a promise. If any of them answers with a 400, you found your flaky agent, and it isn't the model's fault.
Clipwright is in beta. The surface described here (four tools, the disposition registry, the polling contract) is what it ships today.
FAQ
What is an MCP server?
Model Context Protocol is a standard for exposing tools to LLM agents. The server publishes tool definitions (name, description, input schema) through tools/list, and the agent calls them. Practically, it's an API whose integrator is a model rather than a person.
Why not just document which fields don't work?
Because the agent never reads documentation. It reads the tool schema and the tool description, and that's the entire surface. A field marked "not supported yet" in your docs is, to an agent, a supported field.
Does every API need a disposition registry?
The compile-time half is cheap enough for most codebases: one satisfies clause turns "we forgot to decide" into a build failure. The warned-versus-rejected split matters most when callers are autonomous, because a human recovers from a confusing rejection and an agent retries it.
Sources
- MCP tool design: why your AI agent is failing(opens in new tab), AWS Heroes
- Your MCP server speaks HTTP. Your LLM doesn't.(opens in new tab), independent convergence on error design
- Model Context Protocol specification(opens in new tab)
- Why multi-step agents compound failure
- MCP servers in vertical niches solo founders should build now
Three interfaces, one key
MCP server, CLI and REST over one contract, with a registry that stops a field going quiet.
About the Author
Dimantika
Co-founder of Dimantika. Builds Clipwright and ViralFaceless with coding agents. Previously ran GlockSoft with a partner for about 15 years. Writes about products and finding customers.
View all postsAPI contracts for AI agents
How do you design an API that AI agents can call safely?
- We Shipped an Agent-Native API Without OpenAPI
No /openapi.json, no generator, no spec to drift. The machine-readable entry point is the MCP server's tools/list — and that turned out to be the honest choice, not the lazy one.
- Failed Is Not a Terminal Status: A Contract for Agents
A run can fail on our own timeout while the paid work can still finish. Why an agent should not treat failed as a terminal status without a reason it can read.
- A New Enum Value Breaks Clients That Trusted Your List
A new enum value passes every schema diff and still breaks clients that validate responses. Read as text, send from a closed list, and mark it Breaking.
- Negotiate the Format Before You Pay the Vendor
A 3% aspect mismatch is a rounding error. A 40% one is a different video. The threshold between them decides whether you refuse before the bill or apologise after.
- Never Answer an Agent With a Silent Substitution
An unmet parameter should come back named. We learned why the hard way: two warnings went missing for weeks and every test stayed green.
- Your Agent Can Branch on a Refusal. It Cannot Branch on a Warning.
Refusals in our API carry stable codes. Warnings carry none, on purpose — and the bug that proved why cost us two warnings nobody noticed were missing.
- We Charged 900 Credits for a Video That Didn't Exist
Our billing rule said: failed run after a paid vendor call means we charge. It sounded fair. On a run that only paid for speech, it was false, and it cost a customer.
More articles you might like.

A New Enum Value Breaks Clients That Trusted Your List
A new enum value passes every schema diff and still breaks clients that validate responses. Read as text, send from a closed list, and mark it Breaking.

We Charged 900 Credits for a Video That Didn't Exist
Our billing rule said: failed run after a paid vendor call means we charge. It sounded fair. On a run that only paid for speech, it was false, and it cost a customer.

One Reviewer Approved It. The Other Found the Bug.
Six blockers across four consecutive stories, and the two reviews agreed on one of them. Here is what running both actually cost us, and the rule we ended up with.