Skip to content

AI CODING ASSISTANTS

mcp.json Configuration Explained

Every field in an mcp.json configuration, which of the three scopes wins, and the placeholder that silently becomes your API key. Measured on v2.1.226.

An mcp.json configuration is a map of server names to launch instructions, and the whole useful surface is six fields. What costs people an afternoon is not the schema — it is that the same server can be defined in three places with silent precedence between them, and that an unset environment variable is passed to your server as the literal string ${VAR} rather than failing. This walks every field, the scope rules, and the two traps, using the working config in this repository. Measured on Claude Code v2.1.226 on 2026-08-08.

Key takeaways

  • Three scopes hold servers, and the narrowest wins. A local definition silently shadows a project one with the same name, with no warning in claude mcp list.
  • Only .mcp.json is a file you edit. Local and user scope live inside ~/.claude.json, keyed by absolute project path — edit them through claude mcp add, not by hand.
  • A project .mcp.json never connects until approved. It reports ⏸ Pending approval, which reads exactly like a broken server.
  • An unset ${VAR} is not an error. The literal placeholder becomes the value — so a missing token reaches your server as the eight characters ${TOKEN}.
  • Relative paths resolve against the project root, not your shell's working directory, which is what makes a committed config portable.

Where the config file lives and which one wins

Three scopes, and only one of them is a file you open in an editor. This is the configuration layer of MCP server setup in Claude Code, and Anthropic's MCP documentation for Claude Code documents the set:

ScopeStored inApplies to
local~/.claude.json, keyed by project pathYou, in one project
project.mcp.json in the project rootEveryone who clones the repo
user~/.claude.jsonYou, in every project

The one people look for — a global mcp.json in your home directory — does not exist. Local and user scope are stored inside ~/.claude.json, a file that also holds your conversation history and project metadata, which is why the CLI is the supported way to edit them.

Precedence runs narrowest-first: local beats project beats user. That ordering is reasonable and the failure it produces is not, because nothing surfaces the shadowing. Two definitions named github, one committed for the team and one you added locally six weeks ago, and the team's is simply not the one running.

claude mcp get is where the scope becomes visible, and it is worth running before debugging anything else:

Terminal
claude mcp get roadmap
# → roadmap:
# →   Scope: Project config (shared via .mcp.json)
# →   Status: ⏸ Pending approval (run `claude` to approve)
# →   Type: stdio

That ⏸ Pending approval is the second surprise. A .mcp.json arrives with a repository and can run any command on your machine, so Claude Code refuses to start anything in it until you have accepted it once in that project. Until then the server is configured, listed, and not running — a state that looks identical to a broken config from the outside.

Approval is also gated on workspace trust, which catches teams out. Since v2.1.196 a cloned repository cannot approve its own servers: an enableAllProjectMcpServers setting committed to .claude/settings.json is ignored until someone runs claude in the folder and accepts the trust dialog, and the server stays at ⏸ Pending approval until they do. A correct, shared mcp.json configuration still needs one interactive step per machine. For the wider command surface around this, see the complete Claude Code command reference.

A minimum working config

This is the entire .mcp.json committed to this repository. It runs a small server that answers questions about our editorial roadmap, which is a 194 KB file nobody should pull into a session to ask what to write next:

.mcp.json
{
  "mcpServers": {
    "roadmap": {
      "command": "node",
      "args": ["scripts/mcp/roadmap-server.mjs"],
      "env": {}
    }
  }
}

mcpServers is the only top-level key an mcp.json configuration has. Everything else is a server name mapped to an object, and the name is what you will see in /mcp, in tool names, and in every error message — so roadmap beats my-server.

After adding a server, confirm it started before doing anything else:

Terminal
claude mcp list
# → roadmap: node scripts/mcp/roadmap-server.mjs - ⏸ Pending approval (run `claude` to approve)
# → filesystem: npx -y @modelcontextprotocol/server-filesystem D:/repo - ✔ Connected

Every field in an mcp.json configuration

Six fields carry weight for a stdio server. Anything else in the object is ignored rather than rejected, which means a typo in a key name fails silently.

FieldRequiredWhat it does
commandThe executable. Resolved on PATH unless it is a path
argsArgument array. Never a single joined string
envVariables added to the server's environment
typestdio (default), http, or sse
url⚠️Required for http and sse; invalid for stdio
headersRemote transports only — this is where a bearer token goes

args being an array matters more than it looks. "args": "-y @modelcontextprotocol/server-filesystem ." is not a shorthand for three arguments; it is one argument containing spaces, and the server receives a single nonsense parameter.

Environment variables and the placeholder trap

Any string in an mcp.json configuration can reference an environment variable, expanded from the environment Claude Code itself was launched with:

.mcp.json
{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN:-}" }
    }
  }
}

This is what makes a committed .mcp.json safe: the shape of the config is shared, the secret is not. Never write a token into the file itself — it is in the repository forever after the first commit.

The trap is what happens when the variable is not set. We tested it with a probe server that refuses to start unless it receives the literal value expanded, configured with a plain ${MCP_SOURCE_VALUE} and no default:

Terminal
claude mcp get probe-env
# →   Status: ✘ Failed to connect
# →   Issue: -32000: MCP error -32000: Connection closed
# →     MCP_PROBE_TOKEN=${MCP_SOURCE_VALUE}

The server did not receive an empty string and the client did not report a missing variable. It received the twenty characters ${MCP_SOURCE_VALUE} as its token. Every downstream error after that — a 401 from an API, a parse failure, a crash — is caused by a value that looks like a template and is being treated as a credential.

The fix is one character. ${VAR:-default} supplies a fallback, and with it the same probe connects. Anthropic's own documentation reaches the same conclusion from the other direction, noting that referencing a variable Claude Code does not itself set "requires a default such as ${CLAUDE_PROJECT_DIR:-.}":

Terminal
claude mcp add-json probe-env -s local \
  '{"type":"stdio","command":"node","args":["scripts/mcp/_probe-env.mjs"],"env":{"MCP_PROBE_TOKEN":"${MCP_SOURCE_VALUE:-expanded}"}}'
claude mcp get probe-env
# →   Status: ✔ Connected

Relative paths resolve against the project root

A committed config full of C:/Users/you/... paths breaks on every other machine, so the question is whether relative paths work and what they are relative to.

They work, and they resolve against the project root rather than the directory your shell happens to be in. We confirmed it rather than assuming it: a probe server that calls process.exit(2) unless package.json is readable from its working directory connects cleanly.

Terminal
claude mcp list
# → probe-cwd: node scripts/mcp/_probe-cwd.mjs - ✔ Connected

So node scripts/mcp/roadmap-server.mjs is portable, and it is the form to prefer for any server that lives inside the repository. In a Next.js project that server can go further and import the app's own data layer rather than reimplementing it — one flag makes the difference, and connecting an MCP server to a Next.js project covers it.

There is a cost to the npx form worth knowing before you reach for it. Running the same handshake against both, warm, on this machine:

ServerStartup to tools/listTool definitions
npx @modelcontextprotocol/server-filesystem2,110 ms12,973 bytes
node scripts/mcp/roadmap-server.mjs69 ms706 bytes

Cold, before the package was cached, the same filesystem server took 22,752 ms. That is paid once per install, but it is paid at session start, and a config with five npx servers in it is the reason sessions feel slow to begin. Whether that trade is worth it is the subject of which MCP servers are worth the context, and what a whole fleet of them costs together is measured in running multiple MCP servers in one client.

Remote servers take a different shape

An HTTP server replaces command and args with url, and carries auth in headers. The protocol defines two standard transports — stdio and Streamable HTTP — and the shape of the mcp.json configuration follows which one you pick:

.mcp.json
{
  "mcpServers": {
    "sentry": {
      "type": "http",
      "url": "https://mcp.sentry.dev/mcp",
      "headers": { "Authorization": "Bearer ${SENTRY_TOKEN:-}" }
    }
  }
}

Remote transports have one clear advantage when something goes wrong: their errors name the cause. A stdio server that fails to start reports Connection closed no matter why, while a bad URL reports the actual problem:

Terminal
claude mcp list
# → probe-http: https://example.invalid/mcp (HTTP) - ✘ Failed to connect — ENOTFOUND: getaddrinfo ENOTFOUND example.invalid

Servers requiring OAuth rather than a static token are authenticated with claude mcp login, not a headers entry. The three claude.ai connectors on this machine sit in exactly that state, reporting ! Needs authentication until that runs — which is a configuration outcome, not a broken server, and is covered in MCP server not working.

Common mistakes

  • Editing ~/.claude.json by hand. It holds conversation history alongside configuration, it is large, and a JSON syntax error there affects far more than one server. Use claude mcp add and claude mcp add-json.
  • Committing a token in env. The whole point of ${VAR:-} is that the config is shareable. A secret in a committed .mcp.json is a secret in the repository's history.
  • Writing args as one string. It is an array. A joined string arrives as a single argument and the server usually starts, then behaves incomprehensibly.
  • Assuming a listed server is running. ⏸ Pending approval appears in the same list as connected servers and is easy to read past. Check the status column, not the presence of a row.
  • Reusing a server name across scopes. The narrower scope wins silently. If a team config seems to be ignored, run claude mcp get <name> and read the Scope: line first.
  • Reaching for npx for a server that lives in your repo. It costs about two seconds of session startup and a package download to run code already on disk.

Conclusion

Put shared servers in a committed .mcp.json with relative paths and ${VAR:-} placeholders, keep personal ones in local scope via claude mcp add, and run claude mcp get <name> as the first debugging step — the Scope: and Status: lines answer most questions before you open anything. If a server is configured and not working, the error text is usually the same seven words regardless of cause, and troubleshooting an MCP server that will not connect covers how to tell those causes apart. For controlling individual tool execution boundaries on connected MCP servers, see Claude Code permissions. If you are carrying this file to another agent, note that the schema is more portable than the filename: Antigravity CLI reads the same mcpServers object from .agents/mcp_config.json but requires serverUrl where other clients accept url, which the Antigravity CLI comparison measures against this repo's own config.

Frequently asked questions

Where does mcp.json go?
A file named .mcp.json in your project root, committed to the repository, is the shared project scope. Personal servers go in the local scope, which Claude Code stores inside ~/.claude.json keyed by absolute project path rather than in a file you edit. There is no .mcp.json in your home directory — the user-level equivalent is managed through claude mcp add -s user.
Why is my server showing as pending approval?
Servers defined in a project .mcp.json are untrusted until you approve them, because the file arrives with the repository and can run arbitrary commands. Claude Code shows them as ⏸ Pending approval and does not connect. Run claude in that directory and accept the prompt. Approval is recorded per project, so each clone asks once.
Does mcp.json support environment variables?
Yes. ${VAR} in any string is expanded from the environment Claude Code was launched with, and ${VAR:-default} supplies a fallback. An unset variable with no default is not an error — the literal text ${VAR} is passed through to the server as the value, so always use the default form for anything required.
Are relative paths in mcp.json allowed?
Yes, and they resolve against the project root rather than your shell's working directory. We confirmed this with a probe server that exits unless ./package.json is readable at startup; it connects. That makes node scripts/mcp/server.mjs portable across machines in a way an absolute path is not.
What is the difference between local, project, and user scope?
Local is private to you in one project and wins over the others. Project is .mcp.json committed to the repository and shared with everyone who clones it. User applies across all your projects. When the same server name exists in more than one scope, the narrower scope takes precedence, so a local definition silently shadows a project one.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.