Originally published on hexisteme notes.
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 AConnection 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
Simultaneous death is a signal, not a coincidence
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
Enter fullscreen mode Exit fullscreen mode
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.
Pinning everything is the wrong fix
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:
- Pin only what actually broke (three of twenty-two).
- Record the pin date in a pin ledger.
- Flag any pin older than 90 days for “can this bound come off yet?”
- Leave the other sixteen unpinned. Deal with breakage when it happens.
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.
What the health check actually looks for
Two things, and the second one is the point:
- 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.
- 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.
The side finding: enabled ≠ used
While I was in there, I compared 60 days of measured usage against what was actually switched
on.
- A language server for the platform accounting for ~13% of my activity (310 prompts): off.
- A language server for a language I’d mentioned once in five months, with no session 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.
Mirroring configs, and the tool I deliberately left out
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.)
The incident inside the incident
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.
What would falsify all this
- If the SDK diagnosis were wrong, the three servers would keep dying after pinning. Falsified: all three exit 0 and reconnect.
- If the selective-pin policy is wrong, a security patch lands within 90 days that exists 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.
- If leaving sixteen servers unpinned is wrong, another synchronized death from an upstream breaking change happens within 90 days. If it recurs, “pin what broke” becomes “pin the critical N preemptively.”
The two Stop hooks behind this note are on GitHub under MIT: hexisteme/hard-gate-hooks. They ship with their tests and a read-only scanner that prints what they did on **your* machine, not mine — including the case where it tells you they aren’t worth wiring up yet. No email, no signup.*
Get new notes by email: hexisteme.beehiiv.com — no welcome sequence, no course, no upsell.
More notes at hexisteme.github.io/notes.