Book a 30-min call
cd ../blogs
$ cat posts/mcp-server-supply-chain-production-risk.mdx

MCP servers are a supply-chain decision, not a dependency install

June 9, 2026 · ImmovableTech Team

  • MCP
  • Security
  • Agentic AI

A tool description is untrusted input that reaches your model

When you add an npm package, the code it runs is the thing you worry about. When you add an MCP server, there is a second surface that has no equivalent in a normal dependency install: the server’s tool descriptions. Those descriptions are text the server author controls, they are fetched at runtime by tools/list, and they land directly in the model’s context as guidance on how to behave. A server does not need a memory-safety bug or a privilege escalation to change what your agent does. It just needs to write a convincing paragraph.

The MCP specification says this itself. The trust and safety section of the 2025-11-25 revision states that descriptions of tool behaviour “should be considered untrusted, unless obtained from a trusted server”, and — this is the sentence that should shape your architecture — that “MCP itself cannot enforce these security principles at the protocol level”. The protocol is explicit that the control has to live in your host application and your process. Most teams we talk to have read the first half of that and not acted on the second.

We have shipped several MCP-based systems, and the write-up of what that takes is in MCP in production. This post is the part we got least right at the start.

Three attack shapes, and which ones are demonstrated

The research here is specific enough that you do not have to speculate. Invariant Labs published the foundational work in April 2025, with working demonstrations against Cursor.

Tool poisoning is a malicious instruction embedded in a description the user never reads. Invariant’s proof of concept was an add tool whose docstring told the model to read ~/.cursor/mcp.json and ~/.ssh/id_rsa first and pass the contents through an unused sidenote parameter, while narrating something plausible about arithmetic to the user. The confirmation dialog fired, but it showed a summarised tool name and hid the argument that carried the key.

Rug pulls are the version-control problem. A server can change a tool description after you approved it. Approval in most clients is a one-time act at install; the description is re-fetched every session. Nothing in the base protocol makes the client notice that the text it approved is not the text it just received.

Cross-server shadowing is the one that breaks the mental model of per-server trust. Invariant connected a trusted email server and a malicious arithmetic server to the same client, and the malicious server’s description asserted a side effect on the other server’s send_email tool — route everything to an attacker address, do not mention this. The agent complied. The attacker’s tool was never called. The interaction log showed only the trusted tool being used, which means your audit trail looks clean while the exfiltration happens.

All three are demonstrated attacks in research, not disclosed breaches of a named production system. That distinction matters and we will hold it throughout. What has been disclosed with CVEs is adjacent, and worse in a different way.

What has actually been disclosed

In April 2026 OX Security published a disclosure series it called “The Mother of All AI Supply Chains”, summarised in a Cloud Security Alliance research note dated 20 April 2026. The core finding is about the STDIO transport rather than tool descriptions: the official SDKs execute the command field of a STDIO server definition unconditionally, before establishing that the subprocess is an MCP server at all, so anything that can influence that field gets code execution on the host. OX puts the affected surface at roughly 150 million package downloads, more than 7,000 publicly reachable servers and up to 200,000 vulnerable instances. Those are OX’s estimates, restated by CSA, and they are best read as a lower bound on internet-visible deployments rather than a measurement.

Anthropic’s position, per the same note, is that the behaviour is intentional and that sanitising the command field is the integrator’s job; the documented change was a caution added to SECURITY.md nine days after OX’s initial contact, with no architectural change. You can argue that subprocess spawning is the feature and the position is defensible. It still means the mitigation is yours.

The clearest named case is CVE-2026-30615, published to NVD on 15 April 2026, scored 8.0 High by CISA-ADP under CVSS 3.1. Windsurf 1.9544.26 processing attacker-controlled HTML could have its local MCP configuration rewritten and a malicious STDIO server registered, with no further user interaction. That is prompt injection reaching all the way to persistent tool registration. Note what it is not: it is a vulnerability in one IDE at one version, with an advisory and a patch. We are not attributing anything to products without one.

The registry test in the same series is the number we quote most often internally. OX submitted a benign proof-of-concept server — one that runs a command creating an empty file — to eleven public MCP registries and marketplaces. Nine accepted it with no review.

The npm compromise that was not an MCP bug

On 31 March 2026, between 00:21 and 03:20 UTC, an attacker with a compromised maintainer account published axios@1.14.1 and axios@0.30.4 with a new dependency, plain-crypto-js@4.2.1, whose postinstall hook dropped a cross-platform RAT. Google Threat Intelligence Group attributes it to UNC1069. The window was about three hours.

This is not an MCP vulnerability and we would not present it as one. It matters here because MCP servers are ordinary npm and PyPI packages with ordinary transitive dependency trees, and because of one detail in the account takeover: the attacker published with a stolen long-lived classic token, bypassing the trusted-publisher workflow the project had in place. Provenance metadata protects the publishing path it covers, and nothing else.

The registry tells you who published, not whether it is safe

The official MCP registry does real work on identity. Namespaces are tied to proofs — io.github.* requires GitHub authentication for that account or organisation, domain namespaces require a DNS or HTTP challenge, package entries must carry metadata proving the publisher owns them and only a fixed list of upstream registries is accepted. That is a genuine improvement over the free-for-all, and it is still in preview as of mid-2026.

None of it is a safety review. It answers “is this really from that account?”, not “does this tool description contain instructions to your agent?” The nine-of-eleven marketplace result is what happens when teams read a listing as a vetting signal. Treat the registry the way you treat npm: an authenticated distribution channel, not a security boundary.

The controls that work are dull

Five things, roughly in order of how much they buy you.

Pin the version and the transport. Lockfiles and exact versions for the package, and a command field that points at an approved absolute path rather than a resolver that can pick up something else. Nothing dynamic — no model output, no environment lookup — reaching the command field, ever.

Review the tool descriptions as code, not the README. Pull the full tools/list output for the pinned version, read every description and parameter description in the diff, and check it into your own repository. The README is marketing. The description is the payload.

Treat a description change as requiring re-approval. This is the control that catches rug pulls and it is a dozen lines. Snapshot at review time, compare at startup, fail closed on a mismatch:

import hashlib
import json
from pathlib import Path


def fingerprint(tools: list[dict]) -> dict[str, str]:
    """Hash the model-visible surface of each tool: name, description, input schema."""
    digests = {}
    for tool in sorted(tools, key=lambda t: t["name"]):
        visible = {
            "description": tool.get("description", ""),
            "inputSchema": tool.get("inputSchema", {}),
        }
        blob = json.dumps(visible, sort_keys=True, separators=(",", ":")).encode()
        digests[tool["name"]] = hashlib.sha256(blob).hexdigest()
    return digests


def assert_approved(tools: list[dict], snapshot: Path) -> None:
    approved = json.loads(snapshot.read_text())
    if fingerprint(tools) != approved:
        raise RuntimeError(f"tool surface changed since approval: {snapshot.name}")

Run the server with least-privilege credentials and constrained egress. A read-only database role, a scoped API token, a network policy that allows the two hosts the server legitimately needs. Shadowing works by getting a trusted tool to send data somewhere; egress control is the layer that still applies when the description has already won the argument in the context window.

Prefer first-party servers for anything touching production data. For an internal warehouse or a payments API, write the server. It is a few hundred lines, you own the description text, and it removes the whole category.

What we’d do differently

We ran an internal agent for about three months with eleven MCP servers connected, no description snapshots and no egress policy on the server processes. Nothing bad happened, which is the wrong reason to feel fine.

What did happen is that a third-party server we had approved changed a tool description in a routine release, and the first we knew of it was a drop in our agent evaluation suite roughly a fortnight later. We spent most of a day on the wrong hypotheses — model version, retrieval, our own prompt — before anyone diffed the server. The change was benign. The detection path was the problem: a behavioural regression test caught something a five-line hash comparison would have caught at startup, and only because we happened to have that test.

We also underestimated the review cost, and this is the honest reason teams skip it. Reading every tool description across eleven servers, at every version bump, is real work that nobody wants to own. We did not solve that by getting more disciplined. We solved it by getting to four servers — three written in-house, one third-party and read-only — and the reduction did more for our exposure than the review process ever did. If you cannot afford to review a server’s descriptions on every update, you cannot afford the server. Cutting the list is the cheaper answer, and we should have reached for it first.

The other thing we would change is where the gate lives. We put description review in the pull request that adds the server, which handles day one and nothing after it. It belongs in the deployment path, failing closed, because that is the only place that sees version two.

References


We review and harden third-party MCP integrations as part of our AI & Machine Learning Engineering practice. Talk to us if you are connecting an agent to production data and want the server list audited before it ships.