Vetting an MCP server before you install it

· mcp, security, supply-chain, npm, llm

An npm package called postmark-mcp shipped fifteen releases that did exactly what they claimed. Version 1.0.16 added one line. Every email the server sent got blind-copied to an address the attacker controlled.

The package was a near-exact copy of Postmark's real MCP library, published by an account with no relationship to Postmark. Koi Security found it last September, and Postmark put out a notice clarifying that the thing wearing their name was not theirs. Koi's writeup puts it near 1,500 organizations. Password resets, invoices, internal mail, all of it CC'd to a stranger.

I wrote about hardening an MCP server a few days ago. That was about the server you write. This is the other side of it, and I think it's the side with more exposure, because most people run several servers they didn't write and reviewed exactly once.

Why an MCP server is worse than a library

A malicious npm library is bad. A malicious MCP server is worse, for three reasons that stack.

It holds a credential. You gave it an API key so it could do its job, and that key is the whole point of installing it. The postmark package didn't need to escalate anything or find a clever exploit. It was handed sending rights and used them.

Nobody reads the calls. When you import a library you write the call sites yourself, and you see them in review. An agent invokes MCP tools on its own, dozens of times, while you read a summary at the end. The human check that catches "why is this function being called with my whole inbox" is structurally absent.

The output gets summarized. Even if the server returns something suspicious, it goes to a model that compresses it into prose before you see it. Anomalies get smoothed out on the way to your eyes.

That combination is specific to this ecosystem and it makes the usual "read the source before you install" advice weaker than it sounds.

It isn't only the servers

CVE-2025-6514 was an OS command injection in mcp-remote, the shim a lot of clients use to reach remote servers. JFrog documented it at CVSS 9.6. Connecting to a hostile server was enough to get code running on your machine, through a crafted authorization_endpoint in the OAuth metadata. Versions 0.0.5 through 0.1.15, fixed in 0.1.16, with the package sitting north of 437,000 downloads.

Sit with that one. Merely pointing your client at a server you don't control was a full compromise of your laptop. Installing it was never required. Trying it once was enough.

So the attack surface is the servers you run, the client plumbing that reaches them, and any server you connect to even briefly.

What actually would have caught postmark

Going through the checklist against the real incident is more useful than a generic list, because several of the obvious controls would have done nothing.

Check the publisher, not the name

The package name looked right. The code looked right, because it was a copy of code that was right. The thing that was wrong was the account that published it, which had no connection to Postmark.

For anything that gets a credential, find the link from the vendor's own documentation. If the vendor doesn't publish an MCP server, the one you found is by definition unofficial, and that's a decision you should make deliberately rather than discover later. On npm, look at the publishing org and the repository field, and confirm the repo is under the org you expect.

Stars, downloads, and a clean README all pointed the wrong way here. The clone had them because the original earned them.

Pin the version, and delay updates

This is the control that would have worked, and it's one line of config.

The attack arrived in an update. Fifteen releases of good behavior existed specifically so that version sixteen would land on machines that had stopped paying attention. Anyone running a floating version got it automatically.

npm supports a cooldown:

# .npmrc
min-release-age=7

Nothing younger than a week gets installed. It costs you almost nothing, since you rarely need a package the day it ships, and it puts the entire disclosure cycle in front of you instead of behind you. Koi found the postmark backdoor inside that window. I have this set in every project that touches a real credential, and it's the highest ratio of protection to effort on this list.

Pin exact versions for anything holding a key. Review the diff when you bump.

Diff the published tarball, not the repo

GitHub and the registry are different artifacts. Reading the repository tells you what the author wrote; it does not tell you what got published.

npm pack some-mcp-server@1.0.16
tar -xzf some-mcp-server-1.0.16.tgz

Then read what's actually in there. For a package that will hold credentials, this is twenty minutes once and again on each bump. The postmark diff was a single line, and it was legible to anyone who looked.

Scope the credential down

Same principle as writing your own server. The credential you hand an MCP server is the ceiling on what a compromise can do, and it's the one control that survives every other failure.

Give each server its own key rather than a shared one, so revocation is surgical. Take read-only where the tools only read. Ask what the worst use of this key is and decide whether you'd accept that outcome, because you're accepting it either way.

In the postmark case the tool genuinely needed sending rights, so scoping alone wouldn't have saved anyone. It would have bounded the damage to one service.

Watch what leaves

The malicious line made an outbound email. Not a network callback, not a DNS beacon, nothing that looks like exfiltration to a naive filter. It used the exact capability it was supposed to have.

Egress control still helps for the broader category. Run MCP servers in containers with no host mounts and no outbound access beyond the API they exist to reach. A server that should only talk to one vendor's API has no business resolving anything else, and a default-deny egress policy turns a lot of clever exfiltration into a connection error.

Log every call

Full arguments and results, per invocation, somewhere you can query later. I keep saying this and I keep being glad I did.

Prevention isn't the point of this one. When a disclosure lands, "did this thing ever touch our customer data" should be a query rather than a guess, and the gap between an afternoon of incident response and a month of it is whether you kept the arguments.

What doesn't work

A few things people reach for that would have failed here.

Reading the source once, at install time. Version 1.0.15 was clean. Auditing on first use and never again is auditing the wrong thing.

Trusting popularity signals. Downloads and stars measure how many people made the same decision you did, without any of them checking either.

Assuming the official registry solves it. Discovery is still mostly search ranking and vibes, and the registry infrastructure that would let you verify provenance, signing, and publisher identity is arriving slower than the servers are.

Waiting for a scanner to flag it. The postmark payload was a single line using an API the package already legitimately called. There's no signature for that.

The uncomfortable part

The reason this class of attack works is that MCP made it trivially easy to hand a capability to software you didn't write, and the ergonomics are so good that the decision doesn't feel like a decision. Adding a server is a line of JSON. Granting it your production API key is the next line.

Compare that to the equivalent act ten years ago, which was giving a vendor's binary a service account and probably a meeting about it.

I don't think the answer is running fewer MCP servers. I run several and they're useful. The answer is treating each one as what it is, which is a third party with a credential and an autonomous caller, and applying the amount of process you'd apply to any other vendor in that position. For me that means a cooldown on updates, a dedicated scoped credential per server, a container that can only reach one host, and a log of every call.

None of that is hard. It's just easy to skip, and fifteen clean releases is exactly how long it takes to stop thinking about it.