Add MCP CLI for Claude Code subscription users

- Add `headroom mcp install` to configure ~/.claude/mcp.json
- Add `headroom mcp uninstall` to remove configuration
- Add `headroom mcp status` to check setup
- Add `headroom mcp serve` for MCP server (called by Claude Code)
- Add `mcp` optional dependency in pyproject.toml
- Add docs/mcp.md with full documentation
- Add 22 integration tests for MCP CLI
- Fix mypy errors in trained_router.py (None check, type annotation)
- Update README with subscription user instructions

This enables CCR (Compress-Cache-Retrieve) for subscription users who
don't have API access. MCP is Claude's official extension mechanism
that works with subscriptions.

Usage:
  pip install "headroom-ai[mcp]"
  headroom mcp install
  headroom proxy  # Terminal 1
  claude          # Terminal 2
This commit is contained in:
chopratejas 2026-02-02 11:05:24 -08:00
parent d51ea5635c
commit fe2e30a7ef
7 changed files with 923 additions and 4 deletions

View file

@ -269,6 +269,40 @@ Memory auto-detects your provider (Anthropic, OpenAI, Gemini) and uses the appro
Set `x-headroom-user-id` header for per-user memory isolation (defaults to 'default').
**Claude Code Subscription Users** - Use MCP for CCR (Compress-Cache-Retrieve):
If you use Claude Code with a subscription (not API key), you need MCP to enable the `headroom_retrieve` tool:
```bash
# One-time setup
pip install "headroom-ai[mcp]"
headroom mcp install
# Every time you code
headroom proxy # Terminal 1
claude # Terminal 2 - now has headroom_retrieve!
```
What this does:
- Configures Claude Code to use Headroom's MCP server (`~/.claude/mcp.json`)
- When the proxy compresses large tool outputs, Claude sees markers like `[47 items compressed... hash=abc123]`
- Claude can call `headroom_retrieve` to get the full original content when needed
Check your setup:
```bash
headroom mcp status
```
<details>
<summary><b>Why MCP for subscriptions?</b></summary>
- **API users** can inject custom tools directly via the Messages API
- **Subscription users** use Claude Code's built-in tool set and can't inject tools programmatically
- **MCP** (Model Context Protocol) is Claude's official way to extend tools - it works with subscriptions
The MCP server exposes `headroom_retrieve` so Claude can request uncompressed content when the compressed summary isn't enough.
</details>
**Using AWS Bedrock, Google Vertex, or Azure?** Route through Headroom:
```bash
@ -380,7 +414,7 @@ See the full [Agno Integration Guide](docs/agno.md) for hooks, multi-provider su
|-----------|-------------|------|
| **LangChain** | `HeadroomChatModel`, memory, retrievers, agents | [Guide](docs/langchain.md) |
| **Agno** | `HeadroomAgnoModel`, hooks, multi-provider | [Guide](docs/agno.md) |
| **MCP** | Tool output compression for Claude | [Guide](docs/ccr.md) |
| **MCP** | Claude Code subscription support via `headroom mcp install` | [Guide](docs/mcp.md) |
| **Any OpenAI Client** | Proxy server | [Guide](docs/proxy.md) |
---
@ -396,6 +430,7 @@ See the full [Agno Integration Guide](docs/agno.md) for hooks, multi-provider su
| **CacheAligner** | Stabilizes prefixes for provider caching | [Transforms](docs/transforms.md) |
| **IntelligentContext** | Score-based context dropping with TOIN-learned importance | [Transforms](docs/transforms.md) |
| **CCR** | Reversible compression with automatic retrieval | [CCR Guide](docs/ccr.md) |
| **MCP Server** | Claude Code subscription support via `headroom mcp install` | [MCP Guide](docs/mcp.md) |
| **LangChain** | Memory, retrievers, agents, streaming | [LangChain](docs/langchain.md) |
| **Agno** | Agent framework integration with hooks | [Agno](docs/agno.md) |
| **Text Utilities** | Opt-in compression for search/logs | [Text Compression](docs/text-compression.md) |
@ -511,6 +546,7 @@ pip install "headroom-ai[all]"
# Or install specific components
pip install headroom-ai # SDK only
pip install "headroom-ai[proxy]" # Proxy server
pip install "headroom-ai[mcp]" # MCP server for Claude Code subscriptions
pip install "headroom-ai[langchain]" # LangChain integration
pip install "headroom-ai[agno]" # Agno agent framework
pip install "headroom-ai[evals]" # Evaluation framework
@ -537,6 +573,7 @@ pip install "headroom-ai[llmlingua]" # ML-based compression
| [Proxy Guide](docs/proxy.md) | Production deployment |
| [Configuration](docs/configuration.md) | All options |
| [CCR Guide](docs/ccr.md) | Reversible compression |
| [MCP Guide](docs/mcp.md) | Claude Code subscription support |
| [Metrics](docs/metrics.md) | Monitoring |
| [Troubleshooting](docs/troubleshooting.md) | Common issues |

192
docs/mcp.md Normal file
View file

@ -0,0 +1,192 @@
# MCP Server for Claude Code Subscriptions
Headroom's MCP (Model Context Protocol) server enables **CCR (Compress-Cache-Retrieve)** for Claude Code subscription users who don't have direct API access.
## Quick Start
```bash
# Install MCP dependencies
pip install "headroom-ai[mcp]"
# Configure Claude Code (one-time)
headroom mcp install
# Start the proxy
headroom proxy
# Use Claude Code - it now has headroom_retrieve!
claude
```
## Why MCP?
| Authentication | Custom Tools | Solution |
|----------------|--------------|----------|
| **API Key** | Direct injection via Messages API | Works automatically |
| **Subscription** | Claude Code's built-in tools only | MCP server |
Claude Code subscription users can't inject custom tools programmatically. MCP is Claude's official extension mechanism that works with subscriptions.
## How It Works
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Claude Code │────▶│ Headroom Proxy │────▶│ LLM Provider │
└────────┬────────┘ └────────┬────────┘ └─────────────────┘
│ │
│ MCP │ Stores compressed
│ │ content
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ MCP Server │◀───▶│ Compression │
│ (headroom_ │ │ Store │
│ retrieve) │ └─────────────────┘
└─────────────────┘
```
1. **Proxy compresses** large tool outputs (file listings, search results, logs)
2. **Claude sees** compressed summaries with hash markers: `[47 items compressed... hash=abc123]`
3. **When needed**, Claude calls `headroom_retrieve` to get the original content
4. **MCP server** fetches from the proxy's compression store
## CLI Commands
### Install MCP Configuration
```bash
headroom mcp install
```
This writes to `~/.claude/mcp.json`:
```json
{
"mcpServers": {
"headroom": {
"command": "headroom",
"args": ["mcp", "serve"]
}
}
}
```
Options:
- `--proxy-url URL` - Custom proxy URL (default: `http://127.0.0.1:8787`)
- `--force` - Overwrite existing configuration
### Check Status
```bash
headroom mcp status
```
Shows:
- MCP SDK installation status
- Claude Code configuration status
- Proxy connectivity
Example output:
```
Headroom MCP Status
========================================
MCP SDK: ✓ Installed
Claude Config: ✓ Configured
/Users/you/.claude/mcp.json
Proxy URL: http://127.0.0.1:8787
Proxy Status: ✓ Running at http://127.0.0.1:8787
```
### Uninstall
```bash
headroom mcp uninstall
```
Removes headroom from `~/.claude/mcp.json` while preserving other MCP servers.
### Manual Server Start
```bash
headroom mcp serve
```
This is called by Claude Code automatically. For debugging:
```bash
headroom mcp serve --debug
```
## The headroom_retrieve Tool
When the MCP server is active, Claude has access to:
```
Tool: headroom_retrieve
Parameters:
- hash (required): Hash key from compression marker
- query (optional): Search query to filter results
Returns:
- Full original content, or
- Filtered results matching query
```
Example interaction:
```
Claude sees:
[47 log entries compressed. Showing first 3 + anomalies.
Use headroom_retrieve(hash="a1b2c3") for full logs]
Claude calls:
headroom_retrieve(hash="a1b2c3", query="error")
Returns:
[All log entries containing "error"]
```
## Custom Proxy URL
If your proxy runs on a different port:
```bash
# During install
headroom mcp install --proxy-url http://localhost:9000
# Or via environment variable
export HEADROOM_PROXY_URL=http://localhost:9000
headroom mcp serve
```
## Troubleshooting
### "MCP SDK not installed"
```bash
pip install "headroom-ai[mcp]"
```
### "Proxy not running"
Start the proxy in another terminal:
```bash
headroom proxy
```
### "Entry not found or expired"
Compressed entries expire after 5 minutes (TTL). The proxy must be running continuously during your session.
### Claude doesn't see headroom_retrieve
1. Check status: `headroom mcp status`
2. Restart Claude Code after installing MCP
3. Verify `~/.claude/mcp.json` exists and contains headroom
## API Users
If you have an `ANTHROPIC_API_KEY`, you don't need MCP. The proxy automatically injects the `headroom_retrieve` tool into API requests.
MCP is specifically for subscription users who authenticate via Claude Code's OAuth flow rather than an API key.

View file

@ -35,6 +35,7 @@ def _register_commands() -> None:
"""Register all subcommand groups."""
from . import (
evals, # noqa: F401
mcp, # noqa: F401
memory, # noqa: F401
proxy, # noqa: F401
)

325
headroom/cli/mcp.py Normal file
View file

@ -0,0 +1,325 @@
"""MCP (Model Context Protocol) CLI commands for Claude Code integration.
Provides commands to configure and run the Headroom MCP server, enabling
Claude Code subscription users to use CCR (Compress-Cache-Retrieve) without
needing API key access.
"""
import json
import shutil
import sys
from pathlib import Path
from typing import Any
import click
from .main import main
# Default paths
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
MCP_CONFIG_PATH = CLAUDE_CONFIG_DIR / "mcp.json"
DEFAULT_PROXY_URL = "http://127.0.0.1:8787"
def get_headroom_command() -> list[str]:
"""Get the command to run headroom MCP server.
Returns the most reliable way to invoke headroom based on installation.
"""
# Check if headroom is in PATH
headroom_path = shutil.which("headroom")
if headroom_path:
return ["headroom", "mcp", "serve"]
# Fall back to python -m
return [sys.executable, "-m", "headroom.ccr.mcp_server"]
def load_mcp_config() -> dict[str, Any]:
"""Load existing MCP config or return empty structure."""
if MCP_CONFIG_PATH.exists():
try:
with open(MCP_CONFIG_PATH) as f:
result: dict[str, Any] = json.load(f)
return result
except (json.JSONDecodeError, OSError):
return {"mcpServers": {}}
return {"mcpServers": {}}
def save_mcp_config(config: dict) -> None:
"""Save MCP config, creating directory if needed."""
CLAUDE_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(MCP_CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
f.write("\n") # Trailing newline
@main.group()
def mcp() -> None:
"""MCP server for Claude Code integration.
\b
The MCP server exposes headroom_retrieve as a tool that Claude Code
can use to retrieve compressed content. This enables CCR (Compress-
Cache-Retrieve) for subscription users who don't have API access.
\b
Quick Start:
headroom mcp install # Configure Claude Code
headroom proxy # Start the proxy (in another terminal)
claude # Start Claude Code - it now has headroom!
\b
How it works:
1. The proxy compresses large tool outputs (file listings, search results)
2. Claude sees compressed summaries with hash markers
3. When Claude needs full details, it calls headroom_retrieve
4. The MCP server fetches original content from the proxy
"""
pass
@mcp.command("install")
@click.option(
"--proxy-url",
default=DEFAULT_PROXY_URL,
help=f"Headroom proxy URL (default: {DEFAULT_PROXY_URL})",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing headroom config",
)
def mcp_install(proxy_url: str, force: bool) -> None:
"""Install Headroom MCP server into Claude Code config.
\b
This adds headroom to ~/.claude/mcp.json so Claude Code can use
the headroom_retrieve tool for CCR (Compress-Cache-Retrieve).
\b
Example:
headroom mcp install
headroom mcp install --proxy-url http://localhost:9000
"""
# Check for MCP SDK
try:
import mcp # noqa: F401
except ImportError:
click.echo("Error: MCP SDK not installed.", err=True)
click.echo("Install with: pip install 'headroom-ai[mcp]'", err=True)
raise SystemExit(1) from None
config = load_mcp_config()
# Check if already configured
if "headroom" in config.get("mcpServers", {}) and not force:
click.echo("Headroom MCP is already configured in Claude Code.")
click.echo("Use --force to overwrite, or 'headroom mcp uninstall' first.")
raise SystemExit(0)
# Build server config
command = get_headroom_command()
# Add proxy URL as environment variable if non-default
server_config: dict = {
"command": command[0],
"args": command[1:],
}
if proxy_url != DEFAULT_PROXY_URL:
server_config["env"] = {"HEADROOM_PROXY_URL": proxy_url}
# Update config
if "mcpServers" not in config:
config["mcpServers"] = {}
config["mcpServers"]["headroom"] = server_config
# Save
save_mcp_config(config)
click.echo(f"""
Headroom MCP server installed!
Configuration written to: {MCP_CONFIG_PATH}
Next steps:
1. Start the Headroom proxy (if not running):
headroom proxy
2. Start Claude Code:
claude
3. Claude Code now has access to headroom_retrieve tool!
Compressed content will show hash markers like:
[47 items compressed... hash=abc123]
Claude can retrieve full details when needed.
Proxy URL: {proxy_url}
""")
@mcp.command("uninstall")
def mcp_uninstall() -> None:
"""Remove Headroom MCP server from Claude Code config.
\b
This removes headroom from ~/.claude/mcp.json.
Other MCP servers in your config are preserved.
"""
if not MCP_CONFIG_PATH.exists():
click.echo("No MCP config found. Nothing to uninstall.")
raise SystemExit(0)
config = load_mcp_config()
if "headroom" not in config.get("mcpServers", {}):
click.echo("Headroom MCP is not configured. Nothing to uninstall.")
raise SystemExit(0)
# Remove headroom
del config["mcpServers"]["headroom"]
# Save (or delete if empty)
if config.get("mcpServers"):
save_mcp_config(config)
click.echo(f"✓ Headroom MCP server removed from {MCP_CONFIG_PATH}")
else:
# Config is now empty, could delete but safer to leave empty
save_mcp_config(config)
click.echo(f"✓ Headroom MCP server removed from {MCP_CONFIG_PATH}")
@mcp.command("status")
def mcp_status() -> None:
"""Check Headroom MCP configuration status.
\b
Shows whether headroom is configured in Claude Code and if
the proxy is reachable.
"""
click.echo("Headroom MCP Status")
click.echo("=" * 40)
# Check MCP SDK
try:
import mcp # noqa: F401
click.echo("MCP SDK: ✓ Installed")
except ImportError:
click.echo("MCP SDK: ✗ Not installed")
click.echo(" pip install 'headroom-ai[mcp]'")
# Check config
if MCP_CONFIG_PATH.exists():
config = load_mcp_config()
if "headroom" in config.get("mcpServers", {}):
server_config = config["mcpServers"]["headroom"]
click.echo("Claude Config: ✓ Configured")
click.echo(f" {MCP_CONFIG_PATH}")
# Show proxy URL
env = server_config.get("env", {})
proxy_url = env.get("HEADROOM_PROXY_URL", DEFAULT_PROXY_URL)
click.echo(f"Proxy URL: {proxy_url}")
else:
click.echo("Claude Config: ✗ Not configured")
click.echo(" Run: headroom mcp install")
else:
click.echo("Claude Config: ✗ No config file")
click.echo(" Run: headroom mcp install")
# Check proxy connectivity
try:
import httpx
config = load_mcp_config()
env = config.get("mcpServers", {}).get("headroom", {}).get("env", {})
proxy_url = env.get("HEADROOM_PROXY_URL", DEFAULT_PROXY_URL)
try:
response = httpx.get(f"{proxy_url}/health", timeout=2.0)
if response.status_code == 200:
click.echo(f"Proxy Status: ✓ Running at {proxy_url}")
else:
click.echo(f"Proxy Status: ✗ Unhealthy (status {response.status_code})")
except httpx.ConnectError:
click.echo("Proxy Status: ✗ Not running")
click.echo(" Run: headroom proxy")
except httpx.TimeoutException:
click.echo("Proxy Status: ✗ Timeout")
except ImportError:
click.echo("Proxy Status: ? (httpx not installed)")
@mcp.command("serve")
@click.option(
"--proxy-url",
default=None,
envvar="HEADROOM_PROXY_URL",
help=f"Headroom proxy URL (default: {DEFAULT_PROXY_URL})",
)
@click.option(
"--direct",
is_flag=True,
help="Use direct CompressionStore access (same process as proxy)",
)
@click.option(
"--debug",
is_flag=True,
help="Enable debug logging",
)
def mcp_serve(proxy_url: str | None, direct: bool, debug: bool) -> None:
"""Start the MCP server (called by Claude Code).
\b
This command is typically invoked by Claude Code via the MCP config,
not run directly. It starts the MCP server with stdio transport.
\b
For manual testing:
headroom mcp serve --debug
"""
import asyncio
import logging
# Check for MCP SDK
try:
from headroom.ccr.mcp_server import create_ccr_mcp_server
except ImportError as e:
click.echo(f"Error: MCP dependencies not installed: {e}", err=True)
click.echo("Install with: pip install 'headroom-ai[mcp]'", err=True)
raise SystemExit(1) from None
if debug:
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
else:
# Minimal logging for MCP (stdout is used for protocol)
logging.basicConfig(
level=logging.WARNING,
format="%(levelname)s: %(message)s",
)
# Use default if not specified
effective_proxy_url = proxy_url or DEFAULT_PROXY_URL
server = create_ccr_mcp_server(
proxy_url=effective_proxy_url,
direct_mode=direct,
)
async def run() -> None:
try:
await server.run_stdio()
finally:
await server.cleanup()
try:
asyncio.run(run())
except KeyboardInterrupt:
pass # Clean exit on Ctrl+C

View file

@ -40,7 +40,12 @@ def _extract_tensor(output: torch.Tensor | BaseModelOutputWithPooling) -> torch.
# Use pooler_output if available, otherwise last_hidden_state[:, 0]
if output.pooler_output is not None:
return output.pooler_output
return output.last_hidden_state[:, 0]
if output.last_hidden_state is not None:
return output.last_hidden_state[:, 0]
# Fallback: shouldn't happen, but return empty tensor
raise ValueError(
"BaseModelOutputWithPooling has neither pooler_output nor last_hidden_state"
)
return output
@ -260,7 +265,7 @@ class TrainedRouter:
with torch.no_grad():
image_output = self._siglip_model.get_image_features(**inputs)
image_embeds = _extract_tensor(image_output)
image_embeds: torch.Tensor = _extract_tensor(image_output)
image_embeds = image_embeds / image_embeds.norm(dim=-1, keepdim=True)
return image_embeds

View file

@ -94,6 +94,11 @@ agno = [
strands = [
"strands-agents>=0.1.0",
]
# MCP server for Claude Code integration (CCR without API access)
mcp = [
"mcp>=1.0.0",
"httpx>=0.24.0",
]
# Voice filler detection (training and inference)
voice = [
"onnxruntime>=1.16.0", # Fast CPU inference
@ -150,7 +155,7 @@ dev = [
]
# All optional dependencies
all = [
"headroom-ai[relevance,proxy,reports,llmlingua,code,evals,memory,voice,html,benchmark]",
"headroom-ai[relevance,proxy,reports,llmlingua,code,evals,memory,voice,html,benchmark,mcp]",
]
[project.scripts]

354
tests/test_cli/test_mcp.py Normal file
View file

@ -0,0 +1,354 @@
"""Integration tests for MCP CLI commands.
These are real tests that:
- Actually write/read config files
- Test actual CLI behavior
- Test MCP server initialization
"""
import json
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from headroom.cli.main import main
from headroom.cli.mcp import (
get_headroom_command,
load_mcp_config,
save_mcp_config,
)
@pytest.fixture
def temp_claude_dir(tmp_path):
"""Create a temporary .claude directory for testing."""
claude_dir = tmp_path / ".claude"
claude_dir.mkdir()
return claude_dir
@pytest.fixture
def mock_claude_config_path(temp_claude_dir):
"""Patch the MCP config path to use temp directory."""
config_path = temp_claude_dir / "mcp.json"
with patch("headroom.cli.mcp.MCP_CONFIG_PATH", config_path):
with patch("headroom.cli.mcp.CLAUDE_CONFIG_DIR", temp_claude_dir):
yield config_path
class TestMCPConfigFunctions:
"""Test config file handling functions."""
def test_get_headroom_command_returns_list(self):
"""Command should be a list suitable for subprocess."""
cmd = get_headroom_command()
assert isinstance(cmd, list)
assert len(cmd) >= 1
# Should end with mcp serve args
assert "mcp" in cmd or "-m" in cmd
def test_load_mcp_config_empty_when_no_file(self, mock_claude_config_path):
"""Loading non-existent config returns empty structure."""
config = load_mcp_config()
assert config == {"mcpServers": {}}
def test_save_and_load_config(self, mock_claude_config_path):
"""Config can be saved and loaded back."""
test_config = {
"mcpServers": {
"headroom": {
"command": "headroom",
"args": ["mcp", "serve"],
}
}
}
save_mcp_config(test_config)
# File should exist
assert mock_claude_config_path.exists()
# Load it back
loaded = load_mcp_config()
assert loaded == test_config
def test_save_config_creates_directory(self, tmp_path):
"""save_mcp_config creates parent directory if needed."""
claude_dir = tmp_path / "new_dir" / ".claude"
config_path = claude_dir / "mcp.json"
with patch("headroom.cli.mcp.MCP_CONFIG_PATH", config_path):
with patch("headroom.cli.mcp.CLAUDE_CONFIG_DIR", claude_dir):
save_mcp_config({"mcpServers": {}})
assert config_path.exists()
def test_load_config_preserves_other_servers(self, mock_claude_config_path):
"""Loading preserves other MCP servers in config."""
# Write config with another server
existing_config = {
"mcpServers": {
"other-server": {"command": "other", "args": []},
}
}
mock_claude_config_path.write_text(json.dumps(existing_config))
loaded = load_mcp_config()
assert "other-server" in loaded["mcpServers"]
class TestMCPInstallCommand:
"""Test 'headroom mcp install' command."""
def test_install_creates_config(self, mock_claude_config_path):
"""Install creates MCP config file."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "install"])
assert result.exit_code == 0
assert "installed" in result.output.lower()
assert mock_claude_config_path.exists()
# Verify config content
config = json.loads(mock_claude_config_path.read_text())
assert "headroom" in config["mcpServers"]
assert config["mcpServers"]["headroom"]["command"] == "headroom"
assert "mcp" in config["mcpServers"]["headroom"]["args"]
assert "serve" in config["mcpServers"]["headroom"]["args"]
def test_install_preserves_other_servers(self, mock_claude_config_path):
"""Install preserves existing MCP servers."""
# Create config with another server
existing_config = {
"mcpServers": {
"github": {"command": "github-mcp", "args": []},
}
}
mock_claude_config_path.write_text(json.dumps(existing_config))
runner = CliRunner()
result = runner.invoke(main, ["mcp", "install"])
assert result.exit_code == 0
# Both servers should exist
config = json.loads(mock_claude_config_path.read_text())
assert "github" in config["mcpServers"]
assert "headroom" in config["mcpServers"]
def test_install_with_custom_proxy_url(self, mock_claude_config_path):
"""Install with custom proxy URL sets env var."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "install", "--proxy-url", "http://localhost:9000"])
assert result.exit_code == 0
config = json.loads(mock_claude_config_path.read_text())
assert (
config["mcpServers"]["headroom"]["env"]["HEADROOM_PROXY_URL"] == "http://localhost:9000"
)
def test_install_default_proxy_url_no_env(self, mock_claude_config_path):
"""Install with default proxy URL doesn't set env var."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "install"])
assert result.exit_code == 0
config = json.loads(mock_claude_config_path.read_text())
# No env section for default URL
assert "env" not in config["mcpServers"]["headroom"]
def test_install_already_configured_no_force(self, mock_claude_config_path):
"""Install without --force when already configured exits cleanly."""
# First install
runner = CliRunner()
runner.invoke(main, ["mcp", "install"])
# Second install without force
result = runner.invoke(main, ["mcp", "install"])
assert result.exit_code == 0
assert "already configured" in result.output.lower()
def test_install_force_overwrites(self, mock_claude_config_path):
"""Install with --force overwrites existing config."""
runner = CliRunner()
runner.invoke(main, ["mcp", "install", "--proxy-url", "http://old:8787"])
# Force install with new URL
result = runner.invoke(
main, ["mcp", "install", "--force", "--proxy-url", "http://new:9000"]
)
assert result.exit_code == 0
assert "installed" in result.output.lower()
config = json.loads(mock_claude_config_path.read_text())
assert config["mcpServers"]["headroom"]["env"]["HEADROOM_PROXY_URL"] == "http://new:9000"
class TestMCPUninstallCommand:
"""Test 'headroom mcp uninstall' command."""
def test_uninstall_removes_headroom(self, mock_claude_config_path):
"""Uninstall removes headroom from config."""
# First install
runner = CliRunner()
runner.invoke(main, ["mcp", "install"])
# Then uninstall
result = runner.invoke(main, ["mcp", "uninstall"])
assert result.exit_code == 0
assert "removed" in result.output.lower()
config = json.loads(mock_claude_config_path.read_text())
assert "headroom" not in config["mcpServers"]
def test_uninstall_preserves_other_servers(self, mock_claude_config_path):
"""Uninstall preserves other MCP servers."""
# Create config with headroom and another server
config = {
"mcpServers": {
"headroom": {"command": "headroom", "args": ["mcp", "serve"]},
"github": {"command": "github-mcp", "args": []},
}
}
mock_claude_config_path.write_text(json.dumps(config))
runner = CliRunner()
result = runner.invoke(main, ["mcp", "uninstall"])
assert result.exit_code == 0
config = json.loads(mock_claude_config_path.read_text())
assert "headroom" not in config["mcpServers"]
assert "github" in config["mcpServers"]
def test_uninstall_no_config_file(self, mock_claude_config_path):
"""Uninstall with no config file exits cleanly."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "uninstall"])
assert result.exit_code == 0
assert "nothing to uninstall" in result.output.lower()
def test_uninstall_not_configured(self, mock_claude_config_path):
"""Uninstall when headroom not in config exits cleanly."""
# Create config without headroom
config = {"mcpServers": {"other": {"command": "other"}}}
mock_claude_config_path.write_text(json.dumps(config))
runner = CliRunner()
result = runner.invoke(main, ["mcp", "uninstall"])
assert result.exit_code == 0
assert "not configured" in result.output.lower()
class TestMCPStatusCommand:
"""Test 'headroom mcp status' command."""
def test_status_not_configured(self, mock_claude_config_path):
"""Status shows not configured when no config."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "status"])
assert result.exit_code == 0
assert "MCP SDK" in result.output
# Should show not configured
assert (
"" in result.output
or "Not configured" in result.output.lower()
or "No config" in result.output
)
def test_status_configured(self, mock_claude_config_path):
"""Status shows configured when installed."""
runner = CliRunner()
runner.invoke(main, ["mcp", "install"])
result = runner.invoke(main, ["mcp", "status"])
assert result.exit_code == 0
assert "✓ Configured" in result.output
class TestMCPServeCommand:
"""Test 'headroom mcp serve' command."""
def test_serve_help(self):
"""Serve command shows help."""
runner = CliRunner()
result = runner.invoke(main, ["mcp", "serve", "--help"])
assert result.exit_code == 0
assert "proxy-url" in result.output
assert "debug" in result.output
class TestMCPServerInitialization:
"""Test actual MCP server creation."""
def test_mcp_server_can_be_created(self):
"""MCP server can be instantiated."""
from headroom.ccr.mcp_server import create_ccr_mcp_server
server = create_ccr_mcp_server()
assert server is not None
assert server.proxy_url == "http://127.0.0.1:8787"
def test_mcp_server_with_custom_url(self):
"""MCP server accepts custom proxy URL."""
from headroom.ccr.mcp_server import create_ccr_mcp_server
server = create_ccr_mcp_server(proxy_url="http://custom:9000")
assert server.proxy_url == "http://custom:9000"
def test_mcp_server_has_correct_tool_name(self):
"""MCP server is configured for headroom_retrieve tool."""
from headroom.ccr.mcp_server import create_ccr_mcp_server
from headroom.ccr.tool_injection import CCR_TOOL_NAME
server = create_ccr_mcp_server()
# Verify the server was created with correct configuration
assert server.server is not None
assert server.server.name == "headroom-ccr"
# The tool name should be headroom_retrieve
assert CCR_TOOL_NAME == "headroom_retrieve"
class TestEndToEndFlow:
"""Test complete install -> status -> uninstall flow."""
def test_full_lifecycle(self, mock_claude_config_path):
"""Test complete lifecycle of MCP configuration."""
runner = CliRunner()
# Initially not configured
result = runner.invoke(main, ["mcp", "status"])
assert "No config" in result.output or "Not configured" in result.output.lower()
# Install
result = runner.invoke(main, ["mcp", "install"])
assert result.exit_code == 0
assert "installed" in result.output.lower()
# Status shows configured
result = runner.invoke(main, ["mcp", "status"])
assert "✓ Configured" in result.output
# Config file has correct content
config = json.loads(mock_claude_config_path.read_text())
assert config["mcpServers"]["headroom"]["command"] == "headroom"
# Uninstall
result = runner.invoke(main, ["mcp", "uninstall"])
assert result.exit_code == 0
assert "removed" in result.output.lower()
# Status shows not configured
result = runner.invoke(main, ["mcp", "status"])
assert "headroom" not in result.output.lower() or "not configured" in result.output.lower()