Topic: mcp server dependency pinning

MCP server dependency pinning — supply chain risk from unpinned npm packages

When a user runs claude plugin install github:owner/my-mcp-server, their machine fetches not just the MCP server code but every package in its node_modules. A floating ^1.2.3 range in package.json means that if a dependency releases a malicious patch version — even for 10 minutes before it is yanked — any new install in that window gets the malicious version. Dependency pinning is the one-time investment that turns "could have installed malware" into "did not install malware."

Why MCP servers are a higher-value supply chain target

Supply chain attacks via npm have historically targeted developer tooling and CI environments. MCP servers occupy a more dangerous position: they run in the user's agent runtime, with direct access to the filesystem, terminal, and any credentials in the environment. A supply-chain compromise in an MCP server's dependency is not a compromised CI pipeline — it is arbitrary code execution in the context of the agent, with access to every tool the server exposes.

The attack surface is also larger than for typical npm packages. MCP servers are installed interactively by individual developers, often from GitHub directly rather than from a private registry. Each install fetches fresh dependency versions unless a lockfile is present and enforced. An attacker who compromises a popular utility package used by 40 MCP servers reaches all 40 servers' users with one malicious release.

The three floating-range patterns that create exposure

// Pattern 1: caret range — accepts any compatible minor or patch
"dependencies": {
  "zod": "^3.22.4",        // resolves to any 3.x.x >= 3.22.4
  "@anthropic-ai/sdk": "^0.24.0"  // resolves to any 0.24.x or higher 0.x.x
}

// Pattern 2: tilde range — accepts any compatible patch
"dependencies": {
  "zod": "~3.22.4"         // resolves to any 3.22.x >= 3.22.4
}

// Pattern 3: no version constraint — resolves to latest at install time
"dependencies": {
  "some-utility": "*"      // resolves to latest published version
}

Caret ranges (^) are the default behavior of npm install --save and represent the majority of floating-range findings in the corpus. They are appropriate for application development where npm ci uses the lockfile. They are not appropriate for published packages that other developers install fresh, because the lockfile does not travel with the package — users install package.json ranges, not lockfile pins.

Pattern 1 — exact version pinning in package.json

The simplest mitigation: remove the range prefix and specify exact versions in package.json. Users who install fresh get exactly the version you tested against.

// Exact version pinning — no range prefix
"dependencies": {
  "zod": "3.22.4",
  "@anthropic-ai/sdk": "0.24.3",
  "@modelcontextprotocol/sdk": "0.5.0"
},
"devDependencies": {
  "typescript": "5.4.2",
  "@types/node": "20.11.5"
}

The objection to exact pinning is maintenance: you need to manually bump versions to get security patches. That objection applies to devDependencies and production non-critical deps when you are building an app. For a published MCP server, it is the right tradeoff: users get a known-good set of deps, and you control when versions change.

Use npm outdated weekly (or add Dependabot — see Pattern 3) to surface when pinned versions have newer releases, then upgrade intentionally after reviewing the changelog.

Pattern 2 — lockfile integrity enforcement

A lockfile (package-lock.json or yarn.lock) records exact resolved versions and integrity hashes for every transitive dependency. Commit your lockfile. Many MCP server repositories .gitignore their lockfile — this is appropriate for libraries but wrong for deployable server code.

# In your Dockerfile or install instructions:
# Use npm ci instead of npm install.
# npm ci: reads package-lock.json exactly, fails if it's missing or inconsistent.
# npm install: updates package-lock.json to resolve fresh versions from ranges.

# WRONG — resolves fresh ranges at build/install time:
RUN npm install

# CORRECT — installs exactly what the lockfile specifies:
RUN npm ci --omit=dev

# For end users installing from GitHub:
# Document in README that install should be done with:
# git clone && npm ci --omit=dev
# Not: git clone && npm install

The lockfile's integrity hashes catch a specific attack: a package publisher who pushes a new version with the same version number (rare but documented in npm's history). npm ci checks SHA-512 integrity against the hash recorded in the lockfile. If the hash doesn't match, the install fails with an integrity error. npm install with a floating range will silently install the tampered package.

Pattern 3 — safe automated updates with Dependabot

Exact pinning creates a maintenance burden if not automated. Dependabot (GitHub's built-in dependency update bot) can send PRs for patch-only updates on a schedule, keeping pins fresh without the flood of patch bumps that come from running npm install manually.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    # Only open PRs for patch and minor updates; require manual review for major:
    versioning-strategy: increase
    # Group all patch updates into a single PR to reduce noise:
    groups:
      patch-updates:
        patterns: ["*"]
        update-types: ["patch"]
    # Limit concurrent open PRs to avoid queue buildup:
    open-pull-requests-limit: 3

With Dependabot running, your workflow becomes: Dependabot opens a patch-bump PR, CI runs your test suite, you approve and merge. Minor and major updates still require manual review — which is the right policy for an MCP server where a minor upgrade might introduce new network calls or behavior changes that affect security properties.

What SkillAudit's maintenance axis checks

The maintenance axis in a SkillAudit report checks the dependency hygiene of the server's package.json and package-lock.json. Specifically it flags:

Authors targeting a green maintenance axis should: commit a package-lock.json, replace caret ranges with exact versions in dependencies (devDependencies can keep ranges since they don't install for end users), and run npm audit to clear any advisory findings before submission.

See also

Check your server's dependency hygiene before publishing.

Run a free audit → How grading works →