Spaces:
Build error
fix(mcp): use claude mcp add for install and fix startup import
Browse filesTwo 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 +3 -1
- headroom/cli/mcp.py +68 -25
- tests/test_cli/test_mcp.py +17 -2
|
@@ -55,7 +55,9 @@ except ImportError:
|
|
| 55 |
HTTPX_AVAILABLE = False
|
| 56 |
httpx = None # type: ignore[assignment]
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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
|
| 99 |
-
|
| 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 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
}
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
config["mcpServers"] =
|
| 137 |
-
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
click.echo(f"""
|
| 143 |
✓ Headroom MCP server installed!
|
| 144 |
|
| 145 |
-
|
| 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):
|
|
@@ -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 |
-
|
|
|
|
| 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
|