Your MCP config is a plaintext secrets file
Security
Most MCP servers are configured by pasting an API key into a JSON file. The README says to, the client passes it through, and it works:
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxxxxxxxxxx"
}
}
}
}
That file — claude_desktop_config.json for Claude Desktop, .mcp.json in a
Claude Code project, ~/.cursor/mcp.json for Cursor, mcp.json in VS Code —
is now a secrets file. It just has none of the habits that grew up around
.env.
Why it's worse than a .env file
Some of these files are meant to be committed. Claude Code's
project-scoped .mcp.json exists so a team can share server config through
version control — that's the feature. A .env has a decade of never-commit
muscle memory behind it and a line in every gitignore template. MCP config
files have neither, and the same file that's supposed to be shared is the one
the README told you to put a token in.
Your agent reads and edits this file. Ask Claude Code or Cursor to "add the Postgres MCP server" and the agent opens the config — with every other server's key in it — straight into its context. From there the keys are in the transcript, and a credential in an agent's context is a credential that can be talked out of it.
Keys accumulate. Each server brings its own: a GitHub token, a Slack token, a database URL, a search API key. Six months in, the file is a one-stop credential dump for anything that can read your home directory — and it's duplicated per client, because Claude Desktop, Cursor, and VS Code each keep their own copy.
It travels. Dotfiles repos, editor settings sync, laptop backups, "can you send me your working config?" in Slack. The file moves the way config moves, and it carries the keys with it.
Environment variable expansion is only half a fix
Some clients can read values out of the environment instead — Claude Code
expands ${GITHUB_TOKEN} inside .mcp.json env blocks, and VS Code can
prompt for inputs per session. Use that where it exists: it gets the literal
value out of the shared file.
But it usually just moves the plaintext into ~/.zshrc or ~/.bashrc,
which is a different plaintext file with the same problems, plus a new one:
every process you start can now read every key. And session prompts don't
scale past a couple of servers, which is how the value ends up pasted back
into the JSON.
Make the config name credentials instead of holding them
Wrap the server's command with seekrit run. The
config now says which environment the server gets; the values are stored
encrypted, decrypted locally at spawn, and injected into that one server's
process:
{
"mcpServers": {
"github": {
"command": "seekrit",
"args": [
"run", "--app", "agent-tools", "--env", "development", "--",
"npx", "-y", "@modelcontextprotocol/server-github"
]
}
}
}
This file is safe to commit and safe for the agent to read. There is no value in it to leak — it names an app and an environment, and resolving those takes a credential the file doesn't contain.
Setting it up is three commands:
npm install -g @seekrit/cli
seekrit secrets set GITHUB_PERSONAL_ACCESS_TOKEN --app agent-tools --env development
seekrit token create --name mcp-servers --app agent-tools --env development
The token from that last command is how seekrit run authenticates. Keep
it in your shell environment (export SEEKRIT_TOKEN=skt_…), or pass it via
${SEEKRIT_TOKEN} expansion where your client supports it.
"You've just moved the secret"
Partly — but to a much better place. Worst case, you put the seekrit token itself in the config — you've replaced every provider key in the file with one credential that is:
- scoped to a single environment you created for MCP servers, not your production secrets;
- revocable in one click, without rotating anything upstream;
- audited — every resolve is logged by name, so you can see exactly when and from where the environment was read;
- stable across rotation — rotate the GitHub token in seekrit and every client picks it up on next spawn, with no config edits on any machine.
A ghp_ token in a JSON file has none of those properties. And values are
encrypted in your browser or CLI before they're stored, so the service
holding them can't read them either.
When the agent is handed a key mid-session
The other way keys end up in MCP configs is that a provider issues one
while an agent is working, and the agent needs somewhere to put it. Writing
it into config is the path of least resistance — so give the agent a
better one. seekrit's own MCP servers let an agent store a
pasted key as ciphertext (set_secret), then use it without ever reading
it back (run_command injects into a child process and returns only the
output). One plugin install wires up both servers plus the skills that
teach this habit:
npx plugins add seekritdev/agent-plugin
What this doesn't cover
The MCP server process itself still holds the real value — it has to, to call GitHub. What changed is that the value no longer sits in a file that gets committed, synced, and read into transcripts, and no longer sits in your agent's own environment. If the workload is what you don't trust — an autonomous agent with a shell, code you didn't write — you want the value out of the process entirely: a credential broker that holds placeholders on one side and substitutes real keys at the network boundary.
Checklist
grep -r "sk-\|ghp_\|xoxb-" ~/.cursor/mcp.json .mcp.json(and yourclaude_desktop_config.json) — find what's already there.- Move the values into an environment:
seekrit secrets set …(or paste a whole.env— the importer takes it). - Rewrite each server entry to
seekrit run -- <original command>. - Rotate every key that was sitting in the file. A committed or synced credential is compromised even after you delete it.