The .mcp.json that travels

Claude Code has three MCP scopes. Two of them write to a file on your machine that nobody else ever sees. Only one is the one you meant.

Last updated: 2026-09-22

The one-sentence version

.mcp.json is the only scope your teammates get — and because it is committed, every secret in it has to be a ${VAR} reference rather than a value.

That second half has a sharp edge on it, which is most of why this page exists. A specific list of credential variable names expands to an empty string on purpose, even when the variable is set, and even if you wrote a default. More on that below; it is the part that costs an afternoon.

Three scopes, and only one of them is shared

ScopeStored inWho gets it
local (the default)~/.claude.json, under the project pathYou, in this one project
user~/.claude.jsonYou, in every project
project.mcp.json in the project rootAnyone who clones the repo

The trap. claude mcp add defaults to --scope local. So the ordinary way to add a server — run the command, watch it work — writes to a file in your home directory, changes nothing in the repo, and leaves git status clean. Nothing has gone wrong and nothing is shared. The teammate who pulls your branch gets no server and no error, because from their side there was never a server to fail.

Say what you mean with the scope flag, which every one of these commands takes:

# shared with the repo -> writes .mcp.json
claude mcp add --scope project --transport stdio myserver -- npx -y @acme/mcp-server

# a remote one, also shared
claude mcp add --scope project --transport http myapi https://api.example.com/mcp

# paste a whole server object in one go
claude mcp add-json myserver '{"command":"npx","args":["-y","@acme/mcp-server"]}' --scope project

The file itself

One top-level key, mcpServers, keyed by server name. Nothing else belongs at the top level.

{
  "mcpServers": {
    "acme": {
      "command": "npx",
      "args": ["-y", "@acme/mcp-server"],
      "env": { "ACME_WORKSPACE": "${ACME_WORKSPACE:-default-ws}" }
    },
    "internal-api": {
      "type": "http",
      "url": "${INTERNAL_MCP_URL}",
      "headers": { "Authorization": "Bearer ${INTERNAL_MCP_TOKEN}" }
    }
  }
}

A stdio server takes command, args and env. A remote server takes type (http, sse, or ws for WebSocket), url and headers. Configuration that is not a server — disabledMcpjsonServers, for instance — lives in a settings file, not in here.

${VAR}, and the names that read as empty

Two forms, and they work the way the shell taught you to expect:

${VAR}            the variable's value
${VAR:-default}   the value if set, otherwise "default"

Expansion is supported in command, args and env for stdio servers, and in url and headers for remote ones. That is what makes a committed file safe: the reference travels, the value stays on each machine.

Now the edge. In url and headers only, a set of credential variables read as empty even when they are set, so that Claude Code cannot hand your own credentials to a remote server. The covered names:

ANTHROPIC_API_KEY · ANTHROPIC_AUTH_TOKEN · AWS_BEARER_TOKEN_BEDROCK · HTTPS_PROXY · NPM_TOKEN

And :-default is ignored for a covered name, so the usual escape hatch does not fire either. Write "Authorization": "Bearer ${NPM_TOKEN}" and you ship a header that reads Bearer . The server answers 401, your config looks perfect, the variable is set in your shell, and echo $NPM_TOKEN prints it back at you. The fix is a rename: use a variable of your own, ${ACME_REGISTRY_TOKEN}, and it expands normally.

This is the same shape of bug as a hook that exits 1 — the mechanism works, does the safe thing, and gives you no symptom pointing at the cause. It is worth knowing the list by sight, because nothing in the error message will mention it.

Approval: interactive asks, automation does not

A .mcp.json arrives with a cloned repository and describes programs to run, so Claude Code asks before it uses project-scoped servers. In the docs’ words: For security reasons, Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files.

The phrase doing the work is in interactive sessions. Project servers load without a prompt in:

Plan around the asymmetry rather than discovering it: a server that has only ever run in CI is a server whose approval prompt no one has seen yet, and it will stop and ask on a developer’s first interactive run. Two commands are worth knowing before that happens:

claude mcp reset-project-choices   # ask me again about this repo's servers

To block a server outright — in every permission mode, including the ones that skip the prompt — add its name to the disabledMcpjsonServers setting. That is the lever that survives automation; a denied prompt is not.

Workspace trust, v2.1.196 and later. A cloned repository cannot approve its own servers: enableAllProjectMcpServers or enabledMcpjsonServers committed to the project’s .claude/settings.json is ignored in an untrusted folder. That is the whole point — otherwise any repo on the internet could ship its own permission to run. Approvals from your own ~/.claude.json or from managed settings still apply. Run claude interactively once and accept the workspace trust dialog.

Proving it, in two commands

Do not infer from a clean git status that the server is shared, or from an absent error that it started.

claude mcp list          # every configured server
claude mcp get myserver  # one server: scope, transport, how it resolved

claude mcp get is the one that settles arguments, because it tells you which scope a server came from. If you expected to see project and you are looking at local, you have found the bug — your file is fine and your teammate has nothing.

Still not connecting after all this? The failure modes for a server that is configured correctly and still will not start are a separate list: My MCP server will not connect in Claude Code walks the check order.

If you would rather not audit this by hand

None of the above is hard. It is just quiet — four separate ways to end up with a config that looks right and does nothing, none of which raises an error at you.

A finished setup is more than the servers: it is a CLAUDE.md that matches the repo, a tool allowlist that stops the permission prompts without turning everything off, and the scope decisions above made deliberately instead of by default.

Setup Lite — $29. A CLAUDE.md, a tool allowlist and one skill written from your actual repo, handed back as a pull request you review, about 24 hours from a complete intake. You read the diff; nothing lands that you did not approve.

Setup Lite — $29 Or Setup Sprint, $99

Prefer free? kit-claude-code-starter is MIT and has a working .claude/ to copy. The 15-minute setup guide is the same shape done by hand, and we would rather you had it than nothing.

Questions

Which Claude Code MCP scope actually gets shared with my team?

Only project scope. Project scope writes a .mcp.json file in your project root, which you commit. Local scope and user scope both write to ~/.claude.json on your own machine and are never shared — local under the project path so it applies to one project, user so it applies to all of them. Because claude mcp add defaults to --scope local, the common failure is adding a server, committing nothing, and wondering why a teammate does not have it. Use --scope project when you mean to share.

What is the schema of a .mcp.json file?

A single top-level key called mcpServers, whose value is an object keyed by server name. A stdio server takes command, args and env. A remote server takes type (http, sse or ws), url and headers. There is no other top-level key — configuration that is not a server, such as disabledMcpjsonServers, belongs in a settings file, not here.

Can I put an API key in .mcp.json?

No — the file is committed, so a literal key in it is a key in your git history. Reference an environment variable instead: ${VAR} expands to the variable's value, and ${VAR:-default} falls back to default when it is unset. Expansion is supported in command, args and env for stdio servers and in url and headers for remote ones.

Why does my ${NPM_TOKEN} expand to an empty string in .mcp.json?

Because it is on a deny list, and that is deliberate. In url and headers only, a set of credential variables read as empty even when they are set in your environment, so that Claude Code does not leak them to a remote server. The covered names include ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, AWS_BEARER_TOKEN_BEDROCK, HTTPS_PROXY and NPM_TOKEN. A :-default fallback is ignored for a covered name too, so you do not get the default either. Give the secret a different variable name of your own.

Why do project MCP servers prompt for approval, and why did the prompt not appear in CI?

A .mcp.json arrives with a cloned repository, so Claude Code asks before running anything it describes. That prompt is an interactive-session behavior: claude -p runs, Agent SDK sessions, cloud sessions, and bypassPermissions sessions with skipDangerousModePermissionPrompt enabled all load project servers without prompting. That asymmetry is the one to plan around — a server that silently works in CI will stop and ask on a developer's first interactive run.

How do I reset the approval choices for a .mcp.json?

Run claude mcp reset-project-choices. That clears the remembered approve and deny answers for the project's .mcp.json servers so you are asked again on the next interactive run. To block a server outright in every permission mode, add its name to the disabledMcpjsonServers setting instead.

Why are my committed MCP servers ignored in a freshly cloned repo?

Workspace trust. From v2.1.196, a cloned repository cannot approve its own servers: enableAllProjectMcpServers or enabledMcpjsonServers committed to the project's .claude/settings.json is ignored in an untrusted folder. Approvals coming from your own ~/.claude.json or from managed settings still apply. Trust the workspace by running claude interactively once and accepting the workspace trust dialog.