Most of the discussion about attacks on AI coding agents is about prompts: poisoned tool descriptions, injected instructions, a README that talks the agent into running something. The malware records from 2026 tell a plainer story. The packages that hit agent users this year got in the way npm and PyPI malware always has: stolen publish tokens, worms, names that copy real projects, and internal names claimed on the public registry. What's new is how often agents run them. An MCP config that says npx -y some-server installs and executes whatever that name resolves to, every time the client starts.
This post is for teams that let developers run Claude Code, Cursor, Codex, Gemini CLI or similar tools with MCP servers. It covers what OSV's malicious-package data shows for 2026, the cases worth knowing, why the launch command is the control that matters, and a script that reviews MCP configs.
The short answer
In OSV's malicious-package data for 2026, 240 of 14,481 npm and PyPI records have names that point at AI tooling, such as MCP servers, coding agents and LLM SDKs. That's 1.7%, but the share reached 3% or more in May, June and the first half of October. The cases include stolen-token releases of real packages (the Cline CLI, LiteLLM, Mistral's SDKs, @antv/mcp-server-chart), lookalike Claude Code packages, and ten unscoped mcp-server-* names claimed on one evening to catch npx launches. Prompt-level defenses don't touch any of these. Pin every MCP server to an exact version, prefer scoped names from the publisher you mean, add a release-age delay, and check configs against malware data.
What the data shows
OSV publishes every ecosystem's records as a download, including the MAL- entries from the OpenSSF malicious-packages project, which pulls reports from GitHub, Amazon Inspector, SafeDep, ReversingLabs, Socket and independent researchers. We counted 2026 records whose package names match AI-tool patterns. This script reproduces the count from the dumps. It reads the zip files and runs nothing from them:
#!/usr/bin/env python3
"""Count 2026 malicious-package records (OSV MAL-*) whose names point at AI tooling.
Input: OSV's per-ecosystem dumps, e.g.
curl -O https://osv-vulnerabilities.storage.googleapis.com/npm/all.zip
Usage: ai_malware_names.py npm=npm.zip PyPI=pypi.zip
Reads the zips only; executes nothing from them.
"""
import collections
import json
import re
import sys
import zipfile
PATTERNS = {
"MCP server": r"(^|[^a-z])mcp([^a-z]|$)|modelcontextprotocol",
"coding agent": r"claude|anthropic|opencode|codex|gemini-?cli|copilot|openclaw|clawd|cline|windsurf|kiro|aider",
"LLM SDK": r"openai|langchain|llama-?index|ollama|hugging-?face|litellm|mistral|groq|deepseek|vllm",
}
def kinds(name):
n = name.lower()
return [k for k, p in PATTERNS.items() if re.search(p, n)]
def main(args):
total = collections.Counter()
hits = collections.Counter()
months = collections.defaultdict(lambda: [0, 0])
npx = []
for arg in args:
eco, path = arg.split("=", 1)
with zipfile.ZipFile(path) as z:
for member in z.namelist():
if not member.startswith("MAL-"):
continue
rec = json.loads(z.read(member))
if not rec.get("published", "").startswith("2026"):
continue
for aff in rec.get("affected", []):
name = aff.get("package", {}).get("name")
if aff.get("package", {}).get("ecosystem") != eco or not name:
continue
month = rec["published"][:7]
total[eco] += 1
months[month][0] += 1
ks = kinds(name)
if not ks:
continue
hits[(eco, "any")] += 1
months[month][1] += 1
for k in ks:
hits[(eco, k)] += 1
if re.search(r"\b(npx|uvx)\b", rec.get("details", "")):
npx.append((rec["published"][:10], eco, name))
for eco in total:
n = hits[(eco, "any")]
print(f"{eco}: {total[eco]} records in 2026, {n} with AI-tool names ({100 * n / total[eco]:.1f}%)")
for k in PATTERNS:
print(f" {k:13} {hits[(eco, k)]}")
print("by month (all ecosystems): records, AI-named, share")
for m in sorted(months):
t, a = months[m]
print(f" {m} {t:5} {a:4} {100 * a / t:4.1f}%")
print(f"AI-named records whose analysis mentions npx or uvx: {len(npx)}")
if __name__ == "__main__":
main(sys.argv[1:])
On the dumps as of 16 October 2026:
$ python3 ai_malware_names.py npm=npm.zip PyPI=pypi.zip
npm: 13022 records in 2026, 201 with AI-tool names (1.5%)
MCP server 102
coding agent 89
LLM SDK 14
PyPI: 1459 records in 2026, 39 with AI-tool names (2.7%)
MCP server 13
coding agent 10
LLM SDK 18
by month (all ecosystems): records, AI-named, share
2026-01 619 5 0.8%
2026-02 477 8 1.7%
2026-03 1268 9 0.7%
2026-04 805 6 0.7%
2026-05 1864 64 3.4%
2026-06 1607 49 3.0%
2026-07 2222 32 1.4%
2026-08 4180 35 0.8%
2026-09 982 14 1.4%
2026-10 457 18 3.9%
AI-named records whose analysis mentions npx or uvx: 25
Name matching is a blunt instrument. It misses AI-related malware with ordinary names, and it catches a few packages whose names only resemble AI tools. Some records are security-research canaries rather than criminal payloads. So read the share as a lower bound on how often attackers name a package after AI tooling, not as a measure of harm. Two things in it are still clear. MCP server names are the largest group. And the spikes line up with worm waves: May and June are the Mini Shai-Hulud campaign, which reached AI packages because AI packages are on npm.
The cases worth knowing
| Date (2026) | Package | What happened |
|---|---|---|
| 17 Feb | cline 2.3.0 (npm) | A stolen npm publish token pushed a release whose postinstall globally installed another package. Live for about eight hours. Cline now publishes through OIDC with provenance |
| 24 Mar | litellm 1.82.7 and 1.82.8 (PyPI) | Malicious releases that sent secrets to an attacker domain. The OSV record says the project was compromised through a Trivy scan step in a GitHub workflow |
| 11 and 12 May | mistralai (PyPI), @mistralai/mistralai and two sibling SDKs (npm) | Malicious releases. OSV attributes the PyPI one to the Mini Shai-Hulud worm, which steals credentials and republishes every package the stolen tokens can reach |
| 19 May | @antv/mcp-server-chart and other @antv MCP servers | The OSV record attributes it to the same worm campaign. One compromised maintainer account (atool), 631 malicious versions across 314 packages in 22 minutes. The record says the payload also set up persistence through AI agent session hooks |
| 19 to 22 May | @bonsai-ai/claude-code, @cometix/claude-code, @tmecontinue/claude | Packages copying Anthropic's Claude Code, with its README, author field or slash commands |
| 9 Jun | Ten unscoped names, mcp-server-github to mcp-server-supabase | Claimed within two minutes, each beaconing the installer's hostname and working directory. Amazon Inspector's analysis says the author described them as a security-research canary. The point stands: the names were free |
| 23 Sep | @memtensor/memos-cloud-openclaw-plugin | An attacker published a malicious version of an agent plugin carrying a Go implant that collects credentials from the home directory |
| 4 Oct | anthropic-sdk (PyPI) | Re-exports the real anthropic client and, from the third import on, fetches a remote script |
| 13 and 16 Oct | opencode2, @beloved-lenora/opencode-* | Sixteen packages named after the opencode agent, listed by GitHub as malware this week |
Only the unscoped squats relied on how agents and their configs pick package names. The rest are ordinary supply-chain attacks that happened to land on AI packages. That's the argument in one table: the threat to agent users isn't mainly a new kind of malware. It's the old kind, run more often and with more access.
Why the launch command matters more than the prompt
Look at how MCP servers are usually configured. The client runs a command such as npx -y @scope/server, uvx some-server or bunx, often with no version. Three things follow:
- Every launch can be an install. With no version pinned, the runner picks up a newer release whenever it resolves the name again: on a new laptop, after a cache clear, on a fresh CI runner. A poisoned release can reach you the next time the client starts, not the next time someone reviews a lockfile change.
-yremoves the only prompt.npx -yinstalls without asking. Nobody sees a new package name go past.- The process runs as the developer. An MCP server gets the user's home directory, SSH keys, cloud credentials and model API keys, and often a GitHub token passed in by the config itself. That's exactly what the 2026 payloads collected.
Agents add one more path. The ten squats were described as targets for npx mcp-server-github typed by people and by AI coding agents. An agent asked to "add the GitHub MCP server" can produce the unscoped name as easily as a person can.
None of this is solved by prompt-injection defenses, tool-description scanning or an AI gateway. Those matter for a different attack, which we covered in MCP tool poisoning attacks. Install-path malware runs before the model sees anything.
Review your MCP configs
This script reads Claude Desktop, Claude Code .mcp.json, Cursor, VS Code and Codex config files. For each server started through npx, bunx, pnpm dlx or uvx, it flags launches not pinned to an exact version, -y on unpinned launches, and unscoped mcp-server-* npm names. With --osv, it asks OSV's public API whether the package has malicious releases, and whether a pinned version is one of them. It uses only the Python standard library:
#!/usr/bin/env python3
"""Review how MCP client configs launch servers through package runners.
Reads JSON configs (Claude Desktop, Claude Code .mcp.json, Cursor, VS Code)
and Codex TOML. Flags npx/bunx/pnpm dlx/uvx launches that aren't pinned to an
exact version, unscoped names that copy official scoped servers, and, with
--osv, package names that OSV lists as malicious. Usage:
mcp_launch_check.py [--osv] CONFIG_OR_DIR...
"""
import json
import re
import sys
import tomllib
import urllib.request
from pathlib import Path
RUNNERS = {"npx": "npm", "bunx": "npm", "pnpx": "npm", "uvx": "PyPI", "pipx": "PyPI"}
NAMES = {".mcp.json", "mcp.json", "claude_desktop_config.json", "config.toml", "settings.json"}
EXACT_NPM = re.compile(r"^(@[^/]+/)?[^@]+@\d+\.\d+\.\d+([-+][\w.]+)?$")
EXACT_PY = re.compile(r"^[\w.\-\[\],]+==\d[\w.]*$")
def servers(path):
text = path.read_text()
data = tomllib.loads(text) if path.suffix == ".toml" else json.loads(text)
block = data.get("mcpServers") or data.get("mcp_servers") or data.get("servers") \
or data.get("mcp", {}).get("servers") or {}
return block.items()
def package_arg(cmd, args):
exe = Path(cmd).name
if exe in ("pnpm", "yarn") and args[:1] == ["dlx"]:
exe, args = "pnpx", args[1:]
if exe == "uv" and args[:2] == ["tool", "run"]:
exe, args = "uvx", args[2:]
if exe not in RUNNERS:
return None, None, False
auto_yes = any(a in ("-y", "--yes") for a in args)
i = 0
while i < len(args):
a = args[i]
if a in ("--package", "-p", "--from"):
return RUNNERS[exe], args[i + 1], auto_yes
if not a.startswith("-"):
return RUNNERS[exe], a, auto_yes
i += 1
return RUNNERS[exe], None, auto_yes
def bare(spec, eco):
if eco == "npm":
return re.sub(r"(?<!^)@.*$", "", spec)
return re.split(r"[=<>!~\[]", spec)[0]
def osv_malicious(eco, name):
"""Return [(MAL id, affected versions or None for every version)]."""
q = json.dumps({"package": {"ecosystem": eco, "name": name}}).encode()
req = urllib.request.Request("https://api.osv.dev/v1/query", q, {"Content-Type": "application/json"})
out = []
for v in json.load(urllib.request.urlopen(req, timeout=20)).get("vulns", []):
if not v["id"].startswith("MAL-"):
continue
for a in v.get("affected", []):
whole = any(e.get("introduced") == "0" for r in a.get("ranges", []) for e in r.get("events", []))
out.append((v["id"], None if whole else a.get("versions", [])))
return out
def check(path, use_osv):
out = []
try:
entries = servers(path)
except (ValueError, tomllib.TOMLDecodeError, AttributeError):
return out
for name, cfg in entries:
if not isinstance(cfg, dict) or "command" not in cfg:
continue
eco, spec, auto_yes = package_arg(cfg["command"], [str(a) for a in cfg.get("args", [])])
if not eco or not spec:
continue
pkg = bare(spec, eco)
pinned = bool((EXACT_NPM if eco == "npm" else EXACT_PY).match(spec))
if not pinned:
out.append(("WARN", name, f"{spec} is not pinned to an exact version, so each launch can resolve a new release"))
if auto_yes and not pinned:
out.append(("WARN", name, f"{cfg['command']} -y installs whatever {pkg} resolves to without asking"))
m = re.match(r"^mcp-server-([\w.\-]+)$", pkg)
if eco == "npm" and m:
out.append(("CHECK", name, f"unscoped {pkg}: check the publisher; official servers "
"mostly use a scoped name, and squatters target these"))
if use_osv:
version = re.split(r"@|==", spec)[-1] if pinned else None
for mal_id, versions in osv_malicious(eco, pkg):
if versions is None:
out.append(("MALWARE", name, f"{pkg}: every version is malicious ({mal_id})"))
elif version in versions:
out.append(("MALWARE", name, f"{spec} is a malicious release ({mal_id})"))
elif not pinned:
out.append(("WARN", name, f"{pkg} had malicious releases {', '.join(versions)} ({mal_id}); "
"an unpinned launch then would have run one"))
return out
def main(argv):
use_osv = "--osv" in argv
targets = [Path(a) for a in argv if a != "--osv"]
files = []
for t in targets:
files += [p for p in t.rglob("*") if p.name in NAMES] if t.is_dir() else [t]
order = {"MALWARE": 0, "WARN": 1, "CHECK": 2}
rows = [(lvl, str(f), srv, msg) for f in sorted(files) for lvl, srv, msg in check(f, use_osv)]
for lvl, f, srv, msg in sorted(rows, key=lambda r: (order[r[0]], r[1], r[2])):
print(f"{lvl:7} {f} [{srv}] {msg}")
print(f"{len(files)} config file(s), {len(rows)} finding(s)")
return 1 if any(r[0] == "MALWARE" for r in rows) else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
On a test laptop config, a repo with Claude Code, Cursor and Codex configs, and two real squatted names:
$ python3 mcp_launch_check.py --osv fx
MALWARE fx/laptop/claude_desktop_config.json [github] mcp-server-github: every version is malicious (MAL-2026-5479)
MALWARE fx/repo/.codex/config.toml [figma] mcp-server-figma: every version is malicious (MAL-2026-5477)
WARN fx/laptop/claude_desktop_config.json [fetch] mcp-server-fetch is not pinned to an exact version, so each launch can resolve a new release
WARN fx/laptop/claude_desktop_config.json [github] mcp-server-github is not pinned to an exact version, so each launch can resolve a new release
WARN fx/laptop/claude_desktop_config.json [github] npx -y installs whatever mcp-server-github resolves to without asking
WARN fx/laptop/claude_desktop_config.json [postgres] @modelcontextprotocol/server-postgres is not pinned to an exact version, so each launch can resolve a new release
WARN fx/laptop/claude_desktop_config.json [postgres] npx -y installs whatever @modelcontextprotocol/server-postgres resolves to without asking
WARN fx/repo/.codex/config.toml [figma] mcp-server-figma is not pinned to an exact version, so each launch can resolve a new release
WARN fx/repo/.codex/config.toml [figma] npx -y installs whatever mcp-server-figma resolves to without asking
WARN fx/repo/.mcp.json [chart] @antv/mcp-server-chart is not pinned to an exact version, so each launch can resolve a new release
WARN fx/repo/.mcp.json [chart] npx -y installs whatever @antv/mcp-server-chart resolves to without asking
WARN fx/repo/.mcp.json [chart] @antv/mcp-server-chart had malicious releases 0.10.10, 0.11.10 (MAL-2026-4069); an unpinned launch then would have run one
CHECK fx/laptop/claude_desktop_config.json [github] unscoped mcp-server-github: check the publisher; official servers mostly use a scoped name, and squatters target these
CHECK fx/repo/.codex/config.toml [figma] unscoped mcp-server-figma: check the publisher; official servers mostly use a scoped name, and squatters target these
4 config file(s), 14 finding(s)
The uvx mcp-server-fetch line is a warning, not malware: on PyPI that name belongs to the reference fetch server. Unscoped names are only suspicious on npm, where the reference servers are scoped. That difference is easy to get wrong when an agent writes the config.
What to change
- Pin exact versions in every MCP launch:
npx -y @modelcontextprotocol/server-memory@1.0.0,uvx mcp-server-time==2026.10.10. Update them on purpose, the way you'd update a dependency. - Add a release-age delay. npm has
min-release-age(in days) since 11.10.0, and with npm 11.19.0 we checked thatnpxhonors it from.npmrc. pnpm'sminimumReleaseAgedefaults to a day in pnpm 11. uv'sexclude-newertakes a cutoff date. pnpm's documentation says most malicious releases are found and removed within an hour. Our guide to cooldown policies covers the trade-offs. - Prefer scoped names from the publisher you mean, and check the npm page before adding an unscoped
mcp-server-*package. - Treat MCP configs as code. Keep project configs in review, and give agents a list of approved servers rather than letting them pick packages.
- Keep secrets out of the launch environment where you can, and rotate anything a machine held if it ran a listed release.
The fair view
The numbers are small. 240 records out of 14,481 is a niche, and many developers will never meet one of these packages. Some of the most-cited cases, like the June squats, were research beacons that collected hostnames, not credential stealers. And the strongest fix for stolen tokens, trusted publishing, is something maintainers such as Cline's have already adopted.
But security programs for AI tools are being built around the model: what it reads, what it's allowed to say, which tools it can call. The 2026 records point at the step before any of that, the command that fetches and runs a server. It's the least interesting part of an agent and the one most often left unpinned.