MCP Server Security

Supply chain pinning for MCP servers: lockfiles, exact versions, and automated updates

A malicious package published under a dependency's semver range, a tampered lockfile, or a transitive dependency updated without a re-audit can silently introduce attacker-controlled code into every MCP server that runs npm install. Supply chain pinning is the set of practices that makes this attack class detectable and hard to execute.

The attack surface: where supply chain attacks enter

Supply chain attacks against Node.js packages use three main vectors: (1) version range exploitation — publishing a malicious 1.2.4 patch release into a ^1.2.3 range, (2) account takeover — compromising the npm account of a popular dependency maintainer, and (3) typosquatting — registering a package with a name similar to a legitimate one and waiting for a developer to mistype it. MCP servers are a high-value target because they run with credentials and broad filesystem access on developer machines.

Lockfile integrity: the foundation

The package-lock.json file records the exact version and content hash of every dependency. It is your first line of defense against supply chain attacks:

# package-lock.json entry for a dependency:
# "packages/node_modules/express": {
#   "version": "4.18.2",
#   "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
#   "integrity": "sha512-o45xy...(sha512 of the tarball)",
# }

# npm ci verifies this integrity hash on every install
# npm install does NOT — it re-resolves versions and may update the lockfile
npm ci   # ← use this in CI and production deploys
npm install   # ← for local development only, when adding new deps

The key difference: npm ci reads the lockfile exactly, verifies the integrity hash of every package tarball before extracting it, and fails if the lockfile is out of sync with package.json. npm install may silently update the lockfile with newer patch versions. CI pipelines that use npm install instead of npm ci are not benefiting from the lockfile's security guarantees.

Exact versions in package.json

The ^ prefix in "express": "^4.18.2" means "accept any compatible version" — in npm's semver interpretation, that includes 4.18.3, 4.19.0, and 4.100.0. For MCP servers, prefer exact versions for production dependencies:

// Risky — semver range can match future malicious patch releases
{
  "dependencies": {
    "express": "^4.18.2",
    "@modelcontextprotocol/sdk": "^1.0.0"
  }
}

// Better — exact version pins; still protected by lockfile integrity
{
  "dependencies": {
    "express": "4.18.2",
    "@modelcontextprotocol/sdk": "1.0.4"
  }
}

The tradeoff: exact pins mean you need an explicit upgrade process rather than automatic patch updates. This is the correct tradeoff for a security-sensitive tool — pair it with Dependabot or Renovate to get automated PRs when new versions are available.

Running npm audit in CI

Add npm audit to your CI pipeline at an appropriate threshold. For MCP servers, the recommended level is high — fail the build on high or critical vulnerabilities:

# In CI (GitHub Actions, etc.)
- name: Audit dependencies
  run: npm audit --audit-level=high

# package.json script for local development
{
  "scripts": {
    "audit:ci": "npm audit --audit-level=high --json | node scripts/parse-audit.js"
  }
}
// scripts/parse-audit.js — fail with readable output
import { readFileSync } from 'node:fs'
const audit = JSON.parse(readFileSync('/dev/stdin', 'utf-8'))

const vulns = Object.values(audit.vulnerabilities ?? {})
  .filter((v: any) => ['high', 'critical'].includes(v.severity))

if (vulns.length > 0) {
  console.error(`\n${vulns.length} high/critical vulnerabilities found:`)
  for (const v of vulns as any[]) {
    console.error(`  ${v.severity.toUpperCase()}: ${v.name} — ${v.via?.[0]?.title ?? 'advisory'}`)
  }
  process.exit(1)
}
console.log('No high/critical vulnerabilities.')

Lockfile committed and checked in

The lockfile should always be committed to source control. This is the mechanism that ensures reproducible installs across developer machines, CI, and production:

# .gitignore — what NOT to do
node_modules/  # ✓ ignore
package-lock.json  # ✗ NEVER ignore the lockfile

# Check that package-lock.json is not gitignored
git check-ignore -v package-lock.json  # should print nothing

A lockfile that is not committed cannot be verified in CI. A lockfile that is force-updated as part of a dependency PR should be reviewed the same way you review code changes — the integrity hashes tell you exactly what changed.

Automated dependency update PRs

Exact version pins require a process to receive security updates. Use Dependabot or Renovate to get automated PRs when new versions are available:

# .github/dependabot.yml — GitHub Dependabot
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    groups:
      # Group patch updates for review efficiency
      mcp-dependencies:
        patterns: ["@modelcontextprotocol/*"]
    ignore:
      # Major version bumps require manual review
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]

Configure Dependabot to run your full CI suite (including npm audit and tests) on each dependency PR. Never auto-merge — require a human review of the lockfile diff before merging. The diff shows you the exact integrity hashes changing; a supply-chain-compromised package will have a different hash from the expected one.

Verifying the registry source

Configure npm to use the official registry for all public packages and fail on registry downgrades:

# .npmrc — lock down the registry
registry=https://registry.npmjs.org/
audit=true
fund=false

# For private packages in an org scope, specify the private registry for that scope
# and the public registry for everything else
@myorg:registry=https://npm.mycompany.internal/

This prevents the dependency confusion attack where a package from an internal scope name is resolved from the public registry instead of the private one (or vice versa).

SkillAudit grading for supply chain pinning

FindingSeverityGrade impact
No package-lock.json committed to repositoryHigh−12
CI uses npm install instead of npm ciMedium−6
No npm audit step in CI pipelineMedium−5
All production dependencies pinned to exact versions+4
Dependabot or Renovate configured for automated updates+3
npm audit failing on high severity in CI+3

Scan your MCP server's supply chain posture

SkillAudit checks for a committed lockfile, CI install command, npm audit configuration, and dependency version patterns. Run a free audit to see where your dependency supply chain has gaps.