AI coding

Writing Tool Schemas That Survive Model Upgrades

Antonio Evans7 min read

A clean technical diagram on a light background.
Key points

Key points

  1. 1Treat a tool schema as a published contract: explicit JSON types, enums for closed choices, and clear required versus optional fields, so any model produces the same arguments.
  2. 2Put one realistic example per field next to the type, because models read descriptions and examples, not your internal docs.
  3. 3Validate every tool call server-side before execution, since the schema describes intent and does not enforce safety.
  4. 4Version the schema and keep old fields working through a documented deprecation window, so a model switch or a client upgrade never breaks calls mid-flight.

Why the schema is the shared layer

Function calling and MCP look different from the outside, but both hand a model the same thing: a JSON description of a function and its parameters. With function calling you send tool schemas alongside the prompt and the model returns structured arguments that your code executes (OpenAI function calling guide). With MCP, a server exposes tools, resources and prompts that any compatible client discovers at runtime over JSON-RPC 2.0 (MCP specification). The protocol standardizes discovery and transport; it does not standardize how well you describe a single tool.

That is why schema design is the one skill that transfers. A well-typed search_tickets tool with an enum for status and priority behaves the same whether it is shipped as an OpenAI-style function object or registered with FastMCP, and the two definitions are close enough that a translation layer is small (Prefect comparison). Models change. Prompt formats change. The arguments a model produces still come from the same JSON Schema.

The practical decision rule: if the tool has a fixed set of operations and you control every caller, plain function calling keeps the schema in one place. If several clients, IDEs or agents need to discover the same capability, put it behind an MCP server so you maintain one definition instead of N times M client integrations (Databricks overview). Either way, the schema you write is the contract you will be maintaining for years.

Start with types, enums and required fields

Free text is where model upgrades show up first. A schema with an untyped date string invites "next Friday (I think?)"; a schema with format: date and a documented example invites 2026-11-20. Every field in your schema should answer three questions: what type is it, what values are legal, and is it required.

Use these rules in order:

  • Enums for anything closed. Status, priority, region, channel. An enum removes a whole class of drift, because a model cannot invent URGENT when only low, medium, high and critical are listed.
  • Typed scalars with formats. integer with minimum and maximum for limits, string with format: date-time for timestamps.
  • Explicit required. List required fields; leave optional fields genuinely optional and give them a default. A model that must guess between required and optional will sometimes invent values.
  • additionalProperties: false where your executor rejects unknown keys, so the failure is visible at validation time instead of deep in a handler (structured outputs guide).
Field kindWeak schemaContract schema
Closed choicestatus: stringstatus: enum[open, closed, pending]
Identifieruser: stringuser_id: string, pattern: uuid, required
Quantitylimit: numberlimit: integer, min 1, max 100, default 10
Timestampwhen: stringsince: string, format: date-time, required
Moneyamount: stringamount_cents: integer, min 0 + currency: enum[USD, EUR]
Unknown keysallowedadditionalProperties: false

The money row is worth dwelling on. amount_cents as an integer plus an explicit currency enum removes float rounding and unit ambiguity in one move; compare that with a single "amount": "25.00 USD" string that every model will format differently.

Examples are part of the schema

Descriptions tell a model what a field means. Examples tell it what a value looks like. Put one realistic example per non-obvious field, and keep it identical to what your production data actually contains. If your tickets have IDs like TKT-48291, show TKT-48291, not abc123.

{
  "name": "search_tickets",
  "description": "Search support tickets by status and priority. Returns at most `limit` tickets, newest first.",
  "parameters": {
    "type": "object",
    "properties": {
      "status": {
        "type": "string",
        "enum": ["open", "closed", "pending"],
        "description": "Lifecycle state of the ticket. Example: open"
      },
      "priority": {
        "type": "string",
        "enum": ["low", "medium", "high", "critical"],
        "description": "Support tier. Example: critical"
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 100,
        "default": 10,
        "description": "Maximum rows returned. Example: 25"
      }
    },
    "required": ["status"],
    "additionalProperties": false
  }
}

Two habits matter here. First, the description says what the tool returns, not just what it does; a model plans multi-step calls better when it knows the shape of the result. Second, examples live in the schema, never in a vendor-specific prompt. When you switch models mid-project, the schema travels intact and nobody has to rewrite "Remember, dates are ISO" prose.

A worked example shows the cost of skipping this. Say you run 40,000 tool calls a day and 3 percent fail argument validation. At 1,200 failures a day and a five-minute average manual fix, that is 100 engineer-hours a day of cleanup. Adding enums and format checks typically collapses the bulk of those failures into one move: a model can still guess wrong, but it now guesses among legal values.

Validation, safety and the trust gap

A schema is a request for a well formed call. It is not a security control. MCP's own specification treats tool descriptions and annotations as untrusted unless they come from a trusted server, and requires explicit user consent before invoking a tool (MCP specification). Build your validation with that in mind.

The main risk is tool poisoning, where hidden instructions inside a tool description steer the model toward reading files or leaking data; Invariant Labs showed a benign-looking add tool whose docstring told the model to read SSH keys and pass them as a parameter (Invariant Labs). A related variant hides instructions in tool responses rather than descriptions, which the model then treats as trusted context (OWASP MCP Tool Poisoning). Elastic documents related patterns including rug-pull redefinitions, where a tool changes behavior after it has been approved (Elastic Security Labs).

Three defenses belong in your schema layer, not your prompt:

  1. Constrain output shape. Require structured responses with a fixed schema and reject anything that does not match. OWASP lists schema validation as the first practical catch for injected instructions in free text.
  2. Separate privilege. Read-only tools and write tools should not share an agent context with external servers, so a poisoned response cannot chain into a privileged call.
  3. Enforce limits server-side. Check authorization and rate limits in the executor, not in the system prompt, which injected instructions can override.
def execute(tool, args):
    errs = validate(tool.input_schema, args)   # types, enums, required
    if errs:
        return {"error": "invalid_arguments", "details": errs}
    if not acl.allows(current_user, tool.name, args):
        return {"error": "forbidden"}
    return handlers[tool.name](**args)

This loop is the same for function calling and MCP. Validate, authorize, execute, log. Observability tools that trace MCP and agent sessions help you attribute failures to a schema change, a model change or a bad call (Datadog MCP monitoring).

Versioning and deprecation without breaking callers

The MCP specification itself is versioned by date (YYYY-MM-DD) and only bumps the version when a change breaks compatibility; deprecated features stay in place for at least twelve months, or ninety days under an expedited-removal exception (MCP versioning). Copy that pattern for your tools. Date your schema revisions, keep additive changes additive, and never silently rename a field that a model may be calling with today.

The checklist that keeps a schema swap boring:

ChangeCompatible?How to ship it
Add optional fieldYesShip now, document default
Add required fieldNoAdd optional with fallback, require next major
Rename fieldNoKeep old name, add new, warn, remove after window
Narrow enumNoReject clearly, log the model that sent it
Widen enumYesAdd value; old callers unaffected
Change typeNoNew field name, cross-validate both
Change return shapeSometimesVersion result object; keep old key for one cycle

Keep a machine-readable changelog next to the schema. A caller that fails validation should get the schema version and a pointer to the changelog in the error, so the next model that trips on a narrowed enum produces one actionable message instead of a mystery. Resources and prompts follow the same idea and are separately versioned in the spec (MCP resources, MCP prompts), so a tool, a resource and a prompt that ship together carry the same revision.

Test one schema against every model, before and after a switch

The only way to know a schema survives a model upgrade is a golden set of cases run against every model you plan to use. Build it once, run it in CI, and compare.

A practical harness has four parts:

  • Cases: 50 to 200 prompts per tool, including ambiguous ones ("highest priority stuff from yesterday"), edge values (limit 0, limit 1000) and adversarial text that looks like injected instructions.
  • Checks: schema validity rate, exact argument match against an expected object, tool-selection accuracy when several tools are exposed, and a safety check that no privileged tool was called without approval.
  • Baseline: store results per model and schema version, so an upgrade shows a diff instead of a vibe.
  • Gate: fail the build if validity or selection accuracy drops more than a set threshold against the current production model.

When you move to a new model, whether through a provider API or through a single gateway such as Quanta Inference that fronts text and other model types with an OpenAI-compatible interface, the harness runs unchanged because the schema is outside the vendor format. The same holds if your experts or coding agents are the caller: keep the schema fixed and the model swappable.

Two operational rules finish the job. First, keep model-specific hints (system prompt nudges, temperature, reasoning settings) in adapter code that lives alongside each model config, never inside the JSON schema; that way the adapter can be deleted when the model is retired and the contract stays. Second, canary the switch: route 5 percent of traffic to the new model, watch validation-failure rate and tool-selection accuracy for a day, then widen. If the new model produces worse arguments, the cause is almost always a vague description or a missing enum, and that fix improves every model at once.

FAQ

FAQ

Should I write JSON Schema by hand or generate it from code?

Generate it from typed code, then review the output. Most frameworks will produce a schema from a function signature, which guarantees the schema and the handler agree, but it also tends to drop enums and examples. Add those by hand and keep the generated file in version control so changes are reviewable.

Is MCP better than plain function calling for tool reliability?

Neither is more reliable by itself. MCP standardizes discovery and transport and lets many clients share one server, which reduces duplicated, drifting schemas (Zuplo MCP server guide). Reliability comes from the quality of the schema and server-side validation in either model.

How many examples should each field have?

One accurate example per non-obvious field is enough when the type and description are tight. Spend the effort you save on more test cases instead, since examples help a model understand a field but do not prove it will produce legal values.

What do I do when a new model ignores part of my schema?

First check whether the ignored field was optional or had a vague description. Then move any model-specific nudges into adapter code and re-run the golden set. Only change the schema if the field was genuinely ambiguous for every model, not just the new one.

When can I remove a deprecated field?

After the documented window closes and your test harness confirms no model in your supported set sends it. If your schema serves multiple clients, announce the removal in the changelog with the removal date, then watch the rejection logs for stragglers before deleting the fallback handler.

Sources

Sources

  1. Function calling | OpenAI API
  2. Specification
  3. MCP vs Function Calling: When to Use Which - Prefect
  4. What is the Model Context Protocol (MCP)? - Databricks
  5. The guide to structured outputs and function calling with LLMs - Agenta-AI
  6. MCP Security Notification: Tool Poisoning Attacks - Invariant Labs
  7. MCP Tool Poisoning | OWASP Foundation
  8. MCP Tools: Attack Vectors and Defense Recommendations for ...
  9. Understanding MCP security: Common risks to watch for - Datadog
  10. Versioning
  11. Resources
  12. Prompts
  13. What Is an MCP Server? A Complete Guide to Architecture, Use - Zuplo
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles