feat: add MCP server with HTTP/SSE transport for AI integration
Exposes homelab topology to MCP-compatible AI clients (Claude Code, etc.) over LAN via HTTP/SSE on port 8001. - New mcp/ service: FastAPI + mcp SDK, SSE transport - Auth: X-API-Key for AI clients, X-MCP-Service-Key for backend (Docker-internal) - Resources: canvas, nodes, edges, scan/pending, scan/runs - Tools: create/update/delete nodes+edges, trigger scan, approve/hide devices - Backend deps.py: accepts JWT or MCP service key (no plain-text password) - 40 tests (auth, resources, tools) across asyncio + trio - docker-compose.yml: mcp service on port 8001 - README: MCP setup section with Claude Code/Desktop config examples
This commit is contained in:
@@ -15,3 +15,10 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
|||||||
|
|
||||||
# Status checker interval in seconds
|
# Status checker interval in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|
||||||
|
# MCP server — used by the mcp service (port 8001)
|
||||||
|
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
|
||||||
|
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
|
||||||
|
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|||||||
@@ -162,6 +162,88 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MCP Server (AI Integration)
|
||||||
|
|
||||||
|
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
|
### What the AI can do
|
||||||
|
|
||||||
|
| | Action |
|
||||||
|
|---|---|
|
||||||
|
| **Read** | List all nodes, edges, full canvas, pending devices, scan history |
|
||||||
|
| **Write** | Add / update / delete nodes and edges, trigger a network scan, approve or hide discovered devices |
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
**1. Add the keys to your `.env`:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Authenticates AI clients (Claude Code, etc.) → MCP server
|
||||||
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
|
|
||||||
|
# Authenticates MCP server → backend (internal Docker network only, never exposed)
|
||||||
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|
||||||
|
# Generate both with:
|
||||||
|
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
No plain-text passwords involved — `AUTH_PASSWORD_HASH` is only used for the web UI login.
|
||||||
|
|
||||||
|
**2. Start the MCP service:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d mcp
|
||||||
|
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Configure your AI client:**
|
||||||
|
|
||||||
|
**Claude Code** (`~/.claude/claude_desktop_config.json` or via `/mcp` in the CLI):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homelable": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Desktop** (same config file, usually `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"homelable": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
|
"headers": {
|
||||||
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example prompts
|
||||||
|
|
||||||
|
- *"What nodes are currently offline?"*
|
||||||
|
- *"Add a new LXC container named `pihole` at 192.168.1.5, connected to my switch."*
|
||||||
|
- *"Trigger a network scan on 192.168.1.0/24 and show me the pending devices."*
|
||||||
|
- *"Show me the full canvas topology."*
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The MCP server is **not** intended to be exposed to the internet — keep port 8001 firewalled to your LAN.
|
||||||
|
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
|
||||||
|
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Development Mode
|
## Development Mode
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
**Backend (Python 3.13):**
|
||||||
|
|||||||
+21
-3
@@ -1,12 +1,30 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
|
|
||||||
bearer = HTTPBearer()
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
x_mcp_service_key: str | None = Header(default=None),
|
||||||
|
) -> str:
|
||||||
|
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||||
|
if x_mcp_service_key is not None:
|
||||||
|
if not settings.mcp_service_key:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||||
|
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||||
|
return "__mcp_service__"
|
||||||
|
|
||||||
|
# 2. Standard JWT bearer token
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
username = decode_token(credentials.credentials)
|
username = decode_token(credentials.credentials)
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
|||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
|
# Leave empty to disable MCP service key auth.
|
||||||
|
mcp_service_key: str = ""
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile.mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: "http://backend:8000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- homelable
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import hmac
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Health check bypass
|
||||||
|
if request.url.path == "/health":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
key = request.headers.get("X-API-Key", "")
|
||||||
|
expected = settings.mcp_api_key
|
||||||
|
|
||||||
|
if not key or not hmac.compare_digest(key.encode(), expected.encode()):
|
||||||
|
return JSONResponse({"detail": "Invalid or missing X-API-Key"}, status_code=401)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import httpx
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class BackendClient:
|
||||||
|
def __init__(self):
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=settings.backend_url,
|
||||||
|
headers={"X-MCP-Service-Key": settings.mcp_service_key},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def request(self, method: str, path: str, **kwargs) -> dict:
|
||||||
|
resp = await self._client.request(method, path, **kwargs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if resp.status_code == 204:
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def get(self, path: str) -> dict | list:
|
||||||
|
return await self.request("GET", path)
|
||||||
|
|
||||||
|
async def post(self, path: str, body: dict) -> dict:
|
||||||
|
return await self.request("POST", path, json=body)
|
||||||
|
|
||||||
|
async def patch(self, path: str, body: dict) -> dict:
|
||||||
|
return await self.request("PATCH", path, json=body)
|
||||||
|
|
||||||
|
async def delete(self, path: str) -> dict:
|
||||||
|
return await self.request("DELETE", path)
|
||||||
|
|
||||||
|
|
||||||
|
backend = BackendClient()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
mcp_api_key: str = "mcp_sk_changeme" # AI client → MCP server
|
||||||
|
mcp_service_key: str = "svc_changeme" # MCP server → backend
|
||||||
|
backend_url: str = "http://backend:8000"
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.sse import SseServerTransport
|
||||||
|
|
||||||
|
from .auth import ApiKeyMiddleware
|
||||||
|
from .backend_client import backend
|
||||||
|
from .resources import register_resources
|
||||||
|
from .tools import register_tools
|
||||||
|
|
||||||
|
|
||||||
|
mcp_server = Server("homelable")
|
||||||
|
register_resources(mcp_server)
|
||||||
|
register_tools(mcp_server)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await backend.start()
|
||||||
|
yield
|
||||||
|
await backend.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
|
||||||
|
app.add_middleware(ApiKeyMiddleware)
|
||||||
|
|
||||||
|
sse = SseServerTransport("/mcp/messages")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/mcp")
|
||||||
|
async def mcp_sse(request):
|
||||||
|
async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
|
||||||
|
await mcp_server.run(streams[0], streams[1], mcp_server.create_initialization_options())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/mcp/messages")
|
||||||
|
async def mcp_messages(request):
|
||||||
|
await sse.handle_post_message(request.scope, request.receive, request._send)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import json
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.types import Resource, TextContent
|
||||||
|
from .backend_client import backend
|
||||||
|
|
||||||
|
RESOURCE_LIST = [
|
||||||
|
Resource(uri="homelable://canvas", name="Canvas", description="Full canvas state (nodes + edges + viewport)", mimeType="application/json"),
|
||||||
|
Resource(uri="homelable://nodes", name="Nodes", description="All nodes in the homelab", mimeType="application/json"),
|
||||||
|
Resource(uri="homelable://edges", name="Edges", description="All network edges/links", mimeType="application/json"),
|
||||||
|
Resource(uri="homelable://scan/pending", name="Pending devices", description="Discovered devices awaiting approval", mimeType="application/json"),
|
||||||
|
Resource(uri="homelable://scan/runs", name="Scan history", description="Recent scan run history", mimeType="application/json"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROUTES = {
|
||||||
|
"homelable://canvas": "/api/v1/canvas",
|
||||||
|
"homelable://nodes": "/api/v1/nodes",
|
||||||
|
"homelable://edges": "/api/v1/edges",
|
||||||
|
"homelable://scan/pending": "/api/v1/scan/pending",
|
||||||
|
"homelable://scan/runs": "/api/v1/scan/runs",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def read_resource(uri: str) -> list[TextContent]:
|
||||||
|
if uri.startswith("homelable://nodes/") and uri != "homelable://nodes/":
|
||||||
|
node_id = uri.split("/")[-1]
|
||||||
|
data = await backend.get(f"/api/v1/nodes/{node_id}")
|
||||||
|
return [TextContent(type="text", text=json.dumps(data, indent=2))]
|
||||||
|
|
||||||
|
if uri not in ROUTES:
|
||||||
|
raise ValueError(f"Unknown resource URI: {uri}")
|
||||||
|
|
||||||
|
data = await backend.get(ROUTES[uri])
|
||||||
|
return [TextContent(type="text", text=json.dumps(data, indent=2))]
|
||||||
|
|
||||||
|
|
||||||
|
def register_resources(server: Server):
|
||||||
|
@server.list_resources()
|
||||||
|
async def _list():
|
||||||
|
return RESOURCE_LIST
|
||||||
|
|
||||||
|
@server.read_resource()
|
||||||
|
async def _read(uri: str):
|
||||||
|
return await read_resource(uri)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import json
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
from .backend_client import backend
|
||||||
|
|
||||||
|
|
||||||
|
def register_tools(server: Server):
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools():
|
||||||
|
return [
|
||||||
|
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "label"],
|
||||||
|
"properties": {
|
||||||
|
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"ip": {"type": "string"},
|
||||||
|
"hostname": {"type": "string"},
|
||||||
|
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"ip": {"type": "string"},
|
||||||
|
"hostname": {"type": "string"},
|
||||||
|
"status": {"type": "string"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "target"],
|
||||||
|
"properties": {
|
||||||
|
"source": {"type": "string"},
|
||||||
|
"target": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict):
|
||||||
|
result = await _dispatch(name, arguments)
|
||||||
|
return [TextContent(type="text", text=json.dumps(result, indent=2))]
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(name: str, args: dict) -> dict:
|
||||||
|
if name == "create_node":
|
||||||
|
return await backend.post("/api/v1/nodes", args)
|
||||||
|
|
||||||
|
if name == "update_node":
|
||||||
|
node_id = args.pop("id")
|
||||||
|
return await backend.patch(f"/api/v1/nodes/{node_id}", args)
|
||||||
|
|
||||||
|
if name == "delete_node":
|
||||||
|
return await backend.delete(f"/api/v1/nodes/{args['id']}")
|
||||||
|
|
||||||
|
if name == "create_edge":
|
||||||
|
return await backend.post("/api/v1/edges", args)
|
||||||
|
|
||||||
|
if name == "delete_edge":
|
||||||
|
return await backend.delete(f"/api/v1/edges/{args['id']}")
|
||||||
|
|
||||||
|
if name == "trigger_scan":
|
||||||
|
body = {"ranges": args["ranges"]} if "ranges" in args else {}
|
||||||
|
return await backend.post("/api/v1/scan/trigger", body)
|
||||||
|
|
||||||
|
if name == "approve_device":
|
||||||
|
device_id = args.pop("id")
|
||||||
|
return await backend.post(f"/api/v1/scan/pending/{device_id}/approve", args)
|
||||||
|
|
||||||
|
if name == "hide_device":
|
||||||
|
return await backend.post(f"/api/v1/scan/pending/{args['id']}/hide", {})
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mcp[cli]>=1.0
|
||||||
|
httpx>=0.27
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.30
|
||||||
|
pydantic-settings>=2.0
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
os.environ.setdefault("MCP_API_KEY", "test_key")
|
||||||
|
os.environ.setdefault("BACKEND_URL", "http://testbackend")
|
||||||
|
os.environ.setdefault("AUTH_USERNAME", "admin")
|
||||||
|
os.environ.setdefault("AUTH_PASSWORD", "admin")
|
||||||
|
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_key():
|
||||||
|
return "test_key"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(api_key):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_backend():
|
||||||
|
with patch("app.resources.backend") as mock_res, \
|
||||||
|
patch("app.tools.backend") as mock_tools:
|
||||||
|
mock_res.get = AsyncMock()
|
||||||
|
mock_tools.post = AsyncMock()
|
||||||
|
mock_tools.patch = AsyncMock()
|
||||||
|
mock_tools.delete = AsyncMock()
|
||||||
|
yield {"resources": mock_res, "tools": mock_tools}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_health_no_key(client):
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_missing_api_key(client):
|
||||||
|
resp = await client.get("/mcp")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_wrong_api_key(client):
|
||||||
|
resp = await client.get("/mcp", headers={"X-API-Key": "wrong"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_valid_api_key_passes(client, api_key):
|
||||||
|
# SSE endpoint returns 200 with valid key (even if stream is incomplete in tests)
|
||||||
|
resp = await client.get("/mcp", headers={"X-API-Key": api_key})
|
||||||
|
assert resp.status_code != 401
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from app.resources import read_resource
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_backend():
|
||||||
|
with patch("app.resources.backend") as m:
|
||||||
|
m.get = AsyncMock(return_value={"data": "ok"})
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_canvas(mock_backend):
|
||||||
|
result = await read_resource("homelable://canvas")
|
||||||
|
mock_backend.get.assert_called_once_with("/api/v1/canvas")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_nodes(mock_backend):
|
||||||
|
await read_resource("homelable://nodes")
|
||||||
|
mock_backend.get.assert_called_once_with("/api/v1/nodes")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_edges(mock_backend):
|
||||||
|
await read_resource("homelable://edges")
|
||||||
|
mock_backend.get.assert_called_once_with("/api/v1/edges")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_single_node(mock_backend):
|
||||||
|
await read_resource("homelable://nodes/abc123")
|
||||||
|
mock_backend.get.assert_called_once_with("/api/v1/nodes/abc123")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_scan_pending(mock_backend):
|
||||||
|
await read_resource("homelable://scan/pending")
|
||||||
|
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_read_unknown_uri(mock_backend):
|
||||||
|
with pytest.raises(ValueError, match="Unknown resource URI"):
|
||||||
|
await read_resource("homelable://unknown")
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from app.tools import _dispatch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_backend():
|
||||||
|
with patch("app.tools.backend") as m:
|
||||||
|
m.post = AsyncMock(return_value={"id": "1"})
|
||||||
|
m.patch = AsyncMock(return_value={"id": "1"})
|
||||||
|
m.delete = AsyncMock(return_value={})
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_node(mock_backend):
|
||||||
|
result = await _dispatch("create_node", {"type": "server", "label": "Proxmox"})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/nodes", {"type": "server", "label": "Proxmox"})
|
||||||
|
assert result == {"id": "1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_node(mock_backend):
|
||||||
|
await _dispatch("update_node", {"id": "42", "label": "New name"})
|
||||||
|
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"label": "New name"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_delete_node(mock_backend):
|
||||||
|
await _dispatch("delete_node", {"id": "42"})
|
||||||
|
mock_backend.delete.assert_called_once_with("/api/v1/nodes/42")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_edge(mock_backend):
|
||||||
|
await _dispatch("create_edge", {"source": "1", "target": "2", "type": "ethernet"})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_delete_edge(mock_backend):
|
||||||
|
await _dispatch("delete_edge", {"id": "99"})
|
||||||
|
mock_backend.delete.assert_called_once_with("/api/v1/edges/99")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_trigger_scan_no_ranges(mock_backend):
|
||||||
|
await _dispatch("trigger_scan", {})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_trigger_scan_with_ranges(mock_backend):
|
||||||
|
await _dispatch("trigger_scan", {"ranges": ["192.168.1.0/24"]})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {"ranges": ["192.168.1.0/24"]})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_approve_device(mock_backend):
|
||||||
|
await _dispatch("approve_device", {"id": "5", "type": "server", "label": "MyServer"})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/approve", {"type": "server", "label": "MyServer"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_hide_device(mock_backend):
|
||||||
|
await _dispatch("hide_device", {"id": "5"})
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/hide", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unknown_tool():
|
||||||
|
with pytest.raises(ValueError, match="Unknown tool"):
|
||||||
|
await _dispatch("nonexistent", {})
|
||||||
Reference in New Issue
Block a user