"""CCR MCP Server - Exposes headroom_retrieve as an MCP tool. This MCP server allows LLMs to retrieve compressed content via MCP instead of through injected tool definitions. It connects to the Headroom proxy's CompressionStore to serve retrieval requests. Usage: # As standalone server (stdio transport) python -m headroom.ccr.mcp_server # With custom proxy URL python -m headroom.ccr.mcp_server --proxy-url http://localhost:8787 # Add to Claude Code's MCP config (~/.claude/mcp.json): { "mcpServers": { "headroom": { "command": "python", "args": ["-m", "headroom.ccr.mcp_server"] } } } When MCP is configured, the proxy will detect the tool is already present and skip tool injection, avoiding duplicate tools. """ from __future__ import annotations import argparse import asyncio import json import logging import os from typing import Any # Try to import MCP SDK try: from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import TextContent, Tool MCP_AVAILABLE = True except ImportError: MCP_AVAILABLE = False Server = None stdio_server = None # Try to import httpx for proxy communication try: import httpx HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] from .tool_injection import CCR_TOOL_NAME logger = logging.getLogger("headroom.ccr.mcp") # Default proxy URL (can be overridden via env or args) DEFAULT_PROXY_URL = os.environ.get("HEADROOM_PROXY_URL", "http://127.0.0.1:8787") class CCRMCPServer: """MCP Server that exposes headroom_retrieve tool. This server can operate in two modes: 1. HTTP mode: Calls the proxy's /v1/retrieve endpoint (default) 2. Direct mode: Uses CompressionStore directly (same process) HTTP mode is recommended as it ensures consistency with the proxy. """ def __init__( self, proxy_url: str = DEFAULT_PROXY_URL, direct_mode: bool = False, ): """Initialize CCR MCP Server. Args: proxy_url: URL of the Headroom proxy server. direct_mode: If True, access CompressionStore directly instead of via HTTP. """ self.proxy_url = proxy_url self.direct_mode = direct_mode self._http_client: httpx.AsyncClient | None = None if not MCP_AVAILABLE: raise ImportError("MCP SDK not installed. Install with: pip install mcp") if not direct_mode and not HTTPX_AVAILABLE: raise ImportError( "httpx not installed (required for HTTP mode). Install with: pip install httpx" ) self.server = Server("headroom-ccr") self._setup_handlers() def _setup_handlers(self): """Set up MCP tool handlers.""" @self.server.list_tools() async def list_tools() -> list[Tool]: """Return available tools.""" return [ Tool( name=CCR_TOOL_NAME, description=( "Retrieve original uncompressed content that was compressed " "to save tokens. Use this when you need more data than what's " "shown in compressed tool results. The hash is provided in " "compression markers like [N items compressed... hash=abc123]." ), inputSchema={ "type": "object", "properties": { "hash": { "type": "string", "description": "Hash key from the compression marker (e.g., 'abc123' from hash=abc123)", }, "query": { "type": "string", "description": ( "Optional search query to filter results. " "If provided, only returns items matching the query. " "If omitted, returns all original items." ), }, }, "required": ["hash"], }, ) ] @self.server.call_tool() async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: """Handle tool calls.""" if name != CCR_TOOL_NAME: return [ TextContent( type="text", text=json.dumps({"error": f"Unknown tool: {name}"}), ) ] hash_key = arguments.get("hash") query = arguments.get("query") if not hash_key: return [ TextContent( type="text", text=json.dumps({"error": "hash parameter is required"}), ) ] # Retrieve content try: if self.direct_mode: result = await self._retrieve_direct(hash_key, query) else: result = await self._retrieve_via_proxy(hash_key, query) return [ TextContent( type="text", text=json.dumps(result, indent=2), ) ] except Exception as e: logger.error(f"Retrieval failed: {e}") return [ TextContent( type="text", text=json.dumps({"error": str(e)}), ) ] async def _retrieve_via_proxy( self, hash_key: str, query: str | None, ) -> dict[str, Any]: """Retrieve content via proxy's HTTP endpoint.""" if self._http_client is None: self._http_client = httpx.AsyncClient(timeout=30.0) url = f"{self.proxy_url}/v1/retrieve" payload = {"hash": hash_key} if query: payload["query"] = query response = await self._http_client.post(url, json=payload) if response.status_code == 404: return { "error": "Entry not found or expired (TTL: 5 minutes)", "hash": hash_key, } response.raise_for_status() result: dict[str, Any] = response.json() return result async def _retrieve_direct( self, hash_key: str, query: str | None, ) -> dict[str, Any]: """Retrieve content directly from CompressionStore.""" from headroom.cache.compression_store import get_compression_store store = get_compression_store() if query: results = store.search(hash_key, query) return { "hash": hash_key, "query": query, "results": results, "count": len(results), } else: entry = store.retrieve(hash_key) if entry: return { "hash": hash_key, "original_content": entry.original_content, "original_item_count": entry.original_item_count, "compressed_item_count": entry.compressed_item_count, "retrieval_count": entry.retrieval_count, } return { "error": "Entry not found or expired (TTL: 5 minutes)", "hash": hash_key, } async def run_stdio(self): """Run the server with stdio transport.""" async with stdio_server() as (read_stream, write_stream): logger.info(f"CCR MCP Server starting (proxy: {self.proxy_url})") await self.server.run( read_stream, write_stream, self.server.create_initialization_options(), ) async def cleanup(self): """Clean up resources.""" if self._http_client: await self._http_client.aclose() def create_ccr_mcp_server( proxy_url: str = DEFAULT_PROXY_URL, direct_mode: bool = False, ) -> CCRMCPServer: """Create a CCR MCP server instance. Args: proxy_url: URL of the Headroom proxy server. direct_mode: If True, access CompressionStore directly. Returns: CCRMCPServer instance. Example: ```python server = create_ccr_mcp_server() await server.run_stdio() ``` """ return CCRMCPServer(proxy_url=proxy_url, direct_mode=direct_mode) async def main(): """Run the CCR MCP server.""" parser = argparse.ArgumentParser( description="CCR MCP Server - Retrieve compressed content via MCP" ) parser.add_argument( "--proxy-url", default=DEFAULT_PROXY_URL, help=f"Headroom proxy URL (default: {DEFAULT_PROXY_URL})", ) parser.add_argument( "--direct", action="store_true", help="Use direct CompressionStore access instead of HTTP", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) args = parser.parse_args() if args.debug: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.INFO) server = create_ccr_mcp_server( proxy_url=args.proxy_url, direct_mode=args.direct, ) try: await server.run_stdio() finally: await server.cleanup() if __name__ == "__main__": asyncio.run(main())