Skip to content
Chapter 3 of 7 · Consuming serversPinning a server version

16 min read

Pinning a server version

On this page

An MCP server reached by npx -y vendor-server@latest is a dependency your system resolves afresh at every process start, from a pointer the publisher controls. That line lives in a configuration file rather than a manifest, so it does not turn up in dependency review, in a lockfile diff, or in any pull request; it looks like configuration and behaves like an unversioned import. Pinning replaces such a reference with one whose bytes cannot move without a change in your repository, and it is the half of the problem you own.

The other half is the update policy, which is where most of the value is and where almost nobody looks. A pin taken once and never revisited freezes a known state, including the bugs and the vulnerabilities in it, and the failure mode of that is quieter and slower than the failure mode of @latest but not obviously better.

What "latest" resolves to, and when

Two properties of a @latest reference combine into an incident. The first is that latest is a dist-tag, which is a mutable pointer the publisher controls: it names whichever version they most recently marked, it can be moved to a lower version to roll back a bad release, and nothing about it is a promise of compatibility. The second is that npx -y resolves that pointer at process start rather than at deploy time. A version published at 07:12 does not reach a running process at all; it reaches the next process that starts, which may be hours later and may be a node rotation nobody would describe as a change to the agent.

json
{
  "mcpServers": {
    "vendor-docs": {
      "command": "npx",
      "args": ["-y", "vendor-server@latest"],
      "env": { "VENDOR_API_KEY": "${VENDOR_API_KEY}" }
    }
  }
}

That gap between "when the change was published" and "when your system takes it" is what makes this class of incident feel acausal. Resolution happens at container build if the install is baked into an image, at pod start if it is npx at runtime, at cache eviction if a shared npm cache was hiding an old copy, and at any scale-up event that creates a fresh instance. In a cluster with rolling replacement, that can mean a mixed fleet where some replicas are on 2.9.4 and some on 3.0.0 for as long as the rollout takes — which is exactly the intermittent, percentage-of-runs symptom that chapter 5, on diagnosing a failure nobody deployed, has to take apart.

Written out as a chain, each link is checkable on its own. latest is a name rather than a version, so resolving it is a lookup and not a fetch. The lookup happens inside npx at the moment the command runs, which is process start and not deploy time. Process start is caused by events nobody files under changing the agent — a pod restart, a scale-up, a node group rotating, a shared cache evicting an old copy. So the moment your system takes a new version is fixed jointly by your infrastructure's scheduling and the publisher's release timing, and by nothing either party intended as a coordination. That is why this class of incident feels acausal: neither of the two events that combined would have been written down anywhere, because both were routine. It also explains the shape the failure takes during a rollout — if replicas are replaced gradually and traffic is spread evenly across them, the share of runs meeting the new version is the share of replicas already replaced, so what a dashboard shows is a failure rate rather than an outage.

Semver ranges do not fix this, and it is worth being precise about why, because ^3.0.0 looks like restraint. Semver is semantic versioning, the major.minor.patch convention in which a publisher signals the size of a change by which number they increment, and a range is the syntax for accepting versions you have not seen yet. A caret range accepts every future minor and patch, so it accepts code that does not exist yet, published by someone who decides unilaterally whether their change was breaking. The MCP registry's own versioning rules, described in Versioning Published MCP Servers, sort published server.json versions and mark one as latest — a useful ordering for humans browsing a registry, and still an ordering over strings the publisher chose. The hosted build reporting 2026.02.3 alongside a package at 0.4.0, from chapter 2's comparison of local and hosted snapshots, shows the limit: not every server version string is even semver, so range syntax has nothing coherent to operate on.

None of this is new, and that is mildly encouraging, because the industry has already run the experiment. JavaScript dependency management spent 2016 discovering that a mutable registry pointer is a production dependency when left-pad was unpublished and thousands of builds stopped working; the response was npm shrinkwrap, then lockfiles by default, then integrity hashes on every entry. Container practice took the same route from mutable tags to digest references, and Kubernetes manifests that pin by digest are now unremarkable in regulated environments. MCP consumption is repeating that arc from the beginning, partly because the ergonomics of npx -y are so good that the fastest path to a working agent is also the least reproducible one, and partly because an MCP server does not feel like a dependency — it feels like a tool the model uses, which is a category people have not yet learned to be suspicious of.

That is a precedent rather than a picture — the same failure, in a neighbouring ecosystem, with a documented remedy — and treating it as a precedent means asking how far the remedy reaches. For npm the remedy was complete, because every consumer of a package ends up holding a copy of the resolved artefact and can hash it. For containers it was complete for the same reason, since a digest names bytes somebody has. Neither remedy reaches the case where there is no artefact on your side at all, which is the vendor-hosted endpoint in the table further down. What transfers is the finding that a mutable reference is a production dependency; what does not transfer is that lockfiles are the general answer, because the mechanism that made lockfiles work was possession of the artefact, and possession is exactly what a hosted endpoint withholds.

Immutable means the bytes cannot move

The pin you want is one where nobody, including the publisher, can change what you get without your repository changing. For an npm-distributed server that is an exact version plus a lockfile — the file a package manager writes recording the precise version and content hash of everything it resolved — and the lockfile is doing most of the work:

json
{
  "mcpServers": {
    "vendor-docs": {
      "command": "node",
      "args": ["./node_modules/vendor-server/dist/index.js"],
      "env": { "VENDOR_API_KEY": "${VENDOR_API_KEY}" }
    }
  }
}
json
"node_modules/vendor-server": {
  "version": "2.9.4",
  "resolved": "https://registry.npmjs.org/vendor-server/-/vendor-server-2.9.4.tgz",
  "integrity": "sha512-QK0mS6yqf2A1t9V0mBq7uNq0J9nT0m0k1qzq0K2wq1U="
}

Running the installed entry point directly, rather than through npx, removes the runtime resolution step entirely; the install happens once, at image build, under npm ci, which fails rather than silently upgrading if the lockfile and manifest disagree. The integrity hash is the part that makes this a pin rather than a preference. npm forbids republishing an existing version, but a publisher can unpublish within 72 hours and the tarball behind a version can be absent or, on a self-hosted or mirrored registry, replaced; the hash means a substituted artefact fails the install instead of running.

It is worth separating what each half of that pin is doing, because only one of them supplies the immutability. The exact version stops the resolver choosing a different version, and that was a decision your own tooling was making. The integrity hash stops anything changing what the version contains, and that was a decision the registry and the publisher were making. The second is the stronger claim, and it is the one that survives an adversary rather than only an accident: a hash is a value that essentially only those bytes produce, so an install computing a different hash has by construction downloaded different bytes, and the install fails instead of proceeding. Version pinning without a lockfile keeps the first guarantee and gives up the second, which is why the two appear as separate rows in the table below rather than as one.

For a containerised server the equivalent distinction is tag versus digest. A tag is a mutable pointer in exactly the way latest is, and :v2 or :2.1 is a tag like any other. Teams read a numeric tag as a version because it looks like one, which is the reason this particular mistake survives review:

yaml
services:
  vendor-docs:
    # mutable: the vendor can push a new image to this tag at any time
    # image: ghcr.io/vendor/mcp-docs:2.1
    image: ghcr.io/vendor/mcp-docs@sha256:6b9f0c3a1d7e4f28a05c9d3b2e1f8a7c4d6e5b0a9f8c7d6e5b4a3f2e1d0c9b8a
    pull_policy: never

The digest names the exact image manifest, so pulling it either returns those bytes or fails. docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/vendor/mcp-docs:2.1 on a running host tells you which image a tag currently resolves to, and running it on two hosts a month apart is the cheapest possible demonstration that a tag moved.

The tag mistake deserves naming on its own, because it is a belief that a defence is already in place rather than an oversight. :2.1 is tempting because everywhere else a team writes 2.1 it means a version, and because the honest alternative reads as an unmemorable hash nobody can eyeball in a review. What makes the belief false is that a registry stores tags and manifests as separate things: a tag is a mutable name pointing at a manifest, in exactly the way latest is, and the registry will repoint it whenever the publisher pushes. Nothing about the characters in 2.1 changes that, and there is no general flag that makes a tag immutable. A reviewer who reads :2.1 as a pin has read a name as though it were a value, which is the same error as reading @latest as a version — and the numeric tag conceals it better, which is the only difference between them that matters.

MechanismImmutableWho can change itWhen it landsGetting a fix
npx -y pkg@latestNoPublisherAny process startAutomatically, along with everything else
^2.9.0 in a manifest, no lockfileNoPublisher, within the rangeNext installAutomatically, minors included
Exact version + lockfile integrityYesOnly a commit in your repoYour deployYou bump it
Container tag :2.1NoPublisherAny pull or restartAutomatically, silently
Container digestYesOnly a commit in your repoYour deployYou bump it
Vendor-hosted URLNoVendorTheir deployNot your decision

Six mechanisms, and the immutability column carries exactly two yeses. Both of those are the same idea expressed in two ecosystems — a content hash your own repository holds — and the four noes are also one idea expressed four ways, which is a name whose meaning somebody else assigns. That is the taxonomy closing with nothing between the categories: there is no partly-immutable reference, because a reference either names bytes or names a name, and those exhaust the options. What the closure licenses is deciding new cases without consulting the table. A Python server installed from a requirements file that carries hashes is the first kind, the same file without hashes is the second, a Git reference to a branch is the second, and a Git reference to a commit is the first.

The bottom row is the one no pin reaches, and it is the subject of chapter 4, on holding a contract still when there is no artefact to hold.

The half that gets skipped

A pin without an update policy is a decision to run old software forever, taken by nobody in particular. Over four months a vendor may ship 23 releases, three of which fix authentication bugs and one of which patches a path-traversal issue in a file-reading tool, where a crafted argument reads files outside the directory the tool was meant to be confined to. A team that pinned to 2.4.0 in October and never revisited it would have avoided a January breaking change and been running the vulnerable build the whole time, which is not obviously the better outcome.

The policy has three parts and each of them names a person or a number. Who reviews a release: a named owner per server, whose review is reading the changelog and diffing the recorded contract from chapter 1, where connecting once produces a snapshot of what a server serves, against the new version, which takes fifteen to twenty minutes for a typical release. On what cadence: batched at a stated interval, monthly for most servers, because monthly turns 23 releases into four review sessions and keeps the version gap small enough that the changelog is still readable. And which fixes jump the queue: security advisories above a stated severity, taken within a stated window, reviewed the same way but not made to wait for the cadence.

A dependency-update bot, which watches your manifests and opens a pull request when a newer version exists, makes the cadence cheap without making the decision for you:

json
{
  "packageRules": [
    {
      "matchPackageNames": ["vendor-server", "@acme-labs/linear-mcp"],
      "schedule": ["before 6am on the first day of the month"],
      "automerge": false,
      "reviewers": ["team:agent-platform"]
    },
    {
      "matchPackageNames": ["vendor-server", "@acme-labs/linear-mcp"],
      "matchUpdateTypes": ["patch"],
      "vulnerabilityAlerts": { "schedule": ["at any time"], "labels": ["security"] }
    }
  ]
}

The valuable property here is that every upgrade becomes a pull request with a diff, a reviewer and a timestamp, so a breaking release arrives as a proposal on the first of the month rather than as a node rotation at 08:39. It also gives an eventual incident review something to point at, since "we took 3.0.0 on the 3rd, here is who approved it" is a different conversation from "we do not know when we started running this".

Cadence has a cost curve that argues against both extremes. Taking every release means reviewing all 23 of those changelogs across the four months and absorbing every regression on the vendor's schedule; taking one a year means the eventual upgrade spans a major version, the changelog is forty entries long, and the review that should have cost twenty minutes costs a day and gets deferred again. A monthly batch keeps the gap at one or two minor versions, where the diff of the recorded contract is usually empty or a single added tool, and an empty contract diff is a review that finishes in five minutes.

The arithmetic behind that is worth seeing rather than asserting, because it is what makes monthly an answer rather than a compromise. Twenty-three releases arriving across four months, batched at a monthly boundary, is four review sessions, which puts between five and six releases in each. What a session costs is set by how far the version moved, since it reads one changelog range and one contract diff — not by how many releases went past inside that range, and five or six patch releases frequently move the contract not at all. Take every release and the number of review events rises to twenty-three while the cost of each stays near its floor, so the total scales directly with a release frequency the vendor controls. Take one a year and the number of events falls to one while the cost of that one rises steeply, because the range now spans a major version and the contract diff is no longer empty. Monthly sits where the count of events is small and the cost of each is still close to the floor, and the reason it holds for most servers is that most releases are patches.

The cadence is also what makes rollback meaningful: with a pin and a monthly bump you can revert one commit and be back on a build that ran in production for a month, whereas an unpinned system has no version to revert to because nobody recorded which one was working.

Pinning is a reproducibility control, not a trust control

A pinned malicious version is reliably malicious; the pin guarantees you keep getting exactly the artefact you accepted, which is only valuable to the extent that accepting it was a considered act. That is why the review step in the policy matters more than the pin syntax, and why an automerged patch upgrade is a pin in form with @latest semantics in practice, just on a slower clock.

It also does not freeze behaviour. A pinned server that calls a vendor API, reads remote configuration, or proxies a model can change what it returns without changing a byte of the artefact you pinned — the tool schema is stable while the answers move. And a pin says nothing about the tool descriptions that were already in the artefact, which chapter 1 flagged as prompt material, since a description is injected into the model's context; freezing a prompt-injection vector in place is not a mitigation.

Servers without a changelog deserve a stricter version of the policy rather than an exemption. A publisher who ships releases with no notes gives you nothing to review except the contract diff, so that diff becomes the whole review, and a tool rename or a schema tightening shows up there whether or not anyone wrote it down. Where the diff is non-empty and unexplained, the reasonable response is to hold the current pin and open an issue on the repository, since a server whose interface moves without documentation is a supplier risk that belongs in the same conversation as the integration itself.

Two things, then, and neither is optional: a reference only a commit can change — an exact version whose integrity hash your lockfile holds, or a digest rather than a tag that resembles a version — and a policy naming an owner, a cadence and a severity threshold that jumps it, because the alternative to taking releases on the publisher's schedule is not taking them at all.

The most important limit is the one the table's bottom row makes plain. Everything here applies to servers you launch, which in a typical integration is some of them and rarely all of them. A vendor's hosted endpoint has no artefact for you to hold still, and a container tag you believed was a version has been quietly behaving like a hosted endpoint the whole time. What both cases need is a way to hold the contract still, or failing that, to know within minutes when it moves — which is the next chapter, along with an honest account of the difference between knowing and preventing.

Consuming servers — read the current version at https://daviddominguez.dev/learn/en/consuming-servers/pinning-a-server-version

Back to top