DEV Community

Cover image for Pin Your MCP Servers
Andre Burgstahler
Andre Burgstahler

Posted on Originally published at Medium

Pin Your MCP Servers

Nearly half of sampled repos start an MCP server with no version. One script checks Claude Code, Cursor, Codex and OpenCode configs for it.

Abstract illustration: a row of off-white package boxes on a dark navy background, joined by a thin teal line; most are held in place by small amber pins, while the last two have come loose and drift away, blurred, trailing dotted motion lines
Most of the row is pinned. The loose ones are the ones that change under you.

TL;DR

  • A line like npx -y @scope/server starts whatever was published most recently, every time your agent launches it. In a sample of 346 public repos that configure a local MCP server, 46.0% start at least one this way and 18.2% ask for latest outright.
  • This month showed why it matters for more than security. A server image tagged latest stopped loading tools in Claude Code. A popular server package moved up a major version eight days after its 1.0.0. Claude Code shipped two MCP protocol changes in three days.
  • Pin every local server to an exact version (by digest for containers), save its tools/list when you pin it, and let a 96-line check fail CI when someone adds an unpinned line. Script and checklist below.

On 13 October someone opened issue #3431 on the Model Context Protocol repo. The complaint was small and specific. The quickstart pages that people copy MCP configs from start every server unpinned, with lines like npx -y @modelcontextprotocol/server-filesystem. Meanwhile the project's own Local Server Security guide says to "Install a specific version rather than latest."

The issue came with numbers. The author took a random sample of 2,999 public GitHub repositories that ship agent skills or MCP configs. Of the 346 that configure a local MCP server, 159 (46.0%) start at least one with no pinned version, and 63 (18.2%) ask for latest outright.

Two caveats, and the author gives both. It's a sample of what GitHub code search surfaces, not a census. And a hand check found 19 of 22 findings correct, so the scanner isn't perfect either. Still, the study's README has one more number worth knowing: popularity doesn't help. Among repos with 100 or more stars, 57.8% had an unpinned server, though that band is only 45 repos.

The quickstarts aren't the only docs that teach the habit. The config example on OpenAI's Codex MCP page is args = ["-y", "@upstash/context7-mcp"], also with no version.

What "unpinned" means on an ordinary Tuesday

An MCP server configured with npx -y some-package isn't installed. It's resolved. Each time your agent starts the server, npx asks the registry what the newest version is and runs that. uvx some-package does the same for Python. A docker run line with no tag pulls :latest.

Here's what that looked like for the package in the issue's own example. I checked the npm registry on 21 October:

  • @modelcontextprotocol/server-filesystem 1.0.0 was published on 11 October and tagged latest.
  • 2.0.0 was published on 19 October and took over latest.

So an unpinned config that started 1.0.0 last week now starts a new major version, and nothing in the config file changed. The 2.0.0 package depends on the new @modelcontextprotocol/server 2.x package instead of @modelcontextprotocol/sdk 1.x, and its engines field now asks for Node 24 or later.

I ran both versions and saved their tools/list output with the MCP Inspector CLI. The 14 tools have the same names, descriptions and input schemas. The only difference is that 2.0.0 no longer sends an execution field on each tool. That's a harmless change this time. But you only know that because you compared, and an unpinned config never gives you the moment to compare.

It's a compatibility problem too

Most writing about MCP pinning is about supply-chain attacks, and those are real. This month, though, the more common failure was a server and a client upgrading out of step.

Timeline, 7 to 19 October 2026. 7 Oct: GitHub MCP server image latest omits resultType, so Claude Code loads no tools; workaround is pinning v1.14.0. 11 Oct: server-filesystem 1.0.0 is latest. 13 Oct: issue #3431, 46.0% of 346 sampled repos unpinned. 14 Oct: Claude Code v2.1.299 changes connector protocol defaults. 15 Oct: Python SDK advisory. 16 Oct: Claude Code v2.1.302 fix. 19 Oct: server-filesystem 2.0.0 becomes latest.
Twelve days in October. Client and server both moved, and only a pin decides when you move with them.

The clearest example is github/github-mcp-server#3453, opened on 7 October. The reporter ran the server as docker run … ghcr.io/github/github-mcp-server, with no tag, so they got latest, which was v2.0.1. From v2.0.0 the server negotiated the new MCP protocol revision 2026-07-28 but left out the resultType field that revision requires in tools/list. Claude Code connected and then loaded no tools. Codex CLI, which negotiated an earlier revision, was fine. The reporter's workaround was one line: pin ghcr.io/github/github-mcp-server:v1.14.0.

The client side kept moving too. Claude Code v2.1.299 (14 October) changed cloud-session and Remote Control MCP connectors to negotiate protocol 2026-07-28 by default, with MCP_PROTOCOL_NEGOTIATION=legacy as the opt-out. Two days later, v2.1.302 fixed "MCP servers showing no tools, or every tool call failing, when the server negotiates protocol version 2026-07-28 but omits resultType, ttlMs or cacheScope."

Neither change is wrong. Protocols evolve, and both sides are converging. The point is that an unpinned server picks its upgrade date for you, and that date won't match your client's.

Where your MCP servers live

Before you can pin anything you have to find it, and every tool keeps its MCP servers somewhere different. These are the files the check below reads:

Tool Project file User file Servers live under
Claude Code .mcp.json ~/.claude.json (user and local scope) mcpServers
Cursor .cursor/mcp.json ~/.cursor/mcp.json mcpServers
Codex .codex/config.toml (trusted projects) ~/.codex/config.toml [mcp_servers.<name>]
OpenCode opencode.json or .jsonc ~/.config/opencode/opencode.json mcp, with command as an array
Gemini CLI .gemini/settings.json ~/.gemini/settings.json mcpServers

Claude Code's local-scope servers are the easy ones to miss. They're stored in ~/.claude.json under each project's path, not in the repo, so a teammate's review never sees them. The check reads those too. It also reads .vscode/mcp.json, which uses a servers key.

The check

Save this as mcp-pin-check.py and run python3 mcp-pin-check.py from your project root. It needs Python 3.11 or later (for tomllib) and nothing else. It only reads files and prints one line per server. It never prints environment values or tokens from your configs.

What it treats as a failure:

  • No version (npx -y pkg, uvx pkg, an image with no tag).
  • latest, in any form: pkg@latest, uvx pkg@latest, image:latest.
  • Ranges and tags: @^2.1.0, @~0.9, @*, @next. A range isn't a pin. The resolver will happily pick a newer release inside it on a later day.

An image with a version tag gets a warning, not a failure, because a registry tag can be moved to new content and a digest can't. Remote servers and local scripts are listed as information, since there's no package to pin.

Dark terminal panel showing the output of python3 mcp-pin-check.py on sample configs: FAIL lines for an unversioned npx server, a playwright server asking for latest, a docker image with no tag, a range ^2.1.0 in Cursor and an unversioned server in the Codex config; PASS lines for exact versions; a WARN for a docker tag; an INFO for a remote server; then '5 problem(s)' and exit code 1. Four callouts below explain FAIL, WARN, PASS and INFO.
An excerpt from the sample configs I tested it on. Any FAIL line makes the script exit 1.

I tested it against sample configs for every location in the table, including JSONC with comments and trailing commas, npx --package=…, uvx --from pkg==…, pnpm dlx, bunx, cmd /c npx on Windows, Podman, digests and a broken JSON file. It's a heuristic, not a parser for every launcher. If your setup has a launcher it doesn't know, it says INFO rather than guessing.

Fixing what it finds

Look up the current version, read the changelog, then write that exact version into the config:

In Claude Code you can write the pinned form straight into the shared file with claude mcp add --scope project <name> -- npx -y <package>@<version>.

One gotcha from the issue thread. Some MCP packages switched from date-style versions to normal semver, and server-filesystem once published 2026.8.31, which sorts above 1.0.0 and 2.0.0 under semver. A commenter tested it with npm 11.16.0: a date-based range written before 11 October stays on the deprecated 2026.8.31. An exact pin doesn't have this problem, which is one more reason to use one.

Save the tool contract when you pin

A version pin tells you what you installed. It doesn't tell you what the server says to your model. Tool names and descriptions are instructions the model reads, and the Local Server Security guide warns that they "are not fixed at install time either."

So when you pin a server, save its tools/list next to the config. When you bump the pin, save it again and diff. This is the command I used for the comparison above:

npx --yes @modelcontextprotocol/inspector@2.11.0 --cli \
  npx -y @modelcontextprotocol/server-filesystem@2.0.0 /path/to/project \
  -- --method tools/list --format json > mcp-contracts/filesystem-2.0.0.json
Enter fullscreen mode Exit fullscreen mode

The Inspector's own smoke-testing guide makes the same point about itself: "In CI, pin an exact version." Note that I pinned the Inspector too.

A raw byte comparison is noisy, so compare the fields that matter: tool names, descriptions and input schemas. Between filesystem 1.0.0 and 2.0.0 the raw JSON differed on all 14 tools, and every difference was the dropped execution field.

A pin is the floor, not the fence

Pinning stops your config from changing under you. It doesn't make a server safe, and the guide is clear about what it leaves out.

Four stacked layers. 1: a version pin covers the top-level package, not transitive dependencies. 2: a lockfile or image digest covers the dependency tree, not what tools the server describes. 3: a saved, diffed tools/list catches changed descriptions and schemas, not what the server can reach. 4: isolation (container, one mount, no network) limits the blast radius but doesn't fix the version.
Each layer catches something the one above it misses.

Transitive dependencies. A version pin with npx or uvx "fixes only the top-level package; its transitive dependencies are typically resolved fresh at install time." Pinning a container image by digest freezes the whole tree. A commenter on #3431 who watches MCP servers for drift reported that the same mongodb-mcp-server@2.1.2 tarball, installed on different days, typed one parameter differently, depending on which zod minor version resolved.

Reach. A local server is an ordinary process with your user's access. A new arXiv paper catalogued 21,097 MCP servers and triaged 4,806 of them. It flagged 787 (16.4%) as having filesystem capabilities even though they weren't classified as filesystem servers. The authors call that a coarse screening signal, not a vulnerability count. Their dynamic tests then confirmed 150 vulnerable instances, and coordinated disclosure produced 13 advisories, including two CVEs in Anthropic's Git MCP server. The guide's fix is isolation: a container with only the directory the server needs, read-only where possible, and --network=none for servers that don't need the network.

Advisories. Pinning cuts both ways, and the guide says so:

"…when a security advisory lands for something you pinned, the pin is what keeps you vulnerable."

The week's example was GHSA-84m7-p3x7-pcfv (CVE-2026-59951), published on 15 October. In the MCP Python SDK, a Streamable HTTP server in stateful mode, which is the default, never reclaimed session state. Sessions didn't expire, and a client could grow server memory without bound "using ordinary short requests." It affects mcp 1.8.0 through 1.29.1 and 2.0.0 through 2.1.1. It matters if you build or host a Python MCP server that clients you don't fully trust can reach. The fix is 1.30.0 or 2.2.0, which add an idle timeout (1,800 seconds by default) and a session cap (10,000 by default). If you can't upgrade yet, the advisory suggests stateless_http=True where you don't need per-session state.

So pin, and upgrade on purpose. Pinning only works as a habit if bumping a pin is cheap and someone actually watches the advisories.

Make it a rule the agent can't skip

Your agents add MCP servers too, usually by copying the line from a README. I'd put one line in AGENTS.md, so every tool that reads it gets the rule:

## MCP servers
- Pin every local MCP server to an exact version (image digest for containers).
- Save its tools/list to mcp-contracts/ when you add or bump one.
- `python3 scripts/mcp-pin-check.py` must exit 0 before you call the change done.
Enter fullscreen mode Exit fullscreen mode

Then don't rely on the line. Run the script in CI, where the runner's home directory is empty, so it checks only the files committed to the repo. That's the same pattern as the dependency allowlist in Define Done as a Command, and the same reason I gave in How Much Should You Invest in Your AGENTS.md? for moving rules out of prose and into checks.

The checklist

Run through this once per machine and once per repo:

  1. Delete servers you don't use. Each one is code that runs as you, and its tool definitions cost context on every turn. I measured that cost in Where Did My Claude Usage Go?
  2. Run the check. Every local server pinned to an exact version. No latest, no ranges.
  3. Pin containers by digest, not by tag.
  4. Save each server's tools/list when you pin it, and diff on every bump.
  5. Isolate the ones with reach. Use a container, one mount (read-only if you can), and no network unless the server needs it.
  6. Give each server its own narrow token through its env block, not through variables exported in your shell profile.
  7. Watch advisories for what you pinned. If you ship a Python MCP server over Streamable HTTP, that means mcp 1.30.0 or later on 1.x, or 2.2.0 or later on 2.x.
  8. If a server shows zero tools in Claude Code, update Claude Code (v2.1.302 fixed the missing-field case) before you blame the server. Set MCP_PROTOCOL_NEGOTIATION=legacy only as a temporary workaround.
  9. Add the check to CI and the one-line rule to AGENTS.md.

Threads that prompted this

A few Reddit threads circled the same problem from different directions:

Sources


If this was useful, subscribe to Agents in Practice for one practical note on AI coding agents a week. Helping your team adopt Claude Code or Codex safely and without the surprise bill? I consult on exactly that: andre-burgstahler-9n5.pages.dev.

This article was written with AI assistance and reviewed by the author.

Top comments (0)