ctx: prod
back to posts
$kubectl describepost/argocd-mcp-network-service

Your Argo CD MCP server is a network service holding your token

argocd-mcp is the Model Context Protocol server for Argo CD from argoproj-labs: you point an agent at it, and the agent can list applications, read logs, create Applications and trigger syncs. Version 0.8.0, released on June 11, 2026, started its HTTP transport on every network interface and let anyone who reached it open a session without a credential. Every tool call then went to Argo CD with the operator's token.

The fix is 0.9.0, out since August 11 together with advisory GHSA-rp45-5x3v-48mr. VulnCheck assigned CVE-2026-82456 on August 29, CVSS 10.0 in both 3.1 and 4.0. Upgrading takes one command. The more useful part is why it happened, because the same mistake is easy to make in any MCP server that sits next to infrastructure.

▸The 60-second version, flip through the deck9 slides · swipe →
CVE-2026-82456 · CVSS 10

The server had a token. Its callers didn't.

argocd-mcp 0.8.0 as a network service.

The attack

Four steps, no credential

Listen on every interface, initialize, create_application from the attacker's repo, sync_application with your token.

The root cause

The one line

ARGOCD_API_TOKEN authenticates the server to Argo CD. The code treated it as if it authenticated the caller.

My run

Same command, two versions

0.8.0: *:3800 and HTTP 200 with no creds. 0.9.0: 127.0.0.1, and a wider bind without a token exits 1.

By version

0.7.0 behaved the same

The advisory lists only 0.8.0. 0.7.0 also bound * and answered 200.

Read-only

16 tools, 11 left, 0 permissions removed

MCP_READ_ONLY hides tools. Only Argo CD RBAC scopes the token.

The checklist

Four questions per MCP server

Interface, inbound auth, whose token, what scope.

takeaway

The socket never heard about it

"Local, just for the agent" is how you started it. It is not a property of the listener.

Two tokens, one variable

An MCP server for Argo CD has two conversations. The agent talks to the server. The server talks to the Argo CD API. Each of those needs its own answer to "who are you?".

who should authenticate whom
agentthe caller, over HTTP or SSE
→
MCP server0.8.0 checked nothing here
→
Argo CD APIsees ARGOCD_API_TOKEN

In 0.8.0 only the second hop had a credential. This is the line the advisory points at, in src/server/transport.ts:

const argocdApiToken =
  (req.headers['x-argocd-api-token'] as string) ||
  process.env.ARGOCD_API_TOKEN ||
  '';

If the request carries no token, the server falls back to its own. The listener itself was app.listen(port) with no host argument, which in Node means every interface. There was no Host or Origin validation either, so the advisory also flags DNS rebinding: a web page in your browser could reach a listener on your laptop.

The irony is that 0.8.0's headline change, PR #122, was a credential-hardening PR. It moved the token strictly into the transport layer so it would never land in prompts or tool-call logs. That is a real improvement for the second hop. Nobody was looking at the first.

The attack, as the advisory describes it

The reporter reproduced it against the published package with a local HTTP sink instead of a real Argo CD. No cluster was touched. The chain:

from open port to cluster
An operator starts argocd-mcp http with an Argo CD token in the environment.
The listener comes up on *:3000 and asks callers for nothing.
An attacker on the network sends initialize and gets an mcp-session-id.
create_application registers an Application whose repoURL is the attacker's Git repo.
sync_application tells Argo CD to deploy it, carrying Bearer <your token>.
The default AppProject allows any source repo, any destination, any resource kind. The cluster runs their manifests.

The sink recorded exactly what Argo CD would have received:

POST /api/v1/applications
Authorization: Bearer TEST-ARGOCD-TOKEN

POST /api/v1/applications/attacker-controlled/sync
Authorization: Bearer TEST-ARGOCD-TOKEN

What I saw when I ran it

I wanted to see the bind for myself rather than trust a CWE label. Each version ran from npm on my Mac with a placeholder token and ARGOCD_BASE_URL pointing at a closed local port, so nothing could ever reach a real Argo CD. Each listener lived for a few seconds.

export ARGOCD_BASE_URL=http://127.0.0.1:9 ARGOCD_API_TOKEN=PLACEHOLDER
npx [email protected] http --port 3800 &
lsof -nP -iTCP:3800 -sTCP:LISTEN
COMMAND   PID  USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
node    35096 olala   12u  IPv6 0xf88f87c35a211587      0t0  TCP *:3800 (LISTEN)

Then an initialize with no credential of any kind:

curl -s -D - -o /dev/null http://127.0.0.1:3800/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"1"}}}'
HTTP/1.1 200 OK
mcp-session-id: 3dd1a05c-fa39-49ce-be1a-f153025ed8aa

0.9.0 with no extra settings:

node    35328 olala   12u  IPv4 0x2186f97f9d90637b      0t0  TCP 127.0.0.1:3900 (LISTEN)
{"level":30,...,"msg":"Http Stream transport listening on 127.0.0.1:3900"}

0.9.0 asked to listen on all interfaces without an inbound token refuses, with exit code 1:

$ MCP_BIND_ADDRESS=0.0.0.0 npx [email protected] http --port 3901
{"level":50,...,"msg":"Refusing to bind to 0.0.0.0 without inbound authentication. The MCP listener exposes every ArgoCD tool to anything that can reach it, and ARGOCD_API_TOKEN authenticates this server to ArgoCD, not the caller. Either set MCP_AUTH_TOKEN to require an inbound \"Authorization: Bearer <token>\" header, or pass --allow-unauthenticated if the listener is already protected by an external layer (a sidecar proxy, service mesh, or network policy). To listen locally only, set --bind-address to 127.0.0.1."}

That error message is the best one-paragraph summary of the whole CVE, and the maintainers wrote it into the binary.

Then I tried 0.7.0, because the advisory's affected range is = 0.8.0 and I wanted to know whether older pins were actually safe:

VersionListenerinitialize, no credential
0.7.0*:3811HTTP 200
0.8.0*:3800HTTP 200
0.9.0, defaults127.0.0.1:3900loopback only
0.9.0, 0.0.0.0 without MCP_AUTH_TOKENnonerefuses to start, exit 1
0.9.0, 0.0.0.0 with MCP_AUTH_TOKEN*:3902HTTP 401, Missing or invalid inbound bearer token
0.9.0, with the bearer header*:3902HTTP 200

The same env-var fallback and the bare app.listen(port) are in transport.ts at the v0.4.0, v0.5.0, v0.6.0 and v0.7.0 tags. I only ran 0.7.0 end to end (the 0.4.0 package wouldn't even start for me), and I only checked session creation, not tool calls. But "the advisory lists 0.8.0" is not the same statement as "my 0.6.0 pin is fine". Go to 0.9.0.

"Local, just for the agent" is how you started it. The socket never heard about it.

What 0.9.0 changed

the fix, in four parts
Loopback by default--bind-address / MCP_BIND_ADDRESS defaults to 127.0.0.1. A sidecar in the same pod still reaches it.bind
A real inbound tokenMCP_AUTH_TOKEN, checked as Authorization: Bearer in constant time. Separate from ARGOCD_API_TOKEN.auth
Fail closedA wider bind without a token or --allow-unauthenticated exits non-zero instead of running exposed.startup
Host and Origin checksValidated on every request, which closes the DNS rebinding path. /healthz stays open for probes.browser

One detail from the README is worth knowing before you design around it: MCP_AUTH_TOKEN is a single shared secret. Anyone holding it can reach every Argo CD instance the server knows about. If you need different callers with different reach, the maintainers' answer is separate server instances.

If you run it in Kubernetes and something outside the pod has to reach it, the release notes spell out the two settings you now need together:

-e MCP_BIND_ADDRESS=0.0.0.0 -e MCP_AUTH_TOKEN=<inbound_token>

MCP_READ_ONLY only filters the tool list

MCP_READ_ONLY=true looks like the safe switch. Here is what it does on 0.9.0, summarized from two tools/list calls over stdio:

MCP_READ_ONLY=false  16 tools
MCP_READ_ONLY=true   11 tools
hidden: create_application, update_application, delete_application,
        sync_application, run_resource_action

The server stops advertising the write tools. The token in its environment can still do everything it could before. If that token is the admin one somebody generated on day one, a read-only MCP server is one bug away from a read-write one, and this CVE was that bug.

The real fix lives in Argo CD itself: a dedicated local account, its own API key, and RBAC that matches what the agent actually needs:

# argocd-cm
data:
  accounts.mcp-agent: apiKey
---
# argocd-rbac-cm
data:
  policy.csv: |
    g, mcp-agent, role:readonly
argocd account generate-token --account mcp-agent

If the agent really must sync, give it sync on one AppProject, not *. And look at what your default project allows, because the advisory's chain works precisely because default permits any repo and any destination.

Four questions for every MCP server next to infra

This is the checklist I now run, on argocd-mcp and on everything shaped like it:

  • Interface. What does it actually bind to? Run lsof or ss -ltnp on the host or in the pod, don't read the README.
  • Inbound auth. What does a caller have to present? "Nothing, it's local" is an answer about today's network, not about the server.
  • Token owner. Whose credential sits in its environment? Yours, an admin's, or a dedicated account that exists only for this server?
  • Scope. What can that credential write, with every tool enabled? Assume the tool filter will fail one day.
  • Network. If it has to be reachable, what else stands in front: a sidecar proxy, a NetworkPolicy, a mesh policy?

In July I wrote about AI code and trust boundaries, and said that the part of my own Claude Code + MCP pipeline I trust least is the credentials sitting next to it. This CVE is the clean version of that worry, and the model had nothing to do with it. A small Node process held a powerful token and listened on the wrong address.

My position: an MCP server that holds infrastructure credentials gets the same review as any other service that holds them. Interface, auth, owner, scope. That it was started "for the agent" changes nothing.


Which MCP servers in your setup hold a token right now, and have you looked at what they bind to?