Skip to content

[BUG] claude MCP writer ignores CLAUDE_CONFIG_DIR, writes to ~/.claude.json instead of $CLAUDE_CONFIG_DIR/.claude.json #2060

Description

@hyperfocus1337

Describe the bug

At user scope (apm install -g), APM writes MCP servers for the Claude Code target to a hardcoded ~/.claude.json and does not consult CLAUDE_CONFIG_DIR. When CLAUDE_CONFIG_DIR is set (common in devcontainers and relocated setups), Claude Code reads user-scope MCP servers from $CLAUDE_CONFIG_DIR/.claude.json, so APM's writes land in a file Claude Code never reads. The servers silently fail to appear in /mcp, and because APM re-reads the same hardcoded file on the next run it reports them as "already configured (skipped)", which hides the problem.

This is inconsistent with the rest of APM: the user-scope target resolver in integration/targets.py already honors CLAUDE_CONFIG_DIR for every other primitive (skills, commands, agents, instructions, hooks). Only the MCP writer diverges.

Note on default behavior: with CLAUDE_CONFIG_DIR unset, Claude Code reads user config from $HOME/.claude.json (home root), so APM's hardcoded path happens to match. The bug only surfaces when CLAUDE_CONFIG_DIR is set, which relocates the file to $CLAUDE_CONFIG_DIR/.claude.json. That is exactly the scenario the variable exists for (devcontainers, relocated or shared config), so the mismatch is easy to hit and hard to notice.

To Reproduce

Steps to reproduce the behavior:

  1. Point Claude Code at a relocated config directory and confirm it takes effect: export CLAUDE_CONFIG_DIR="$HOME/.claude" (any dir Claude Code uses). In a Claude Code session, /mcp shows the user MCP source as $CLAUDE_CONFIG_DIR/.claude.json.
  2. Install one or more MCP servers at global scope for the claude target: apm install -g --mcp <some-server>.
  3. APM reports the server as configured (or "already configured" on a re-run).
  4. Start a new Claude Code session and run /mcp. The server is absent.
  5. Confirm where APM actually wrote it: jq -r '.mcpServers // {} | keys[]' "$HOME/.claude.json". The server is present in $HOME/.claude.json, not in $CLAUDE_CONFIG_DIR/.claude.json.

Expected behavior

MCP servers for the claude target at user scope are written to $CLAUDE_CONFIG_DIR/.claude.json when CLAUDE_CONFIG_DIR is set, falling back to ~/.claude.json when it is not. This matches the file Claude Code reads and mirrors the resolution APM already performs for other user-scope primitives. Instead, the servers are written to a file Claude Code never reads, and the "already configured" skip on re-runs makes the mismatch silent.

Environment (please complete the following information):

  • OS: [e.g. Linux, devcontainer based on the node image]
  • Python Version: [e.g. 3.12.0]
  • APM Version: [your apm --version; source references below are against 0.24.0, commit b915f8d]
  • VSCode Version (if relevant): [n/a, reproduced with the Claude Code CLI]
  • Claude Code Version: [your claude --version]
  • CLAUDE_CONFIG_DIR: $HOME/.claude (set in a devcontainer, so Claude Code reads $HOME/.claude/.claude.json)

Logs

APM reports the servers as configured while /mcp does not list them. The two files diverge:

$ echo "$CLAUDE_CONFIG_DIR"
/home/node/.claude

# Claude Code /mcp panel reads:
#   User MCPs (/home/node/.claude/.claude.json)  ->  server absent

# APM wrote here instead:
$ jq -r '.mcpServers // {} | keys[]' "$HOME/.claude.json"
<server-name>

Additional context

Root cause. The Claude MCP adapter hardcodes the user-scope path.

src/apm_cli/adapters/client/claude.py:

def _user_claude_json_path(self) -> Path:
    return Path.home() / ".claude.json"   # line 143-144

def get_config_path(self):
    if self._is_user_scope():
        return str(self._user_claude_json_path())   # line 149-151
    return str(self._project_mcp_path())

def _merge_user_mcp(self, config_updates) -> bool:
    path = self._user_claude_json_path()   # line 192-193
    ...

No part of the MCP write path reads CLAUDE_CONFIG_DIR. Confirmed with:

$ grep -rn "CLAUDE_CONFIG_DIR" src/apm_cli/adapters/ \
    src/apm_cli/integration/mcp_integrator.py src/apm_cli/install/mcp/
# (no matches)

By contrast, the user-scope target resolver already implements the right behavior for every other primitive.

src/apm_cli/integration/targets.py (around line 389):

# Claude Code honors CLAUDE_CONFIG_DIR (default ~/.claude) and Hermes
# honors HERMES_HOME (default ~/.hermes); mirror that at user scope so
# `apm install -g` lands where the tool reads.
if self.name in ("claude", "hermes"):
    env_var = "CLAUDE_CONFIG_DIR" if self.name == "claude" else "HERMES_HOME"
    env = os.environ.get(env_var, "").strip()
    if env:
        abs_path = Path(env).expanduser().resolve(strict=False)
        home = Path.home().resolve(strict=False)
        try:
            new_root = abs_path.relative_to(home).as_posix()
        except ValueError:
            new_root = str(abs_path)

So APM deploys skills, commands, agents, instructions, and hooks under $CLAUDE_CONFIG_DIR, but the MCP servers for the same install land in ~/.claude.json. On any host where those two locations differ, the MCP half of an apm install -g is effectively a no-op from Claude Code's perspective.

Suggested fix. Resolve the user-scope path through CLAUDE_CONFIG_DIR, preserving the current default when it is unset:

import os

def _user_claude_json_path(self) -> Path:
    base = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
    if base:
        return Path(base).expanduser() / ".claude.json"
    return Path.home() / ".claude.json"

Unset CLAUDE_CONFIG_DIR keeps today's behavior (~/.claude.json); set relocates to $CLAUDE_CONFIG_DIR/.claude.json. Ideally this reuses the same resolver already in targets.py so the two sites cannot drift, including the "points outside $HOME" fallback that block already handles.

Metadata

Metadata

Assignees

No one assigned

    Labels

    type/bugSomething does not work as documented.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions