Chapter 8 of 8 · 15 min read
Deciding between a server and a function
On this page
MCP amortises against consumers. Everything the protocol buys — discovery at runtime, one schema next to one handler, a fifth client that costs a config entry — is a saving on integrations you would otherwise write per consumer, and the saving is proportional to how many consumers there are. Everything it costs is charged per capability and per call regardless: serialisation and validation on both sides of every invocation, a deployable with a version and a rollout, and a class of failure in which a session establishes successfully and serves nothing.
That makes the decision a counting problem rather than a matter of taste, which is useful because the pressure to decide it by taste is real. The instruction usually arrives as a line in a planning document — expose our capabilities as MCP servers this quarter, because that is how AI integrations are done now — and the sentence sounds like standardisation, which is usually the responsible choice. Counting is what turns the disagreement into two estimates that can be compared.
A single consumer buys nothing
A capability called by exactly one application gains no reuse from a protocol, because reuse is the entire product. What a server buys is that any compliant client can connect, discover and call without a per-client adapter. With one client, and that client being yours, there is no adapter to avoid — you were never going to write one for yourself, since the caller and the callee are in the same process and the call is an import.
Set against that nothing, the costs are all still charged in full. Every invocation pays
roughly 27 ms of serialisation, validation and event-loop scheduling. The capability
acquires a lifecycle it did not have: a deployable, a version, a rollout, a health check
and a line in an on-call runbook, where previously it shipped and rolled back atomically
with the agent that used it. It acquires the failure boundaries of a protocol connection,
so a capability that used to fail with a NameError at import can now fail by connecting
successfully and serving zero tools. And it acquires version skew, which is a category of
bug that did not exist when the caller and the callee were compiled together.
The strongest objection to wrapping such a capability "so it's future-proof" is that there is no second consumer to amortise the integration against — to spread its fixed cost over, the way a shared library pays for itself only once several callers use it — so what has been bought is serialisation, a transport boundary and a version-skew surface over a function call. That is not an argument against MCP. It is the same argument you would make against putting an HTTP hop between two functions in one service, and nobody finds that controversial.
Future-proofing deserves the sharper treatment, because it is the argument that wins these rooms and it is wrong in a way that is easy to miss. It is tempting for a good reason: building the boundary now really does mean you will not have to build it later, and avoidable rework is a genuine thing to avoid. What makes it false is that the rework it avoids is a cost you might never pay, while the thing it buys is a cost you certainly will. The days spent building and the days spent operating are charged whether or not a second consumer ever appears; the one day of migration is charged only in the worlds where one does. Insurance is the right shape to compare it against, and the comparison is unfavourable in the only way that matters: this premium is larger than the loss it covers. Once that is on the table the disagreement stops being about engineering values and becomes a disagreement about one probability, which is a much better argument to be having. Framed that way the counting argument is an invitation to name a number rather than a refusal to build anything.
Capabilities called in a loop fail the test for a second, independent reason. Forty invocations at 27 ms of overhead is 1,092 ms of protocol per user request against roughly 160 ms of actual work, which makes the boundary 87% of the elapsed time. Even if a second consumer appeared tomorrow, the right first move is to collapse the loop into a batch tool that takes forty ids in one call, and once that exists the capability has a completely different cost profile and can be reconsidered on its merits.
The counting argument
The counter to deferring is the reasonable one: we will have more consumers later, and rewriting then costs more than doing it now. Answering that with a preference loses. Answering it with the two costs it compares wins, because they are both estimable.
Converting an existing function into a server is cheap, and the reason is structural rather than lucky. The tool body does not change at all — a minimal server is a decorator, a docstring and a transport line around a query that already exists. Measured on a ticket search, the conversion was about two days including packaging as an entry point, a deployment and a schema review. Doing that conversion later costs the same two days plus roughly one day migrating the existing caller, because the caller has to move from an import to a session. So the entire penalty for deferring is about one day per capability, and it is only paid on the capabilities where a second consumer actually shows up.
Building now, on a capability that never gets a second consumer, costs the two days and then keeps costing. A small internal server takes something like two hours a month of attention — dependency bumps, a base image change, a protocol revision, an incident where it was the thing that was down — which is about three days a year, forever, per server.
That last conversion is worth doing out loud, because operating cost quoted per month and build cost quoted in days are not comparable until they are in the same unit, and quietly mixing the two is how these arguments go wrong. Two hours a month, repeated for twelve months, is twenty-four hours of attention a year. Twenty-four hours is three working days at the length a working day is normally estimated in. So the recurring cost is three days a year per server, which is now in the same currency as the two days it took to build the thing — and the comparison that matters becomes visible immediately, which is that the recurring figure is larger than the one-off one and arrives again every year.
| Build all 11 now | Build 3, defer 8 | |
|---|---|---|
| Build cost, year one | 22 days | 6 days |
| Operating cost, year one | 33 days | 9 days |
| Deployables to run | 11 | 3 |
| Migration penalty if a consumer appears | 0 | 1 day, per capability that does |
| Expected penalty across 8 deferrals | — | ~1.6 days |
Every cell in the first four rows is one of the two per-capability figures times a count, which is what makes the table checkable rather than something to be trusted. Build cost is two days per capability, so the two columns are eleven twos and three twos. Operating cost is three days a year per capability, so the two columns are eleven threes and three threes. The deployables row is the counts themselves. Nothing in the table is an estimate that has not already been stated once above it, and the only judgement anywhere in it is the migration penalty in the fifth row, which is a day per capability that turns out to need converting.
The last row is where the argument is decided. If roughly one in five of the deferred capabilities gains a second consumer within a year, the expected cost of having deferred is 8 × 0.2 × 1 day, which is under two days. The cost of not deferring is 8 × 5 days in the first year alone. Deferring is cheaper by a factor of twenty, and the estimate would have to be wrong by more than an order of magnitude to reverse it. That is a number a director can argue with, which is the point of putting it on the table.
Three steps are compressed into that paragraph and each is worth unpacking, because this is the arithmetic somebody will check in the meeting. The expected penalty multiplies three things: the number of deferrals, the probability that any one of them turns out to need converting, and the extra day that conversion costs when it does. Multiplying a cost by the probability of paying it is what "expected" means, and it is the right operation here because the deferrals are independent — nothing about one capability acquiring a second consumer makes another one more likely to. Eight deferrals, one in five, one day: eight fifths of a day, which is the under-two-days figure.
The other side of the comparison is the cost actually avoided, and the five in it is a sum rather than a new estimate. Building a capability nobody else calls costs the two days to build plus the three days a year to keep, which is five days inside the first year — the two figures from the paragraphs above, added. Eight capabilities at five days each is forty days. Dividing forty by the under-two-days on the other side is where the factor of twenty comes from, and it is deliberately quoted against the rounded-up two rather than against the true eight fifths, which makes it a floor: the real ratio is larger, and stating the smaller one means nobody can improve their position by disputing the rounding. That is the property that makes an estimate survive a meeting, and it is worth building into any figure you expect to be argued with.
There is one more term that a spreadsheet misses and it runs the other way, so it belongs in the honest version. Eight deployables that nobody uses are not merely idle; they dilute attention across an on-call surface, and each one is another server whose tools might get connected to an agent by someone who does not know they are unmaintained. The token arithmetic of a connected server makes that concrete — a client configured against eleven servers ships every one of their tool definitions in every request it sends — so unused servers degrade the agents that do exist.
Keeping the function without keeping two of them
The tempting compromise — build the server for the new teams, and leave the existing agent calling the Python function directly so it pays no overhead — is the one worth refusing hardest, because it is genuinely attractive and it fails quietly.
Two call paths into a capability means two places where the behaviour is defined, and they will diverge. Somebody fixes a ranking bug in the server because that is where the new team reported it. Somebody widens a filter in the function because that is where the agent needed it. Six weeks later a bug reproduces for one caller and not the other, or worse, the two paths return subtly different result sets and nobody thinks to ask whether they are the same code, because from the outside it is one capability with one name. This is exactly the duplication the server existed to remove, reintroduced inside your own team.
The version that is fine, and is what "keep the function" should always mean, is one implementation with two entry points:
# contracts/core.py — the only place the capability is defined
def search_contracts(query: str, party: str | None = None, limit: int = 10) -> list[Contract]:
...
# contracts/mcp_server.py — a thin adapter, no logic
from contracts.core import search_contracts as _search
@mcp.tool()
def search_contracts(query: str, party: str | None = None, limit: int = 10) -> list[dict]:
"""Search executed contracts by free text. Returns at most 20, newest first."""
return [c.as_dict() for c in _search(query, party, limit)]The existing agent imports contracts.core and pays 6 ms. Legal and the editors connect to
the server and pay 33 ms. There is one ranking algorithm, one filter, one set of tests, and
a bug fixed in core.py is fixed for everyone in the same commit. What you must not let
happen is the adapter accumulating logic — a clamp here, a formatting rule there — until it
is a second implementation that merely started thin.
The mechanism of that accumulation is worth naming, because every instance of it is
individually reasonable and the sum is not. The adapter is the only place that knows it is
serving a protocol, so anything that is true of the protocol path and not of the import path
lands there by default: a cap because a model asked for too many rows, a date format because
one client rendered the raw value badly, a field renamed because a schema keyword was
awkward. Each of those is correctly scoped to the adapter on the day it is written. What
turns them into a second implementation is that they change what a caller gets back, and
once two callers get back different things, the two paths have different behaviour whatever
the file headers say. The line to hold is therefore not "keep the adapter small" — size is
not the property — but "the adapter may translate and may not decide". Anything that would
change the answer belongs in core.py, where both callers see it.
A test that runs the same fixtures through both entry points and asserts identical results is cheap insurance and makes the divergence a build failure rather than a support thread.
Committing to a decision like this without a trigger for revisiting it is the other way it goes wrong, and three observations should reopen the deferrals. The first is a second consumer, which usually appears as a question in a channel rather than a request — someone asking how they would call your reporting query from their assistant. The second is a consumer you never hear about, which shows up as an unexpected client name in a session log or as somebody having written their own copy of your query. The third is call-pattern change: a capability that was one call per turn becoming a loop makes the protocol boundary dominant, and a capability that was a loop becoming a batch removes the objection to it entirely.
None of that is visible unless something records it today, and two things are worth
instrumenting now because both are cheap. Log the identity of every caller into each
capability — for a function that means the module or service that imported it, for a server
it is clientInfo — so the consumer count is a query rather than a recollection. And
record tool invocations per user request as a distribution, not a total, because the mean
hides the tail and it is the p95 of forty that decides whether a boundary is affordable.
With those two series in hand you can revisit the decision in six months against evidence
rather than recollection, and if the answer has changed, the conversion is the two days it
always was — which is the whole reason deferring is safe. A decision that is cheap to
reverse does not need to be right the first time; it needs a trigger and a record.
The rule underneath all of this is short enough to carry into a design review: put a capability behind a server when more than one client will call it and the clients are not yours to change, and leave it as a function otherwise. Both halves of that sentence are load-bearing. More than one client is what the protocol amortises against, and clients you cannot change is what makes the adapter unavoidable in the alternative — two agents in your own repository do not need a protocol between them, because you can edit both.
Everything in this topic is the working-out of that rule: what a server actually decouples, what the four-step exchange costs, where a call fails before your code runs, and how to tell real portability from an adapter in a wrapper script. What makes the rule usable rather than dogmatic is that both sides of it are measurable in days and milliseconds, and that the decision has a trigger for being revisited when the count changes. The place to apply it is the capability on your own list with the most named consumers: build that one server, and connect the second client before you connect the first, because the second client is the only one that can tell you whether the property you are paying for is actually there.
What MCP is and why it exists — read the current version at https://daviddominguez.dev/learn/en/what-mcp-is-and-why-it-exists/deciding-between-a-server-and-a-function