Compromised MCP Server: Detect and Contain a Supply-Chain Attack

A trusted MCP package ships a malicious update that steals keys or makes unexpected tool calls. Detect the compromise, contain it, rotate secrets, and harden the install with version pinning and a release-age cooldown.

You reviewed an MCP server package six months ago and it passed. This morning your CI ran npm install, pulled a fresh version whose maintainer account was hijacked overnight, and that version quietly streams conversation context and scanned secrets to an external host. The symptom is one of: the agent opens outbound connections to an unfamiliar domain, sensitive values that should only live in agent memory appear in logs, npm audit flags a dependency, or a postinstall script writes a .github/workflows/ file you never authored.

Fastest containment (do this first, in order): stop the agent and any process running the MCP server, then git diff / npm pack the suspect version against the last known-good one to confirm. Treat every credential the process could read as compromised and revoke-and-reissue (not rotate-in-place) — npm tokens, cloud keys, OPENAI_API_KEY, CI/CD secrets. Then re-pin to the last clean version with npm ci against a committed lockfile. Hardening (release-age cooldown, ignore-scripts, egress firewall) comes after containment.

This is not hypothetical. The self-replicating Shai-Hulud npm worm hit 500+ packages in late 2025, and Mini Shai-Hulud (Microsoft, May 2026) compromised 170+ npm packages across 404 malicious versions, specifically scanning the filesystem with TruffleHog for high-entropy secrets and dumping process.env (capturing GITHUB_TOKEN, AWS_ACCESS_KEY_ID, and similar). The 2.0 variant added any package with mcp-server in its name to its target list, because an MCP server runs inside your agent process with the same file system, environment, and tool-call permissions as a legitimate one.

Which bucket are you in

Symptom you seeMost likely causeJump to
Outbound connection to an unknown host from the node/python processCompromised version is exfiltrating; or a transitive dep isStep 1 (confirm), Step 5 (egress)
Secret appears in logs / a key you never logged shows upServer inherited the full parent env; payload dumped process.envStep 3 (minimal env), incident response
A .github/workflows/*.yml file you didn’t write appearedWorm persistence (Shai-Hulud pattern) via postinstallIncident response, Step 4 (ignore-scripts)
npm audit or Socket flags the package after a recent installKnown malicious version reached you before takedownStep 1, Step 2 (pin + cooldown)
It worked yesterday, broke after an auto-deployRange version (^/~) + no npm ci pulled a bad releaseStep 1, Step 2

Common causes

1. Package installed with a range version, not a pin

npm install @vendor/mcp-server with "^1.0.0" in package.json lets the next install on a fresh machine pull a newer, possibly compromised 1.0.1 automatically. The caret allows minor/patch upgrades.

How to spot it: open package.json and look for ^ or ~ on any MCP server or security-sensitive dependency. Anything but an exact pin ("1.2.3") plus a committed lockfile is exposed.

2. No integrity check on installed packages

npm records an integrity hash (sha512-...) per package in package-lock.json. If the lockfile was not committed, or was committed without integrity fields, there is no tamper detection and a tarball-swap attack passes silently.

How to spot it:

# Count integrity hashes in the lockfile — should roughly equal package count
grep -c '"integrity"' package-lock.json

3. A postinstall lifecycle script runs at install time

npm’s postinstall script executes automatically on npm install and can run arbitrary code. This is the single most-used supply-chain entry point — Shai-Hulud, Mini Shai-Hulud, and the April 2026 Axios compromise all triggered via install hooks.

How to spot it:

# List every install/postinstall script in the tree before trusting it
npm query ':attr(scripts, [postinstall])' 2>/dev/null
# or inspect the package directly
cat node_modules/@vendor/mcp-server/package.json | grep -A2 '"scripts"'

4. MCP server spawned with the full parent environment

The server process inherits every OPENAI_API_KEY, DATABASE_URL, and AWS_SECRET_ACCESS_KEY visible in the shell. A payload that dumps process.env gets all of them.

How to spot it: log the env keys visible to the spawned process. Any key the server does not strictly need is unnecessary exposure.

5. Outbound network access is unrestricted

A compromised server opens a connection to its command-and-control host and streams context. Mini Shai-Hulud used HTTPS to an encrypted C2 on port 443 with a Git Data API fallback, so a “looks like normal HTTPS” connection is not automatically safe.

How to spot it: check egress logs or firewall counters for the process UID running the server. Any destination not in an explicit allowlist is suspect.

6. Running MCP servers via bare npx (uncontrolled resolution)

MCP servers are commonly launched with npx @vendor/mcp-server, and every invocation is an uncontrolled package resolution that can fetch and run whatever the registry currently serves. Because MCP servers have filesystem and env access, this is among the riskiest patterns.

How to spot it: grep your agent/MCP config (.cursor/mcp.json, claude_desktop_config.json, CI scripts) for npx invocations of MCP servers without a pinned version or pre-installed lockfile.

7. The transitive dependency, not the server, is compromised

The server can be clean while one of its nested dependencies was hijacked. A deep dependency with network access is just as dangerous.

How to spot it: npm audit for known advisories and npm ls @vendor/mcp-server to see the full tree; new malicious versions may not be in the advisory DB yet, so combine with a release-age cooldown (Step 2).

Incident response: you think it already happened

Do these in order before any hardening. Speed matters — many malicious versions are taken down within hours, but the secrets they read are already gone.

  1. Stop the blast radius. Kill the agent and the MCP server process; take affected CI runners offline.
  2. Confirm the compromise. Diff the suspect version against the last known-good (see Step 1). Look for new network calls, process.env reads, base64 blobs, and added install scripts.
  3. Hunt for persistence (Shai-Hulud pattern). Check for files you did not author: .github/workflows/shai-hulud-workflow.yml or any unexpected workflow, plus repos created under your account during the window. The 2.0 strain marked compromised repos with a reversed-text description niagA oG eW ereH :duluH-iahS.
  4. Revoke and reissue every reachable secret — not rotate-in-place. Treat as burned: npm tokens, GITHUB_TOKEN, SSH keys, cloud keys (AWS_*, GCP, Azure), and any model API keys the process could read. GitHub invalidated 61,274 npm tokens during the May 2026 wave for exactly this reason.
  5. Review tool-call and egress logs for the exposure window to scope what data left.
  6. Re-pin to a clean version and rebuild from a committed lockfile with npm ci.

Shortest path to harden

Step 1: Confirm by diffing the suspect version

# Pull both versions side by side and diff for high-risk patterns
npm pack @vendor/mcp-server@1.2.2   # last known-good
npm pack @vendor/mcp-server@1.2.3   # suspect
mkdir old new
tar xzf vendor-mcp-server-1.2.2.tgz -C old
tar xzf vendor-mcp-server-1.2.3.tgz -C new
diff -r old new | grep -Ei "fetch|http|axios|net\.|child_process|eval|process\.env|postinstall|atob|base64"

Step 2: Pin exact versions, commit the lockfile, add a release-age cooldown

Pinning stops silent upgrades. A release-age cooldown is the 2026 addition that buys time for the community to flag a bad release before you install it — most malicious versions are reported and removed within hours.

// package.json — exact pin, no ^ or ~
{ "dependencies": { "@vendor/mcp-server": "1.2.3" } }
# .npmrc — npm 11.10.0+ (Feb 2026): refuse versions newer than 3 days
min-release-age=3
# CI: enforce the lockfile exactly; fail if anything would change
npm ci
# pnpm v11 (Apr 2026) ships minimumReleaseAge=1440 (1 day) by default

As of June 2026, npm’s min-release-age has no per-package exclusion mechanism yet, and npm v12 is expected to block install scripts by default. pnpm v11 already defaults to a 1-day cooldown.

Step 3: Restrict the server’s environment to the minimum

import { spawn } from "child_process";

const mcpProcess = spawn("npx", ["@vendor/mcp-server"], {
  env: {
    PATH: process.env.PATH,
    MCP_SERVER_TOKEN: process.env.MCP_SERVER_TOKEN, // only what it needs
    // Do NOT pass OPENAI_API_KEY, DATABASE_URL, AWS_* — a payload that
    // dumps process.env can only steal what you handed it.
  },
  stdio: ["pipe", "pipe", "pipe"],
});

Step 4: Block install-time code execution

# .npmrc — disable lifecycle scripts globally
ignore-scripts=true
# Or per-run; allow scripts only for vetted packages you control
npm ci --ignore-scripts

Step 5: Restrict outbound network access at the OS level

# Linux: scope egress to the UID running the server; reject all but the allowlist
iptables -A OUTPUT -p tcp -m owner --uid-owner mcp-server-user ! -d 10.0.0.0/8 -j REJECT
# macOS: pf rules or Little Snitch scoped to the node process
# Kubernetes: a NetworkPolicy Egress block restricted to your internal CIDR/443

Step 6: Audit tool calls against an allowlist

const EXPECTED_TOOL_CALLS = new Set(["read_file", "write_file", "list_directory"]);

function auditToolCall(toolName: string, sessionId: string): void {
  if (!EXPECTED_TOOL_CALLS.has(toolName)) {
    logger.error({ event: "unexpected_tool_call", tool: toolName, sessionId });
    throw new Error(`Unexpected tool call blocked: ${toolName}`);
  }
}

Step 7: Verify package hash in CI before deploy

#!/bin/bash
# ci/verify-mcp-hashes.sh
EXPECTED_HASH="sha256:abc123def456..."
ACTUAL_HASH=$(sha256sum node_modules/@vendor/mcp-server/dist/index.js | cut -d' ' -f1)
if [ "sha256:$ACTUAL_HASH" != "$EXPECTED_HASH" ]; then
  echo "SECURITY: MCP server hash mismatch — aborting deploy"
  exit 1
fi
echo "MCP server integrity verified."

How to confirm it’s fixed

  • npm ci reproduces the install with no lockfile changes, and the pinned version matches your last clean diff.
  • npm config get ignore-scripts returns true and npm config get min-release-age returns your cooldown (e.g. 3).
  • A test run shows no outbound connections from the server process to anything outside your allowlist (watch egress logs or firewall counters during a session).
  • No unauthorized .github/workflows/ files and no repos created under your account during the exposure window.
  • Every reachable secret has been reissued, and old tokens fail when tested.

Prevention

  • Pin exact versions for every MCP server and security-sensitive dependency; never ^/~. Commit the lockfile and run npm ci in CI — never bare npm install.
  • Add a release-age cooldown (min-release-age on npm 11.10.0+, default on pnpm v11) so brand-new versions wait out the takedown window.
  • Set ignore-scripts=true and only allow lifecycle scripts for packages you have vetted.
  • Pre-install pinned MCP servers in a lockfile-controlled workspace rather than launching them via bare npx; if you must use npx, pin the exact version.
  • Spawn MCP servers with a minimal, explicit environment; restrict outbound network access with an OS-level firewall or NetworkPolicy.
  • Subscribe to GitHub Security Advisories and npm advisories for every MCP package; review the changelog and diff before bumping a pin.
  • Use a scanner (Socket, npm audit, Snyk) in CI and fail the build on high-severity findings.

FAQ

Q: How fast can a compromised npm package reach production? A: With a range version and bare npm install, within hours of a malicious publish. A lockfile-pinned npm ci deploy is protected until you deliberately bump the pin — and a min-release-age cooldown adds a buffer even then.

Q: I rotated my keys — am I done? A: Only if you reissued them, not rotated in place, and only after confirming the malware had no other persistence (no rogue workflow file, no created repos). Microsoft’s guidance for the 2026 waves was to treat all reachable credentials as burned and reissue them, because the payload exfiltrates the secret value itself.

Q: How do I tell if my current install is already compromised? A: Diff the installed version against a known-good npm pack (Step 1), grep for process.env dumps and base64 blobs, check egress logs for unknown hosts, and look for unexpected .github/workflows/ files. If Index.js/cat.py-style files or a reversed Shai-Hulud repo description appear, assume compromise.

Q: Does a release-age cooldown break my workflow? A: Rarely. It only delays brand-new releases; anything older than the threshold installs immediately. As of June 2026 npm has no per-package exclusion, so set the window to a few days (3 is common) rather than weeks if you need fresh releases occasionally.

Q: What’s the difference between tool poisoning and a supply-chain attack? A: Tool poisoning changes the tool description/behavior sent to the model without altering installed code (detect by auditing the manifest). A supply-chain attack changes the installed code (detect by file-integrity and diff). Both cause unexpected behavior but need different detection.

Tags: #ai-security #prompt-injection #Troubleshooting