[BUG] Empty `projects[path].mcpServers: {}` shadows user-scope servers — should be passthrough, explicit deny required to override

Resolved 💬 3 comments Opened Apr 20, 2026 by mackenzieclark Closed May 6, 2026

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

  1. Violates least surprise. --scope user is documented as "available in all your projects." Current behavior makes that false in any project whose record exists and has mcpServers: {} — which is most of them, because Claude Code writes the empty object itself.
  1. 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.
  1. Inconsistent with Claude Code's own CLAUDE.md behavior. User-level ~/.claude/CLAUDE.md composes with project-level CLAUDE.md. Users reasonably expect mcpServers to follow the same mental model.
  1. No coherent trust story. If the concern is "projects should opt in to MCP execution," the top-level mcpServers key 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 to hasTrustDialogAccepted explicitly, not to the incidental presence of an empty object.
  1. 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"] or inheritMcpServers: false at 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.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗