prerequisite
API contracts and versioning
What a contract between two programs is, which changes keep it and which break it, and how version numbers and deprecation windows signal the difference.
Before this
This page assumes you are comfortable with:
Why you need this
Once anyone else's program talks to your server, every tool name, argument, and result field is a promise. You will want to change those things: rename a confusing tool, add a feature, fix a field that was the wrong type. This page gives you the vocabulary to tell which changes are safe and which break someone, and the two tools (version numbers and deprecation windows) for making the unsafe ones without surprising anyone.
The idea
An API (application programming interface) is the part of a program that other programs use: the names they call, the inputs they send, and the outputs they get back. The contract is everything a caller is allowed to rely on: which fields exist, what type each has, which inputs are required, what errors mean. The program on the other side is a consumer.
The hard part is that you usually do not control the consumers. They were written by other people, at other times, against the version of your API that existed then. You cannot update them, and often you cannot even list them. So "does this change break anything?" has to be answered by reasoning about the contract, not by testing every consumer.
Additive versus breaking changes
A change is additive (also called backward compatible) if every consumer that worked before still works after. It is breaking if some correctly written consumer stops working.
| Change | Kind | Why |
|---|---|---|
| Add a new optional input field | additive | Old callers do not send it and get the old behavior. |
| Add a new output field | additive, usually | Consumers that ignore unknown fields are fine. Consumers that reject unknown fields break (see below). |
| Add a new endpoint, method, or tool | additive | Nobody was calling it before. |
| Add a new value to an output enum | breaking for some | A consumer with a switch over the old values hits a case it never handled. |
| Make an optional input required | breaking | Old callers do not send it and are now rejected. |
| Add a new required input | breaking | Same reason. |
| Rename or remove a field, method, or tool | breaking | Consumers look for the old name and find nothing. |
| Change a field's type (number to string, string to array) | breaking | Consumer code that does arithmetic or string operations on it fails. |
| Narrow what an input accepts (lower a maximum) | breaking | Previously valid calls are rejected. |
| Widen what an input accepts (raise a maximum) | additive | Every previously valid call is still valid. |
| Change what a field means while keeping its name and type | breaking, and invisible | Nothing fails; the numbers are just wrong. |
The last row is the dangerous one, because no validator or test of the shape will catch it.
Strict in what you send, tolerant in what you accept
The robustness principle, often called Postel's law after the engineer who wrote it into an early internet standard, says: be conservative in what you send, be liberal in what you accept. For an API consumer that means reading only the fields you need and ignoring the rest, so the producer can add fields without breaking you. For a producer it means sending exactly what the contract promises, nothing malformed and nothing "close enough".
It has limits. Being liberal in what you accept makes mistakes permanent: if your server quietly accepts "days": "3", some consumer will rely on it, and you can never tighten the rule. It also invites attackers, who probe exactly the inputs that are "probably fine". A reasonable modern reading: ignore unknown output fields freely, but validate inputs against a schema and reject what does not match, with an error that says why.
Version numbers
A version number tells consumers which contract they are getting. Two common schemes:
- Semantic versioning writes versions as
MAJOR.MINOR.PATCH, such as2.4.1. Increase PATCH for a fix that changes no contract, MINOR for an additive change, and MAJOR for a breaking change. A consumer built against2.4.1can accept any2.x.ywithxat least 4, and must be checked before moving to3.0.0. - Date-based versions name a version by a date, such as
2026-07-28. The Model Context Protocol uses this: its versioning policy says the date is the last day a backwards-incompatible change was made. Additive changes can be added to the current revision without changing its date. So two MCP implementations that name the same date can always talk, even if one is newer.
A version only helps if both sides say which one they mean. How MCP clients and servers agree on one is the subject of version negotiation.
Deprecation with a window
You rarely remove something in one step. Deprecation means announcing that a feature will be removed, while it keeps working. A deprecation window is the promised minimum time between the announcement and the removal, so consumers can plan the migration. A good deprecation notice says what replaces the feature, how to migrate, and the earliest date of removal. MCP's own feature lifecycle policy, for example, keeps a deprecated feature in the specification for at least twelve months before it can be removed, and lists each one with its replacement in a deprecated features registry.
Worked example
A weather service returns this JSON. It changes three times. Classify each change, then check the classification by running two consumers against every version.
{"city": "Oslo", "temp": 11, "conditions": "rain"}
Version 2 adds a field:
{"city": "Oslo", "temp": 11, "conditions": "rain", "humidity": 87}
Version 3 renames temp to temp_c and turns conditions from a string into an array:
{"city": "Oslo", "temp_c": 11, "conditions": ["rain", "wind"], "humidity": 87}
By the table: version 2 is additive (a new output field). Version 3 has two breaking changes (a rename and a type change). Under semantic versioning, version 2 would be a MINOR bump and version 3 a MAJOR one.
Now the two consumers. One is strict: it refuses anything with keys it does not expect. The other is tolerant: it reads the two fields it needs and ignores the rest.
import json
responses = {
"v1": '{"city": "Oslo", "temp": 11, "conditions": "rain"}',
"v2": '{"city": "Oslo", "temp": 11, "conditions": "rain", "humidity": 87}',
"v3": '{"city": "Oslo", "temp_c": 11, "conditions": ["rain", "wind"], "humidity": 87}',
}
def strict_reader(data):
"""Refuses anything it was not built for."""
expected = {"city", "temp", "conditions"}
if set(data) != expected:
raise ValueError(f"unexpected keys: {sorted(set(data) - expected)}")
return f"{data['temp']} C, {data['conditions'].upper()}"
def tolerant_reader(data):
"""Reads only the fields it needs and ignores the rest."""
return f"{data['temp']} C, {data['conditions'].upper()}"
for version, text in responses.items():
data = json.loads(text)
for reader in (strict_reader, tolerant_reader):
try:
out = reader(data)
except Exception as e:
out = f"FAILS: {type(e).__name__}: {e}"
print(f"{version} {reader.__name__:15} {out}")
Run it with python readers.py. It prints:
v1 strict_reader 11 C, RAIN
v1 tolerant_reader 11 C, RAIN
v2 strict_reader FAILS: ValueError: unexpected keys: ['humidity']
v2 tolerant_reader 11 C, RAIN
v3 strict_reader FAILS: ValueError: unexpected keys: ['humidity', 'temp_c']
v3 tolerant_reader FAILS: KeyError: 'temp'
| Version | Change | Classification | Strict consumer | Tolerant consumer |
|---|---|---|---|---|
| v1 | (original) | works | works | |
| v2 | add humidity |
additive | breaks | works |
| v3 | rename temp, retype conditions |
breaking | breaks | breaks |
Version 2 is why "add an output field" says "usually" in the table: it is safe only for tolerant consumers. Version 3 breaks everyone; without the rename, .upper() on a list would have failed next. The safe way to ship version 3 is to add temp_c and a new array field such as conditions_list alongside the old ones, deprecate the old ones with a window, and remove them only in a new major version.
In a server's life
This is the theory behind stage 6, maintain it. A tool's name, its inputSchema, and its output are a contract with every host and model that uses it, and the table above applies to them almost unchanged. It also explains stage 1's version negotiation: the MCP protocol is itself an API with a date-based version and a deprecation policy.
Common mistakes
- Assuming you know all your consumers. You renamed a field after checking only your own app. Symptom: a bug report from a stranger.
- Calling a new enum value additive. The consumer's code has a branch per known value and crashes or silently skips the new one. Symptom: a feature that "works" except for the new case.
- Changing meaning without changing shape.
tempswitches from Celsius to Fahrenheit. Symptom: no errors anywhere, and every reading is wrong. - Deprecating without a date. "Will be removed in the future" lets everyone postpone. Symptom: the removal day breaks as many consumers as an unannounced change would.
- Over-tolerant inputs. Accepting near-miss values forever freezes them into the contract. Symptom: you cannot add validation later without breaking callers who rely on the leniency.
Cost
Additive changes cost almost nothing to ship. Breaking changes cost engineering time on both sides: you maintain the old and new shapes in parallel for the length of the deprecation window, document the migration, and answer questions, while every consumer spends time migrating. That cost grows with the number of consumers you do not control. Planning the contract up front is cheaper than any migration.
Going further
- Version negotiation, for how MCP clients and servers pick a protocol revision.
- Evolving a server without breaking clients, which applies this table to MCP tools.
- The semantic versioning specification, which defines the rules precisely, including pre-release labels.
- Consumer-driven contract testing, a way to learn which fields your consumers actually read.