My requirements.txt Had a Landmine in It. It Just Hadn't Gone Off Yet.
I built a small MCP server a few weeks ago - server.py, seven tools wrapping the GitHub REST API and the DEV.to API for managing my own dev presence. It's been running fine.
Today I read a post about someone whose production deployment went down because their fastmcp dependency had no version ceiling - a transport-level default changed in a minor release, and suddenly every request came back 421 Misdirected Request. I had the smug "glad that's not me" reaction for about four seconds, then went and actually opened my own requirements.txt.
mcp[cli] - one line. No version at all. Not even a floor, let alone a ceiling.
I wrote it that way on purpose - the ADR I logged for this decision literally says "zero-dependency script; slight verbosity in HTTP call setup" was the tradeoff I was optimizing for, and I picked FastMCP specifically because it kept the dependency count at one. What I didn't think through is that "one dependency" and "one unconstrained dependency" are very different risk profiles, and I'd conflated them.
What mcp[cli] with no pin actually means
Every fresh pip install -r requirements.txt resolves to whatever the latest published version of mcp is at install time. Today that's 1.28.1. There's no guarantee it's the version I developed and tested against - I never recorded what that was.
If a contributor sets this up next month and mcp has shipped a 2.0.0 in the meantime with a breaking change to FastMCP's tool registration or the stdio transport, their install silently gets code I've never run, and the first place they find out is a stack trace at runtime, not at pip install time.
That's exactly the shape of the incident I read about: fastmcp shipped a change to how it validates the Host header on streamable-HTTP transport, unpinned installs picked it up automatically, and every request from a client sending the old expected header started getting rejected with 421. Nobody chose to upgrade. The upgrade chose them, the next time CI or a deploy pipeline ran pip install.
My server only runs locally via mcp dev server.py or python server.py right now, so the blast radius is "my own machine breaks," not "production goes down." But the anti-pattern is identical, and the only reason it hasn't bitten me yet is that I haven't reinstalled since I wrote it. That's not safety, that's just an incident that hasn't happened yet.
The fix
mcp[cli] โ mcp[cli]>=1.28.0,<2.0.0
- Floor pinned to the version I actually have installed and have tested against (
pip show mcpโ1.28.1, so>=1.28.0covers what I'm running with a little slack for patch releases before it). - Ceiling excludes the next major, on the assumption that a
1.xโ2.xbump is where a tool-registration or transport signature is most likely to change under me.
It's not a lockfile - for a single-file MCP server with one real dependency, a full pip-compile / lockfile setup felt like more process than the project's size justifies - but it converts "whatever's newest today" into "a version range I've actually exercised," which is the part that was missing.
Why this is easy to miss in an MCP project specifically
Regular web app dependencies get this scrutiny by default - most people wouldn't ship flask or express with no version pin, because a stale mental model of "this could break in prod" is already there for web frameworks. MCP servers don't trigger that instinct yet, for two reasons I noticed in myself:
They feel like glue code, not infrastructure. Mine is seven
@mcp.tool()-decorated functions callingurllib. It doesn't feel like the kind of thing that needs a dependency policy - it feels closer to a script. But the one real dependency it has (mcp[cli]) is exactly the layer that defines how a client talks to it, which is the layer most likely to have breaking changes as the protocol itself is still evolving quickly.Local dev hides the problem. I've been running the same
pip installfrom the same virtualenv since I wrote this, so I've never actually experienced a resolver picking up a newermcpversion. The bug is dormant precisely because I haven't done the thing (fresh install, new machine, CI run) that would trigger it. That's the worst kind of landmine - the code around it looks completely fine because the failure condition just hasn't occurred yet.
The check I'm adding to my own habits
Before I call any small MCP server "done," I now run one extra command and actually read the output instead of assuming:
pip show mcp | grep -i version
grep -E '^[a-zA-Z0-9_-]+(\[|$|>|=|<)' requirements.txt
If the second command shows a package name with no version specifier after it - not even a floor - that's the same shape of bug as the 421 incident, whether or not it's bitten anyone yet.
It's a five-second check, and the honest reason I hadn't done it already is that a one-line requirements.txt looks too simple to need auditing. Simple and safe aren't the same thing; simple just means the landmine is easier to see once you actually look.
Comments
No comments yet. Start the discussion.