Claude Code Bot Claude Sonnet 4.6 commited on
Commit
4d381a9
·
1 Parent(s): 99107cb

fix(mcp): use claude mcp add for install and fix startup import

Browse files

Two fixes to get the headroom MCP server working with Claude Code CLI:

1. mcp_server.py: define CCR_TOOL_NAME inline instead of importing from
tool_injection, which triggered headroom/__init__.py -> LiteLLM -> HTTP
requests to GitHub, adding 4-5 seconds to startup time.

2. cli/mcp.py: prefer `claude mcp add -s user` when the claude CLI is
available (Claude Code CLI ≥2.x stores servers in ~/.claude/.claude.json,
not ~/.claude/mcp.json). Falls back to writing mcp.json for older versions
and the claude.ai desktop app.

3. tests/test_cli/test_mcp.py: update mock_claude_config_path fixture to
also stub out the claude CLI so install tests exercise the mcp.json
fallback path, matching the fixture's intent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

headroom/ccr/mcp_server.py CHANGED
@@ -55,7 +55,9 @@ except ImportError:
55
  HTTPX_AVAILABLE = False
56
  httpx = None # type: ignore[assignment]
57
 
58
- from .tool_injection import CCR_TOOL_NAME
 
 
59
 
60
  logger = logging.getLogger("headroom.ccr.mcp")
61
 
 
55
  HTTPX_AVAILABLE = False
56
  httpx = None # type: ignore[assignment]
57
 
58
+ # Defined inline to avoid importing the full headroom package (which loads LiteLLM
59
+ # and makes HTTP requests to GitHub, adding 4-5 seconds to startup time).
60
+ CCR_TOOL_NAME = "headroom_retrieve"
61
 
62
  logger = logging.getLogger("headroom.ccr.mcp")
63
 
headroom/cli/mcp.py CHANGED
@@ -95,8 +95,8 @@ def mcp_install(proxy_url: str, force: bool) -> None:
95
  """Install Headroom MCP server into Claude Code config.
96
 
97
  \b
98
- This adds headroom to ~/.claude/mcp.json so Claude Code can use
99
- the headroom_retrieve tool for CCR (Compress-Cache-Retrieve).
100
 
101
  \b
102
  Example:
@@ -111,38 +111,81 @@ def mcp_install(proxy_url: str, force: bool) -> None:
111
  click.echo("Install with: pip install 'headroom-ai[mcp]'", err=True)
112
  raise SystemExit(1) from None
113
 
114
- config = load_mcp_config()
115
-
116
- # Check if already configured
117
- if "headroom" in config.get("mcpServers", {}) and not force:
118
- click.echo("Headroom MCP is already configured in Claude Code.")
119
- click.echo("Use --force to overwrite, or 'headroom mcp uninstall' first.")
120
- raise SystemExit(0)
121
-
122
- # Build server config
123
  command = get_headroom_command()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
- # Add proxy URL as environment variable if non-default
126
- server_config: dict = {
127
- "command": command[0],
128
- "args": command[1:],
129
- }
130
 
131
- if proxy_url != DEFAULT_PROXY_URL:
132
- server_config["env"] = {"HEADROOM_PROXY_URL": proxy_url}
 
133
 
134
- # Update config
135
- if "mcpServers" not in config:
136
- config["mcpServers"] = {}
137
- config["mcpServers"]["headroom"] = server_config
138
 
139
- # Save
140
- save_mcp_config(config)
 
 
 
141
 
142
  click.echo(f"""
143
  ✓ Headroom MCP server installed!
144
 
145
- Configuration written to: {MCP_CONFIG_PATH}
146
 
147
  Next steps:
148
  1. Start the Headroom proxy (if not running):
 
95
  """Install Headroom MCP server into Claude Code config.
96
 
97
  \b
98
+ This registers headroom with Claude Code so it can use the
99
+ headroom_retrieve tool for CCR (Compress-Cache-Retrieve).
100
 
101
  \b
102
  Example:
 
111
  click.echo("Install with: pip install 'headroom-ai[mcp]'", err=True)
112
  raise SystemExit(1) from None
113
 
 
 
 
 
 
 
 
 
 
114
  command = get_headroom_command()
115
+ env: dict[str, str] = {}
116
+ if proxy_url != DEFAULT_PROXY_URL:
117
+ env["HEADROOM_PROXY_URL"] = proxy_url
118
+
119
+ # Prefer `claude mcp add` (Claude Code CLI ≥2.x stores servers in
120
+ # ~/.claude/.claude.json, which is what `claude mcp list` reads).
121
+ claude_cli = shutil.which("claude")
122
+ used_claude_cli = False
123
+ if claude_cli:
124
+ import subprocess
125
+
126
+ # Check if already registered
127
+ result = subprocess.run(
128
+ [claude_cli, "mcp", "get", "headroom"],
129
+ capture_output=True,
130
+ text=True,
131
+ )
132
+ already_registered = result.returncode == 0
133
+
134
+ if already_registered and not force:
135
+ click.echo("Headroom MCP is already configured in Claude Code.")
136
+ click.echo("Use --force to overwrite, or 'headroom mcp uninstall' first.")
137
+ raise SystemExit(0)
138
+
139
+ if already_registered and force:
140
+ subprocess.run(
141
+ [claude_cli, "mcp", "remove", "headroom", "-s", "user"],
142
+ capture_output=True,
143
+ )
144
+
145
+ add_cmd = [claude_cli, "mcp", "add", "headroom", "-s", "user"]
146
+ for k, v in env.items():
147
+ add_cmd += ["-e", f"{k}={v}"]
148
+ add_cmd += ["--", *command]
149
+
150
+ result = subprocess.run(add_cmd, capture_output=True, text=True)
151
+ if result.returncode == 0:
152
+ used_claude_cli = True
153
+ else:
154
+ click.echo(
155
+ f"Warning: 'claude mcp add' failed ({result.stderr.strip()}), "
156
+ "falling back to mcp.json.",
157
+ err=True,
158
+ )
159
+
160
+ if not used_claude_cli:
161
+ # Fallback: write ~/.claude/mcp.json (used by older Claude Code versions
162
+ # and the Claude.ai desktop app).
163
+ config = load_mcp_config()
164
 
165
+ if "headroom" in config.get("mcpServers", {}) and not force:
166
+ click.echo("Headroom MCP is already configured in Claude Code.")
167
+ click.echo("Use --force to overwrite, or 'headroom mcp uninstall' first.")
168
+ raise SystemExit(0)
 
169
 
170
+ server_config: dict = {"command": command[0], "args": command[1:]}
171
+ if env:
172
+ server_config["env"] = env
173
 
174
+ if "mcpServers" not in config:
175
+ config["mcpServers"] = {}
176
+ config["mcpServers"]["headroom"] = server_config
177
+ save_mcp_config(config)
178
 
179
+ config_note = (
180
+ "Registered via: claude mcp add (scope: user)"
181
+ if used_claude_cli
182
+ else f"Configuration written to: {MCP_CONFIG_PATH}"
183
+ )
184
 
185
  click.echo(f"""
186
  ✓ Headroom MCP server installed!
187
 
188
+ {config_note}
189
 
190
  Next steps:
191
  1. Start the Headroom proxy (if not running):
tests/test_cli/test_mcp.py CHANGED
@@ -39,11 +39,26 @@ def temp_claude_dir(tmp_path):
39
 
40
  @pytest.fixture
41
  def mock_claude_config_path(temp_claude_dir):
42
- """Patch the MCP config path to use temp directory."""
 
 
 
 
 
 
43
  config_path = temp_claude_dir / "mcp.json"
 
 
 
 
 
 
 
 
44
  with patch("headroom.cli.mcp.MCP_CONFIG_PATH", config_path):
45
  with patch("headroom.cli.mcp.CLAUDE_CONFIG_DIR", temp_claude_dir):
46
- yield config_path
 
47
 
48
 
49
  @pytest.fixture
 
39
 
40
  @pytest.fixture
41
  def mock_claude_config_path(temp_claude_dir):
42
+ """Patch the MCP config path to use temp directory.
43
+
44
+ Also mocks the claude CLI as absent so tests exercise the mcp.json
45
+ fallback path rather than the `claude mcp add` path.
46
+ """
47
+ import shutil as _shutil
48
+
49
  config_path = temp_claude_dir / "mcp.json"
50
+ # Capture the original function reference before patching so we don't recurse.
51
+ _real_which = _shutil.which
52
+
53
+ def which_no_claude(cmd):
54
+ if cmd == "claude":
55
+ return None
56
+ return _real_which(cmd)
57
+
58
  with patch("headroom.cli.mcp.MCP_CONFIG_PATH", config_path):
59
  with patch("headroom.cli.mcp.CLAUDE_CONFIG_DIR", temp_claude_dir):
60
+ with patch("headroom.cli.mcp.shutil.which", side_effect=which_no_claude):
61
+ yield config_path
62
 
63
 
64
  @pytest.fixture