Skip to content
AI agents7 min read

API keys in MCP server configs: where they end up

MCP clients keep server settings as plain JSON, and the API key usually sits in it. Where those files live, who can read them and how to keep keys out.

Crunu teamPublished 7 min read

Example. Kiteline is a fictional company.
On this page

An MCP server's API key usually ends up in a plain JSON file. Claude Desktop, Claude Code and Cursor all configure servers in JSON, and the quickest way to give a server a key is to type it into that file's env or headers block. The file then sits in your home directory or in your repository, readable by every program that runs as you, including the coding agent that uses the server, and it travels into backups, dotfile repositories and sometimes Git. The fix has three parts: reference variables instead of writing values, give each server its own narrow key, and start local servers through a launcher that injects the key when the process starts.

The guide to secrets and AI coding agents covers the wider question of keeping secrets out of an agent's reach. This post is about one file type.

Where each client keeps its servers

ClientFileNotes
Claude Desktop~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windowsone file for all servers
Claude Code, local and user scope~/.claude.jsonlocal scope is stored "under that project's path"
Claude Code, project scope.mcp.json in the project rootmade to be committed
Cursor.cursor/mcp.json in a project, ~/.cursor/mcp.json globallysupports envFile for local servers

The paths come from each product's documentation (MCP: connect to local servers, Claude Code: MCP, Cursor: MCP).

One row deserves attention. Claude Code's documentation says of project scope: "Check .mcp.json into version control so everyone on your team gets the same MCP tools and services." That is a good way to share tools and a bad place for a literal key. Anyone who adds a server with --scope project and pastes a token into its env block has put that token into the repository.

What a typical entry looks like

A local server is a program the client starts and talks to over standard input and output. Its configuration names the command and, often, the key:

JSON
{
  "mcpServers": {
    "issues": {
      "command": "npx",
      "args": ["-y", "@example/issues-mcp-server"],
      "env": {
        "ISSUES_API_KEY": "EXAMPLE_not_a_real_key"
      }
    }
  }
}

A remote server is an address, and the key goes into a header:

JSON
{
  "mcpServers": {
    "tickets": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer EXAMPLE_not_a_real_token" }
    }
  }
}

Both are valid, and both store the value in clear text next to dozens of other settings.

Who can read that file

  • Every process that runs as you. The file has your user's permissions. A script, an editor extension or a package's install hook can open it.
  • The coding agent. An agent with a file-reading tool or a shell can read ~/.claude.json or ~/.cursor/mcp.json as easily as a .env. Whether it does depends on its permission rules, which do AI coding agents read your .env files? walks through, and on what it's been told by content it read; see prompt injection meets your environment variables.
  • Copies. Home-directory backups, a dotfiles repository, a synced settings folder or a support bundle sent to a vendor all carry the file with them.
  • Logs. Claude Desktop writes each local server's standard error to mcp-server-SERVERNAME.log. A server that prints its configuration on startup puts the key there too.

The MCP documentation is direct about the program itself: a local server "runs with your user account permissions, so it can perform any file operations you can perform manually." A server you install from a package registry is code with your key and your file system.

Example. Kiteline is a fictional company.

Step 1: reference variables, not values

Both Claude Code and Cursor expand variables in the configuration, so the file can name a key without holding it.

Claude Code reads ${VAR} and ${VAR:-default} in a server's command, args, env, url and headers. Its documentation presents this as the way to share .mcp.json "while maintaining flexibility for machine-specific paths and sensitive values like API keys":

JSON
{
  "mcpServers": {
    "tickets": {
      "type": "http",
      "url": "https://mcp.example.com/mcp",
      "headers": { "Authorization": "Bearer ${TICKETS_API_KEY}" }
    }
  }
}

One detail is worth knowing: in a remote server's url and headers, Claude Code reads certain credential variables, such as ANTHROPIC_API_KEY and NPM_TOKEN, as empty, so a committed config can't send them to a server it names. Use a variable name of your own for each server's key.

Cursor uses ${env:NAME} in the same fields, and its MCP page recommends to "use environment variables for secrets, never hardcode them." Cursor also accepts envFile for local servers. That moves the key out of the JSON, but into a .env file, which is still a plaintext file on disk that the agent can open.

Variables only move the problem to wherever the variable is set. If that is an export line in ~/.zshrc, the key is in a different plaintext file, and now in every program your shell starts.

Step 2: one narrow key per server

Give each MCP server its own key, created for that server alone. It's least privilege applied to tools:

  • Scope it. A server that reads issues needs a token that reads issues. GitHub's fine-grained tokens, OpenAI project keys with restricted permissions and Anthropic keys scoped to one workspace all allow that; the OpenAI, Anthropic and GitHub rotation pages describe what each key covers.
  • Cap what it can spend. Where the provider offers per-project or per-workspace limits, put the agent's key in its own project with its own limit, so a runaway loop or a stolen key has a ceiling.
  • Name it after the server and the machine. When you need to revoke it, you'll know which one to revoke, and nothing else breaks.

A separate key doesn't keep the value secret. It makes the damage smaller and the cleanup faster.

Step 3: start the server through crunu run

For local servers, the key can stay out of every file. The client starts whatever command says, so the command can be a launcher that fetches the variables and then starts the real server. With Crunu, that launcher is crunu run:

JSON
{
  "mcpServers": {
    "issues": {
      "command": "crunu",
      "args": [
        "run", "--env", "agent-tools/local", "--",
        "npx", "-y", "@example/issues-mcp-server"
      ]
    }
  }
}

Here agent-tools/local is an example environment holding ISSUES_API_KEY, and the package name is a placeholder for the server you use. Before starting the client, sign in once with crunu login and keep your keys in memory with crunu unlock --ttl 2h (up to 12 hours). crunu run decrypts the environment, starts the server with a clean environment (PATH, HOME and a few others) plus the environment's variables, and passes standard input and output through, so the client talks to the server as before. Its own messages go to standard error, and the values never appear in them. The config file contains a project name, not a key.

Example. Kiteline is a fictional company.

What this changes and what it doesn't:

  • No key on disk. The JSON, the repository, the backups and the dotfiles hold nothing worth stealing.
  • Locked means off. After crunu lock or when the time runs out, the server can't start. You decide when the agent's tools have credentials.
  • The process still has the key. The server needs it to call the API, so it sits in that process's environment while it runs. Other code running as you can often read a process's environment, for example through /proc on Linux, and the server can still log what it receives.
  • Platforms. The crunu command line runs on Linux and macOS. There is no Windows build.

Remote servers and OAuth

For HTTP servers, prefer OAuth when the server supports it. Claude Code's documentation says that for OAuth the client secret "is stored securely in your system keychain (macOS) or a credentials file, not in your config." A token in the system keychain is still readable by you, but it isn't in a JSON file that gets committed, synced or pasted into a bug report.

If a config file leaked

Treat it like a leaked .env:

  1. List every key in the file, including old entries for servers you no longer use.
  2. Reissue each one at its provider, then revoke the old one. Reissue first, because copies may already exist.
  3. If it was .mcp.json, check Git history and every fork; deleting the line in a new commit doesn't remove it from history. A .env file leaked into Git covers the order of the cleanup.
  4. Check dotfile repositories and backups that include the home directory.

The glossary entry on MCP servers has the short definition.

Checklist

Sources