Four of my MCP servers were dead. All four reported the same useless thing: Connection closed.
It looked like four incidents. It was two — and one of them accounted for three.
| Server | Symptom | Actual cause |
|---|---|---|
| A | Connection closed | Corrupted package-runner cache — a partial install left a dependency missing |
| B | Connection closed | SDK 2.0.0: McpError renamed to MCPError |
| C | Connection closed | SDK 2.0.0: the …server.fastmcp module removed |
| D | Connection closed | Same as C |
B, C, and D were different repos, different authors, different purposes. They died the same morning.
Three independent failures landing on the same day is possible. One shared thing moving is overwhelmingly more likely. And the shared thing was sitting in the launch commands:
uv run --with 'mcp[cli]' mcp run .../server-c.py # no upper bound
uv run --with 'mcp[cli]' mcp run .../server-d.py # no upper bound
uvx mcp-server-b # pulls the SDK transitively
A dependency with no upper bound is a different program every time you run it. The SDK cut 2.0.0 and three deterministic imports became undefined simultaneously.
The diagnostic mistake I nearly made was going server by server. That path reads the same stack trace three times and calls it three bugs. The moment I saw …server.fastmcp in the first trace, the right question wasn't "how do I fix this server" — it was "what else shares this SDK?"
When several components fail at the same time, stop looking at the components and look at the shared dependency graph.
That's a cheap habit and it collapses an afternoon into ten minutes. Same-timestamp failures across unrelated systems are usually one upstream event.
The reflex is "pin all of it." That's worse than the disease.
An upper bound freezes security patches along with breaking changes. Pinning all 22 of my servers would trade three outages for twenty-two units of staleness debt, forever, most of it on servers that were never going to break.
What I adopted:
The principle underneath:
A pin is a response to an incident, not a prevention. The prevention is detection.
You cannot pin your way out of upstream churn — you can only choose whether you find out from a health check or from a user. So I built the health check.
Two things, and the second one is the point:
1. Retrospective — which servers are dead right now, plus the exact command to reproduce each failure, so diagnosis starts at second zero rather than after ten minutes of reconstructing the invocation. 2. Prospective — which servers could die the same way. This is the real product.
Scoping that second list took a revision. My first pass flagged 19 servers and the signal was mush. The correct population is narrower: servers that re-resolve upstream on every launch through a package runner (npx, uvx, uv, npm). An absolute-path binary — a venv Python, a built Node script — has no version to pin; neither does a URL transport. Including them wasn't cautious, it was noise. Sixteen, not nineteen.
A watchlist that flags things you can't act on trains you to ignore the watchlist.
While I was in there, I compared 60 days of measured usage against what was actually switched on.
history in any relevant directory: on.
I swapped them.
Nobody chose that configuration. There was a good reason for each toggle at the moment it was set, the reason expired, and the toggle stayed. Plugin state rots silently because nothing in the system ever asks whether the original justification still holds.
Worth a periodic diff: what's enabled, against what you actually use. Both halves are measurable. Almost nobody measures them together.
I also mirrored the server set into a second client. Two things worth stealing:
Package-runner servers need an explicit PATH. The desktop client launches processes without a login shell, so a server invoked as uvx --from git+https://… can't find git. It fails in a way that looks nothing like a PATH problem.
I excluded the trading and wallet servers — the ones exposing order placement, transfers, and token mint/burn. That client had a permission-bypass mode enabled, meaning a single misinterpretation on a general chat surface could place a real order or move real funds. My project rules define a safety hierarchy for exactly those operations, and that surface was the one place the hierarchy had no enforcement point.
The rule I'd write from that: when a capability's guardrail lives in one surface's config, that capability doesn't belong in surfaces that don't read that config. Copying a tool list across clients copies the tools; it doesn't copy the constraints.
(And: that client reads its config only at startup. A full quit and relaunch, or you're debugging a file the process never opened.)
A subagent I'd delegated the mirroring to ran a jq '… | tojson' over the config's value objects while diagnosing — and printed eight live API keys into its own transcript. It self-reported.
Remediation was straightforward: replace the values in that transcript, chmod 600. Residual risk is real, because the values passed through a model API; whether to rotate is the owner's call.
The lesson is about the delegation brief, and it's uncomfortable:
My brief said "don't print secret values." The agent complied with that. It never intended to print a secret. It serialized a container that happened to hold them.
The ban has to name forbidden operators, not forbidden intentions.
tojson, to_entries, dump, repr, console.log(obj), print(vars(x)) — bulk serialization of any structure that might transitively contain a credential. "Don't print secrets" is a rule about goals, and careless disclosure isn't goal-directed. Since then my delegation briefs name the operators.
Falsified: all three exit 0 and reconnect.
only above the bound and I miss it. The ledger's re-check reminder is designed to catch that — and if the reminder fires and nobody reads it, the policy failed, not the tooling.
breaking change happens within 90 days. If it recurs, "pin what broke" becomes "pin the critical N preemptively."
Stop looking at the services and diff their shared dependency graph. Three independent failures landing in the same hour is possible; one shared thing moving is overwhelmingly more likely. Going service by service reads the same stack trace several times and reports it as several bugs — the moment the first trace names a shared library, the next question is which other services pull it.
No. An upper bound freezes security patches along with breaking changes, so pinning everything trades a handful of outages for permanent staleness debt across the whole fleet. Pin only what actually broke, record the pin date in a ledger, and re-check each bound on a schedule. A pin is a response to an incident; the prevention is detection.
Name forbidden operators rather than forbidden intentions. An instruction like do not print secrets is a rule about goals, and careless disclosure is not goal-directed — an agent that never intends to print a key will still serialize a container that holds one. Ban the bulk-serialization operations themselves: tojson, to_entries, dump, repr, and logging whole config objects.