When we published our finding that 5 of 5 official MCP servers carry known-vulnerable transitive dependencies, the most common follow-up from engineers was: how is Dependabot not catching this? The advisories are in the database. OSV has both GHSAs. The fix versions are published.

The answer is a specific, reproducible gap between what PURL-based scanning was built to do and what MCP server package topology actually looks like. This post walks through exactly where the gap is and why it's structural rather than accidental.

Two ways to scan a dependency tree

PURL-based scanning uses a Package URL - a standardized identifier like pkg:npm/%40modelcontextprotocol%2Fserver-filesystem@0.6.0 - to query a vulnerability database for each package. The scanner reads declared dependencies (from package.json, a manifest, or a lock file's direct entries), constructs a PURL per package, and queries OSV or NVD for matches. Dependabot, Snyk's free tier, GitHub's built-in dependency graph alerts, and most CI advisory checks work this way.

npm-tree scanning runs npm ls --json --all on the installed package, walks the nested JSON tree recursively, and queries every node - including deep transitives that never appear in any package.json. The scan sees the full resolved install, not the declared intent.

The distinction is invisible for most packages. It matters when:

  1. The vulnerable package is a transitive, not a direct dependency
  2. The transitive's parent has no GHSA entry of its own
  3. The scanner queries at the declared-dependency level

MCP reference servers hit all three simultaneously.

The MCP package topology

The @modelcontextprotocol/server-* packages are thin wrappers. Their core functionality lives in @modelcontextprotocol/sdk, which every server declares as a direct dependency. The version they resolve to is 1.0.1 - shipped well before the two HIGH advisories were published against the SDK.

@modelcontextprotocol/server-filesystem@0.6.0
  - @modelcontextprotocol/sdk@1.0.1   <- both GHSAs live here
       - zod, eventsource, ...

The advisory on sdk@1.0.1 is in OSV. The advisory on server-filesystem@0.6.0 is not - because the server package itself has no vulnerability. Its only security relevance is that it transitively carries vulnerable code.

Query OSV for pkg:npm/%40modelcontextprotocol%2Fserver-filesystem@0.6.0 -> zero results.
Query OSV for pkg:npm/%40modelcontextprotocol%2Fsdk@1.0.1 -> two HIGH GHSAs.

PURL scanning only issues the first query.

What the data shows

From our April 2026 scan of all five official @modelcontextprotocol reference servers:

Scan methodServersFindingsSeverity
PURL query on top-level packages50-
Recursive npm-tree walk51010 HIGH

Same servers. Same OSV database. Same advisory data. The difference is query depth.

The two GHSAs in question:

GHSA-8r9q-7v3j-jr4g - ReDoS
Catastrophic backtracking in the SDK's message parser. A crafted string in an MCP tool response pins the Node.js event loop - the attack arrives through tool output, not inbound traffic. An agent process calling through sdk@1.0.1 can be frozen by a malicious or compromised upstream server. Fixed in sdk@1.25.2. Installed version: 1.0.1. That's a 25 minor-version lag.

GHSA-w48q-cv73-mx4w - DNS rebinding
No Host header validation on the SDK's HTTP listener. An attacker's webpage rebinds its domain to 127.0.0.1 after the DNS TTL expires. Browser same-origin policy no longer protects the localhost server. Attacker's JavaScript calls any tool the server exposes and reads the output - no malware, no privilege escalation, just a browser tab open while an MCP server is running locally. Fixed in sdk@1.24.0. Installed: 1.0.1.

How the recursive tree walk works

The scanner runs npm ls --json --all on the installed server directory, then walks the output:

cmd := exec.CommandContext(ctx, "npm", "ls", "--json", "--all", "--prefix", dir)
out, _ := cmd.Output()

var root npmTreeNode
json.Unmarshal(out, &root)

deps := make([]vuln.DependencyInput, 0, 64)
walkNPMTree(root.Dependencies, &deps)

npm ls --json --all returns a nested object where each key is a package name and each value contains version and a dependencies map of the same shape, recursively. walkNPMTree does a depth-first traversal:

func walkNPMTree(tree map[string]npmTreeNode, out *[]vuln.DependencyInput) {
    for name, node := range tree {
        if name != "" && node.Version != "" {
            *out = append(*out, vuln.DependencyInput{
                Name:      name,
                Version:   node.Version,
                Ecosystem: "npm",
            })
        }
        if len(node.Dependencies) > 0 {
            walkNPMTree(node.Dependencies, out)
        }
    }
}

For a typical MCP server install, this produces roughly 179 DependencyInput records. Each one becomes a PURL query to OSV's batch endpoint. @modelcontextprotocol/sdk@1.0.1 is among them - and that's where the findings come from.

The --all flag matters. Without it, npm may collapse deduped packages and omit nodes from the tree output. With --all, every resolved install location appears, including versions that differ from the top-level lock file entries.

Why PURL scanning breaks here

PURL scanning works correctly for its original design target: a consumer who declares a direct dependency on a vulnerable package version sees the advisory when they query that package. The model is:

maintainer publishes advisory on pkg@bad-version
consumer's package.json has pkg@bad-version
scanner queries pkg@bad-version -> gets hit

The model breaks when the vulnerable package is not declared anywhere by the consumer - it's pulled in transitively, and the package that pulled it in has no advisory of its own. This is structurally more common when:

  • A shared "core" library is used by many thin wrapper packages
  • The wrappers pin an old version of the core in their lockfiles
  • Advisories are published on the core but the wrappers haven't updated

The @modelcontextprotocol namespace fits this pattern: sdk is the core, the server-* packages are the wrappers, and both published advisories live on sdk, not on any server package.

The check

Verify what's actually installed, not what package.json declares:

npm ls @modelcontextprotocol/sdk --all

Interpreting the result:

Installed versionStatus
Below 1.24.0Both vulnerabilities present
1.24.0-1.25.1ReDoS (GHSA-8r9q) still present; DNS rebinding patched
1.25.2+Both patched

To fix: update the lockfile entry to resolve >=1.25.2 and regenerate. Updating package.json alone is insufficient if the lock file already pins 1.0.1 - npm install will use the lock and leave the vulnerable version in place.

The general case

This isn't an MCP-specific quirk. Any npm package that:

  1. Has no direct GHSA advisory
  2. Transitively depends on a package that does
  3. Is evaluated by a scanner that queries declared packages by PURL

...will pass clean while carrying real findings. The @modelcontextprotocol namespace makes it highly visible - the pattern applies uniformly across five official servers with identical results - but the underlying issue is a property of shallow scanning against npm's resolution model.

Before approving any MCP server for a production agentic pipeline, run a full dependency-tree scan against the installed tree, not just a PURL query on the top-level package name.

See also the companion deep-dive on the two SDK advisories: The two GHSAs hiding across popular MCP servers.