Home / Articles

Each MCP Server Is One More Secret Your Agent Can Leak

Every MCP server is one more live secret and widens what a poisoned input can reach. Audit the token scopes and apply four fixes that shrink the blast radius.

Publisher: REACTAPP.TOP4 min read950 words

This article is published in English.

Sources: Every MCP Server You Add Gives Your Agent Another Credential

It starts innocently. GitHub gets connected on Monday, Slack on Tuesday, then Postgres, then Notion, then an internal billing server a colleague wrote in an afternoon and never documented. Each addition makes the agent more capable. Each one also adds a key to the ring it carries around.

The trouble tends to surface by accident, while someone is debugging something unrelated and opens the config file. Six servers, six live secrets, all in plain text in a home directory, with no expiry and no owner. At that point MCP stops looking like handy tooling and starts looking like a liability.

The arithmetic

After a couple of months of enthusiasm, a typical setup looks like this:

{
  "mcpServers": {
    "gh": { "cmd": "gh-mcp",  "env": { "GH_TOKEN": "ghp_live_9f2a" } },
    "db": { "cmd": "pg-mcp",  "env": { "PG_URL": "postgres://app:hunter2@prod/main" } },
    "sl": { "cmd": "slk-mcp", "env": { "SLACK_TOKEN": "xoxb-live-771" } }
  }
}

Three servers, three secrets, one file and no rotation. You can check your own machine in seconds by searching the usual config folders for strings that look like tokens.

grep -rIl "ghp_\|xoxb-\|sk-" ~/.config ~/.claude ~/Library 2>/dev/null | wc -l

People who run it often find more than they expected. A count of eleven when you assumed three is not unusual.

The part that actually hurts

Credentials sitting on disk are the dull half of the problem. The sharper half is that every one of those keys now sits behind a single decision maker, and that decision maker reads text from the internet.

                    one agent loop
                          |
      +---------+---------+---------+---------+
      |         |         |         |         |
    github    slack   postgres   notion    billing
   (14 scp)  (9 scp)  (rw all)  (rw ws)   (rw all)
      |         |         |         |         |
      +---------+---------+---------+---------+
                          |
                 one poisoned input

An issue comment, a Notion page or a row in a table can each suggest an action, and the agent holds every key needed to carry it out. This is the confused deputy problem in new clothes. The agent isn't compromised. It's being helpful, and it can't tell your instruction apart from text that merely resembles one.

Measuring the gap

A useful exercise is to log every tool call for two weeks and then compare what each token is allowed to do with what it actually did. A result from one such audit:

| Service  |  Granted Scopes |         Scopes Used | Utilization |
| -------- | --------------: | ------------------: | ----------: |
| GitHub   |              14 |                   3 |       21.4% |
| Slack    |               9 |                   2 |       22.2% |
| Postgres |         Full RW | 1 table, reads only |     Minimal |
| Notion   | Whole workspace |             2 pages |     Minimal |

Roughly four fifths of the granted access went unused. That gap isn't convenience. It's blast radius you're carrying and getting nothing for.

Fixes, ranked by payoff

One token per server, scoped to that server's real job. Shared tokens mean shared blame. When something goes wrong, the audit log should point at a server and not at a person.

Read-only by default for anything holding data. Two lines of SQL, a dedicated role and select rights on the tables the agent truly reads, change the worst case from a dropped table to a leaked query.

create role agent login password 'rotate_me';
grant usage on schema public to agent;
grant select on orders, users to agent;

Short-lived tokens in place of permanent ones. Have a vault issue a credential on demand with a lifetime measured in minutes. A secret stolen on Tuesday morning is worthless by Tuesday afternoon.

async function tok(srv) {
  const r = await fetch(`${VAULT}/issue/${srv}`, { method: "POST" });
  const { token, ttl } = await r.json();
  return { token, exp: Date.now() + ttl * 1000 };
}

Logging the calls, not only the chat. A thin wrapper that records the server, the arguments and a timestamp around every tool function is a few lines of code. In the audit mentioned above, it exposed a server calling an endpoint nobody had asked it to touch.

function wrap(name, fn) {
  return async (args) => {
    log({ srv: name, args, at: Date.now() });
    return fn(args);
  };
}

A rule to adopt

Before a server goes in, picture it hostile and ask what it could get to without any help. When the honest answer is uncomfortable, narrow the token or leave the server out. Don't promise to fix it later, because later rarely arrives, least of all on a Friday.

None of this is an argument against MCP. It solved the integration mess that made agents feel like toys. The point is that each server you add also adds a credential, and credentials deserve the same care you'd give any other key to production.