DEV Community

Cover image for Tool Schemas Are an API Contract (Treat Them Like One)
Andrii B.
Andrii B.

Posted on AI-assisted

Tool Schemas Are an API Contract (Treat Them Like One)

Earlier this year, the people who maintain the Model Context Protocol loosened the types on tool schemas. The headline change was to structuredContent, the field a tool uses to return machine-readable results: it went from { [key: string]: unknown } to unknown, so tools could return arrays and primitives instead of wrapping everything in an object. One line of TypeScript.

And the proposal that made the change, SEP-2106, needed a four-cell compatibility matrix to explain it. It calls the change "wire-format backward compatible," then, a few paragraphs later, a "source-breaking change for typed consumers," because result.structuredContent?.temperature stops type-checking the moment the type becomes unknown. The authors are refreshingly blunt about it: "This break is intentional - the previous type was a lie whenever a tool returned a non-object."

If the protocol's own maintainers have to reason about old-client/new-server combinations for one widened field, you should probably be doing the same for the forty tools your MCP server exposes. Most teams aren't. They'd never rename a field in a public REST API without a deprecation window, but they'll rename a tool argument on a Tuesday afternoon because "the agent will figure it out."

Sometimes it does. Sometimes it silently drops the argument and reports success. Let's look at why a tool schema is a stricter contract than a REST API, not a looser one, and what the same discipline looks like when the consumer is a model.

What the contract actually is

When a client connects to an MCP server, it sends tools/list and gets back an array of tool definitions. Per the 2025-11-25 revision of the spec, each one has a name, an optional title, a description, an inputSchema, an optional outputSchema, and optional annotations (plus display icons and execution hints). Here's the shape the spec itself uses as its example:

{
  "name": "get_weather_data",
  "title": "Weather Data Retriever",
  "description": "Get current weather data for a location",
  "inputSchema": {
    "type": "object",
    "properties": {
      "location": { "type": "string", "description": "City name or zip code" }
    },
    "required": ["location"]
  },
  "outputSchema": {
    "type": "object",
    "properties": {
      "temperature": { "type": "number", "description": "Temperature in celsius" },
      "conditions": { "type": "string", "description": "Weather conditions description" },
      "humidity": { "type": "number", "description": "Humidity percentage" }
    },
    "required": ["temperature", "conditions", "humidity"]
  }
}
Enter fullscreen mode Exit fullscreen mode

If you've written an OpenAPI spec, this looks familiar, and that familiarity is the trap. In OpenAPI, the schema is the contract and the prose description is documentation. A human reads the docs once, writes code against the schema, and the code keeps doing the same thing until someone changes it.

In a tool definition, every one of those fields is consumed at runtime by the caller. The description isn't documentation, it's part of the prompt. The per-property description strings are prompt. The name is prompt. The model rereads all of it on every turn and decides what to do based on it. So the contract surface is:

  1. The name. It's the routing key for tools/call.
  2. The input schema. Types, required fields, enums, and the descriptions attached to each property.
  3. The output schema and structured result. If you declare an outputSchema, the spec says servers MUST return conforming structured results and clients SHOULD validate them.
  4. The descriptions. Tool-level and property-level. They change behaviour without changing a single type.
  5. The error text. More on this below, because it's the part nobody thinks of as contract.

That's a wider surface than a REST endpoint, and it has a consumer with much worse upgrade habits.

Your consumers auto-upgrade and never read the changelog

Stripe is the usual gold standard for API versioning, so let's use it as the baseline. Stripe pins your account to the API version that was current on your first request, and that pin doesn't move until you upgrade it yourself. You can override it per request with a Stripe-Version header. Monthly releases carry only backward-compatible changes; breaking changes ship in named major releases, and after you upgrade you get a 72-hour window to roll back. The whole system exists so a client's behaviour never changes unless the client asks for it.

MCP has none of that at the tool level. There's no version field on a tool definition in the current spec. There is a proposal for one, SEP-1575 ("Tool Semantic Versioning"), which would add a SemVer version to each tool and let clients send tool_requirements constraints like ^2.0.0. It never made it into the spec: the proposal went dormant and was closed in June 2026. A community issue filed in November 2025 (#1915) asked the maintainers for exactly the guidance you want: version in the name, or in that future field?

So what actually happens when you deploy a change? Every client picks it up the next time it calls tools/list. If you declared the listChanged capability, you SHOULD send notifications/tools/list_changed (under the 2026-07-28 revision, to clients that opened a subscriptions/listen stream for it) and clients refetch. In other words, every consumer of your API is permanently on "latest," and the upgrade happens without their knowledge, mid-session in some cases.

And "the client" here is two things stacked on top of each other: the host application that caches the list, and the model that reads it. The model doesn't read changelogs. It doesn't know a v2 exists. It knows whatever is in its context window right now, which may be the list it was given twenty minutes ago.

Note
The 2026-07-28 spec revision added caching hints to list results (SEP-2549): servers now return a ttlMs freshness hint and a cacheScope of "public" or "private" on tools/list. That's good for load, but read it as a contract statement too. If you tell clients the list is fresh for ten minutes, you've promised the old list stays callable for at least ten minutes after you change it.

Stripe's "safe changes" list, run through a tool schema

Stripe publishes the changes it considers backward-compatible: adding new resources, adding new optional request parameters, adding new properties to responses, changing the order of properties, changing the length or format of opaque strings like IDs and error messages, and adding new event types. It's a great list. Let's see how much of it survives when the consumer is a model.

Change Safe for a REST API? Safe for a tool?
Add an optional input parameter Yes Structurally yes, but the model now sees it and may start filling it in
Add a property to the response Yes Only if no client validates against a stale, closed outputSchema
Reorder response properties Yes Yes
Change error message wording Yes No. Error text is the model's retry instruction
Reword a description Not even a "change" Behavioural change, same as editing a prompt
Rename a field or tool No No, and it can fail silently

Three rows deserve a closer look.

Adding an optional parameter

For a REST client, a new optional parameter is invisible until someone reads the docs and writes code to use it. For a model, it's visible on the very next turn, with whatever description you gave it. If you add priority: "low" | "normal" | "urgent" to a create_ticket tool, you haven't just added capability. You've added a decision the model now makes on every call, and it'll make it based on your one-line description. That's not a reason not to add parameters. It's a reason to treat the description as part of the release, and to check what the model actually sends before and after.

Adding a response property

This one's subtle. The spec says clients SHOULD validate structuredContent against the tool's outputSchema. Now look at what a common schema library emits. With Zod 4, the schema library the official MCP TypeScript SDK's docs use, z.toJSONSchema() on a plain z.object() produces "additionalProperties": false in output mode and leaves it off in input mode. I checked this against Zod 4.6.5:

{
  "type": "object",
  "properties": { "orderId": { "type": "string" }, "reason": { "type": "string" } },
  "required": ["orderId"],
  "additionalProperties": false
}
Enter fullscreen mode Exit fullscreen mode

So if your output schema is generated from Zod, it's closed by default. A client that's holding the old outputSchema (cached, or pinned) and validating strictly will reject a result that has one extra property you just added. A client that refetched is fine. This is the SEP-2106 matrix in miniature: whether the change is "compatible" depends on which copy of the contract the other side is holding.

Changing an error message

Stripe treats error messages as opaque strings you shouldn't parse, which is the right call for code. The MCP spec takes the opposite position for models. It splits errors into two kinds: protocol errors (JSON-RPC errors for unknown tools, malformed requests, server failures) and tool execution errors, returned as a normal result with isError: true, which cover API failures, business logic errors, and input validation errors. And it's explicit about the purpose: tool execution errors "contain actionable feedback that language models can use to self-correct and retry with adjusted parameters," and clients SHOULD pass them to the model.

The spec's own example error reads "Invalid departure date: must be in the future. Current date is 08/08/2025." That's not a log line. That's an instruction. If you "clean up" that message to "Validation failed", you haven't changed a type, but you've removed the model's ability to fix its own call. For tools, error text is interface.

Comparison table titled Same Change, Different Consumer: adding an optional param, adding a response field, rewording an error message and editing a description are safe for a REST client but risky or breaking for a tool-calling model, and renaming a field breaks both

The rename that reports success

Here's the failure mode that should scare you most, because it doesn't fail.

Imagine an order-management MCP server with a refund_order tool. Version one had an optional boolean called notify. Someone decides that's ambiguous (notify whom?) and renames it to notifyCustomer. The server is written with the official TypeScript SDK (v2, @modelcontextprotocol/server) and Zod, which is exactly how the SDK's own docs register tools:

server.ts

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const server = new McpServer({ name: 'orders', version: '1.5.0' });

server.registerTool(
  'refund_order',
  {
    description: 'Refund an order in full or in part. Set notifyCustomer to email the customer.',
    inputSchema: z.object({
      orderId: z.string(),
      amountCents: z.number().int().positive().optional(),
      notifyCustomer: z.boolean().optional(),
    }),
  },
  async ({ orderId, amountCents, notifyCustomer }) => {
    // your refund service goes here
    const refund = await refunds.create({ orderId, amountCents, notify: notifyCustomer ?? false });
    return { content: [{ type: 'text', text: `Refund ${refund.id} created for ${orderId}` }] };
  },
);
Enter fullscreen mode Exit fullscreen mode

Now a client that still has the old list in context sends this:

{ "name": "refund_order", "arguments": { "orderId": "ord_812", "notify": true } }
Enter fullscreen mode Exit fullscreen mode

What happens? A plain z.object() strips unknown keys when it parses. I ran it: safeParse({ orderId: 'a', notify: true }) returns success: true with data: { orderId: 'a' }. The notify key is gone. notifyCustomer is undefined, the handler defaults it to false, the refund goes through, and the tool returns a cheerful success message. The model tells the user the customer has been notified. Nobody was notified.

Nothing in that chain is a bug in isolation. Zod's default is a reasonable "tolerant reader." The input JSON schema that Zod emits for a plain object doesn't even include additionalProperties: false, so by the letter of the advertised schema, extra keys were allowed. The rename just turned an optional field into a silently ignored one.

Fail loudly instead

The fix is to make unknown input keys an error. In Zod 4 that's z.strictObject():

inputSchema: z.strictObject({
  orderId: z.string(),
  amountCents: z.number().int().positive().optional(),
  notifyCustomer: z.boolean().optional(),
}),
Enter fullscreen mode Exit fullscreen mode

Two things change. The advertised input schema now carries "additionalProperties": false, so the contract says what the server enforces. And the stale call now fails validation with Zod's Unrecognized key: "notify" issue.

Here's the part that makes this work well with models. In the SDK's tools/call handler, input validation throws an InvalidParams error, but the handler catches it and converts it into a tool result with isError: true and a message that starts with Input validation error: Invalid arguments for tool refund_order:. That's the spec's "tool execution error" path, the one clients SHOULD feed back to the model. So the model sees the rejection in the same turn, and on its next attempt it can call again with different arguments.

Compare that with calling a tool that no longer exists. The SDK throws a protocol error (InvalidParams, "Tool refund_order not found") before it ever gets to the handler, and the spec says clients only MAY show protocol errors to the model, noting they're "less likely to result in successful recovery." A removed tool is a harder break than a removed field, because the recovery signal might never reach the model at all.

My take: for tools, be strict on input. The usual argument for tolerant readers is that a REST client's fix is a code change and a deploy away, so you'd rather accept a slightly-off request than break production. A model's fix is one retry away, inside the same conversation, as long as the error tells it what went wrong. Strictness plus a good error message beats silent acceptance every time the caller can read.

Expand, then contract

Strictness catches the stale call, but it still breaks it. For a real rename, do what you'd do with a database column: expand first, contract later. Keep the old key for a window, mark it deprecated in the schema, and map it in the handler:

inputSchema: z.strictObject({
  orderId: z.string(),
  amountCents: z.number().int().positive().optional(),
  notifyCustomer: z.boolean().optional(),
  notify: z
    .boolean()
    .optional()
    .meta({ deprecated: true, description: 'Deprecated: use notifyCustomer instead.' }),
}),
Enter fullscreen mode Exit fullscreen mode
async ({ orderId, amountCents, notifyCustomer, notify }) => {
  const shouldNotify = notifyCustomer ?? notify ?? false;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

JSON Schema 2020-12 has a real deprecated annotation keyword, and Zod 4's .meta() passes it straight through into the emitted schema, next to the description. The description is the part the model actually acts on; deprecated: true is for your tooling, your diff checks, and any client that surfaces it. Then log how often notify still shows up. When it's been zero for longer than your ttlMs plus your longest realistic session, delete it.

Four-phase timeline titled Renaming a Tool Argument Safely: expand with both notify and notifyCustomer, observe old-key usage fall to zero, wait out ttlMs plus the longest session, then contract and reject notify in strict mode, with tools/list_changed notifications at the start and end

Versioning without a version field

For changes you can't smooth over with an alias (a changed meaning, a split into two tools, a different result shape) you need an actual version boundary. Today you have two honest options.

Put the version in the name. The spec's own list of valid tool names includes DATA_EXPORT_v2, and names can use letters, digits, underscores, hyphens, and dots, up to 128 characters. So refund_order_v2 next to refund_order is fully spec-compliant and works with every client that exists. The cost is on the model side: two near-identical tools in the list is a selection problem you've created yourself. OpenAI's function-calling guide suggests aiming for fewer than 20 functions available at the start of a turn (it calls this a soft suggestion), and every duplicate eats into that budget and invites the model to pick the wrong one. If you go this route, make the old tool's description say so plainly ("Deprecated. Use refund_order_v2."), and keep the overlap short.

Run separate servers or endpoints per major version. Heavier, but it keeps each tool list clean, and it's the closest thing to Stripe's pinning you can build today: a client that connects to /mcp/v1 stays on v1 until someone reconfigures it. That fits internal platforms where you control the host configuration. It fits badly for public servers, where you don't.

What I'd avoid is waiting for a protocol-level version field and doing nothing in the meantime. SEP-1575 already stalled once, and even if something like it comes back, your clients won't send tool_requirements until their hosts support it anyway.

The SDK gives you the mechanics for the deprecation half. registerTool returns a RegisteredTool with disable(), enable(), remove(), and update(), and every one of them calls sendToolListChanged(). So flipping off refund_order once refund_order_v2 has soaked is one call, and connected clients get notified. Disabled tools still exist on the server; a call to one comes back as a protocol error saying it's disabled, which is at least a clearer signal than "not found."

Put the contract under test

Here's the thing a REST team gets for free and most MCP teams don't have: a diff of the contract in code review. With OpenAPI, the spec file is in the repo and a breaking change shows up in the PR. With MCP, the contract is generated at runtime from registerTool calls, so nobody sees it change unless you capture it.

The SDK's testing guide already shows how to run a real Client against your server in-process, with no port: pass createMcpHandler(...)'s fetch as the transport's fetch option. Use that to snapshot tools/list and fail the build on breaking changes:

scripts/check-tool-contract.ts

import { readFile } from 'node:fs/promises';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from '../src/server.js';

type JsonSchema = { properties?: Record<string, unknown>; required?: string[] };
type ToolDef = { name: string; description?: string; inputSchema: JsonSchema; outputSchema?: JsonSchema };

const handler = createMcpHandler(createServer);
const transport = new StreamableHTTPClientTransport(new URL('http://contract.local/mcp'), {
  fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client({ name: 'contract-check', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);

const { tools } = await client.listTools(); // follow nextCursor if you paginate
const current = tools as ToolDef[];
const baseline: ToolDef[] = JSON.parse(await readFile('tool-contract.json', 'utf8'));

const breaking: string[] = [];
const behavioural: string[] = [];

for (const old of baseline) {
  const now = current.find((t) => t.name === old.name);
  if (!now) {
    breaking.push(`tool removed or renamed: ${old.name}`);
    continue;
  }
  for (const prop of Object.keys(old.inputSchema.properties ?? {})) {
    if (!(prop in (now.inputSchema.properties ?? {}))) breaking.push(`${old.name}: input "${prop}" removed`);
  }
  for (const req of now.inputSchema.required ?? []) {
    if (!(old.inputSchema.required ?? []).includes(req)) breaking.push(`${old.name}: input "${req}" is newly required`);
  }
  for (const req of old.outputSchema?.required ?? []) {
    if (!(now.outputSchema?.required ?? []).includes(req)) breaking.push(`${old.name}: output "${req}" no longer guaranteed`);
  }
  if (JSON.stringify(old) !== JSON.stringify(now)) behavioural.push(`${old.name}: definition changed, rerun tool evals`);
}

await client.close();

if (behavioural.length) console.warn(behavioural.join('\n'));
if (breaking.length) {
  console.error(breaking.join('\n'));
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

This is deliberately dumb. It doesn't understand every JSON Schema keyword, and it won't catch a property whose type changed from string to number (add that check if your schemas churn). But it catches the three changes that hurt most (removed tools, removed inputs, new required inputs), and it turns every description edit into a visible line in CI instead of an invisible prompt change. Commit tool-contract.json, regenerate it deliberately when a change is intended, and let the diff show up in review like any other API change.

The "behavioural" bucket matters more than it looks. A reworded description can change which tool the model picks, or what it puts in an argument, with zero schema change. There's no static check for that. What you can do is keep a small set of recorded prompts per tool, replay them against the new definitions, and compare the tool calls the model produces. That's the tool-calling equivalent of contract tests plus a canary, and it's the only thing that tells you whether a "docs-only" change was actually docs-only.

Some clients will hash your descriptions

One more reason to take description edits seriously, and it comes from security, not reliability.

On April 1, 2025, Invariant Labs published their write-up on tool poisoning attacks, and one of the related attacks they described is the "rug pull": a malicious server can change a tool's description after the user has already approved it. Their recommended mitigation, among others, is that clients "pin the version of the MCP server and its tools," using "a hash or checksum to verify the integrity of the tool description before executing it." The spec pushes in the same direction from another angle: clients MUST consider tool annotations untrusted unless they come from trusted servers.

Follow that through from the server author's side. If a client implements hash pinning, your harmless typo fix in a description is indistinguishable from a rug pull. The hash changed, so the tool changed, so the user gets asked to re-approve, or the tool gets blocked. From the client's point of view, that's the system working correctly.

That's the cleanest argument for this whole article. Security-minded clients already treat your tool definitions as a versioned, signed contract, whether or not you do. You might as well ship changes to them the way they're going to be received: deliberately, in batches, with a note about what changed and why, instead of a steady drip of "tiny" edits that each trigger a re-approval.

The short version

If you already know how to run a public API, you know most of this. Additive changes are cheap, renames need an expand-and-contract window, removals need a deprecation period, and the contract belongs in version control where a reviewer can see it change.

The differences are all about the consumer. It upgrades automatically, it reads your descriptions as instructions, it acts on your error messages, and it can recover in the same turn if you tell it what went wrong. So be stricter on input than you'd be for a REST client, write error text for a reader who'll act on it, treat description edits as releases, and remember that "backward compatible" depends on which copy of the contract the other side is holding. The MCP maintainers needed a four-cell matrix for one field. Your forty tools deserve at least a snapshot file.


Originally published at andriiboyko.com.

Top comments (2)

Collapse
 
agentsearchhq profile image
AgentSearch •

The point that descriptions are prompt, not docs, is the one I'd underline. We've seen a one-line description edit change call behavior more than an actual schema change did. What helped: snapshot the full tools/list output in tests and diff it on every PR, descriptions and property docs included, so a renamed argument or a reworded hint shows up in review before an agent silently starts dropping it.

Collapse
 
codearchitect_j profile image
CodeArchitect_J •

Snapshotting tools/list and diffing it in CI is such a cheap win. Treating description edits as releases is the part most teams miss: a one-line wording change can shift which tool the model picks with no code change at all. How big does your replay set need to be before it reliably catches those behavior shifts?