Skip to content
Chapter 2 of 7 · When MCP is the wrong answerThe deploy boundary test

15 min read

The deploy boundary test

On this page

Reuse is the argument that carries most MCP proposals, and it is made about the artefact rather than about anybody's situation: a server can be called by clients you never meet, so building one is reusable. That is true and it is not yet a reason, because the cost of the boundary starts on the day it ships and the benefit starts on the day somebody integrates. What converts the argument into evidence is a specific caller that a function cannot serve.

The test that separates them is not about how many callers there are. It is about whether a caller can put your implementation inside its own deployable artefact, which is a question about release trains — one set of code that is built, released and rolled back together on a single schedule. Callers on your train get a change because you deployed. Callers on another train get it because they redeployed, which means a contract has to exist between you, and a contract is where the cost of a boundary actually lives.

Three call sites are one consumer

Here is a list of five callers offered in support of one such proposal, and it is the shape almost every one of these lists takes:

  1. The support agent, in the agent service, same repository.
  2. A backfill-summaries batch job, same repository, run nightly from the same image.
  3. An internal CLI that support leads use to inspect a customer before a call — same repository, published as a console script from the same package.
  4. An engineer's local Claude Desktop, pointed at staging, used for exploratory questions about ticket data.
  5. The Growth team's churn-risk agent, in the Growth team's repository, deployed by the Growth team on their own schedule.

Items 1 through 3 are three call sites, and a call site is not a consumer. They all get the capability by writing from tickets import recent_tickets and they all get the new version of it the moment the function's repository deploys, because they are the same deployable unit. Turning that import into a network call between two processes built from the same commit and released together buys nothing and adds the seven failure modes counted in chapter 1, on measuring a function against a server.

Why a call site inside one train cannot be a consumer follows from what a release train is, and the chain is short enough to check link by link. A train is one set of code built, released and rolled back together, so everything on it comes from a single commit. Anything built from a single commit holds one version of every part of itself. Two versions of recent_tickets therefore cannot be live at the same moment inside that train — the skew is not unlikely, it is unavailable. A contract exists to describe what is true while two parties disagree about a version, and where no disagreement can arise there is nothing for a contract to describe. A boundary is a contract with a network hop attached, so it has nothing to do either.

Run the chain across two trains and every link inverts. The Growth team builds from their own commit, so their artefact carries whichever version of the capability they last took. They release on their own schedule, so the moment your behaviour changes is not the moment theirs does. Two versions are live simultaneously by construction, and the interval between your deploy and their redeploy is a window in which the two sides disagree about what the capability returns. Something has to state what is true during that window, and that statement is the contract. A protocol with a versioned tool schema is one way of writing it down, and the fact that something must be written down at all is what separates the two situations.

Ask one question about each proposed consumer: if this capability changes tomorrow, does the consumer get the change by redeploying itself, or does it get the change because you deployed?

Flowchart: recent_tickets() → support agent; recent_tickets() → backfill job; recent_tickets() → support CLI; churn-risk agent → recent_tickets().

Everything inside one box moves together. A change to the function reaches the agent, the batch job and the CLI in the same deploy, at the same second, with one rollback lever covering all three. There is no version skew possible between them, because there is only one version. The dotted arrow across the boundary is where the trouble is: the Growth team cannot put your Python function inside their artefact, so any coupling between them has to survive the two of you deploying independently, which is precisely the problem a protocol with a versioned tool schema exists to manage.

Three separate conveniences in that paragraph are one property wearing three faces, and noticing that is what lets a reader predict the next case instead of memorising this one. One rollback lever covers all three call sites because there is one artefact to roll back. The change reaches all three in the same second because there is one deploy for it to arrive in. The versioned tool schema has nothing to manage because what it manages is disagreement between versions, and only one version exists. An argument for keeping a capability in-process that lists several benefits is usually listing that one property several times, which matters for a practical reason: a second train does not remove those benefits one at a time, it removes all of them at once.

This reframes the question from "how many clients" to "how many release trains". Client count is easy to inflate and easy to argue about, and a proposal with four clients on one train describes a well-factored module. Two trains is a different situation regardless of how many callers sit on each one, because two trains means a contract has to exist somewhere, and the only remaining question is whether that contract is MCP or something cheaper.

CallerShips fromCan import the functionCounts as a second consumer
Support agentyour repo, your deployyesno
Backfill jobyour repo, your deployyesno
Support CLIyour repo, your deployyesno
Growth churn-risk agentGrowth repo, Growth deploynoyes
Local Claude Desktopan engineer's laptopnosee below

Sorting the five that way leaves nothing over, and the fact that nothing is left over is what makes the count trustworthy. Every caller lands in exactly one of three places: on your train, on another team's train, or on no train at all. Three of the five sit in the first, one in the second and one in the third, and three and one and one account for the whole list with no unclassified remainder. Count trains rather than callers and the same list yields two, one of which is yours, with the laptop sitting outside the count because it is not a train at all. Two is the number the decision turns on, and the partition is why a list of callers can grow to any length without moving it — adding a caller to a train that has already been counted changes the first column and leaves the second exactly where it was.

The list is worth reading as an honest mistake rather than as a trick, because the belief behind it is the one most likely to survive this chapter. A call site really is a caller. It invokes the capability, it breaks when the capability breaks, and it turns up in the same search. What makes it not a consumer is invisible in the code and visible only in the deploy: the question a consumer count is meant to answer is how many parties you would have to coordinate with before changing a field name, and three call sites on one train are one party. The correction is therefore not to argue about the number but to ask, of each entry, who would need telling before the rename shipped. A caller nobody needs to tell is a caller nobody has to build a boundary for.

Why the boundary is the thing and not the language

The test as stated is about deploys, and there is a version of it that is about packaging, which catches a case the deploy question misses. If the Growth team's agent were written in Go, they could not import your Python function even if they were somehow on your release train. Language is one way to be unable to ship someone else's code inside your own artefact; being on a different train is the more common way, and the underlying condition covers both. A caller who cannot embed your implementation needs a contract with you, and the contract is where the cost goes.

The underlying condition is worth stating on its own, because the two versions of the test are special cases of it and a third case turns up eventually. Ask whether the caller can execute your implementation inside their own artefact. Being on another train makes the answer no, because they build separately from you. Being written in another language makes the answer no, because they cannot link your code at all. A client running on hardware you do not control, or one whose build cannot take a dependency on your registry, fails the same condition for a third reason. Each of those is a different obstacle and every one of them produces the same consequence: the caller has to be handed a description of the capability rather than the capability itself. A description that two parties maintain independently is a contract, and every cost in this chapter is downstream of having one.

This is the same decision backend engineers already make about shared libraries. When three services in one repository need the same billing arithmetic, you extract a module and everyone imports it. When a team you do not deploy with needs the same arithmetic, you either publish a package they vendor — which means they now hold a copy, on their own upgrade schedule, and you have a version matrix — or you put a service in front of it and own a request contract. MCP is the second of those, specialised for agent callers, and the reason to reach for it is the same reason you would reach for an internal HTTP API: the caller is not on your train.

Publishing a package is the cheaper of the two answers and it is the one that gets skipped in these conversations. If the Growth agent is also Python, on a similar dependency baseline, pip install support-tickets==2.1.0 gives them the capability with none of the seven added failure modes, at the cost of a version matrix and the possibility of two teams running different behaviour at the same time. That trade is exactly the one you would evaluate for any shared library, and MCP wins it when the capability needs to be centrally controlled rather than merely shared — when everyone must be on the current behaviour, or when the capability holds something a library cannot ship, like a credential.

What the package option trades is worth setting out precisely, since it is the alternative most often dismissed in a clause. Shipping a version of your code into somebody else's artefact puts your implementation back inside their train, and that is exactly the condition under which the added failure modes cannot arise: there is no separate process to be down, no endpoint to be unreachable, no token to expire and no caller-to-tenant mapping to get wrong, because the code is running inside the caller. What you acquire in exchange is a copy whose age you no longer control. The Growth team upgrades when they choose, so the set of behaviours running in production is the set of versions anybody has installed, and that set is the version matrix. MCP wins the trade in the two cases just named, and both are cases where a copy is not merely older but wrong: a stale copy is a defect rather than a lag when everyone must be on the current behaviour, and a copy cannot carry a credential at all.

The laptop client is a real boundary and usually a weak one

Item 4 on the list deserves more than a line, because it is outside the deploy boundary by any reading. An engineer running Claude Desktop on their laptop cannot import your function; they are not on your train; they are not even on a train. By the test as stated, they count.

They usually should not, and the reason is that the test is a proxy for a durable coupling that someone will maintain. A local desktop client used for exploration has no service-level agreement behind it, no on-call implication, and no consequence when it breaks other than an engineer shrugging and reading the database directly. Building and operating a network-reachable, authenticated, tenant-scoped server so that three engineers can ask ad hoc questions is a large fixed cost against a demand that a read-only database credential and a saved query already satisfy. The honest version of the test adds a second clause: a second consumer counts when it is outside your deploy boundary and somebody is committing to keep it working.

The shape of that correction is worth naming, because it recurs wherever a good test is written down and then applied literally. Being outside your deploy boundary is a necessary condition for a caller to be a second consumer: any caller that can embed your code never needs a contract with you, so nothing inside the boundary can qualify. It is not a sufficient one. Reading a necessary condition as a sufficient one is the commonest way a sound test produces an unsound answer, and the laptop is this chapter's instance of it. What the first clause cannot see is whether anybody has committed to keeping the integration working, because that is a fact about people rather than about deploys, and no inspection of repositories or release schedules will turn it up. The test as first stated is true and incomplete, and the version that belongs in a record carries both clauses.

There is a case where the laptop client flips. If the exploration turns into a workflow that a dozen non-engineers depend on — support leads, account managers, people who cannot be handed a database credential — then the consumer is durable, it is outside your boundary, and the credential problem is real. That is a different fact pattern from the one on the list, and it is the fact pattern the proposal would need to demonstrate rather than assert.

Demonstrating rather than asserting is a demand about evidence, and it comes down to whether a later reader can check the claim without having been in the room. A named system with an owner and a date can be checked by opening a ticket. A claim recorded as "Interest" from two teams cannot be checked at all, because there is no observation next quarter that would show it to have been false — the teams are still interested, and they were interested before. Unfalsifiable is the useful word here rather than vague. A vague claim can be sharpened by asking a better question; an unfalsifiable one stays exactly as true whatever happens, which is why it survives every review and settles none of them.

What makes a second consumer nameable is a specific team, a specific system, and a date by which they are integrating — ideally a branch. "Growth's churn-risk agent, integrating in Q3, tracked as GRW-412" is a fact. "Two teams have expressed interest" is a forecast wearing a fact's clothes, and it has a failure mode that is worse than being wrong: the server ships, the integrations do not materialise, and a platform team now operates a service with one consumer and no path to deleting it, because deleting it means going back to the agent team and asking them to re-inline the code. None of this makes expressed interest worthless. It makes it an input to a scheduling decision rather than a build decision, and chapter 7 is about what to do with a proposal built on it, because refusing outright is a lazier answer than it looks.

The deploy boundary is one axis, and it is the one that carries the most weight in the largest number of cases, because it is the only one that can be checked by asking two teams what their release schedules are. It answers the reuse argument on the reuse argument's own terms: not "is this reusable" but "who is unable to embed it", which is a question with names in the answer.

It is not the only axis, and the next chapter is about the three that sit beside it — because a capability another team already owns argues for a boundary at any client count, a capability that changes weekly argues against a shared server precisely because consumers are on their own trains, and a capability holding credentials the caller must not hold can make a server correct at a single consumer. Those axes do not all point the same way, and knowing which way each one pushes is what stops a decision record from being a list of adjectives.

When MCP is the wrong answer — read the current version at https://daviddominguez.dev/learn/en/when-mcp-is-the-wrong-answer/the-deploy-boundary-test

Back to top