Skip to content

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:

json
{
  "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 toolMCP server
Credential holderYour agent's secret storeThe server's, acting for N consumers
Release couplingSame artefact, same deployIndependent; consumers may lag
Schema driftCaught at build timeCaught at runtime, by the model, quietly
Failure modeException in your stack traceTimeout, 5xx, or a version-skew error
AvailabilitySame as the agentA separate number that multiplies in
Who is pagedYouWhoever owns the server, if anyone does
Cost to add consumer 2They import your library, or copy itConfiguration only
Blast radius of a bad changeOne agentEvery 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.

ProposalWhat it actually isCorrect mechanismWhy not a server
format_release_notes — reshape drafts into the company formatStanding procedural knowledge, one consumerSkillNothing leaves the process; a server buys deploys and auth for text
query_pricing — read the pricing table your team ownsAction, one consumer, your credentialIn-process toolSame 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 itAction, three consumers on three release trains, shared credentialMCP serverThis is the case it is for
run_kubectl — shell out to kubectl so the agent can inspect clustersAction with an unbounded surfaceNarrow tools with an explicit allow-list, in-processA 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

Back to top