The npm worm could reach 18 MCP servers, not the 36 an IOC name match reports

On 4 August the GitHub account behind keyv was taken over and used to publish a credential stealer that republished itself through every npm token it could steal. It’s the third Shai-Hulud wave; vendors are calling this one ChainDrop. Somewhere north of 440 packages were affected.

A week earlier I had measured every npm-packaged stdio server in the official MCP registry for a post about install size, which left me holding the dataset needed to ask how far this got into MCP.

My first answer was 36 servers. It was wrong, and how it was wrong is the part worth reading, so that’s where I’ll start.

A name is not a version

I re-resolved all 6,139 registry entries, kept the full package name of every node in every tree, and intersected those against a 463-name compromised list. 36 trees contained at least one name. That’s the number I’d written a whole post around before I checked the versions.

Here is what those 36 trees actually resolve:

packagemaliciousresolves herereachable
keyv6.0.05.6.0, 4.5.40 of 36
cacheable-request13.0.2013.0.19, 7.0.4, 10.2.1414 of 18
file-entry-cache11.1.68.0.00 of 10
flat-cache6.1.244.0.10 of 10
@cacheable/utils2.5.12.5.04 of 4
@cacheable/memory2.2.12.2.03 of 3
cacheable2.5.12.5.03 of 3
@cacheable/node-cache3.1.21.7.60 of 3
@keyv/bigmap6.0.01.3.10 of 3
@keyv/sqlite6.0.04.0.80 of 1
cache-manager7.2.107.2.91 of 1

keyv is the name that produces all 36 — it’s in every one of those trees, and grepping for that single name reproduces the identical set. The malicious version is 6.0.0. Every tree sits on 5.x or 4.x. A caret range cannot cross a major version, so not one of those 36 was ever going to receive it, on 4 August or any other day.

I checked that the ranges are genuinely the constraint rather than an artefact of npm having pulled the bad versions: cacheable-request@13.0.19 declares keyv: ^5.6.0, and ^5.6.0 excludes 6.0.0 by construction. The same holds for the entire eslint branch — those trees carry file-entry-cache 8.0.0 against a malicious 11.1.6.

Filter to hits where the resolved version shares a major with a malicious one, so a caret could actually have carried the payload:

18 of 6,001, or 0.30%.

And look at where they sit: cacheable-request at 13.0.19 against a malicious 13.0.20. @cacheable/utils at 2.5.0 against 2.5.1. cacheable at 2.5.0 against 2.5.1. cache-manager at 7.2.9 against 7.2.10. Every reachable hit resolves to exactly one patch below the malicious version — which is the shape you get when npm yanks a release and the range falls back to the one underneath. Those are the trees that would have taken the payload.

This isn’t a subtle distinction. It halved the finding, and it’s the difference between what an IOC list can tell you and what it cannot. A list of names answers “is this package in your tree.” Exposure requires “could your declared ranges have resolved to that version during the window.” Nearly every scanner and every blog table published this week does the first one.

What the 463-name list bought me

Nothing, and I’d rather say so than let that sourcing paragraph imply otherwise.

I merged names from JFrog, SafeDep, Wiz, Palo Alto Unit 42 and four community mirrors, cross-checked against OSV, and got 463. Of those 463, exactly 11 appear in any MCP tree at all. 452 appear in zero. Seven of the eight sources contributed nothing unique — operationally I used JFrog’s list. And since keyv alone reproduces the whole name-level set, the merge changed no number in this post.

That also collapses a finding I was briefly excited about. Wiz’s published list has 443 names and contains no @keyv/* scoped package, although OSV has advisories for them (@keyv/redis is MAL-2026-12020, published 4 August at 13:01 UTC). A real 20-name gap in a widely-copied list. In this population it costs nothing: the four servers reaching a @keyv/* name also carry bare keyv, so a short-list scan flags them anyway — and after the version check, none of them were reachable through either.

Where the 18 are, and the honest version of why

tree sizeserversreachable
under 100 packages3,85300%
100–1991,79110.1%
200–399294155.1%
400+6323.2%

There are two routes in, not eighteen. 14 of the 18 pull got, which pulls cacheable-request, which was itself compromised at 13.0.20. The other 4 use the @cacheable v2 family directly — three through cacheable and @cacheable/memory, one through cache-manager. So this is two observations with an n of 18 hanging off them, and every rate below should be read that way.

The tempting headline here is that small servers were safe. The population median is 95 packages and nothing at or below it was reachable. I’m not going to lead with that, because when I looked at what the denominator is made of, it stopped meaning what it appears to mean:

So “zero out of 3,118” decodes to “the SDK’s own dependency tree does not contain the @cacheable family, republished 2,349 times.” I had a Poisson null and a p-value in this paragraph. Both were wrong: the arithmetic was wrong, and the test doesn’t apply in the first place, because those 2,349 servers are one dependency graph counted 2,349 times rather than 2,349 independent draws. Zero isn’t surprising and there is nothing here to run a significance test on.

There’s a real one, and it’s less flattering to everybody:

The servers that were safe were not safe on purpose. Two thousand three hundred and forty-nine of them shipped the default scaffold and nothing else, and the default scaffold happens not to include an HTTP client. The authors who were exposed aren’t less careful — they’re the ones who needed to make an outbound HTTP request and reached for got, which is the ordinary thing to reach for.

The smallest reachable tree is 195 packages, the largest 433, the median 273.5. Below 100 packages there is nothing at all. Above that the rate climbs with size and then stops meaning much: 5.1% across the 200–399 band, 3.2% above 400, where two hits on 63 servers is not so much a rate as two hits.

Nobody’s server was compromised

Worth stating plainly, because it’s the part that gets lost: of the 6,139 MCP packages in the registry, zero were themselves in the 4 August compromised set. No MCP server was taken over or republished in this incident, and every hit in this post is transitive — a package the author never named and couldn’t see.

That’s a claim about this incident, and I originally wrote it as a claim about the registry, which is a different and less comfortable thing. Four of these packages have carried malware at some other point: @postman/postman-mcp-server (MAL-2025-190909), @browserbasehq/mcp-server-browserbase (MAL-2025-191196), @antv/mcp-server-chart (MAL-2026-4069) and supership-scan (MAL-2026-4675). None is at a flagged version in the registry’s current declaration and none has anything to do with ChainDrop — but the registry has shipped malware before, and a post about not over-reading an IOC match shouldn’t under-read one either.

So I’m not going to tabulate the 36, or the 18, in this post. They’re not compromised servers, half of them were never reachable at all, and a list of names under a headline containing the word “worm” gets screenshotted without whichever caveat mattered.

What I’m not going to do is pretend that withholding a table is protection. The per-tree data ships in the repo with the package names in it, and a three-line join gets you the list — I’m not redacting it, because a redaction I can defeat myself is theatre, and because you can’t check my arithmetic without it. The distinction I’m drawing is narrow and I’d rather state it than dress it up: the aggregate is what I’m asserting, the rows are there so you can falsify the aggregate, and I’m not going to hand anyone a ready-made list of servers to be wrong about.

What this measurement cannot tell you

More than usual, so all of it in one place:

Checking your own

npm install <your-package> --omit=dev --package-lock-only --ignore-scripts
python3 -c "import json; p=json.load(open('package-lock.json'))['packages']; \
print('\n'.join(sorted({k.split('node_modules/')[-1] for k in p if k.startswith('node_modules/')})))" > tree.txt
curl -sO https://raw.githubusercontent.com/Jiangw2718i/frisk/main/research/npm-worm-reach/compromised-names.txt
grep -Fxf compromised-names.txt tree.txt

Two things about that snippet. grep -Fx matters: matching substrings instead of whole names reports keyv-file, keyv-registry and @keyv/serialize as hits on a tree whose only real hit is keyv, three false positives out of four from packages that merely share a prefix. That’s the version I wrote first.

And a name it prints is a name, not an exposure — which is the entire point of this post. Compare the version it resolved against the malicious one. If your tree was already a major behind, you were never in range. For what you actually shipped on 4 August, read your lockfile from that week, not today’s resolution.

What I would change

npm install --ignore-scripts would have blocked this worm outright, because the payload ran from preinstall. Two honest limits: it breaks native builds, which is 16.8% of these trees including better-sqlite3, protobufjs, fsevents and sharp, so you’ll need npm rebuild for the ones you trust. And it’s specific to this delivery vector — an MCP server is a program your agent executes, so a payload sitting in the module body is untouched by it.

The larger lever, if you build these: the v1 SDK is where the 92-package baseline comes from, and v2 took my own server’s tree from 95 packages to 5. That’s a bigger reduction in surface than any amount of dependency hygiene applied on top of v1.

And on IOC lists generally — this is the part I didn’t expect to be writing. Matching names is the cheap half of the problem and it over-counted here by a factor of two. Both halves of that error hurt: the 18 unreachable trees are people who would have spent a day chasing nothing, and a scanner that reports them all identically has no way to tell you which half you are in.

Data

resolve_trees.py, analyze.py, the merged 463-name list with its sources, and the per-tree output are in research/npm-worm-reach, alongside the earlier census.

Every figure in the tables comes out of analyze.py, labelled as it appears here. Some of the figures in the prose do not, and I’d rather enumerate them than let one sentence cover for all of them. analyze.py prints its size buckets at different boundaries, so the 3,853 under 100 packages is the sum of three of them. It prints how many exposed trees carry got (18 of 36), not the 14-of-18 split quoted here. It doesn’t print the smallest, largest and median reachable tree at all. The OSV advisory IDs come from api.osv.dev; the declared-range checks (cacheable-request@13.0.19 declaring keyv: ^5.6.0) come from npm view; the 31 July comparison figures come from the previous run rather than this data.

Corrections

31 August. Re-resolved: the tree sizes above were undercounted, and every size figure has been re-derived. The median moves from 92 to 95, the size table and the reachable-tree range move with it, and there is now a reachable tree above 400 packages where this post said there were none. The 36 and the 18 do not move, and neither does any per-name result. The cause and the fix are in resolve_trees.py.

6 August, after publication. Re-checking this post against the data turned up four errors, all fixed above: a Poisson test that was both miscalculated and inapplicable, one sentence that overstated the registry’s clean record, a scaffold count stated on the wrong definition, and a miscomputed median.

An earlier version of this post, at a different URL, counted 36 name matches as 36 exposures. That one was retracted rather than corrected, and this post opens with it.