mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310, S306, S110, TID251, UP042, F401) and run black on 526 files. Co-authored-by: Hanzo Dev <[email protected]>
602 lines
21 KiB
Python
602 lines
21 KiB
Python
"""Dynamic MCP Proxy for lazy-loading external MCP servers.
|
|
|
|
This module provides functionality to:
|
|
1. Connect to external MCP servers on-demand
|
|
2. Discover their tools dynamically
|
|
3. Proxy tool calls through hanzo-mcp
|
|
4. Support lazy loading - only start when needed
|
|
|
|
Example servers that can be proxied:
|
|
- platform-mcp (Hanzo Platform)
|
|
- @modelcontextprotocol/server-github (GitHub)
|
|
- @modelcontextprotocol/server-cloudflare (Cloudflare)
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import shutil
|
|
import asyncio
|
|
import logging
|
|
import subprocess
|
|
from typing import Any, Dict, List, Callable, Optional, Awaitable
|
|
from pathlib import Path
|
|
from dataclasses import field, dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class MCPServerConfig:
|
|
"""Configuration for an external MCP server."""
|
|
|
|
name: str
|
|
command: List[str]
|
|
env: Dict[str, str] = field(default_factory=dict)
|
|
working_dir: Optional[str] = None
|
|
description: str = ""
|
|
auto_start: bool = False
|
|
lazy_load: bool = True
|
|
auth_required: bool = False
|
|
auth_env_var: Optional[str] = None
|
|
auth_url: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ProxiedTool:
|
|
"""A tool proxied from an external MCP server."""
|
|
|
|
name: str
|
|
description: str
|
|
input_schema: Dict[str, Any]
|
|
server_name: str
|
|
|
|
|
|
class MCPServerConnection:
|
|
"""Connection to an external MCP server."""
|
|
|
|
def __init__(self, config: MCPServerConfig):
|
|
self.config = config
|
|
self.process: Optional[subprocess.Popen] = None
|
|
self.tools: List[ProxiedTool] = []
|
|
self.resources: List[Dict[str, Any]] = []
|
|
self.prompts: List[Dict[str, Any]] = []
|
|
self._reader: Optional[asyncio.StreamReader] = None
|
|
self._writer: Optional[asyncio.StreamWriter] = None
|
|
self._request_id = 0
|
|
self._pending_requests: Dict[int, asyncio.Future] = {}
|
|
self._read_task: Optional[asyncio.Task] = None
|
|
|
|
async def connect(self) -> bool:
|
|
"""Connect to the MCP server."""
|
|
if self.process is not None:
|
|
return True
|
|
|
|
# Check auth if required
|
|
if self.config.auth_required and self.config.auth_env_var:
|
|
if not os.environ.get(self.config.auth_env_var):
|
|
logger.warning(
|
|
f"MCP server '{self.config.name}' requires {self.config.auth_env_var} to be set"
|
|
)
|
|
return False
|
|
|
|
# Prepare environment
|
|
env = os.environ.copy()
|
|
env.update(self.config.env)
|
|
|
|
# Process environment variable references
|
|
for key, value in list(env.items()):
|
|
if (
|
|
isinstance(value, str)
|
|
and value.startswith("${")
|
|
and value.endswith("}")
|
|
):
|
|
var_name = value[2:-1]
|
|
env[key] = os.environ.get(var_name, "")
|
|
|
|
try:
|
|
# Start the MCP server process
|
|
self.process = await asyncio.create_subprocess_exec(
|
|
*self.config.command,
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
cwd=self.config.working_dir,
|
|
)
|
|
|
|
self._reader = self.process.stdout
|
|
self._writer = self.process.stdin
|
|
|
|
# Start reading responses
|
|
self._read_task = asyncio.create_task(self._read_loop())
|
|
|
|
# Initialize the connection
|
|
await self._initialize()
|
|
|
|
# Discover tools
|
|
await self._discover_tools()
|
|
|
|
logger.info(
|
|
f"Connected to MCP server '{self.config.name}' with {len(self.tools)} tools"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to MCP server '{self.config.name}': {e}")
|
|
await self.disconnect()
|
|
return False
|
|
|
|
async def disconnect(self):
|
|
"""Disconnect from the MCP server."""
|
|
if self._read_task:
|
|
self._read_task.cancel()
|
|
try:
|
|
await self._read_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
self._read_task = None
|
|
|
|
if self.process:
|
|
self.process.terminate()
|
|
try:
|
|
await asyncio.wait_for(self.process.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
self.process.kill()
|
|
self.process = None
|
|
|
|
self._reader = None
|
|
self._writer = None
|
|
self.tools = []
|
|
|
|
async def _read_loop(self):
|
|
"""Read responses from the MCP server."""
|
|
try:
|
|
while True:
|
|
if not self._reader:
|
|
break
|
|
|
|
# Read content-length header
|
|
header = await self._reader.readline()
|
|
if not header:
|
|
break
|
|
|
|
# Handle JSON-RPC over stdio (newline-delimited)
|
|
line = header.decode("utf-8").strip()
|
|
if not line:
|
|
continue
|
|
|
|
try:
|
|
response = json.loads(line)
|
|
|
|
# Handle response
|
|
if "id" in response and response["id"] in self._pending_requests:
|
|
future = self._pending_requests.pop(response["id"])
|
|
if "error" in response:
|
|
future.set_exception(Exception(response["error"]))
|
|
else:
|
|
future.set_result(response.get("result"))
|
|
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception as e:
|
|
logger.error(f"Error reading from MCP server: {e}")
|
|
|
|
async def _send_request(self, method: str, params: Optional[Dict] = None) -> Any:
|
|
"""Send a JSON-RPC request to the MCP server."""
|
|
if not self._writer:
|
|
raise ConnectionError("Not connected to MCP server")
|
|
|
|
self._request_id += 1
|
|
request_id = self._request_id
|
|
|
|
request = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": method,
|
|
}
|
|
if params:
|
|
request["params"] = params
|
|
|
|
# Create future for response
|
|
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
self._pending_requests[request_id] = future
|
|
|
|
# Send request
|
|
request_json = json.dumps(request) + "\n"
|
|
self._writer.write(request_json.encode("utf-8"))
|
|
await self._writer.drain()
|
|
|
|
# Wait for response with timeout
|
|
try:
|
|
result = await asyncio.wait_for(future, timeout=30.0)
|
|
return result
|
|
except asyncio.TimeoutError:
|
|
self._pending_requests.pop(request_id, None)
|
|
raise TimeoutError(f"Request to MCP server timed out: {method}")
|
|
|
|
async def _initialize(self):
|
|
"""Initialize the MCP connection."""
|
|
result = await self._send_request(
|
|
"initialize",
|
|
{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {
|
|
"roots": {"listChanged": True},
|
|
},
|
|
"clientInfo": {
|
|
"name": "hanzo-mcp-proxy",
|
|
"version": "1.0.0",
|
|
},
|
|
},
|
|
)
|
|
|
|
# Send initialized notification
|
|
if self._writer:
|
|
notification = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
self._writer.write((json.dumps(notification) + "\n").encode("utf-8"))
|
|
await self._writer.drain()
|
|
|
|
return result
|
|
|
|
async def _discover_tools(self):
|
|
"""Discover tools from the MCP server."""
|
|
try:
|
|
result = await self._send_request("tools/list")
|
|
|
|
self.tools = []
|
|
for tool in result.get("tools", []):
|
|
self.tools.append(
|
|
ProxiedTool(
|
|
name=tool["name"],
|
|
description=tool.get("description", ""),
|
|
input_schema=tool.get("inputSchema", {}),
|
|
server_name=self.config.name,
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to discover tools from '{self.config.name}': {e}")
|
|
|
|
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any:
|
|
"""Call a tool on the MCP server."""
|
|
result = await self._send_request(
|
|
"tools/call",
|
|
{
|
|
"name": tool_name,
|
|
"arguments": arguments,
|
|
},
|
|
)
|
|
return result
|
|
|
|
|
|
class MCPProxyRegistry:
|
|
"""Registry for managing external MCP server proxies."""
|
|
|
|
_instance: Optional["MCPProxyRegistry"] = None
|
|
|
|
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "proxy.json"
|
|
|
|
# Pre-configured servers
|
|
BUILTIN_SERVERS: Dict[str, MCPServerConfig] = {
|
|
"platform": MCPServerConfig(
|
|
name="platform",
|
|
command=["npx", "--yes", "@hanzo/platform-mcp"],
|
|
env={"PLATFORM_API_KEY": "${PLATFORM_API_KEY}"},
|
|
description="Hanzo Platform - deploy and manage applications",
|
|
auth_required=True,
|
|
auth_env_var="PLATFORM_API_KEY",
|
|
auth_url="https://platform.hanzo.ai/settings/api-keys",
|
|
lazy_load=True,
|
|
),
|
|
"github": MCPServerConfig(
|
|
name="github",
|
|
command=["npx", "--yes", "@modelcontextprotocol/server-github"],
|
|
env={"GITHUB_TOKEN": "${GITHUB_TOKEN}"},
|
|
description="GitHub API - repositories, issues, PRs",
|
|
auth_required=True,
|
|
auth_env_var="GITHUB_TOKEN",
|
|
lazy_load=True,
|
|
),
|
|
"cloudflare": MCPServerConfig(
|
|
name="cloudflare",
|
|
command=["npx", "--yes", "mcp-server-cloudflare"],
|
|
env={"CLOUDFLARE_API_TOKEN": "${CLOUDFLARE_API_TOKEN}"},
|
|
description="Cloudflare API - DNS, Workers, R2, KV",
|
|
auth_required=True,
|
|
auth_env_var="CLOUDFLARE_API_TOKEN",
|
|
lazy_load=True,
|
|
),
|
|
"filesystem": MCPServerConfig(
|
|
name="filesystem",
|
|
command=["npx", "--yes", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
|
description="File system access",
|
|
lazy_load=True,
|
|
),
|
|
"postgres": MCPServerConfig(
|
|
name="postgres",
|
|
command=["npx", "--yes", "@modelcontextprotocol/server-postgres"],
|
|
env={"DATABASE_URL": "${DATABASE_URL}"},
|
|
description="PostgreSQL database access",
|
|
auth_required=True,
|
|
auth_env_var="DATABASE_URL",
|
|
lazy_load=True,
|
|
),
|
|
"sqlite": MCPServerConfig(
|
|
name="sqlite",
|
|
command=["uvx", "mcp-server-sqlite"],
|
|
description="SQLite database access",
|
|
lazy_load=True,
|
|
),
|
|
"docker": MCPServerConfig(
|
|
name="docker",
|
|
command=["uvx", "mcp-server-docker"],
|
|
description="Docker container management",
|
|
lazy_load=True,
|
|
),
|
|
"kubernetes": MCPServerConfig(
|
|
name="kubernetes",
|
|
command=["uvx", "mcp-server-kubernetes"],
|
|
description="Kubernetes cluster management",
|
|
lazy_load=True,
|
|
),
|
|
}
|
|
|
|
def __init__(self):
|
|
self._connections: Dict[str, MCPServerConnection] = {}
|
|
self._custom_servers: Dict[str, MCPServerConfig] = {}
|
|
self._load_config()
|
|
|
|
@classmethod
|
|
def get_instance(cls) -> "MCPProxyRegistry":
|
|
"""Get singleton instance."""
|
|
if cls._instance is None:
|
|
cls._instance = MCPProxyRegistry()
|
|
return cls._instance
|
|
|
|
def _load_config(self):
|
|
"""Load custom server configurations."""
|
|
if self.CONFIG_FILE.exists():
|
|
try:
|
|
with open(self.CONFIG_FILE, "r") as f:
|
|
data = json.load(f)
|
|
for name, config in data.get("servers", {}).items():
|
|
self._custom_servers[name] = MCPServerConfig(
|
|
name=name,
|
|
command=config.get("command", []),
|
|
env=config.get("env", {}),
|
|
working_dir=config.get("working_dir"),
|
|
description=config.get("description", ""),
|
|
auto_start=config.get("auto_start", False),
|
|
lazy_load=config.get("lazy_load", True),
|
|
auth_required=config.get("auth_required", False),
|
|
auth_env_var=config.get("auth_env_var"),
|
|
auth_url=config.get("auth_url"),
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load proxy config: {e}")
|
|
|
|
def _save_config(self):
|
|
"""Save custom server configurations."""
|
|
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
servers = {}
|
|
for name, config in self._custom_servers.items():
|
|
servers[name] = {
|
|
"command": config.command,
|
|
"env": config.env,
|
|
"working_dir": config.working_dir,
|
|
"description": config.description,
|
|
"auto_start": config.auto_start,
|
|
"lazy_load": config.lazy_load,
|
|
"auth_required": config.auth_required,
|
|
"auth_env_var": config.auth_env_var,
|
|
"auth_url": config.auth_url,
|
|
}
|
|
|
|
with open(self.CONFIG_FILE, "w") as f:
|
|
json.dump({"servers": servers}, f, indent=2)
|
|
|
|
def get_server_config(self, name: str) -> Optional[MCPServerConfig]:
|
|
"""Get server configuration by name."""
|
|
if name in self._custom_servers:
|
|
return self._custom_servers[name]
|
|
if name in self.BUILTIN_SERVERS:
|
|
return self.BUILTIN_SERVERS[name]
|
|
return None
|
|
|
|
def list_servers(self) -> List[Dict[str, Any]]:
|
|
"""List all available servers."""
|
|
servers = []
|
|
|
|
# Add builtin servers
|
|
for name, config in self.BUILTIN_SERVERS.items():
|
|
is_connected = name in self._connections
|
|
auth_configured = True
|
|
if config.auth_required and config.auth_env_var:
|
|
auth_configured = bool(os.environ.get(config.auth_env_var))
|
|
|
|
servers.append(
|
|
{
|
|
"name": name,
|
|
"description": config.description,
|
|
"builtin": True,
|
|
"connected": is_connected,
|
|
"auth_required": config.auth_required,
|
|
"auth_configured": auth_configured,
|
|
"auth_env_var": config.auth_env_var,
|
|
"auth_url": config.auth_url,
|
|
"tool_count": (
|
|
len(self._connections[name].tools) if is_connected else 0
|
|
),
|
|
}
|
|
)
|
|
|
|
# Add custom servers
|
|
for name, config in self._custom_servers.items():
|
|
if name in self.BUILTIN_SERVERS:
|
|
continue
|
|
|
|
is_connected = name in self._connections
|
|
auth_configured = True
|
|
if config.auth_required and config.auth_env_var:
|
|
auth_configured = bool(os.environ.get(config.auth_env_var))
|
|
|
|
servers.append(
|
|
{
|
|
"name": name,
|
|
"description": config.description,
|
|
"builtin": False,
|
|
"connected": is_connected,
|
|
"auth_required": config.auth_required,
|
|
"auth_configured": auth_configured,
|
|
"auth_env_var": config.auth_env_var,
|
|
"auth_url": config.auth_url,
|
|
"tool_count": (
|
|
len(self._connections[name].tools) if is_connected else 0
|
|
),
|
|
}
|
|
)
|
|
|
|
return servers
|
|
|
|
async def enable_server(self, name: str) -> Dict[str, Any]:
|
|
"""Enable and connect to an MCP server.
|
|
|
|
This starts the server if not running and discovers its tools.
|
|
"""
|
|
config = self.get_server_config(name)
|
|
if not config:
|
|
return {"success": False, "error": f"Server '{name}' not found"}
|
|
|
|
# Check auth
|
|
if config.auth_required and config.auth_env_var:
|
|
if not os.environ.get(config.auth_env_var):
|
|
return {
|
|
"success": False,
|
|
"error": f"Authentication required. Set {config.auth_env_var}",
|
|
"auth_url": config.auth_url,
|
|
}
|
|
|
|
# Check if already connected
|
|
if name in self._connections:
|
|
conn = self._connections[name]
|
|
return {
|
|
"success": True,
|
|
"message": f"Already connected to '{name}'",
|
|
"tools": [
|
|
{"name": t.name, "description": t.description} for t in conn.tools
|
|
],
|
|
}
|
|
|
|
# Create and connect
|
|
conn = MCPServerConnection(config)
|
|
if await conn.connect():
|
|
self._connections[name] = conn
|
|
return {
|
|
"success": True,
|
|
"message": f"Connected to '{name}'",
|
|
"tools": [
|
|
{"name": t.name, "description": t.description} for t in conn.tools
|
|
],
|
|
}
|
|
else:
|
|
return {"success": False, "error": f"Failed to connect to '{name}'"}
|
|
|
|
async def disable_server(self, name: str) -> Dict[str, Any]:
|
|
"""Disable and disconnect from an MCP server."""
|
|
if name not in self._connections:
|
|
return {"success": False, "error": f"Server '{name}' is not connected"}
|
|
|
|
conn = self._connections.pop(name)
|
|
await conn.disconnect()
|
|
|
|
return {"success": True, "message": f"Disconnected from '{name}'"}
|
|
|
|
def add_server(self, config: MCPServerConfig) -> Dict[str, Any]:
|
|
"""Add a custom MCP server configuration."""
|
|
self._custom_servers[config.name] = config
|
|
self._save_config()
|
|
return {"success": True, "message": f"Added server '{config.name}'"}
|
|
|
|
def remove_server(self, name: str) -> Dict[str, Any]:
|
|
"""Remove a custom MCP server configuration."""
|
|
if name not in self._custom_servers:
|
|
if name in self.BUILTIN_SERVERS:
|
|
return {
|
|
"success": False,
|
|
"error": f"Cannot remove builtin server '{name}'",
|
|
}
|
|
return {"success": False, "error": f"Server '{name}' not found"}
|
|
|
|
# Disconnect if connected
|
|
if name in self._connections:
|
|
asyncio.create_task(self.disable_server(name))
|
|
|
|
del self._custom_servers[name]
|
|
self._save_config()
|
|
return {"success": True, "message": f"Removed server '{name}'"}
|
|
|
|
def get_all_proxied_tools(self) -> List[ProxiedTool]:
|
|
"""Get all tools from connected servers."""
|
|
tools = []
|
|
for conn in self._connections.values():
|
|
tools.extend(conn.tools)
|
|
return tools
|
|
|
|
async def call_proxied_tool(
|
|
self, server_name: str, tool_name: str, arguments: Dict[str, Any]
|
|
) -> Any:
|
|
"""Call a tool on a proxied server.
|
|
|
|
If lazy_load is enabled, the server will be connected on first use.
|
|
"""
|
|
# Lazy load if not connected
|
|
if server_name not in self._connections:
|
|
config = self.get_server_config(server_name)
|
|
if config and config.lazy_load:
|
|
result = await self.enable_server(server_name)
|
|
if not result.get("success"):
|
|
raise ConnectionError(result.get("error", "Failed to connect"))
|
|
else:
|
|
raise ConnectionError(f"Server '{server_name}' is not connected")
|
|
|
|
conn = self._connections.get(server_name)
|
|
if not conn:
|
|
raise ConnectionError(f"Server '{server_name}' is not connected")
|
|
|
|
return await conn.call_tool(tool_name, arguments)
|
|
|
|
|
|
# Convenience function for lazy loading
|
|
async def enable_mcp_server(name: str) -> Dict[str, Any]:
|
|
"""Enable an MCP server by name.
|
|
|
|
This is a convenience function for lazy-loading MCP servers.
|
|
|
|
Example:
|
|
result = await enable_mcp_server("platform")
|
|
if result["success"]:
|
|
print(f"Enabled with {len(result['tools'])} tools")
|
|
"""
|
|
registry = MCPProxyRegistry.get_instance()
|
|
return await registry.enable_server(name)
|
|
|
|
|
|
async def call_mcp_tool(server: str, tool: str, **kwargs) -> Any:
|
|
"""Call a tool on an MCP server.
|
|
|
|
This is a convenience function that handles lazy loading.
|
|
|
|
Example:
|
|
result = await call_mcp_tool("platform", "application-list")
|
|
"""
|
|
registry = MCPProxyRegistry.get_instance()
|
|
return await registry.call_proxied_tool(server, tool, kwargs)
|
|
|
|
|
|
# Export builtin servers for convenience
|
|
BUILTIN_SERVERS = MCPProxyRegistry.BUILTIN_SERVERS
|