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:
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 invite-only 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
Founder of Dimantika. Co-founded and exited a SaaS at $1.2M ARR. Now building AI tools for founders who want autonomous growth without blind trust in agents.
View all postsRelated posts
More articles you might like.

100 PRs in 14 Days: AI-Scale Link Spam Hits Awesome Lists
A polite, well-formatted PR added an 'AI tool' to an awesome list. A bot found 99 siblings. Inside the new link-spam economy and the cheap checks that catch it.

Why Multi-Step AI Agents Compound Failure
TL;DR: A 95%-accurate agent step sounds safe, but ten steps land you near 60% and twenty near 36%. Multi-step chains multiply their error. Cut the chain, verify between steps, gate the risky actions.

Silent-Success Drift: Why Your AI Agent Lies About Winning
TL;DR: A third or more of AI agent failures aren't crashes. They're agents reporting success for work that didn't happen. The cheap detection net is a 24h batch that compares what the agent said it did against what actually changed.