""" UniFi MCP Light Server A Hybrid MCP Light server for UniFi Network Controller. Provides minimal specific tools with a raw API pass-through escape hatch. Following the BLUEPRINT.md pattern: - 4 specific tools for common operations - Meta tools (tool_index, api_call) for flexibility - Embedded API documentation resource - Starlette wrapper with health endpoint """ import json import logging import os from typing import Optional from dotenv import load_dotenv from fastmcp import FastMCP from starlette.responses import JSONResponse from api_docs import get_api_docs from unifi_client import UnifiClient, UnifiClientError, UnifiWriteBlockedError # Load environment variables load_dotenv() # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger("unifi-mcp-light") def _parse_port(value: str, default: int = 443) -> int: """Safely parse port from environment variable.""" try: return int(value) except (ValueError, TypeError): logger.warning(f"Invalid UNIFI_PORT '{value}', using default {default}") return default # Configuration from environment CONFIG = { "host": os.getenv("UNIFI_HOST", ""), "username": os.getenv("UNIFI_USERNAME", ""), "password": os.getenv("UNIFI_PASSWORD", ""), "port": _parse_port(os.getenv("UNIFI_PORT", "443")), "site": os.getenv("UNIFI_SITE", "default"), "verify_ssl": os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true", "allow_writes": os.getenv("UNIFI_ALLOW_WRITES", "false").lower() == "true", } # Global client instance _client: Optional[UnifiClient] = None async def get_client() -> UnifiClient: """Get or create the UniFi client singleton.""" global _client if _client is None: if not CONFIG["host"]: raise UnifiClientError("UNIFI_HOST environment variable not set") if not CONFIG["username"]: raise UnifiClientError("UNIFI_USERNAME environment variable not set") if not CONFIG["password"]: raise UnifiClientError("UNIFI_PASSWORD environment variable not set") _client = UnifiClient( host=CONFIG["host"], username=CONFIG["username"], password=CONFIG["password"], port=CONFIG["port"], site=CONFIG["site"], verify_ssl=CONFIG["verify_ssl"], allow_writes=CONFIG["allow_writes"], ) await _client.connect() return _client async def close_client() -> None: """Close the UniFi client connection.""" global _client if _client: await _client.close() _client = None # Create MCP server mcp = FastMCP( name="unifi-mcp-light", instructions=""" UniFi Network Controller MCP Server (Light Edition) This server provides access to your UniFi Network Controller with: - 4 specific tools for common read operations - Raw API pass-through for any endpoint - API documentation resource for reference Use 'unifi://api-reference' resource for API documentation. Write operations require UNIFI_ALLOW_WRITES=true. """, ) # ============================================================================= # MCP Resources # ============================================================================= @mcp.resource("unifi://api-reference") def api_reference() -> str: """ UniFi API Reference Documentation. Returns curated documentation for UniFi Network Controller API endpoints. Use this to understand how to construct raw API calls with unifi_api_call. """ return get_api_docs() # ============================================================================= # Specific Tools (4 total - don't exceed BLUEPRINT limit) # ============================================================================= @mcp.tool() async def unifi_list_clients() -> str: """ List all currently connected network clients. Returns information about all devices (phones, laptops, IoT devices, etc.) currently connected to your UniFi network, including: - MAC and IP addresses - Hostname and friendly name - Connection type (wired/wireless) - Traffic statistics - Signal strength (wireless clients) - Connected SSID (wireless clients) Returns: JSON array of client objects """ try: client = await get_client() result = await client.get_clients() return json.dumps(result, indent=2) except UnifiClientError as e: return json.dumps({"error": str(e)}) @mcp.tool() async def unifi_list_devices() -> str: """ List all UniFi network devices. Returns information about all adopted UniFi devices including: - Access Points (UAP) - Switches (USW) - Gateways/Routers (UGW, UDM) - Other UniFi devices Each device includes: - Model and firmware version - IP address and status - Uptime and connected clients - Port information (switches) Returns: JSON array of device objects """ try: client = await get_client() result = await client.get_devices() return json.dumps(result, indent=2) except UnifiClientError as e: return json.dumps({"error": str(e)}) @mcp.tool() async def unifi_get_system_info() -> str: """ Get UniFi controller system information. Returns controller details including: - Controller version and build - Hostname and site name - Timezone configuration Returns: JSON array with system info object """ try: client = await get_client() result = await client.get_system_info() return json.dumps(result, indent=2) except UnifiClientError as e: return json.dumps({"error": str(e)}) @mcp.tool() async def unifi_get_network_health() -> str: """ Get overall network health status. Returns health metrics for each network subsystem: - WAN: Internet connectivity status - LAN: Local network status - WLAN: Wireless network status - VPN: VPN tunnel status Each subsystem includes: - Status (ok, warning, error) - Connected user/guest counts - Transfer rates Returns: JSON array of health status objects per subsystem """ try: client = await get_client() result = await client.get_health() return json.dumps(result, indent=2) except UnifiClientError as e: return json.dumps({"error": str(e)}) # ============================================================================= # Meta Tools (don't count toward BLUEPRINT limit) # ============================================================================= @mcp.tool() async def unifi_tool_index() -> str: """ Get the complete index of available UniFi tools. Returns a machine-readable catalog of all registered tools with their: - Name and description - Input parameter schemas - Return type information Use this for programmatic tool discovery and dynamic invocation. Returns: JSON object with tools array containing tool schemas """ tools = [] # Get all registered tools from MCP tool_definitions = [ { "name": "unifi_list_clients", "description": "List all currently connected network clients", "parameters": {}, }, { "name": "unifi_list_devices", "description": "List all UniFi network devices (APs, switches, gateways)", "parameters": {}, }, { "name": "unifi_get_system_info", "description": "Get UniFi controller system information", "parameters": {}, }, { "name": "unifi_get_network_health", "description": "Get overall network health status", "parameters": {}, }, { "name": "unifi_api_call", "description": "Execute raw UniFi API call (escape hatch)", "parameters": { "endpoint": { "type": "string", "description": "API path (e.g., /api/s/default/stat/sta)", "required": True, }, "method": { "type": "string", "description": "HTTP method (GET, POST, PUT, DELETE)", "default": "GET", }, "params": { "type": "string", "description": "JSON string of request body/parameters", "default": "{}", }, }, }, ] return json.dumps({"tools": tool_definitions}, indent=2) @mcp.tool() async def unifi_api_call( endpoint: str, method: str = "GET", params: str = "{}", ) -> str: """ Execute a raw UniFi API call (escape hatch). This tool provides direct access to any UniFi API endpoint. Use the 'unifi://api-reference' resource for API documentation. IMPORTANT: Write operations (POST, PUT, DELETE, PATCH) require UNIFI_ALLOW_WRITES=true environment variable. Args: endpoint: API path (e.g., '/api/s/default/stat/sta') The server automatically handles UniFi OS path prefixing. method: HTTP method - GET, POST, PUT, DELETE, PATCH (default: GET) params: JSON string of request body/parameters (default: "{}") Returns: JSON response from the UniFi API, or error object Examples: # List all events from last 24 hours endpoint: "/api/s/default/stat/event" method: "GET" params: "{}" # Get specific client by MAC endpoint: "/api/s/default/stat/user/aa:bb:cc:dd:ee:ff" method: "GET" params: "{}" # Block a client (requires UNIFI_ALLOW_WRITES=true) endpoint: \"/api/s/default/cmd/stamgr\" method: \"POST\" params: '{\"cmd\": \"block-sta\", \"mac\": \"aa:bb:cc:dd:ee:ff\"}' """ try: # Validate endpoint to prevent path traversal attacks if ".." in endpoint: return json.dumps({"error": "Invalid endpoint: path traversal not allowed"}) if not endpoint.startswith("/api/"): return json.dumps( { "error": "Invalid endpoint: must start with /api/", "hint": "Example: /api/s/default/stat/sta", } ) # Parse params JSON try: payload = json.loads(params) if params and params != "{}" else None except json.JSONDecodeError as e: logger.warning(f"Invalid params JSON in unifi_api_call: {e}") return json.dumps({"error": f"Invalid params JSON: {e}"}) client = await get_client() result = await client.request(method, endpoint, payload) return json.dumps(result, indent=2) except UnifiWriteBlockedError as e: return json.dumps( { "error": str(e), "hint": "Set UNIFI_ALLOW_WRITES=true environment variable to enable write operations", } ) except UnifiClientError as e: return json.dumps({"error": str(e)}) # ============================================================================= # Health Check Endpoint (using FastMCP custom_route) # ============================================================================= from starlette.requests import Request @mcp.custom_route("/health", methods=["GET"]) async def health_endpoint(request: Request) -> JSONResponse: """ Health check endpoint for Docker/Kubernetes. Returns controller connectivity status and configuration. """ try: client = await get_client() await client.ping() return JSONResponse( { "status": "ok", "controller": CONFIG["host"], "site": CONFIG["site"], "write_enabled": CONFIG["allow_writes"], } ) except Exception as e: return JSONResponse( { "status": "error", "error": str(e), }, status_code=503, ) def main(): """Entry point for the server.""" logger.info("Starting UniFi MCP Light server...") logger.info(f"Controller: {CONFIG['host']}:{CONFIG['port']}") logger.info(f"Site: {CONFIG['site']}") logger.info( f"Write operations: {'enabled' if CONFIG['allow_writes'] else 'disabled'}" ) # Run with HTTP transport - MCP endpoint at /mcp, health at /health mcp.run(transport="http", host="0.0.0.0", port=8000) if __name__ == "__main__": main()