Corelight Bright Ideas Blog: NDR & Threat Hunting Blog

The Supply-Chain Risk Hiding in Your MCP Servers | Corelight

Written by James Lagermann, Director of Technical Alliances | Nov 10, 2026, 11:19:03 PM

In my last post I used an AI agent to work out the blast radius of a compromise, and the agent found something I had not been looking for: other AI agents inside the blast radius. On an administrator's workstation, AI coding assistants were running Model Context Protocol (MCP) servers that the administrator had set up, and those servers held privileged credentials for security tools and other administrative resources. Nothing on that workstation was compromised, but it raised a question: where does the code in those servers come from, and how does it get updated?

Here's what we (me and the agent) found. One of those MCP servers was configured to run one specific release, built from one specific commit. Over five weeks it ran with 15 different sets of dependencies. Nobody had changed its configuration. The way it was launched resolved its dependencies again from the Python Package Index (PyPI) whenever it rebuilt its environment, so 23 of its 69 packages changed versions underneath a release that looked pinned.

That's the main point of this post: pinning a package's version doesn't necessarily pin the packages it depends on. And this is an issue that isn't only specific to MCP. Any software installed or launched this way behaves in the same fashion. It matters more for MCP servers because of what they're given. An MCP server acts for a user, so it's often set up with that user's credentials and, as this workstation showed, an administrator's credentials reach a lot.

This post shows:

  • What we found and why our findings originated from how the servers were launched rather than from the servers themselves.
  • How to find hosts with this practice using Corelight network evidence, and how endpoint detection and response (EDR) telemetry confirms what's behind it.
  • How we fixed it, and how we checked the fix.

What we found: same commit, different dependencies

The workstation's AI coding assistants started their MCP servers with a one-step launcher, uvx, which downloads a Python package and runs it from a single line of configuration. One of them, a community MCP server for the network gateway, was configured like this:

 

The others named an exact PyPI version (uvx --from <package>==<version> <package>) or an exact Git commit. Either way, each one looked like the content would not change.

The community server's author had done the hard part. The project pinned its seven direct dependencies exactly, and it published a hash-pinned lock file. But the launcher was installed from the package's declared dependencies, not from the lock file, so the roughly 60 packages underneath were resolved fresh from PyPI, within whatever version ranges the dependency tree allowed.

The launcher didn’t download everything with every launch. It kept a cached environment and reused it when nothing had changed. Here's what we saw:

  • Every launch contacted GitHub. With a Git tag as the source, the launcher checked the tag each time it started the server, to see whether it was still pointing to the same commit. The assistant started the server 162 times in one week. Most of those launches only checked the tag and reused the cached environment.
  • Some launches rebuilt the environment. When the launcher's check of PyPI found newer releases within the allowed ranges, it built a new environment with them. The launcher's cache held 19 builds of the same release and commit, made over five weeks; one every couple of days.
  • Those builds weren't the same. The 19 builds produced 15 distinct environments. Twenty-three of the 69 packages changed versions along the way, including the cryptography library, the JSON Web Token library (four versions), and the MCP software development kit itself.
  • We saw it happen. During our test, an ordinary launch built a new environment that included two dependencies released since the previous day's build. Nobody had asked for an update.

It wasn't one server. Every MCP server which was launched in the same way on that workstation showed the same pattern, whether it was pinned to a release tag, an exact PyPI version, or an exact Git commit. One of the servers that was pinned to a commit had 19 builds and 17 distinct environments, and the MCP framework it was built on was running two major versions ahead of the version in the project's own lock file. In fact, one security vendor's official MCP server, added later with broad read and write access to its platform, declared its direct dependencies with no upper version limit.

It isn't limited to Python, either. Several vendors offer hosted MCP servers, and AI clients that can't add an authentication header themselves connect through a small open-source proxy that runs on the user's machine and attaches the user's API token to each request. In several vendors' official setup instructions we reviewed, that proxy is started with npm's one-step runner and a "latest" tag (npx <proxy>@latest <url>), so the client asks the npm registry for the newest version each time it starts. At one point, the proxy released six versions in one recent month, four of them within 14 hours.

In an example from another vendor, the setup also turned off TLS certificate checking for the whole proxy process. In 2025, researchers found a critical command-injection flaw in a widely used MCP proxy (CVSS 9.6) that a malicious or intercepted server could trigger on the user's machine (JFrog). Following "latest" got the fix to users quickly. However, it would deliver a hijacked release just as quickly.

None of this is a flaw in MCP, and MCP servers don't have to run this way. Each case was a choice in the setup instructions or the configuration, and some of the servers we reviewed avoid it entirely (see the note for maintainers below). Of the 35 MCP servers we reviewed, from two vendor catalogs and our own lab, 23 had a recommended install that would have resolved dependencies fresh from a public registry, either with every launch or with each install or image build.

Why it matters

A software supply-chain attack doesn't break into your systems directly. It compromises something you already install, and lets your own update process deliver it.

Software that fetches updates and holds credentials is normal. Your EDR agent, your browser, and your operating system all do both. What makes that acceptable isn't a signature by itself. It's that the update comes from a controlled build, is reviewed and tested before release, and you can see which version is running. An unfrozen environment has none of that:

  A managed software update An MCP server through a one-step launcher
Who can change what runs A publisher, through a build and release process you've chosen to trust The maintainers of any package in the dependency tree. In our example, about 70 packages, each with its own accounts and release process.
How changes are controlled Built from a defined set of inputs, then reviewed and tested before release Whatever the registry serves, within the allowed version ranges, when the environment is rebuilt
When it changes When the publisher releases, often in stages When a rebuild happens. In our example, every couple of days, during ordinary assistant sessions.
How you'd know A version number and release notes You wouldn't. The MCP server's own version number doesn't change.
What new code can reach What the software was designed to do Everything the MCP server's credentials can reach

 

Managed updates aren't immune. Signed updates are compromised as soon as attackers get into the build process itself. For example, the SolarWinds vulnerability in 2020 involved a trojanized update that was signed with the company's own certificate (CISA). Then in 2023 3CX happened because of a signed installer from another software vendor (Mandiant). The difference between these examples and the ones I’m diving into in this post is that a controlled build has defined inputs and someone accountable for each change; but an environment that re-resolves its dependencies at launch has neither.

There are two properties of MCP servers that make this noteworthy:

Every dependency runs with the MCP server's access. When an MCP server starts, it loads its dependencies into the same process. Code in any of them, including packages that no one chose deliberately, runs with whatever the server has: the API tokens in its environment, the user's files, and the network paths from that workstation. A malicious package doesn't have to be called. Some of the payloads described below ran as soon as Python started or the package loaded.

MCP servers are started automatically. The administrator adds the configuration line once. After that, the assistant starts the server at the beginning of each session, so the launcher runs far more often than anyone realizes, and no one runs an install command.

Packages do get hijacked. Attackers target whichever dependency is easiest—a maintainer account with a reused password or a phished token, a release pipeline with a weak secret, or a project handed to a new maintainer. A malicious version then looks like a normal update to anything that accepts new versions automatically. Here are some recent examples of this:

  • xz Utils, March 2024. A contributor who had spent about two years earning maintainer rights on a widely used compression library added a backdoor to two releases, aimed at SSH servers that load the library. An engineer noticed SSH logins using more CPU than normal, shortly before the releases reached mainstream Linux distributions (CVE-2024-3094, Snyk).
  • Ultralytics, December 2024. Attackers abused the GitHub Actions release workflow of this computer-vision package to publish PyPI releases that installed a cryptominer. The releases were available for about 12 hours, and a follow-up "fix" release was compromised too (Wiz, HiddenLayer).
  • Nx "s1ngularity", August 2025. Malicious versions of the Nx build system were published to the npm registry. A post-install script stole GitHub and npm tokens, SSH keys and cryptocurrency wallets, and ran the AI coding assistant command-line tools it found on the developer's machine, with their safety prompts disabled, to search for more secrets (Wiz).
  • Shai-Hulud, September 2025 to June 2026. A self-spreading worm used stolen npm tokens to publish infected versions of the packages each victim maintained, reaching at least 187 packages in its first days (Krebs on Security). In June 2026 a variant reached PyPI, including MCP-themed packages. It hid the payload in a file that runs whenever Python starts, or in a compiled extension that runs when the package loads, and the install completed normally. Its targets included cloud credentials, GitHub tokens, SSH keys, and API keys for AI coding assistants. In one wave, six malicious releases were published in under a minute and quarantined within 30 minutes (Endor Labs, SecurityWeek).

Two things about all of these examples stand out. Attackers now look for AI coding assistants and the keys they hold. And a malicious release may be available for only minutes or hours, so whether you're affected comes down to whether anything is rebuilt during that window. A launcher that rebuilds every couple of days, started automatically, has more chances to land in one.

This is why the typical mitigation advice in the community follows one of two paths: pin specific versions of dependencies and only change them periodically, or set your preferences to only download packages once they have been available for a certain amount of time, such as a few days. This gives people a chance to catch malicious packages and have them removed from the repositories before you download. Unfortunately, this automatic dependency installation behavior threatens to undermine these best practices.

Finding hosts with this practice: what the network shows and what the endpoint confirms

The evidence comes in three layers, and it helps to keep them separate. The network tells you which hosts contact code registries and whether their activity looks automated. This provides security teams with a short list of hosts to look at. The endpoint tells you which process made those connections and what started it. Only a look at what's installed (the launcher's cache, or the endpoint's file events) tells you whether the code actually changed. Contacting a registry isn't evidence of drift on its own; a cached launcher may only be checking a tag.

What the network shows

Step 1: Start with DNS. Corelight's DNS logs record each lookup and the internal host that made it, even when that host's internet traffic leaves through network address translation (NAT). We searched seven days of lookups for the domains that serve code and packages: GitHub's download domains, PyPI, and the npm registry. Our internal DNS resolvers topped the list, because they forward everyone else's lookups, so we set those forwarded queries aside. That left three hosts: a development server, an administrator's workstation and a jump host.

Step 2: Fill the gaps with TLS. Corelight doesn't just record that a connection happened. Zeek® extracts metadata from each transaction, across dozens of protocols, and ties each protocol record to the connection behind it. Corelight's SSL logs record the server name and timing of each TLS session. Because the server name is sent in the TLS handshake, the SSL logs identify the destination even when the matching DNS lookup never crosses the network: an answer cached on the host, a resolver inside a container host, or encrypted DNS. That added a fourth host, a container host that reached GitHub with no DNS lookup on the wire. One caveat: this depends on the server name being visible. Encrypted ClientHello (ECH) hides it, so as ECH adoption grows, hunting by server name will see less. In our lab, all of these sessions carried a readable server name.

The SSL logs can also help separate people from tools. Here's what we saw over 80 minutes on the administrator's workstation. These are observations from our lab, not general rules:

  A person in a browser The launcher starting an MCP server
Domains GitHub plus its supporting services: page assets, avatars, the API, telemetry and a live-update channel Only code and package hosts: GitHub, its raw-file host, PyPI, and PyPI's file host
Session length About 30 seconds, kept open for reuse; the live-update channel stayed open for 5 to 23 minutes 0.1 to 1.5 seconds
Rhythm Clusters on page loads, then API polling every one to four minutes while the tab was open Two to five sessions within about three seconds of each launch, then nothing
Data Varied with the page; up to 4 MB of assets About 24 KB for each tag check against GitHub, and more only when new package versions downloaded

 

To label each connection with the process behind it, we matched Corelight's records to the EDR used in this lab’s by source port, which our gateway preserved through NAT. We also hoped the TLS client fingerprint would identify the tool, but JA3 didn't. This client randomized its TLS handshake, so one burst produced about a hundred different JA3 hashes. JA4 handles that, and enabling it in this lab is on our list.

Step 3: Rank by what the host can reach. A developer's machine pulling code is normal. An administrator's workstation or a server doing it on a schedule nobody chose is worth a closer look, because of the credentials it holds. That put the workstation first.

What the endpoint confirms

Step 4: Attribute the connections. The EDR now had four hosts to look at instead of the whole network. For each one, we asked which process made the connections to GitHub and PyPI, using each process's network events. In the EDR records we had, Windows DNS events were attributed to the system DNS service rather than to the program that asked, so network events were the reliable way to tie a connection to a process. Three hosts had an expected explanation:

Host What fetched code Started by GitHub PyPI Verdict
Development server Corelight's content updater, plus Git, the GitHub CLI, and test builds System service and the developer 1,700 0 Expected
Jump host Corelight Fleet Manager System service 16 0 Expected
Container host A threat-intelligence connector Container 4 0 Expected
Administrator's workstation The one-step launcher, plus Git Two AI coding assistants, each starting its configured MCP servers 203 136 Investigate

 

GitHub and PyPI columns: connections seen by the EDR over one week. Browser and code-editor traffic on the workstation (607 GitHub connections) is excluded as normal.

Volume wasn't the signal: the development server pulled far more from GitHub. The workstation stood out because it was the only host installing packages from PyPI, the installs happened when an AI coding assistant started its MCP servers rather than on a service's schedule, and the processes doing it held management-plane credentials.

Step 5: Follow the process. The EDR’s process lineage showed the same chain on each launch. The AI coding assistant started the launcher, the launcher checked GitHub and sometimes PyPI, then it started the MCP server. The same telemetry showed where those servers connected next; more than 1,600 connections in a week to 11 management systems, including the firewall, the SIEM and sensor management. That's the combination worth acting on: a process whose code can change without review, holding credentials to the management plane.

Whether the code changed

The network and process records tell you where to look. To know whether the code changed, look at what was installed. Each rebuild ran the server from a new cache folder named in its command line, so counting those folders in the EDR’s process events counted rebuilds: 17 to 21 per MCP server over 30 days. The EDR's file events also recorded the version-numbered package folders the launcher created during each rebuild. We confirmed the build and version counts from the launcher's cache on disk, and the endpoint record matched.

The fix, and how we checked it

The fix is to freeze the environment once, not just pin the release, and stop rebuilding it at launch:

  1. Pin to a commit, not a tag. Tags can be moved.
  2. Install from the project's hash-pinned lock file. The author's lock targeted a different operating system, so we generated one for ours, constrained to the author's versions. The only differences were platform-specific packages.
  3. Install into a dedicated environment with hash checking enforced, then install the server itself without dependency resolution.
  4. Launch the installed server directly, not through the one-step launcher.
  5. Turn off startup update checks. Even the frozen server made one HTTPS connection to PyPI on its first launch. The EDR attributed it to the server process, and the code showed why—the MCP framework it was built on checked PyPI for a newer version of itself at startup and cached the answer over 12 hours. It read metadata, not code, but it was still internet traffic from a credential-holding process, making the network comparison harder to read. A documented setting would have turned it off.

We froze five MCP servers this way and checked three things:

  • The servers still worked. Each frozen server completed an MCP handshake and offered the same tools as before.
  • The installed versions stayed the same. Over the following two weeks, the five frozen servers were started 457 times. In the end, each server’s installed package list was identical to the list recorded on the day it was installed. This is the check that mattered most.
  • The network and endpoint records were consistent with this finding. We ran the old and new launch methods side by side:
Launch Corelight (TLS to GitHub and PyPI) EDR (process connections)
Original method A burst within seconds of each launch Git to GitHub and the launcher to PyPI, even when the cached environment was reused
Frozen install None None, once the update check was off

 

Since then, the EDR has recorded no connections to GitHub or PyPI from the frozen servers, apart from the one-time install. Quiet network logs alone don't prove a fix, though. A cached, unfrozen install can also be quiet between rebuilds. It’s important to use the network comparison alongside the version check, not instead of it.

The frozen servers were also started in about 2 seconds instead of 4 to 11.

After the freeze: who owns updates, and what the server can reach

Freezing stops the drift. It doesn't decide who updates the code, or how much that code can do.

  • Someone has to own updates. Freezing pins older code. When we froze the community server, 40 of its packages were older than what the launcher would have pulled that day, including security-sensitive ones. Pinning swaps silent drift for updates that are made on purpose, meaning a named owner needs to re-lock on a schedule, review what changed, and check the pinned versions for known vulnerabilities. Without that, the environment just ages.
  • Scope the credentials down. A frozen server still has whatever access it was given. Give each MCP server its own account or API client with the least access its tools need: read-only where the server supports it, limited to the systems it actually uses, and with tokens that expire. In our lab, one gateway server already ran in read-only mode. Others still used full-administrator gateway accounts, and the security vendor's server had read and write access across its platform. Those are the obvious next candidates to scope down.

Look for it in your own environment

If you have an AI agent connected to your Corelight data (and optionally your EDR) through an MCP, you can run this hunt yourself. Copy the prompt below, and adjust the time range and domain list to suit your environment.

Using Corelight DNS and SSL logs from the last 7 days, find the internal hosts that download code from public repositories: github.com, codeload.github.com, raw.githubusercontent.com, objects.githubusercontent.com, pypi.org, files.pythonhosted.org, and registry.npmjs.org.

  1. Identify your internal DNS resolvers first. Don't count lookups a resolver forwards on behalf of clients as the resolver's own activity, but do include any TLS sessions the resolver makes itself.
  2. For each host, list the domains it contacted, the number of lookups per day, and whether the activity comes in short bursts (several sessions within about 10 seconds).
  3. Classify each host as a developer workstation, administrator workstation, server, jump host, or unknown. Flag every host that isn't a developer workstation, and any host that didn't contact these domains in the previous 30 days.
  4. If EDR data is available, take each burst and identify the process that made the connections, its parent, and its command line. If the EDR attributes DNS lookups to a system DNS service rather than the requesting program, use the process's network connections instead. Note any chain that starts from an AI coding assistant or from a tool that downloads and runs a package in one step (such as a Python or npm package runner), and identify the MCP server or proxy it launches.
  5. For each MCP server you find, list the internal systems it connected to in the same period and what kind of access it appears to hold (for example a firewall, SIEM, or hypervisor API).
  6. List visibility gaps: hosts with lookups but no matching SSL sessions (which suggests NAT or missing sensor coverage), hosts with SSL sessions but no lookups (DNS that never crossed the network, such as cached answers or resolvers inside a container host), TLS sessions with no readable server name, and hosts without EDR coverage.

Treat registry contact as a lead, not proof that code changed. Keep what you observed separate from what you inferred, and finish with a short, prioritized list of hosts to review.

Expect developer machines in the results. What matters is everything else: servers, jump hosts and admin workstations that pull code on a schedule nobody chose, especially when the process behind it holds credentials. For each one, the follow-up is to check what's installed and whether it has changed.

A note for MCP server maintainers

Most of this is fixable at the source, and some vendors already get it right. One official MCP server we reviewed ships a hash-locked lock file, builds its container image strictly from that lock, and stops the runtime from re-syncing at startup; this is the frozen pattern described above. Another installs through its lock file from a cloned repository. The community server we looked at had done the hard part too, with exact pins and a hash-pinned lock file. What was missing was an install path that used the lock file. A short addition to an MCP server's README would close the gap:

  • Publish a hash-pinned requirements file with each release, generated from your lock file for each platform you support, and attach it to the release.
  • Make the frozen install the default example. Show a dedicated environment installed with hash checking, then the server without dependency resolution, and an assistant config that launches that program directly. Keep the one-step launcher example for quick trials, and label it that way.
  • Pin examples to a release commit or image digest, not a branch, tag or "latest".
  • Bound your dependency ranges so a major release of a dependency can't arrive unannounced.
  • Document what the server does on the network at startup, such as update checks or model downloads, and how to turn it off or pin it.
  • Document the least access the server needs, and support a read-only mode where it makes sense.
  • If clients need a local proxy to reach your hosted server, pin it. Give an exact proxy version, or a one-time install, rather than a "latest" tag. Don't tell users to disable TLS verification; show how to trust your certificate authority instead.
  • Lock container builds too. A Dockerfile that installs from loose version ranges drifts each time the image is rebuilt. Install from the lock file and pin the base image by digest.
  • Give users a setup prompt. Many people now install MCP servers by asking their AI coding assistant to do it, so tell the assistant how. For example:

Install the [server name] MCP server version [X] as a frozen build. Download the hash-pinned requirements file published with that release, create a dedicated environment in [path], install the dependencies with hash checking enforced, then install the server itself without resolving dependencies. Configure this assistant to launch the installed program directly, not through a one-step launcher, and turn off any startup update checks. Before changing my configuration, show me the installed versions and the exact config change, and tell me how to repeat this when I upgrade.

Users can paste the same prompt today for any MCP server that publishes a lock file. If a server doesn't publish one, that's worth knowing before you give it your credentials.

What to do

  • Find hosts that install code at runtime. Use DNS and TLS logs to list the hosts that contact code-hosting and package-registry domains. Expect developers. Look into servers, jump hosts, admin workstations, and anything new.
  • Check AI client configurations for one-step runners and "latest". Any MCP entry, or local proxy for a hosted MCP server, that's started by a Python or npm package runner, or tagged "latest", can resolve new code from a public registry. Pin it or freeze it.
  • Inventory credential-holding AI tooling. Find which MCP servers your AI assistants run and what each one can reach. In our example, that included the firewall, the SIEM, sensor management, hypervisor management, and other management tools.
  • Freeze MCP servers that hold credentials. Commit-pinned, hash-verified, installed once, launched directly, with update checks off.
  • Check the fix by what's installed. The installed package versions should stay the same across restarts, and the server should still work. Fewer launch-time connections in your network logs, attributed by endpoint telemetry, supports that, but isn't proof on its own.
  • Name an owner and scope the access. Someone re-locks and reviews updates on a schedule, and each server gets only the access its tools need.
  • Use JA4 for clients that randomize the TLS handshake, if it's available.

AI agents are increasingly acting as privileged users on the network. Treat each one like any other admin account: know what it can reach, what it runs, and where its code comes from.

Want to follow more insights like this? Subscribe to our newsletter in the footer of our website below.