Chapter 3 of 8 · 9 min read
A tool fails inside your deploy; a server is someone else's uptime
On this page
For two and a half days, the support agent silently stopped attaching tickets to escalations. Nobody noticed until a customer success manager asked why the Tuesday escalation had no Jira link, at which point someone pulled the traces and found 340 identical failures:
{
"tool": "jira.create_issue",
"arguments": {"project": "SUP", "summary": "...", "priority": "P2"},
"error": {
"code": -32602,
"message": "Invalid params: unknown property 'priority'; did you mean 'priority_id'?"
}
}The Jira MCP server had shipped version 2.4.0 on Friday afternoon, renaming priority to priority_id and making it an integer. The server team ran their tests, deployed, and moved on. Three agent products consumed that server, and two of them had prompts, examples and evaluation fixtures written against the old field name. There was no compile step to catch it, because there is no compile step between an agent and an MCP server. There is a schema fetched at connection time, carried in JSON-RPC — the request-and-response envelope MCP speaks, which is where that -32602 code comes from — and a model that does its best with it. Given a schema saying priority_id and a system prompt containing an example using priority, the model followed the example. This is version skew: the server's interface moved and the text its consumers were written against did not, and the two drifted apart with nothing in between to notice.
The failure is not interesting as a bug. It is interesting because it cannot happen to a tool. A function compiled into your agent that renames a parameter renames it for its only caller, in the same commit, in the same deploy, and if the caller is stale the build breaks before anything ships. Every property that made this incident possible came from the decision to put the capability in a separate process owned by a separate team, and none of them came from the capability itself.
The column that does not appear in a benchmark
If you benchmark the tool version and the server version of the same Jira integration, you get two numbers a few tens of milliseconds apart, and you learn nothing about the difference between them. What actually separates them is operational, and it is a set of questions with the same shape as the questions you would ask before extracting a library into a microservice.
Who holds the credential. In the tool version, the Jira service account token lives in the agent's secret store and is used by the agent's own process. In the server version it lives in the server's secret store, which means the server is now acting on behalf of three different agent products, and either it uses one shared identity — in which case Jira's audit log attributes every action to svc-agent-platform and you cannot tell which product did what — or it forwards a per-user identity, which is a whole authentication design with token exchange in it.
Who cuts the release. A tool releases when the agent releases; they are the same artefact. A server releases on its own schedule, which is why the field rename could land on a Friday without any of its consumers being asked. The contract between them is now a versioned interface, and versioned interfaces require deprecation windows, additive-only change discipline, and someone to enforce both.
Who gets paged. A tool cannot be down independently of the agent; if the agent is running, the function is there. A server has an availability number, and that number multiplies into the agent's. At 99.9% each, an agent depending on three internal servers is looking at roughly 99.7% for the composite, which is about two hours a month of some capability being unavailable — and someone has to be on a rotation for it, or those two hours become two days like the ones above.
| In-process tool | MCP server | |
|---|---|---|
| Credential holder | Your agent's secret store | The server's, acting for N consumers |
| Release coupling | Same artefact, same deploy | Independent; consumers may lag |
| Schema drift | Caught at build time | Caught at runtime, by the model, quietly |
| Failure mode | Exception in your stack trace | Timeout, 5xx, or a version-skew error |
| Availability | Same as the agent | A separate number that multiplies in |
| Who is paged | You | Whoever owns the server, if anyone does |
| Cost to add consumer 2 | They import your library, or copy it | Configuration only |
| Blast radius of a bad change | One agent | Every consumer |
Six of those eight rows are about operation rather than capability. That is the ratio the argument usually has backwards, because capability is what gets demonstrated in the proposal and operation is what gets discovered in the second quarter. The last row uses a term that recurs through the rest of this topic: the blast radius of a change is how much stops working, or becomes reachable, when that one thing goes wrong. For a tool it is the single agent that compiled it in; for a server it is every product connected to it, which is the same property the sharing argument depends on, read from the other direction.
The library-to-service comparison, and where it breaks
Backend engineers have made this decision before under a different name. Extracting shared logic from a library into a service is the same trade: you gain a single deployment of the truth and lose compile-time coupling, and the standard advice is that you do it when the consumers cannot be kept on the same release train — the shared schedule on which a set of components are built and shipped together — when the thing holds state or credentials the consumers should not, or when it must be operated independently. 6.16 established the deploy-boundary version of that test for MCP specifically, and nothing here revises it.
Two things about agents make the comparison imperfect in ways that push toward fewer servers rather than more. The first is that the consumer is a language model rather than a compiler, so every class of drift that a type system would have caught becomes a behavioural failure discovered in production, as in the incident above. A library upgrade that renames a field produces a red build; a server upgrade that renames a field produces a slightly lower success rate, distributed across whichever runs happened to touch that field.
The second is that the interface surface is not just the schema. It includes the tool descriptions, which are prompt text the model reads and reasons over, and those are tuned per consumer. A shared server hands all three products the same description of create_issue, and the support agent wants it phrased one way while the release agent wants it phrased another. Teams work around this by wrapping the shared tools in per-product prompt text, and at that point each consumer is maintaining a local adaptation layer over a remote dependency, which is most of the duplication the server was supposed to remove, plus the server.
Four proposals, one of which is a server
The clearest way to hold the rule is to run it over several requests at once. Here are four that arrived in the same planning cycle, each of which somebody proposed as an MCP server.
| Proposal | What it actually is | Correct mechanism | Why not a server |
|---|---|---|---|
format_release_notes — reshape drafts into the company format | Standing procedural knowledge, one consumer | Skill | Nothing leaves the process; a server buys deploys and auth for text |
query_pricing — read the pricing table your team owns | Action, one consumer, your credential | In-process tool | Same team, same release train; a hop adds failure modes and nothing else |
jira_issues — create and search issues, needed by three agent products owned by three teams, platform team will run it | Action, three consumers on three release trains, shared credential | MCP server | This is the case it is for |
run_kubectl — shell out to kubectl so the agent can inspect clusters | Action with an unbounded surface | Narrow tools with an explicit allow-list, in-process | A server does not bound the action; it relocates it and adds a privileged identity |
Only the third crosses the line, and it crosses it on three facts at once: multiple consumers, separate owning teams, and a named team willing to run it. Remove any one of those and the answer changes. Two consumers on the same release train can share a library. Three consumers with nobody willing to operate the server means you are proposing to create a dependency with no owner, which is the topic of chapter 7.
The fourth row is the one that catches people, because "put it behind a server" sounds like it adds control. Wrapping kubectl in an MCP server does not restrict what the agent can do; it restricts where the code runs, and it does so by giving a network-reachable process a kubeconfig with whatever permissions the broadest caller needs. If the goal is to limit the action surface, the mechanism is a narrow schema — list_pods(namespace), get_pod_logs(namespace, pod, since) — and that schema does the same work in-process, without a privileged daemon.
What a server is not
A server is not a security boundary by default. It becomes one when it authenticates its callers, authorises per-caller, and holds a credential that the callers genuinely must not hold — that last case being the one from 6.16 that can justify a server at a single consumer. Absent those, a server is a network hop with a shared secret in it, and moving the credential from your process into a process that answers requests from three products has usually widened access rather than narrowed it.
A server also does not eliminate the tool. The model's side of the interaction is identical: a name, a description, an input schema, a returned value. Everything the last two chapters said about invocation being a model decision applies unchanged, because relocating a body does not change how a call gets initiated. That symmetry is not a curiosity; it is the migration path, and chapter 7 uses it.
Finally, the operational column is not exclusive to the tool-versus-server pair, even though that is where it bites hardest. Every mechanism has an owner, and asking who that is exposes real problems elsewhere. A skill's owner is whoever keeps the procedure accurate, and skills routinely have none, which is why they go stale. A system prompt's owner is contested by construction, since anyone can append. A sub-agent's owner is whoever tunes its prompt and its tool list, and that is a separate maintenance surface from the parent agent even though both ship in the same repository.
Which brings up the mechanism that has not been examined yet. A sub-agent adds no deployment, no credential and no network dependency, so on the operational column it looks nearly free — it ships inside your process like a tool, and it fails inside your stack trace like a tool. The cost is somewhere else entirely, and it is large enough that a team can move one task into a sub-agent and watch the bill for that task rise by 40% while every dashboard they are looking at improves.
The extensibility landscape — read the current version at https://daviddominguez.dev/learn/en/extensibility-landscape/a-tool-fails-inside-your-deploy-a-server-is-someone-else-s-uptime