Initial implementation of UniFi MCP Light server
Some checks failed
Build and Push Docker Image / build (push) Failing after 12s
Some checks failed
Build and Push Docker Image / build (push) Failing after 12s
Implements Hybrid MCP Light pattern with: - 4 specific tools: list_clients, list_devices, get_system_info, get_network_health - Meta tools: tool_index, api_call (raw API pass-through) - API documentation resource at unifi://api-reference - Starlette wrapper with /health endpoint - Write protection (UNIFI_ALLOW_WRITES env var) - UniFi OS auto-detection (proxy vs direct paths) - Docker multi-stage build - Gitea CI workflow Closes #1, #2, #3, #4, #5, #7
This commit is contained in:
427
server.py
Normal file
427
server.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
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 asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastmcp import FastMCP
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
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")
|
||||
|
||||
# Configuration from environment
|
||||
CONFIG = {
|
||||
"host": os.getenv("UNIFI_HOST", ""),
|
||||
"username": os.getenv("UNIFI_USERNAME", ""),
|
||||
"password": os.getenv("UNIFI_PASSWORD", ""),
|
||||
"port": int(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:
|
||||
# Parse params JSON
|
||||
try:
|
||||
payload = json.loads(params) if params and params != "{}" else None
|
||||
except json.JSONDecodeError as 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)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Starlette Wrapper with Health Endpoint
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def health_endpoint(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,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
"""Application lifespan manager."""
|
||||
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'}"
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
logger.info("Shutting down UniFi MCP Light server...")
|
||||
await close_client()
|
||||
|
||||
|
||||
def create_app() -> Starlette:
|
||||
"""Create the Starlette ASGI application."""
|
||||
mcp_app = mcp.http_app()
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/health", health_endpoint),
|
||||
Mount("/", app=mcp_app),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the server."""
|
||||
import uvicorn
|
||||
|
||||
app = create_app()
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user