[BUG] Empty `projects[path].mcpServers: {}` shadows user-scope servers — should be passthrough, explicit deny required to override
Summary
An empty mcpServers: {} object in a project record inside ~/.claude.json silently shadows the top-level user-scope mcpServers registration for sessions launched in that directory. The user-scope servers appear in claude mcp list and claude mcp get <name> but do not get registered into the Claude Code session's tool catalog.
This reproduces cleanly in v2.1.114 — the most recent stable at time of filing — on Linux (WSL2). Same machine, same user, two sessions launched within minutes: one in a directory whose project record had a populated mcpServers, one in a directory whose project record had mcpServers: {}. Only the former got the MCP tools.
Our contention — the intended fix: a present but empty mcpServers object should be semantically equivalent to absent — pure passthrough. Only an explicit deny (e.g. disabledUserServers: [...] or inheritMcpServers: false) should suppress user-scope inheritance for a project. {} is not a deny; it is "I have nothing to add," and the CLI itself writes it automatically during project onboarding. Treating {} as shadow-override silently zeroes out user configuration through no action of the user.
Reference
This is the same root issue as #16728 (auto-closed as stale by the bot, then locked — not fixed). Filing fresh per the bot's own instruction. Repro confirmed on a newer version (2.1.114 vs. 2.1.1 in the original).
Repro
# 1. Have a user-scope MCP server registered (top-level `mcpServers` key in ~/.claude.json).
claude mcp add -t http -s user my-server https://example.com/mcp -H "Authorization: Bearer ..."
# 2. Create two directories, open one in Claude Code to generate a project record.
mkdir -p ~/dev/proj-a ~/dev/proj-b
cd ~/dev/proj-a && claude # accept trust dialog, exit
# 3. Inspect ~/.claude.json:
# - top-level mcpServers: { "my-server": {...} }
# - projects["/home/user/dev/proj-a"].mcpServers: {} (auto-written)
# - projects["/home/user/dev/proj-b"]: does not exist yet
# 4. Launch a session in each:
cd ~/dev/proj-a && claude # my-server tools are NOT in the catalog
cd ~/dev/proj-b && claude # my-server tools ARE in the catalog (no project record yet)
claude mcp list reports the server as registered in both cases. /mcp inside the broken session doesn't list the mcp__my-server__* tools. The handshake completes — initialize and resources/list succeed against the server, confirming it's reachable — but tools/list output never installs into the session's deferred-tool registry.
Workaround
Manually edit ~/.claude.json to delete the mcpServers key on every project record where it equals {}. Do not set it to {}; delete the key entirely. Sessions launched afterward in those directories inherit user scope as expected.
Caveat: if Claude Code re-writes mcpServers: {} during normal operation (e.g. on every session start or on trust-dialog acceptance), the workaround is unstable and users will be silently re-broken.
Why current behavior is wrong
- Violates least surprise.
--scope useris documented as "available in all your projects." Current behavior makes that false in any project whose record exists and hasmcpServers: {}— which is most of them, because Claude Code writes the empty object itself.
- Inconsistent with every other config system Claude Code users touch. git, npm, shell env, and CLAUDE.md itself all merge hierarchically. Empty collections at lower scopes never shadow higher ones; explicit unset/deny mechanisms handle removal. Claude Code's MCP resolution is the only place this invariant breaks.
- Inconsistent with Claude Code's own CLAUDE.md behavior. User-level
~/.claude/CLAUDE.mdcomposes with project-levelCLAUDE.md. Users reasonably expectmcpServersto follow the same mental model.
- No coherent trust story. If the concern is "projects should opt in to MCP execution," the top-level
mcpServerskey already bypasses that gate for projects without a record, so the shadowing behavior isn't providing a real security boundary — just an inconsistent one. Trust should be wired tohasTrustDialogAcceptedexplicitly, not to the incidental presence of an empty object.
- Empty vs. deny are semantically different. Users should be able to express both:
- "This project has nothing to add beyond user scope" →
mcpServers: {}or absent. - "This project explicitly opts out of specific user-scope servers" → explicit deny list or
inheritMcpServers: false.
The current design conflates them into one representation, erasing the second intent.
Proposed behavior
- Absent
mcpServers: inherit user-scope. (Already works.) - Empty
mcpServers: {}: inherit user-scope. (Change.) - Populated
mcpServers: { ... }: merge with user-scope; same-named keys in project scope override user scope. Project scope cannot remove a user-scope server by being silent about it. - New explicit mechanism for opting out of user scope:
disabledUserServers: ["server-name"]orinheritMcpServers: falseat the project record level. This is where deliberate project-level sandboxing lives.
Environment
- Claude Code version: 2.1.114
- Platform: Linux (WSL2 on Windows)
- Installation: native binary
- MCP server type reproduced against: HTTP (remote MCP over Cloudflare + ALB + ECS)
Impact
Every project record auto-written by the CLI with mcpServers: {} is a silent shadow of user-scope registrations. Users who rely on --scope user for convenience end up with MCP access that appears to work in some sessions and not others, with no error message and no warning in /mcp. Debugging it requires inspecting ~/.claude.json by hand and knowing to compare present-but-empty vs. absent — not obvious behavior for any user who hasn't already been burned by this.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗