On September 17, 2025, version 1.0.16 of an npm package called postmark-mcp added one line. From then on, every email sent through the server was copied by BCC to an outside address. The package had been uploaded two days earlier as a replica of a legitimate Postmark Labs library, Postmark said it had no involvement, and it was downloaded 1,643 times before the publisher deleted it [2]. The line needed no exploit. It used exactly the authority its owners had handed the tool.
Most teams still treat MCP servers as configuration, not as dependencies. That is the mistake. A server reaches a company as a JSON block an engineer pasted into a desktop client or IDE, so nobody filed a ticket and nothing landed in a lockfile. Yet it is the package ecosystem you already worry about, with two differences: the code starts on a developer's machine or agent host with no build step, and once running it holds the keys and data access you gave the agent. Scanning against malicious-package feeds is worth doing, but it is a lagging signal. Knowing exactly which version runs where, and limiting what each server can touch, is the control that works on the day of the compromise.
A launch line is an install that repeats on every restart
The typical install instruction is a command, arguments and an environment block. The command is often a package runner: npx -y some-server for npm, uvx some-server for Python. The -y flag skips the confirmation prompt, and with no version in the argument the runner resolves the newest release at launch. The environment block holds the server's API key, often in plain text.
Read as an operator, that entry says: every time this agent starts, fetch the latest code published under this name, run it with my user's privileges, and hand it this credential. Two runs of the same agent on the same day can execute different code, and nothing records which version ran.
A README snippet becomes an unpinned launch command, the registry serves whatever is newest, the code runs with the user's privileges and holds the keys from the environment block. Each step has a failure seen in 2025 above it and the control that removes it below.
The public 2025 cases fall into five groups. They need different controls, which is why no single check covers MCP supply chain risk.
| Path | Public example | What changed | Control that would have held |
|---|---|---|---|
| Lookalike or impersonated package | postmark-mcp on npm, malicious from version 1.0.16 [1][2] | A package named after a vendor's product, not published by the vendor, added a hidden BCC to every email | Registry and namespace allowlist, exact version pin, malicious-package check |
| Stolen maintainer credentials | Shai-Hulud npm worm, over 500 packages [3] | Legitimate package names published new versions with credential-stealing code | Exact version pins, lockfiles, delayed adoption of new releases |
| Stolen publish token | nx malicious releases, August 26, 2025 [4] | An attacker used a token taken from CI to publish trojaned versions of a popular build tool | Pinning, plus never launching tools at @latest |
| Compromised hosting or registry platform | Smithery hosted MCP servers, June 2025 [5] | An over-scoped platform token could have reached more than 3,000 hosted apps | Prefer servers you build or host; scoped credentials per server |
| Honest vulnerability in a reputable server | Filesystem reference server path check, fixed in 2025.7.1 [6] | No attacker changed the code; the code had a boundary bug | Version inventory and advisory monitoring, least-privilege file access |
Eight checks, and why each one is on the list
1. Inventory every MCP configuration, with the version that actually ran
Search repositories, dotfiles and managed device profiles for client files such as claude_desktop_config.json, .cursor/mcp.json and .vscode/mcp.json, and for mcpServers blocks in any JSON or YAML. For each server, record the package or image, the version actually resolved, the machine or agent that runs it, and the credentials in its environment.
Why: picture an illustrative sales-operations agent whose email server was added from a README with npx -y and a full-access key, and which restarts nightly. If a poisoned release lands on a Tuesday, the agent runs it from Wednesday, every message is silently copied out, and the provider's dashboard shows normal sends from the right account. The review then needs three answers: which machines ran the package, which version on which day, and which credentials it held. Without an inventory, none comes quickly.
Test: pick one production MCP server and time how long it takes to say which version ran where last Tuesday. More than an hour means the inventory is not done.
2. Pin exact versions, and lock what the pin resolves
Replace bare package names with exact versions in every launch line, and use image digests rather than tags for containers. A pinned top-level version still resolves its own dependencies by range unless the package ships a shrinkwrap, so for servers you depend on heavily, install them into a small project with a lockfile and launch the installed binary.
Why: in September 2025 CISA warned of Shai-Hulud, a self-replicating worm that had compromised over 500 npm packages [3]. It harvested GitHub personal access tokens and cloud keys, authenticated to npm as the compromised developer, and published infected versions of that developer's other packages. A trusted name is only as good as the credentials that can publish under it. CISA's first recommendation was to pin dependencies to releases from before September 16, 2025 [3]. Pinning does not stop an upstream compromise. It moves your exposure from whenever the attacker publishes to whenever you choose to upgrade.
3. Treat a missing version or @latest as a policy violation
Flag a missing version, a latest tag or a floating range in any MCP launch line, in code review and in configuration scans.
Why: malicious versions of nx and several plugins were published on August 26, 2025, between 6:32 PM and 8:37 PM EDT, and removed by 10:44 PM [4]. The maintainers concluded the npm token was likely stolen through a GitHub Actions workflow that interpolated a pull request title into a shell step, and the payload scanned the file system for credentials and posted them to a repository in the victim's own GitHub account. The detail that matters for MCP: the Nx Console editor extension ran nx@latest to check the installed version, so opening the editor during those hours could run the payload [4]. An unpinned npx -y line has the same property. If a release is poisoned for two hours, every agent that restarts in those two hours runs it.
Test: add an entry with npx -y and no version to a test repository and confirm the scan or review flags it.
4. Allow named publishers, and know what registry verification proves
Allow only named packages from named publishers or verified registry namespaces, mirror approved versions into an internal registry where you can, and treat a server whose name resembles a vendor's but is not published by that vendor as a finding.
Why: postmark-mcp worked because it carried a vendor's product name without the vendor behind it [2]. The official MCP Registry, still in preview, helps here: a name such as io.github.username/server can only be published by the owner of that GitHub account or domain [7]. That answers who published an entry. It does not answer what the code does. The registry delegates security scanning to the underlying package registries and to downstream aggregators, and expects host applications to consume curated downstream registries rather than the official one directly [7]. Namespace verification stops a lookalike. It does nothing against a stolen token publishing under the real name.
Test: add a server whose name resembles an approved vendor's but comes from a different publisher, and confirm the allowlist rejects it.
5. Check launch packages against malicious-package data, and expect it to lag
Query OSV for each pinned package and version when it is added, when it is bumped, and daily, covering GHSA advisories and MAL entries from the OpenSSF malicious-packages feed [1].
Why: the OSV record for postmark-mcp, MAL-2025-47604, lists every version from 1.0.16 onward as affected, with no fixed version [1]. It is dated September 26, nine days after the malicious release. For those nine days, the only controls that helped were a pin to an earlier version or never installing a package the vendor did not publish.
The package appeared on September 15, 2025, turned malicious with version 1.0.16 on September 17, and gained an OSV malicious-package entry on September 26. Checks that read the feed only help from the last date; a pin to an earlier version helps throughout.
Test: in a test environment, pin a package version with a known OSV entry and confirm the launch-time check blocks it and records why.
6. Give each server its own least-privilege credential
Create a dedicated key per server with the narrowest scope the provider offers, keep cloud and source-control credentials out of MCP environment blocks entirely, and never reuse a key across integrations.
Why: the postmark-mcp server sat on the path of every message the agent sent, holding the email provider's key. A key scoped to sending from one domain and used by nothing else limits what a compromised server holds and makes rotation a matter of minutes. Shai-Hulud harvested GitHub tokens and cloud keys, and CISA told developers to rotate their credentials [3].
Test: time how long it takes to rotate one production server's credential.
7. Watch advisories for the versions you pinned
Subscribe to advisories for every pinned version and move to fixed releases deliberately and quickly.
Why: not every failure needs an attacker upstream. The filesystem server in the MCP project's own reference repository accepted a path whose prefix matched an allowed directory even when it pointed outside it [6]. GitHub's advisory, credited to Elad Beber of Cymulate, lists versions through 0.6.2 and from 2025.1.14 up to 2025.7.1 as affected, with fixes in 0.6.3 and 2025.7.1 [6]. A team that pinned an old version and never revisited it kept the bug. This is the trade-off in pinning, and the answer is not to float versions again. The goal is known versions, not frozen ones.
8. Adopt releases deliberately, and host sensitive servers yourself
Wait a short, fixed period before taking a new version, read the changelog and diff for servers that touch email, payments, source code or credentials, and bump through a pull request so the change has an owner. For anything holding production credentials, prefer a small server you maintain, or a vetted vendor-published one run in your own environment.
Why: hosted platforms remove some risks and add a party to trust. In June 2025 GitGuardian found that Smithery's build pipeline accepted any value for a server's dockerBuildPath, including paths outside the repository [5]. Pointing it at the parent directory exposed the builder's home directory and an API token meant for the container registry. The token was over-privileged: it reached the platform's machine API for more than 3,000 apps, most of them hosted MCP servers, and GitGuardian showed it could run commands on hosted servers and capture traffic, including a client's API key. Smithery fixed the flaw and rotated the token within two days, and GitGuardian reports no evidence of exploitation [5].
The gap a gateway cannot close
Intertrace Discovery finds MCP configurations and committed keys in GitHub repositories, storing keys only as a masked form and fingerprint, and checks launch packages against OSV, including the OpenSSF malicious-packages feed. The first call to an unapproved MCP server asks a person once, and approved servers have their tool lists pinned so a changed server is held. But a gateway sees the agent's tool calls, not the request a server makes to its own upstream API. A hidden BCC would show up only in the email provider's logs. There, pins, publisher allowlists and scoped keys are the controls, which is why check 1 is Monday's job.