mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
d51ea5635c
commit
fe2e30a7ef
7 changed files with 923 additions and 4 deletions
39
README.md
39
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
|
||||
```
|
||||
|
||||
<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
192
docs/mcp.md
Normal 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.
|
||||
|
|
@ -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
325
headroom/cli/mcp.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
354
tests/test_cli/test_mcp.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue