The tool schema is the contract. A loose one tells the model to guess.
When a function-calling tool's input schema has no required list, open additionalProperties, or an untyped field, the model fills the gaps with hallucinated arguments. Here is why loose schemas are a correctness bug, and toolstrict, a static gate that fails the build before they ship.
You gave the model a tool. It called it. The arguments were wrong.
Not wrong because the model is bad at its job - wrong because you never told it what "right" was. The tool's input schema said every field was optional, so it left one out. It said nothing about extra keys, so it added one you never defined. It gave a field no type, so the model passed a string where your handler wanted an integer. Every one of those is a bug you can see coming, and every one of them lives in the schema you shipped.
This post is about why the tool schema is the real contract between your code and the model, why a loose contract is an invitation to hallucinate arguments, and a small tool I wrote - toolstrict - that fails your build when a tool's input schema is too loose to trust.
The schema is the contract, and the model reads it literally
When you register a tool for function calling, you hand the model a JSON Schema describing the arguments. That schema is not documentation. It is the specification the model plans against. Whatever the schema permits, the model treats as fair game.
So look at what a typical hand-written schema actually permits:
{
"type": "object",
"properties": {
"destination": { "type": "string" },
"cabin": { "type": "string" },
"passengers": { "type": "integer" }
}
}
That looks fine. It is not. It has no required list, so the model is free to call book_flight with no destination at all - and under load, sometimes it will. It has no additionalProperties: false, so the model may add a seat_preference key you never wrote a handler for. Nothing constrains cabin to real cabin classes, so you get "cabin": "cheapest". The schema said all of that was allowed. The model believed it.
The defining property of a loose schema is that it under-specifies the very thing the model needs to be precise about. Add real volume and the model will eventually exercise every freedom you left open. "It has produced valid arguments every time so far" is not evidence the schema is tight - it is evidence you have been lucky.
Three ways a loose schema hurts you
The failures cluster into three shapes, and they are the reason toolstrict's blocker rule exists:
Omitted arguments. With no
requiredlist, every field is optional. The model drops the one your handler dereferences, and you get aKeyErrorin production for an argument the model would have supplied if you had simply marked it required.Invented arguments. With
additionalPropertiesleft open (the JSON-Schema default), the model can add keys that were never part of the contract. Now your handler has to defend against arguments you never designed for.Mis-typed arguments. A property with no
typeplaces no constraint on the value. The model passes"3"instead of3, or an object where you wanted a string, and the failure surfaces deep in your code instead of at the boundary.
The remedies are completely standard: a required array, additionalProperties: false, a type on every property (OpenAI's strict mode makes all three mandatory; Anthropic recommends them). The hard part was never how to tighten a schema. It is noticing the one tool definition that is loose, in an agent with thirty tools, before it reaches a user.
Why this needs its own linter (and isn't two tools I already wrote)
I want to be precise about scope, because three small tools of mine sit near each other and it would be easy to assume they overlap. They do not:
toolstrict - this one - lints the tool's input schema rigor. Is the schema strict enough that the model cannot emit invalid arguments? That is
required,additionalProperties: false, typed properties, enums, bounds,strict: true.toolens lints the tool's description and naming quality - is the wording legible, are two tools overlapping, are there simply too many tools for the model to choose between? It is about whether the model picks the right tool.
shapelint works the response side - is the value coming back validated against a schema?
toolstrict never comments on your prose and never looks at responses. It reads the argument schema and asks one question: is this tight enough to trust? A generic JSON-Schema validator can't answer that - it checks whether data matches a schema, not whether the schema itself is strict. And your linters (ruff, ESLint) have no idea a blob of JSON is a tool definition at all.
The two rules
toolstrict has exactly two rules, and they map to "unsafe" versus "could be tighter."
TS001 (blocker) - the schema is not safe. Any of: an object with no (or empty)
requiredlist,additionalPropertiesnot set tofalse, or a property with notype. Each is a direct path to omitted, invented, or mis-typed arguments. Exit code1.TS002 (warning) - the schema works but could be tighter: a string whose description implies a fixed set ("must be one of…") but has no
enum, a numeric property with nominimum/maximum, or an OpenAI function withoutstrict: true.
One rule for "this will let the model be wrong," one for "this lets the model be sloppier than it needs to be."
It reads the schemas you already ship
toolstrict reads the three shapes teams actually use, from two kinds of source:
OpenAI -
tools=[{"type":"function","function":{"parameters":{…}}}](thestrictflag is read from the function).Anthropic -
tools=[{"name","input_schema":{…}}].MCP - a
tools/listresult:{"tools":[{"name","inputSchema":{…}}]}.
Those can live in a .json file, arrive on stdin, or be written as inline dict literals right in your Python source. For Python, toolstrict parses the file with the standard-library ast module and evaluates only literal dicts via ast.literal_eval - it never imports or runs your code, so it is safe on untrusted source in CI. A schema assembled at runtime (values that are variables or function calls) can't be read statically, so it is skipped rather than guessed at. Nested objects and array items get the same checks as the top level, because a strict root wrapped around a loose nested object is still loose.
A real before and after
Here is that flight-booking tool as many people first write it:
{
"type": "function",
"function": {
"name": "book_flight",
"parameters": {
"type": "object",
"properties": {
"destination": { "type": "string" },
"cabin": { "type": "string", "description": "one of economy, business, first" },
"passengers": { "type": "integer" },
"notes": {}
}
}
}
}
Run toolstrict over it:
$ toolstrict tools.json
BLOCKER TS001 book_flight (root) has properties but no "required" list.
BLOCKER TS001 book_flight (root) does not set "additionalProperties": false.
BLOCKER TS001 book_flight property 'notes' has no "type".
WARN TS002 book_flight 'cabin' describes a fixed set of values but has no "enum".
WARN TS002 book_flight numeric property 'passengers' has no minimum/maximum bound.
WARN TS002 book_flight OpenAI function 'book_flight' is not declared strict.
exit 1
And the version that passes - the contract the model can't wander outside of:
{
"type": "function",
"function": {
"name": "book_flight",
"strict": true,
"parameters": {
"type": "object",
"additionalProperties": false,
"required": ["destination", "cabin", "passengers"],
"properties": {
"destination": { "type": "string", "description": "IATA airport code." },
"cabin": { "type": "string", "enum": ["economy", "business", "first"] },
"passengers": { "type": "integer", "minimum": 1, "maximum": 9 }
}
}
}
}
$ toolstrict tools.json
✓ toolstrict: every tool input schema is strict (1 tool, 1 file).
exit 0
Try it
pip install toolstrict
toolstrict agent/tools.json # lint a JSON file
toolstrict src/ # recurse .json + .py
cat tools.json | toolstrict - # or pipe it
toolstrict agent/tools.json --strict # fail on warnings too
Exit codes are CI-native: 0 clean, 1 on a blocker (or any finding under --strict), 2 for a usage error. Drop it into a pre-commit hook or a CI step and a loose tool schema stops being something you find in an incident review.
The project is open source (MIT) at https://github.com/jay-tank/toolstrict. It pairs naturally with toolens (tool description and naming quality) and shapelint (response-side schema validation) - three narrow gates for three different failure modes of the same agent.
A transient bug hides until traffic finds it; a loose schema hides until the model does. toolstrict makes the schema tell the truth about what it allows - before that truth reaches a user.
