Control agent access
How to pin MCP server versions
An unpinned npx, uvx or docker command runs whatever the registry currently serves. Pinning means telling it exactly which build to run instead.
New to MCP? What is an MCP server? covers the basics first.
Most MCP configs tell a client to launch a server with npx -y some-package, uvx some-package, or docker run some-image. None of those three names a version. Each one runs whatever the registry hands back the moment the client starts the server, which can be a different build today than it was last week. Pinning means adding the version or digest to the command, so the same config always starts the same code.
This covers the three ways you launch a server yourself, and what to do about the one kind you can't pin: a remote server you only reach by URL.
Why an unpinned command is a risk
An MCP server runs with your credentials and decides what an agent sees as a tool. Malicious and typosquatted MCP servers covers postmark-mcp, an npm package that worked as described from 1.0.0 until version 1.0.16 added a blind copy to an outside address on every email it sent. An unpinned npx -y or uvx picks up a release like that the next time the client restarts the server, with no step where you'd notice. What is an MCP rug pull? covers the version of this where the thing that changes is a tool's description rather than the package itself; Pinning the install command and hashing a tool's description are two different defenses. This article is about the first.
Prerequisites
- Node.js and npm, for the npx form. I used node 23.7.0 and npm 10.9.2.
- uv, for the uvx form. I used uv 0.11.14.
- Docker, for the digest form.
- An MCP client config you can edit directly, such as a
.mcp.jsonfile or amcpServersblock in Claude Code or Cursor's settings.
Step 1: Find the unpinned command in your config
An unpinned entry names a package or image and nothing else:
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
}Neither command nor args names a version. npx -y <package> and docker run <image> with no tag are the same shape. Go through your client's config and list every server launched this way. Each one is a candidate for the steps below.
Step 2: Pin an npx server to an exact version
Add @<version> to the package name. First check which versions actually exist, since a package can retire old numbering:
npm view @modelcontextprotocol/server-everything versions --jsonThat printed a list running from 0.1.0 to 2026.8.31, with 1.0.0 near the start. 1.0.0 is the newest release even so: the package went back to semantic versioning in October 2026, and npm view @modelcontextprotocol/server-everything dist-tags shows latest pointing at it. I picked 1.0.0 and put it in the config:
{
"mcpServers": {
"everything": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/[email protected]"]
}
}
}To confirm this actually runs 1.0.0 and not something npm substitutes, I sent the same initialize request a client sends on every connection, straight to the command a client would launch:
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"pin-test","version":"0.0.1"}}}\n' \
| npx -y @modelcontextprotocol/[email protected]What you should see, among the rest of the initialize response, is the server naming its own version:
Starting default (STDIO) server...
{"result":{...,"serverInfo":{"name":"mcp-servers/everything","title":"Everything Reference Server","version":"1.0.0"},...}}serverInfo.version is 1.0.0, the exact build the config named. Run that command again next year and you get the same answer, because npx now has a version number to match instead of a moving target.
One thing I ran into while picking a version: trying an older date-based release (2025.7.1) printed npm warn deprecated ... Date-based versions are no longer published. This package has resumed semantic versioning at 1.0.0. The version still ran, but check npm view <package> versions before you pin, so you're not locking in a scheme the maintainer has already moved off.
Step 3: Pin a uvx server to an exact version
uv's own docs describe uvx as "exactly equivalent to uv tool run", and the syntax for an exact pin is command@version:
uvx [email protected]When the package name and the command it installs differ, pin with --from instead:
uvx --from 'package-name==2026.8.18' command-nameI checked which published versions exist first:
curl -s https://pypi.org/pypi/mcp-server-fetch/json | python3 -c "import json,sys; print(sorted(json.load(sys.stdin)['releases']))"which printed 2026.8.18 last. That list is sorted as text, so the newest release, 2026.10.10 from October 11, sorts before it. I pinned 2026.8.18, the version I tested, and ran the pinned install (with uv tool run, the command uvx is an alias for) and checked the version actually installed:
uv tool run --from mcp-server-fetch==2026.8.18 python -c \
"import importlib.metadata as m; print(m.version('mcp-server-fetch'))"What you should see is the version you asked for:
2026.8.18Same idea as Step 2: the config now points at a build that won't change out from under it, and uv.lock or a pinned requirement does the same thing for a server you install into a project instead of launching with uvx.
Step 4: Pin a Docker image to a digest
A tag like :latest or even :2.0.2 can be reassigned to a different image by whoever controls the registry entry. Docker's own docs describe the fix: "a digest takes the place of the tag when pulling an image", and pulling by digest "guarantees that the image you're using is always the same." The form is:
docker pull <image>@sha256:<digest>and the same form works in a Dockerfile's FROM line. The digest for a given tag is whatever the registry currently serves it as, so you still have to look it up once, the same way you'd check a package's version list in Steps 2 and 3. For ghcr.io/github/github-mcp-server:v2.0.2, the registry's own manifest API currently answers with:
sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796so the pinned pull is:
docker pull ghcr.io/github/github-mcp-server@sha256:ffced0d76e77428532a2ced185516992d77fd146d69eec2f244e6237d1a97796Step 5: Decide what you can do for a remote server
Steps 2 through 4 all pin a command you run yourself. A server you only reach over Streamable HTTP, at a URL someone else operates, has no install command for you to edit. You're trusting whatever code answers that URL today, and tomorrow, and the operator can change it whenever they want.
The two things left to you are watching for a change and limiting what a change can reach. For the first, What is an MCP rug pull? covers hashing a tool's name, description and schema at approval time and comparing on every tools/list, which catches a change whether or not the operator announces it. For the second, the per-tool grants covered in Where Statio fits below limit what a changed server can do even if nobody notices the change itself.
Step 6: Keep a record and review every bump
A pin only helps as long as you remember what you pinned and when you last looked at it. Keep a short list next to your MCP config: each server, the version or digest it's pinned to, the date, and who checked it. A plain file in the same repository as the config works, and committing it means a version bump shows up as an ordinary diff in review, the same way package-lock.json or uv.lock turns a dependency bump into something a reviewer actually sees instead of something that happens silently on the next install.
When you do bump a pin, treat it like updating any other dependency with access to your credentials: look at what changed between the old version and the new one before you deploy it, not after. Malicious and typosquatted MCP servers has the fuller checklist for vetting a server; pinning is one item on it.
Where Statio fits
Statio scans a server's tool names and descriptions for prompt injection and hidden Unicode characters when the server is added, and flags anything suspicious for an admin to review. That check runs once, at add time, against whatever the server currently returns. It doesn't replace pinning the install command, since a server added clean today can still ship a different build next week if nothing you did stops it from being re-pulled unpinned.
Per-tool grants help with the gap this leaves. If an agent is granted named tools rather than the whole server, a tool the server adds later needs its own grant before the agent can call it (MCP permissions covers how grants work). Statio doesn't diff a granted tool's description when it changes, so the hashing approach in What is an MCP rug pull? still applies.
Conclusion
Pin an npx server with @<version>, a uvx server with @<version> or --from 'pkg==version', and a Docker image with @sha256:<digest> instead of a tag. None of this covers a remote server you don't run yourself, where watching tool descriptions and limiting what the server can call are what's left.
Next
- Malicious and typosquatted MCP servers
- What is an MCP rug pull?
- How to test an MCP server with the MCP Inspector
- How to debug an MCP server that won't connect
- How to build an MCP server in Python