Fix UniFi OS authentication and simplify server architecture
All checks were successful
Build and Push Docker Image / build (push) Successful in 15s

- Use /api/auth/login for UniFi OS controllers (UDM, Cloud Gateway)
- Use /api/login for standalone controllers
- Refactor to use FastMCP's native mcp.run() with custom_route for /health
- Switch to network_mode: host for local network access
- Include README.md in Dockerfile for hatchling build
This commit is contained in:
Ben
2026-01-02 02:49:43 +00:00
parent cb57b8f537
commit 487f5355a0
4 changed files with 24 additions and 41 deletions

View File

@@ -6,8 +6,8 @@ WORKDIR /app
# Install build dependencies # Install build dependencies
RUN pip install --no-cache-dir uv RUN pip install --no-cache-dir uv
# Copy dependency files # Copy dependency files and README (required by hatchling)
COPY pyproject.toml ./ COPY pyproject.toml README.md ./
# Create virtual environment and install dependencies # Create virtual environment and install dependencies
RUN uv venv /app/.venv RUN uv venv /app/.venv

View File

@@ -4,8 +4,7 @@ services:
image: gitea.ext.ben.io/b3nw/unifi-mcp-light:latest image: gitea.ext.ben.io/b3nw/unifi-mcp-light:latest
container_name: unifi-mcp-light container_name: unifi-mcp-light
restart: unless-stopped restart: unless-stopped
ports: network_mode: host
- "${PORT:-8100}:8000"
environment: environment:
- UNIFI_HOST=${UNIFI_HOST} - UNIFI_HOST=${UNIFI_HOST}
- UNIFI_USERNAME=${UNIFI_USERNAME} - UNIFI_USERNAME=${UNIFI_USERNAME}

View File

@@ -11,18 +11,14 @@ Following the BLUEPRINT.md pattern:
- Starlette wrapper with health endpoint - Starlette wrapper with health endpoint
""" """
import asyncio
import json import json
import logging import logging
import os import os
from contextlib import asynccontextmanager from typing import Optional
from typing import Any, Optional
from dotenv import load_dotenv from dotenv import load_dotenv
from fastmcp import FastMCP from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from api_docs import get_api_docs from api_docs import get_api_docs
from unifi_client import UnifiClient, UnifiClientError, UnifiWriteBlockedError from unifi_client import UnifiClient, UnifiClientError, UnifiWriteBlockedError
@@ -355,11 +351,14 @@ async def unifi_api_call(
# ============================================================================= # =============================================================================
# Starlette Wrapper with Health Endpoint # Health Check Endpoint (using FastMCP custom_route)
# ============================================================================= # =============================================================================
from starlette.requests import Request
async def health_endpoint(request) -> JSONResponse:
@mcp.custom_route("/health", methods=["GET"])
async def health_endpoint(request: Request) -> JSONResponse:
""" """
Health check endpoint for Docker/Kubernetes. Health check endpoint for Docker/Kubernetes.
@@ -386,9 +385,8 @@ async def health_endpoint(request) -> JSONResponse:
) )
@asynccontextmanager def main():
async def lifespan(app): """Entry point for the server."""
"""Application lifespan manager."""
logger.info("Starting UniFi MCP Light server...") logger.info("Starting UniFi MCP Light server...")
logger.info(f"Controller: {CONFIG['host']}:{CONFIG['port']}") logger.info(f"Controller: {CONFIG['host']}:{CONFIG['port']}")
logger.info(f"Site: {CONFIG['site']}") logger.info(f"Site: {CONFIG['site']}")
@@ -396,31 +394,8 @@ async def lifespan(app):
f"Write operations: {'enabled' if CONFIG['allow_writes'] else 'disabled'}" f"Write operations: {'enabled' if CONFIG['allow_writes'] else 'disabled'}"
) )
yield # Run with HTTP transport - MCP endpoint at /mcp, health at /health
mcp.run(transport="http", host="0.0.0.0", port=8000)
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__": if __name__ == "__main__":

View File

@@ -158,8 +158,17 @@ class UnifiClient:
logger.warning("Controller type detection failed, defaulting to UniFi OS") logger.warning("Controller type detection failed, defaulting to UniFi OS")
async def _authenticate(self) -> None: async def _authenticate(self) -> None:
"""Authenticate with the UniFi controller.""" """Authenticate with the UniFi controller.
login_url = f"{self.base_url}{self.api_prefix}/api/login"
UniFi OS (UDM, Cloud Gateway) uses /api/auth/login
Standalone controllers use /api/login
"""
if self._is_unifi_os:
# UniFi OS uses a different auth endpoint (no api_prefix needed)
login_url = f"{self.base_url}/api/auth/login"
else:
# Standalone controller
login_url = f"{self.base_url}/api/login"
payload = { payload = {
"username": self.username, "username": self.username,