Your MCP configuration is part of your security boundary
Everyone reviewing a pull request knows to slow down at auth.ts. Nobody slows down at .mcp.json, and .mcp.json is where the permissions actually are.
The diff nobody reads
{
"mcpServers": {
"docs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem@2025.8.21", "./docs"]
+ },
+ "db": {
+ "command": "npx",
+ "args": ["-y", "mcp-postgres-server"],
+ "env": { "DATABASE_URL": "${env:PROD_DATABASE_URL}" }
}
}
}
Six added lines. No code. No dependency change that a scanner would flag: the token is referenced rather than hardcoded, which is the recommended practice.
What actually changed:
- The agent can now run arbitrary SQL.
- Against production.
- Which means it can read every table, write every table, and drop every table.
- And on several engines, reach the filesystem and the network from inside the database.
Every reviewer who looked at that diff saw "added a database server". The change is a privilege grant with the review process of a configuration tweak, and that mismatch is the single most reliable way agents acquire dangerous permissions.
What each field grants
An MCP server entry is small. Every field in it is a security decision.
{
"mcpServers": {
"example": {
"command": "npx", // what code runs
"args": ["-y", "pkg", "/path"], // what it runs on, and how it is found
"env": { "TOKEN": "${env:TOKEN}" }, // what authority it acts with
"url": "https://mcp.example/v1", // what it talks to, and how
"headers": { "Authorization": "…" } // how it proves who it is
}
}
}
Commands and arguments
command and args are a program and its argv. Reviewing them means asking
what that program is.
A shell wrapper is a red flag.
{ "command": "bash", "args": ["-c", "source ./.env && node ./tools/server.js"] }
Two problems compound. The actual program is now a string inside a config file,
so nothing in your toolchain reviews it as code: no linter, no dependency
scanner, no code owner. And a shell re-interprets it: variables expand,
backticks and $() run, globs match, ; chains further commands. A value that
reaches that string from anywhere else becomes command injection.
A shell wrapper is also a strong signal that something has been moved out of review, deliberately or not. GATE006.
A remote fetch is worse.
{
"command": "sh",
"args": ["-c", "curl -fsSL https://tools.example/mcp.sh | sh"]
}
Whatever that host serves at agent start is what runs, with your credentials behind it. No signature, no version, no record of what previously ran. GATE021.
A credential in argv is visible to the machine. Arguments are readable from
/proc and ps on Unix and through WMI on Windows, and they are captured by
process monitors, crash reporters and container runtimes.
GATE002.
Environment variables
env is where the authority lives, and it is the field that most deserves the
question "what can this actually do?"
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}" }
That is correct practice: the value is referenced, not embedded. It still tells
you nothing about scope. GITHUB_TOKEN might be a fine-grained token for one
repository or a classic PAT with repo across your entire organisation, and the
diff looks identical either way.
Review the credential, not the reference. The line to ask about is not in the file.
Three specific things to look for:
- A literal value. Rotate it. It is in git history now, and history is forever. GATE001.
- Production indicators.
PROD_,_PRODUCTION,sk_live_. Every other finding gets worse when this is present. GATE014. - Too many credentials on one server. Three or more means a single
compromise of that server yields all of them. An
.envfile passed wholesale means every secret the application has. GATE020.
Remote URLs
{ "type": "http", "url": "http://mcp.vendor.example/v1" }
http rather than https means the bearer token, every tool argument and every
tool result cross the network in the clear. The result is the underrated part:
an attacker who can rewrite tool results has a direct prompt-injection channel
into the agent. GATE003.
{ "url": "https://sk_live_51H8xY2eZvKYlo@mcp.vendor.example/v1" }
Credentials in a URL are credentials you have published to your entire request
path. Proxy logs, CDN logs, Referer headers, error reports.
GATE004.
And a remote server with no visible authentication is worth a question: if it does not authenticate you, it probably does not authenticate itself to you either, so you have no assurance the server answering is the one you configured. GATE017.
Filesystem scopes
The highest-leverage line in most configurations is a single path argument.
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me"]
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/code/project"]
The first grants the agent your SSH keys, your cloud credentials, your kubeconfig, your npm publish token, every other client's code, and every document you have. The second grants it the project.
One argument. See filesystem security. GATE008, GATE009.
Packages
{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
No version. This resolves and executes whatever the registry serves at agent start, with no lockfile and no review, inside a process holding your GitHub token. A compromised maintainer account takes effect on the next restart.
Pin it. GATE007.
{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github@2025.4.8"]
}
Better still, add the server as a project dependency so it is covered by your lockfile, your dependency scanner and your existing review process.
Settings that are not servers
Server definitions are not the only security-relevant configuration.
.claude/settings.json
{
"enableAllProjectMcpServers": true,
"permissions": { "allow": ["Bash(*)"] }
}
.vscode/settings.json
{ "chat.tools.autoApprove": true }
These do not add a capability. They remove the last control standing between a bad decision and its consequences, for every tool at once, including the ones added later. They are also frequently committed by accident: switched on to get through a tedious session, then landing in a shared file forever. GATE019.
Put it in source control, then scan it
Some teams respond to all of this by keeping agent configuration out of git. That is exactly backwards.
Untracked configuration grants real capability and receives no review. Nobody
sees it in a pull request, nobody notices when it changes, and git log cannot
tell you when the agent gained a shell. It also differs silently between
machines, so "it works on my laptop" becomes a security statement.
Commit shared agent configuration. Keep genuinely personal settings in the documented local-override file and git-ignore that explicitly. GATE022.
Then scan it, in CI, on every pull request:
npx @usegate/cli scan
What CI should say
The useful output is the difference, not the total:
This change increases the agent's blast radius.
NEW capabilities: execute, delete
+ db: PostgreSQL server; can execute arbitrary commands or queries
Baseline blast radius was MODERATE, recorded 2026-08-01.
That is a sentence a reviewer acts on. It is specific, it points at the six lines that caused it, and it reframes the diff as what it actually is, not "added a database server", but "granted the agent the ability to drop production tables".
Record a baseline, turn on fail-on-new-only, and the
configuration file stops being the part of the diff nobody reads.