Chapter 4 of 7 · 15 min read
Detecting contract drift
On this page
The contract a server serves is what its handshake returns: the protocol version, the capability set, the instructions string, and the tool list with input schemas. It is published anew on every connection, by the process running at that moment, which makes it the authoritative statement of what your client may call — and it is not derived from the artefact you installed. A hosted endpoint has no artefact at all, and a container referenced by tag has one that can be replaced under the same label after any restart.
Contract drift is that statement changing without anything changing on your side. Detecting it means connecting on a schedule, recording what comes back, and comparing it against a copy committed in your repository — cheap to run, worth nothing unless the connection is made from where production makes it, and a purchase strictly smaller than a pin.
Two mechanisms, two different blind spots
A pin acts on the artefact — the tarball or the image, the packaged bytes a server is installed from — before it runs. A snapshot check acts on the running server. They are not two implementations of one idea, and the easiest way to see that is to ask what each one misses.
| Failure | Pin + lockfile | Contract snapshot |
|---|---|---|
@latest resolves to a new version at pod start | Prevents | Detects after the fact |
Container tag :2.1 repointed to a new image | Prevents, with a digest | Detects |
| Vendor redeploys their hosted endpoint | No mechanism | Detects |
| Artefact substituted, same version, identical tool list | Prevents, via integrity hash | Blind — the contract is unchanged |
| Server behaviour changes via remote config, schema stable | Blind | Blind |
| Tool description edited server-side | No mechanism for hosted | Detects |
| Capability removed at runtime by a feature flag | Blind | Detects |
| Server unreachable | Blind | Detects, and reports it as a failure |
Two rows carry most of the argument. A tampered artefact that keeps the same tool list is invisible to a snapshot, because the snapshot only ever sees names, descriptions and schemas — a compromised build serving an identical contract passes the check and exfiltrates whatever it likes. The lockfile's integrity hash catches that before the code runs, which is prevention in the strict sense.
Going the other way, a hosted endpoint has no artefact at any point in its lifecycle that you could hash. A team that pinned every server they launch and stopped there has, for that endpoint, no mechanism whatsoever; a team running snapshots has a check that keeps working unchanged, because it interrogates the running server rather than the package that produced it. The bottom row is the honest shared blind spot: neither approach sees a server that keeps its schemas and changes its answers, which is a real and common form of drift and is why acceptance tests belong alongside both — a fixed call with known inputs, run on a schedule, whose result is compared against an answer recorded while the integration was behaving.
Sorted by which mechanism reaches them, the eight rows partition exactly, with none in two buckets and none left over. Both controls reach two of them: @latest resolving to a new version at pod start, and a container tag repointed to a new image. The pin alone reaches one, the substituted artefact serving an identical tool list. The snapshot alone reaches four — the vendor redeploying, a tool description edited server-side, a capability removed by a feature flag, and the server being unreachable. Neither reaches one, which is behaviour changing while the schemas hold still. Two, one, four and one accounts for all eight.
What that closure licenses is a cost statement rather than a preference. Dropping the pins costs exactly one failure mode nothing else in the table catches; dropping the snapshots costs four. And the row belonging to neither is not an argument for a better version of either control, because both instruments only ever read a contract or an artefact, while that row is about the answers — which is why an acceptance test is a third instrument rather than a refinement of the first two.
The practical reading is that the halves are not the same size for every server in an estate. For an npm-installed server, the pin is the stronger control and the snapshot is a cheap confirmation that the pin is having the effect you think — worth keeping precisely because a long run of empty diffs is evidence, not noise. For a hosted endpoint, the snapshot is the only control that exists. For a container, which of the two applies depends entirely on whether the reference is a tag or a digest, and that is a one-line distinction that no dashboard displays.
The update policy from chapter 3, where a pin is paired with a named owner and a review cadence, needs a hosted-shaped version as well, since "who reviews a release, on what cadence" has no meaning when the vendor decides both. What replaces it is an owner per hosted server whose job is to subscribe to whatever notice channel the vendor has, triage the drift pull requests when they open, and re-run an acceptance test against the new contract. The cadence is set by the vendor and the policy states what you do inside it rather than when you take it.
Recording the contract the way production connects
The value of the snapshot is entirely in the words "exactly the way production does". A job that connects to the vendor's endpoint over the public internet from a CI runner, with a test tenant's token, is checking a different server from the one your workloads reach — the four properties that move between environments in chapter 2, on local and hosted builds, apply to the checker as much as to the agent. Run it from inside the production network, with the production credential, on the production transport.
import { readFileSync } from "node:fs";
type ToolShape = { name: string; description: string; inputSchema: unknown };
type Contract = {
serverInfo: { name: string; version: string };
protocolVersion: string;
capabilities: Record<string, unknown>;
instructions: string | undefined;
tools: ToolShape[];
};
function normalise(c: Contract): Contract {
return {
...c,
tools: [...c.tools].sort((a, b) => a.name.localeCompare(b.name)),
};
}
const live = normalise(await recordContract(process.env.MCP_URL!));
const committed = normalise(JSON.parse(readFileSync("contracts/vendor-pricing.json", "utf8")));
if (JSON.stringify(live) !== JSON.stringify(committed)) {
await openDriftPullRequest("vendor-pricing", committed, live);
process.exitCode = 1;
}Sorting the tool array is not cosmetic. Servers that build their tool list from a hash map, a plugin directory or a set of concurrently resolved registrations return it in an order that varies between processes, and an unsorted comparison produces a drift alert every second night until somebody turns the job off. The same discipline applies to any field the server generates rather than declares: an instructions string containing today's date, a description ending in a build number, or a _meta block with a request id all have to be normalised out or the check trains its readers to ignore it. Deciding what to exclude is a judgement about which fields are contract and which are noise, and it is worth writing that judgement into the normaliser where a reviewer can see it, rather than into a regex somebody adds during an alert storm.
What goes into the committed file is the whole handshake, because each part fails differently. The tool list and input schemas break calls outright. The capability set changes what your client is allowed to do and, as chapter 2 showed with a resources capability that appeared only in the hosted build, what material reaches the model. The instructions string is prompt content and a change there is a change to your agent's behaviour with no code path involved. The serverInfo.version is the one field that is pure diagnostics: it is unreliable as a pin, since a hosted server may report a version that never moves or a build string with no ordering, and it is invaluable in an incident, since "the failing runs saw 2026.02.7 and the snapshot recorded 2026.02.3" turns an argument into a fact.
The branch on connection failure matters more than it looks. A drift check that reports "diff failed" when the endpoint is down conflates two very different events, and the availability signal is the more urgent of the two, so it should leave through a different door.
The reason is a property of the comparison rather than a matter of taste. The job answers one question — is the live contract equal to the committed one — and it can only answer it by obtaining a live contract. When the connection fails there is no live contract, so the comparison has no left-hand side, and the job has not returned false; it has failed to run. Reporting a check that did not run as a check that found a difference sends an unavailability into a queue triaged by whoever owns contract changes, through a workflow that opens a pull request, at the moment when what is wanted is somebody establishing whether the endpoint is up. The two also differ in urgency by a wide margin, and a single alert channel resolves that difference in favour of whichever event arrives more often.
The belief worth guarding against here is that a long run of green is evidence the dependency is stable. It is tempting because that is precisely what a long run of green means for a test suite, where the thing under test is held still by your own repository and a passing run rules out change. This job is not held still by anything of yours. A green run is a statement about the past on a component somebody else operates, so the ninety consecutive empty diffs in the case below are evidence that the pinned five are pinned — a fact about your configuration — and evidence of nothing whatever about the sixth. A hosted endpoint's history of not moving is not a property of the endpoint. It is a record of decisions its operator has not taken yet.
Detection is not the same purchase as prevention
Run that job every six hours against a vendor who deploys on most weekdays, and the average time between their change landing and your alert is three hours, with a worst case of six. Every minute of that window is production traffic hitting a contract you have not seen. Tightening the schedule helps and does not change the category: at every fifteen minutes you have cut mean exposure to about seven and a half minutes and you are now connecting to the vendor 96 times a day per server, which some vendors will rate-limit and all of them will notice.
Both of those numbers come out of one step, and doing the step rather than accepting the numbers is what shows that tightening the schedule cannot change the category. A change lands at some moment inside the gap between two checks. If nothing ties the vendor's release timing to your schedule, that moment is equally likely anywhere in the gap, so the expected wait until the next check is half the gap and the worst case is the whole of it. Half of six hours is three hours; half of fifteen minutes is seven and a half. The request count is the other side of the same division — a day divided into fifteen-minute periods is four checks an hour, and four an hour across twenty-four hours is ninety-six. Halving the exposure doubles the request volume, every time, which makes this an exchange rate rather than a tuning parameter.
The assumption carrying that arithmetic is that the vendor's deploy times are independent of your check times, and it is worth knowing which way it fails when they are not. A vendor deploying at a fixed hour, against a job that runs on the hour, produces an exposure of either nearly nothing or nearly the whole period, depending on which side of the boundary the deploy lands — and because the offset is stable, a team can be lucky for months and then unlucky permanently after one schedule change nobody connected to this. Correlation of that kind does not average out; it pins the exposure to one end of the range. Offsetting the check by an arbitrary number of minutes, rather than running it on the hour with everything else, is what restores the average the arithmetic assumed.
Detection tells you the contract moved. It does not keep the agent working in the interval, it does not roll the vendor back, and it does not give you a version to return to. Approving a hosted dependency on the strength of a monitor is approving the outage and buying a faster notification of it, and that is a legitimate trade for an internal batch job that runs at 02:00 and can be re-run. It is a different trade in the synchronous path of a customer-facing agent, where the interval is measured in conversations rather than in minutes.
The closest available substitute for a pin, when there is no artefact, is an assertable version identifier — a vendor API version you send as a header or a path segment, of the dated form that hosted APIs commonly offer, so that your requests select a frozen interface and the vendor's deploys land on a version you did not select. Where a vendor offers it, use it and add an assertion that the response's reported version matches what you asked for, so a silently retired version fails loudly instead of quietly upgrading you. What it still fails to give you is control over the code behind that interface: the vendor can fix, regress or change the behaviour of the frozen version, and the day they retire it is their decision and not yours. Where no such identifier exists, serverInfo.version recorded in the snapshot is a diagnostic, not a lever.
That option is borrowed from a neighbouring practice rather than invented for MCP, and it is worth reading as a precedent with a stated edge. Hosted HTTP APIs have offered dated or numbered interfaces for years, selected by a header or a path segment, and the reason it works there is that the vendor commits to serving old interfaces alongside new ones for a published period. What transfers is the shape of the commitment. What does not is that an MCP server's interface is a tool list the model is shown, so freezing a version freezes what the model can see as well as what your code may call — which is the behaviour you want, and also means that a version you hold is a set of capabilities you have decided not to receive until somebody re-reads the metadata. The other edge is the one already stated: the vendor owns the code behind a frozen interface and owns the date it is withdrawn, so what the identifier buys is a stable shape rather than stable behaviour.
Getting a hosted endpoint into the request path
Put plainly, running a vendor-hosted MCP endpoint synchronously in a customer-facing agent means accepting that a third party can change your product's behaviour, mid-conversation, without notice, with no rollback available to you and no artefact you can hold. That is a real acceptance rather than a rhetorical one, and there are integrations where it is obviously correct — the whole point of the vendor is that they run the thing.
Two conditions make it approvable. The first is a commitment you can point at: either a contractual change-notice with a deprecation window long enough for your release cycle, or an assertable version identifier of the kind above. Either one moves the vendor's deploys off your critical path, and a status page showing deploys most weekdays, with no version identifier and no notice commitment, gives you neither. The second is a degraded path that does not need the vendor: the agent completes the conversation with the tool absent, saying what it could not check, and the path is exercised regularly rather than being a code branch nobody has run. Chapter 7, on what an agent does when a server is unreachable, is about specifying that path properly, because the failure mode it exists to prevent is worse than the outage.
The refusal is the case where neither condition holds and the dependency is synchronous and customer-facing. No notice, no version to assert on, no degraded path, weekday deploys — that combination means the vendor's release process is your product's release process, and a scheduled snapshot does not change the answer; it only guarantees that you will be able to describe the incident accurately while it is happening.
Which control carries a given server is therefore decided by whether an artefact exists at all. Pin what you launch and hash it, because prevention is available there and costs nothing after the first commit; snapshot everything, pinned servers included, because the confirmation is cheap and the empty diff is the evidence. Where the snapshot is the only control, be explicit that what you bought is a shorter interval between a change and your knowledge of it, and decide separately whether that interval is one your product can spend.
Which is where the next chapter starts. When the contract moves and the alert arrives after the failures rather than before them, the first instinct in the room will be to read the client code, and the evidence that settles it in ten minutes is sitting in the snapshot directory.
Consuming servers — read the current version at https://daviddominguez.dev/learn/en/consuming-servers/detecting-contract-drift