Overview

An MCP server runs with credentials. It has access to APIs, databases, and filesystems that the model would not otherwise reach. Every tool call is an opportunity for a buggy agent loop, a malicious prompt injection, or a misconfigured allowlist to cause real harm. The rules on this page treat the MCP server as an API gateway: authenticate at the boundary, redact what leaves it, rate-limit what enters, and keep the blast radius small.

Authenticate at the transport, not inside tool logic

Auth belongs at the connection layer, not scattered through individual tool handlers. For stdio transports, the server process inherits environment variables from the parent. Pass credentials as environment variables resolved by the client at startup. For HTTP transports, require a bearer token or mutual TLS on every request before any method is dispatched.

{
  "mcpServers": {
    "github": {
      "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
    }
  }
}

Centralizing auth means a single misconfiguration is visible in one place. Distributing it through tool handlers means each handler becomes an auth bypass opportunity.

Never embed credentials as literal values in settings.json or in tool schema descriptions. Literal values appear in diffs, logs, and PR reviews. See claude-code-mcp for the env-substitution pattern.

Redact secrets and PII from tool outputs before they reach the model

Tool outputs flow into the model’s context window and potentially into logs. An API response that contains a token, email address, or private key will be quoted verbatim in the conversation unless the server strips it.

Apply a redaction pass to every tool response. Common targets: API keys matching sk-* or gh*, email addresses, OAuth tokens, private key blocks, internal IPs, and UUIDs that serve as capability tokens.

import re
SECRET_PATTERN = re.compile(r'(sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{36,})')
def redact(text: str) -> str:
    return SECRET_PATTERN.sub('[REDACTED]', text)

Redact at the server boundary, not in the client. The server is the only place that has full knowledge of what its responses may contain.

Apply per-session rate limits on every tool

A loop in the agent’s planning code can call a tool thousands of times in minutes. Without rate limits, this exhausts API quota, incurs cost, and may trigger bans from third-party services.

Set hard rate limits per tool, per session. Useful defaults: 60 calls per minute per tool, 500 calls per session per tool. Expose the limits in the tool description so the model can plan accordingly.

Implement a circuit breaker: after N consecutive errors from a tool, disable it for the session and return a structured error telling the agent to stop retrying. See mcp-logging for the error logging pattern that feeds the circuit breaker.

Constrain the filesystem surface to explicit allowlisted paths

A filesystem-capable MCP server that can read or write anywhere on the host is a critical vulnerability. A prompt injection in a document the agent processes could instruct the server to exfiltrate ~/.ssh/id_rsa or overwrite a system file.

Enforce a path allowlist at the server level:

ALLOWED_ROOTS = [Path("/home/user/projects"), Path("/tmp/agent-workspace")]
def check_path(p: Path) -> None:
    resolved = p.resolve()
    if not any(resolved.is_relative_to(root) for root in ALLOWED_ROOTS):
        raise PermissionError(f"Path outside allowlist: {p}")

Resolve symlinks before checking. A symlink inside the allowed root that points outside it is an escape vector.

Never execute arbitrary strings passed from the model

Tool inputs originate from a language model. That model can be manipulated by prompt injection in data it reads. A tool that passes a model-supplied string to eval(), exec(), subprocess.run(shell=True), or any equivalent is a remote code execution vulnerability.

Design tools to accept structured parameters only. If a tool must run a command, use an allowlist of permitted commands and reject anything outside it. Parameterize the command; never interpolate user-controlled strings into a shell command.

ALLOWED_COMMANDS = {"npm test", "npm run build", "pytest"}
def run_command(cmd: str) -> str:
    if cmd not in ALLOWED_COMMANDS:
        raise ValueError(f"Command not allowed: {cmd}")
    return subprocess.run(cmd.split(), capture_output=True, text=True).stdout