When an LLM agent calls the wrong tool or sends malformed arguments, the postmortem usually blames the model — but the actual defect is almost always in the JSON Schema the tool was registered with.
Every team running LLM agents in production has a folder of tool-calling failures they've learned to live with: the model calls search_docs when it meant search_code, it passes a string where the schema wanted an enum, it fills a required user_id field with the literal text "the current user". The postmortem usually says "the model hallucinated" and the fix is a longer system prompt telling it not to. That's treating the symptom. In the overwhelming majority of cases the model didn't hallucinate anything — it made a locally reasonable decision given an ambiguous or underspecified tool definition, and the schema is the artifact that was actually wrong.
This matters more now than it did two years ago, because tool-calling has quietly become the primary interface between LLMs and the world. Chat is the demo; function calls are the product. An agent that can read a filesystem, hit an API, or query a database via MCP is only as reliable as the JSON Schema sitting behind each of those capabilities — and most teams write that schema the way they'd write an internal-only type definition, not the way they'd write a public API contract for a consumer with no ability to ask a clarifying question.
The first thing to internalize is that a tool definition isn't consumed by a type checker — it's consumed by an attention mechanism reading serialized text. When you register a tool with the OpenAI, Anthropic, or MCP tool-calling APIs, the runtime doesn't hand the model a TypeScript interface or a Pydantic class. It flattens your JSON Schema into a block of text — property names, types, descriptions, enum values, required-field lists — and inserts it into the context window alongside the system prompt and conversation history. The model then has to infer, from that flattened text, what the tool does, when to call it, and how to fill in each argument.
This has a concrete consequence: every part of a tool schema that a human reader would skim past — a vague description, a type left as string when it should be a constrained enum, a parameter name like data or value — is load-bearing for the model in a way it isn't for a compiler. A TypeScript compiler doesn't care if data: string is a bad name; it only cares about the type. A model calling that tool has only the name and description to go on, because by the time it's generating the call, the schema is the entire specification of intent it has access to.
Anthropic's and OpenAI's own tool-use documentation both say some version of "write tool descriptions like you're onboarding a new engineer who has never seen your codebase," which is the right instinct but understates the problem. A new engineer can read your source code, ask in Slack, or infer conventions from sibling files. A model mid-generation can do none of that. It has the schema, the conversation so far, and nothing else.
Four failure patterns account for most of the tool-calling bugs teams report, and none of them are exotic.
Ambiguous or overlapping tool boundaries. Registering both get_user and fetch_user_details in the same tool list, with descriptions that both say roughly "retrieves user information," gives the model a coin flip. It will pick whichever one appears first in the list, or whichever one's description happens to echo a word from the user's message, and that choice will look arbitrary from the outside because it effectively was. The fix isn't a longer description on either tool — it's not registering two tools that do the same thing, or if they genuinely differ, making the difference the first sentence of each description ("get_user returns only the fields visible on a public profile; use fetch_user_details when you need billing or admin fields").
Types that are technically valid but semantically useless. A status parameter typed as string when the backend only accepts "pending" | "active" | "closed" will get filled with "Active", "is_active", or "currently active" about as often as it gets filled correctly, because nothing in the schema told the model the universe of valid values was that small. Constraining it to an enum doesn't just validate the output after the fact — it changes what the model generates in the first place, because the enum values are now part of the text it's conditioning on.
Required fields with no matching property, or properties absent from required that the backend actually needs. This one sounds like it shouldn't happen, but it's endemic in hand-maintained JSON Schema, especially schemas that were edited by hand after being auto-generated once and drifting out of sync with the actual function signature. The model has no way to know your required array is stale; it will happily omit a field the array doesn't list, and your backend will 500.
Deeply nested optional objects for what's really a flat set of choices. Modeling a tool's configuration as a nested options.formatting.dateStyle object because that's how your internal config file is structured asks the model to correctly reconstruct a shape it has to infer from a schema tree, instead of filling in a handful of top-level parameters. Flatter schemas measurably reduce malformed-call rates for the same reason flatter function signatures are easier for a junior engineer to call correctly — there's less structure to get wrong.
Here's a tool definition that looks reasonable at a glance and fails in exactly the ways described above, next to a version that fixes each issue without changing what the tool does:
// Before — ambiguous, loosely typed, and out of sync with the backend
{
"name": "update_record",
"description": "Updates a record",
"parameters": {
"type": "object",
"properties": {
"id": { "type": "string" },
"data": { "type": "object" },
"status": { "type": "string" }
},
"required": ["id"]
}
}
// After — scoped name, enum instead of free string, required list matches reality
{
"name": "update_ticket_status",
"description": "Updates the status of an existing support ticket. Use this only to change status (open/pending/resolved/closed); use update_ticket_fields to change subject, assignee, or priority.",
"parameters": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string",
"description": "The ticket's unique ID, e.g. 'TKT-4821'. Not the customer's user ID."
},
"status": {
"type": "string",
"enum": ["open", "pending", "resolved", "closed"],
"description": "The new status. 'resolved' means the fix shipped; 'closed' means the customer confirmed it."
}
},
"required": ["ticket_id", "status"]
}
}
Nothing about the second version is exotic — it's just specific where the first one was vague, and every place it's specific removes one axis of guesswork the model would otherwise have had to fill in by pattern-matching against its training data instead of your actual system.
Modern tool-calling APIs let a model emit several tool calls in a single turn, which is where schema ambiguity compounds instead of just costing one retry. If two tools have overlapping purposes, a model asked to "check the user's order status and update their shipping address" might call the wrong one for each sub-task, in parallel, with no intermediate result to self-correct against before both calls execute. Sequential tool-calling gives the model a chance to observe a bad result and recover; parallel calling front-loads the decision and executes on it blind. That raises the cost of an ambiguous schema from "one wrong call, one retry" to "two wrong calls, two side effects, and a harder-to-untangle failure to explain to the user."
The practical implication is that as you adopt parallel tool calls for latency reasons, schema precision stops being a nice-to-have and becomes a correctness requirement, because you're removing the model's ability to course-correct mid-task.
The diagram makes the actual leverage point obvious: schema quality only gets to act once, at the "model generates call" step, before either branch — reject-and-retry or execute — happens. Everything downstream (validation errors, malformed-argument retries, wrong-tool selection) is a symptom that traces back to that single upstream point. Adding retries, better error messages on validation failure, or a longer system prompt all intervene after the point of leverage instead of at it.
Model Context Protocol servers expose tools the same way — name, JSON Schema, description — but MCP's ecosystem model makes schema quality matter even more, because a single MCP server's tool list is often assembled by combining functionality from a team that didn't write the description with any specific downstream model in mind. An MCP tool's description has to work simultaneously as documentation for the human wiring up the server, as the disambiguating text a model uses to pick between tools, and increasingly as the input to automated schema linting that catches naming and structure problems before a client ever calls the tool. When those three audiences pull in different directions — terse for the human, exhaustive for the model, structurally clean for a linter — descriptions tend to collapse to the lowest common denominator, which is usually "terse," which is the one that hurts model accuracy most.
If you're exposing tools over MCP or building a tool list for direct OpenAI/Anthropic function calling, it's worth linting the schema itself before you ever test it against a model — checking for missing descriptions, empty enums, required fields with no matching property, and vague names catches a large fraction of these issues mechanically, before you spend a debugging session attributing a validation failure to "the model." Utilix's Tool-Definition Schema Linter runs exactly that class of check against OpenAI, Anthropic, and MCP-format tool definitions, and it's a five-second gate worth running on every tool before it goes anywhere near a model.
When a tool call comes back malformed or aimed at the wrong function, the fix that actually moves the reliability needle is almost never a longer system prompt — it's tightening the schema: give every tool a name and description that disambiguates it from its neighbors in one sentence, replace free-form strings with enums wherever the valid set is actually small and known, keep required in lockstep with what the backend enforces, and flatten nested structures the model has no way to infer correctly from text alone. Treat your tool schema like a public API contract written for a caller who can't ask a follow-up question, because that's exactly what it is.