Engineering · 2026-06-01
MCP server dependency pinning: a supply chain incident walkthrough
A floating ^ or ~ in your package.json is a standing invitation for the npm registry to fetch whatever the upstream maintainer publishes next. This post walks through a concrete supply chain incident timeline — a compromised transitive dependency fetched at install time, with code execution on every workstation that installs your MCP server — and the three changes that prevent it: exact version pinning, lockfile integrity enforcement, and automated patch-only Dependabot updates.
Why MCP servers are a high-value supply chain target
An installed MCP server runs with the same filesystem and network access as the user who invoked claude plugin install. It has read access to ~/.ssh/, ~/.aws/credentials, and any secrets stored in ~/.config/. In team deployments it runs with service account credentials that have org-level API access. The agent invokes its tools during normal usage — no further user action required after install.
This access profile makes MCP servers a more interesting supply chain target than a typical CLI tool. A compromised package that exfiltrates ~/.ssh/id_rsa achieves source-code access to every repo the user has committed to. A compromised package that reads ~/.aws/credentials achieves cloud infrastructure access. Neither requires any interaction after the initial npm install.
The exploit does not require compromising your repository. It requires compromising any package in your transitive dependency graph. The average MCP server built on the official @modelcontextprotocol/sdk has 80–150 transitive dependencies. Each one is a potential attack surface.
The incident timeline
This is a hypothetical but technically accurate walkthrough of how a supply chain attack through a floating dependency range plays out:
You ship my-github-mcp@1.0.0 with "markdown-utils": "^2.3.0" in package.json. The caret means npm will accept any 2.x.x where x ≥ 3. At publish time, markdown-utils@2.3.0 is clean. Your tests pass. You don't check in a package-lock.json because "it's an MCP server, not a web app."
The markdown-utils maintainer's npm publish token leaks in a GitHub Actions log (a common path — the NODE_AUTH_TOKEN was echoed by a debug step). An attacker publishes markdown-utils@2.4.0 with a postinstall script that reads ~/.ssh/id_rsa, ~/.aws/credentials, and ~/.config/gh/hosts.yml and POSTs them to an attacker-controlled endpoint via https.get. The package passes a cursory review — the postinstall is obfuscated as a "telemetry" call.
400 users run claude plugin install my-github-mcp. Because there is no package-lock.json, npm resolves dependencies fresh and fetches markdown-utils@2.4.0 — the latest satisfying ^2.3.0. The postinstall script runs automatically. 400 SSH keys, AWS credential files, and GitHub CLI tokens are silently exfiltrated. None of those users see any error.
npm security team receives a report and unpublishes markdown-utils@2.4.0. A CVE is issued. You are notified. You patch by pinning "markdown-utils": "2.3.0" and publishing my-github-mcp@1.0.1. But the damage for the 400 prior installers already happened. Their credentials were exfiltrated while the malicious version was live.
The Anthropic Skills Directory marks your server with a security advisory. SkillAudit re-scans and gives it an F on the maintenance axis. Users who trusted your badge stop recommending it. The timeline from compromise to discovery was 14 days. The fix took 20 minutes. The preventive fix — pinning the version before publish — would have taken 30 seconds.
Where the vulnerability lived
The root cause was not a bug in your code. It was the combination of three choices:
- Floating range (
^2.3.0) — allows npm to resolve to any semver-compatible later version at install time - No committed lockfile — means every fresh install resolves dependencies independently, with no integrity anchor
- No automated advisory monitoring — means the CVE on the transitive dep was not surfaced to you within hours of publication
Any one of these three being absent would have contained or prevented the incident. A lockfile with SHA-512 hashes would have caused the install to fail when the attacker's version was fetched (hash mismatch). An exact pin would have prevented npm from fetching the compromised version at all. Advisory monitoring would have triggered a patch publish within hours of the CVE, shortening the exposure window from 14 days to a few hours.
Fix 1: Exact version pinning in package.json
Replace caret and tilde ranges with exact version strings for all direct dependencies:
// BEFORE — floating ranges
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"markdown-utils": "^2.3.0",
"zod": "^3.22.4"
}
}
// AFTER — exact pins
{
"dependencies": {
"@modelcontextprotocol/sdk": "1.0.0",
"markdown-utils": "2.3.0",
"zod": "3.22.4"
}
}
Exact pinning prevents npm from silently upgrading your direct dependencies. It does not prevent transitive dependency floating — markdown-utils's own package.json may still use caret ranges. That is why the lockfile is essential: it pins the entire tree, not just the first level.
Common objection: "Exact pins mean I miss security patches."
This is the correct objection. The answer is Dependabot (Fix 3 below): configure it to open patch-only PRs automatically. You get security updates within 24 hours of advisory publication, with a PR you can review before merging, rather than getting the update silently at the next npm install.
Fix 2: Lockfile integrity enforcement
Commit a package-lock.json generated with npm install and enforce it in all install commands:
# Generate and commit the lockfile
npm install # creates/updates package-lock.json
git add package-lock.json
git commit -m "chore: pin dependency lockfile"
# From now on, all installs use the lockfile — fresh install FAILS if
# the lockfile is out of date or any package hash doesn't match
npm ci # NOT npm install
# In Dockerfile / CI:
RUN npm ci --ignore-scripts # --ignore-scripts also disables postinstall
The package-lock.json stores SHA-512 integrity hashes for every resolved package:
// excerpt from package-lock.json — this is what protects against substitution
"node_modules/markdown-utils": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/markdown-utils/-/markdown-utils-2.3.0.tgz",
"integrity": "sha512-abc...xyz==",
// ^ if the published tarball changes (even for the same version), this hash
// won't match and npm ci will fail with "integrity check failed"
"dependencies": { ... }
}
When npm fetches markdown-utils@2.3.0 (which you pinned exactly), it computes the SHA-512 of the downloaded tarball and compares it to the stored hash. If the attacker had managed to overwrite the existing 2.3.0 version rather than publishing a new 2.4.0 (a rarer but possible attack called a "package corruption"), the hash mismatch would cause npm ci to fail. No install, no code execution.
Note: --ignore-scripts in CI further disables postinstall scripts entirely. This is appropriate for CI environments and Dockerfile builds where you control what runs. It is not appropriate for development installs where postinstall scripts may do legitimate build steps. Evaluate which contexts benefit from --ignore-scripts.
Fix 3: Dependabot for patch-only automated updates
With exact pins, you will not automatically receive security patches. Configure Dependabot to open PRs when patch updates are available:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily # daily check; critical advisories surface within ~24h
open-pull-requests-limit: 5
versioning-strategy: lockfile-only # only update lockfile entries, not semver ranges
groups:
patch-updates:
patterns: ["*"]
update-types: ["patch"]
labels: ["dependencies", "automated"]
# Only auto-merge patch updates; minor/major require manual review
# (configure with a branch protection rule + GitHub Actions auto-merge action)
With this configuration, Dependabot opens a PR within 24 hours of a new patch version being published for any of your dependencies. You review the diff, see that markdown-utils went from 2.3.0 to 2.3.1 (a patch version, so only bug fixes per semver convention), run your tests in CI, and merge. The window between advisory publication and your patched version being available drops from "whenever you notice it" to 24–48 hours.
For security-critical advisories (CVSS ≥ 7), consider adding a GitHub Actions workflow that auto-merges Dependabot patch PRs after CI passes — further reducing the response window.
What the SkillAudit maintenance axis checks
The maintenance axis in a SkillAudit report covers three dependency checks:
| What we check | Finding if present | Explanation |
|---|---|---|
| Floating ranges in direct deps | WARN | Caret (^) or tilde (~) in package.json dependency values — allows silent upgrades at install time |
| Missing lockfile | HIGH | No package-lock.json or yarn.lock in the repository — every fresh install resolves dependencies independently |
| Known-vulnerable pinned version | HIGH (if active exploit), WARN (advisory only) | Exact pin resolving to a version with a published npm advisory at CVSS ≥ 5 — pinning the wrong version is not a fix |
Authors who want a clean maintenance axis should run npm audit before submitting to any directory. A zero-finding npm audit output is not sufficient — it only checks your installed tree, not whether your package.json allows future installs to resolve to vulnerable versions. The combination of exact pins + lockfile + zero-advisory scan gives a clean maintenance axis finding on all three checks.
The supply chain argument for pin-everything
The security argument against caret ranges is not theoretical. In 2025, npm saw 147 malicious package publications that used the squatting or dependency confusion attack pattern — publishing a version that satisfies an existing range in a popular dependency's own dependencies. The attack surface grows with every indirect dependency your server adds.
The MCP ecosystem is specifically attractive because servers run with credential access and typically install on developer workstations (high-value targets) rather than servers (where containers and restricted IAM reduce blast radius). The combination of high credential access, install-time code execution via postinstall, and a growing ecosystem where users are less security-aware than in the npm ecosystem's earlier days makes supply chain attacks through MCP server dependencies a realistic threat — not a theoretical one.
Pinning everything and checking your lockfile into version control is the single most effective dependency hygiene change you can make. It takes five minutes. The alternative is being the MCP server author who explains to 400 users why their SSH keys were exfiltrated.
Quick reference: the three-file change
# 1. Pin all direct deps to exact versions
npm install # updates package-lock.json
# Then manually convert all "^x.y.z" and "~x.y.z" in package.json to "x.y.z"
# Or: npx pin-it (converts ranges to exact for all deps)
# 2. Verify and commit the lockfile
cat package-lock.json | grep '"integrity"' | head -5 # should show sha512 hashes
git add package.json package-lock.json
git commit -m "chore: pin all dependencies to exact versions"
# 3. Add .github/dependabot.yml (see Fix 3 above)
git add .github/dependabot.yml
git commit -m "ci: add Dependabot for daily patch updates"
# 4. Update your install instructions in README to use npm ci, not npm install
# Replace: npm install
# With: npm ci
Further reading
- MCP install gate policy — how security-conscious teams implement a minimum-grade policy that requires a clean maintenance axis before installing any community server.
- MCP server security checklist — the full pre-publish checklist, including dependency hygiene alongside SSRF, credential handling, and input validation.
- Public audit corpus — maintenance axis scores across our scanned servers; see how many have a missing lockfile or floating ranges.
- Methodology — how the maintenance axis score is computed and what each finding severity means.
Check your server's maintenance axis score before your next directory submission.
Run a free audit → Browse the corpus →