From fe2e30a7efb600f2c50e78b6d5fa551344005d4c Mon Sep 17 00:00:00 2001 From: chopratejas Date: Mon, 2 Feb 2026 11:05:24 -0800 Subject: [PATCH] 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 --- README.md | 39 +++- docs/mcp.md | 192 +++++++++++++++++ headroom/cli/main.py | 1 + headroom/cli/mcp.py | 325 ++++++++++++++++++++++++++++ headroom/image/trained_router.py | 9 +- pyproject.toml | 7 +- tests/test_cli/test_mcp.py | 354 +++++++++++++++++++++++++++++++ 7 files changed, 923 insertions(+), 4 deletions(-) create mode 100644 docs/mcp.md create mode 100644 headroom/cli/mcp.py create mode 100644 tests/test_cli/test_mcp.py diff --git a/README.md b/README.md index cdc4b26bb..51d9a0028 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +
+Why MCP for subscriptions? + +- **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. +
+ **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 | diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..5b8a65bee --- /dev/null +++ b/docs/mcp.md @@ -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. diff --git a/headroom/cli/main.py b/headroom/cli/main.py index 4dc16e131..1992162ee 100644 --- a/headroom/cli/main.py +++ b/headroom/cli/main.py @@ -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 ) diff --git a/headroom/cli/mcp.py b/headroom/cli/mcp.py new file mode 100644 index 000000000..318abeafc --- /dev/null +++ b/headroom/cli/mcp.py @@ -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 diff --git a/headroom/image/trained_router.py b/headroom/image/trained_router.py index bf18cae70..8d068cfa6 100644 --- a/headroom/image/trained_router.py +++ b/headroom/image/trained_router.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6e5974fc8..e3fee8b03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/test_cli/test_mcp.py b/tests/test_cli/test_mcp.py new file mode 100644 index 000000000..feb1896f3 --- /dev/null +++ b/tests/test_cli/test_mcp.py @@ -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()