Merge pull request #2 from Pouzor/feat/mcp-server

feat: MCP server for AI integration
This commit is contained in:
Remy
2026-03-16 00:53:56 +01:00
committed by GitHub
26 changed files with 782 additions and 14 deletions
+7
View File
@@ -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
+1
View File
@@ -48,3 +48,4 @@ htmlcov/
# Docker # Docker
.docker/ .docker/
Ideas.md
+88
View File
@@ -162,6 +162,94 @@ 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** — run this command in your terminal:
```bash
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
--header "X-API-Key: mcp_sk_yourkey"
```
Or add it manually to `~/.claude.json`:
```json
{
"mcpServers": {
"homelable": {
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
}
}
}
```
**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```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
View File
@@ -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")
+5
View File
@@ -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"
+1
View File
@@ -42,6 +42,7 @@ class NodeUpdate(BaseModel):
notes: str | None = None notes: str | None = None
pos_x: float | None = None pos_x: float | None = None
pos_y: float | None = None pos_y: float | None = None
parent_id: str | None = None
container_mode: bool | None = None container_mode: bool | None = None
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
+28
View File
@@ -1,3 +1,4 @@
import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
res = await client.get("/api/v1/health") res = await client.get("/api/v1/health")
assert res.status_code == 200 assert res.status_code == 200
assert res.json() == {"status": "ok"} assert res.json() == {"status": "ok"}
# --- MCP service key auth ---
@pytest.fixture
def with_service_key():
from app.core.config import settings
settings.mcp_service_key = "test-service-key"
yield "test-service-key"
settings.mcp_service_key = ""
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
assert res.status_code == 200
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
assert res.status_code == 401
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
from app.core.config import settings
settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401
+10
View File
@@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict):
assert res.json()["container_mode"] is True assert res.json()["container_mode"] is True
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
parent_id = parent.json()["id"]
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
child_id = child.json()["id"]
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
assert res.status_code == 200
assert res.json()["parent_id"] == parent_id
async def test_create_node_requires_auth(client: AsyncClient): async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401 assert res.status_code == 401
+16
View File
@@ -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: .
+11 -11
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
@@ -5474,9 +5474,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.3.4", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -5826,9 +5826,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.12.5", "version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -8770,9 +8770,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.22.0", "version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
+12
View File
@@ -0,0 +1,12 @@
# MCP Server — copy to .env and fill in values
# Authenticates AI clients (Claude Code, Claude Desktop, etc.) → MCP server
# Generate: python3 -c "import secrets; print('mcp_sk_' + secrets.token_hex(24))"
MCP_API_KEY=mcp_sk_changeme
# Authenticates MCP server → backend (must match MCP_SERVICE_KEY in backend .env)
# Generate: python3 -c "import secrets; print('svc_' + secrets.token_hex(24))"
MCP_SERVICE_KEY=svc_changeme
# Backend URL — use http://backend:8000 in Docker, http://localhost:8000 for local dev
BACKEND_URL=http://localhost:8000
+10
View File
@@ -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"]
View File
+44
View File
@@ -0,0 +1,44 @@
import hmac
import json
from starlette.types import ASGIApp, Receive, Scope, Send
from .config import settings
_BYPASS_PATHS = {"/health", "/register"}
class ApiKeyMiddleware:
"""Pure ASGI middleware — compatible with SSE/streaming responses.
BaseHTTPMiddleware buffers the full response body and breaks SSE streams.
This implementation operates at the ASGI scope level and never touches
the response stream.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path: str = scope.get("path", "")
if path in _BYPASS_PATHS or path.startswith("/.well-known/"):
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers", []))
key = headers.get(b"x-api-key", b"").decode()
expected = settings.mcp_api_key
if not key or not hmac.compare_digest(key.encode(), expected.encode()):
body = json.dumps({"detail": "Invalid or missing X-API-Key"}).encode()
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode())]})
await send({"type": "http.response.body", "body": body, "more_body": False})
return
await self.app(scope, receive, send)
+40
View File
@@ -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()
+12
View File
@@ -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()
+42
View File
@@ -0,0 +1,42 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
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)
session_manager = StreamableHTTPSessionManager(
app=mcp_server,
json_response=False,
stateless=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
await backend.start()
async with session_manager.run():
yield
await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
app.add_middleware(ApiKeyMiddleware)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request):
await session_manager.handle_request(request.scope, request.receive, request._send)
@app.get("/health")
async def health():
return {"status": "ok"}
+43
View File
@@ -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)
+154
View File
@@ -0,0 +1,154 @@
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"},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
},
}),
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"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
@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))]
def _slim_canvas(raw: dict) -> dict:
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict:
data = n.get("data", {})
out = {k: v for k, v in data.items() if k in NODE_KEEP and v not in (None, "", [])}
out["id"] = n.get("id")
out["node_type"] = n.get("type")
return out
def slim_edge(e: dict) -> dict:
return {k: v for k, v in e.items() if k in EDGE_KEEP and v not in (None, "")}
return {
"nodes": [slim_node(n) for n in raw.get("nodes", [])],
"edges": [slim_edge(e) for e in raw.get("edges", [])],
}
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", {})
if name == "get_canvas":
raw = await backend.get("/api/v1/canvas")
return _slim_canvas(raw)
if name == "list_nodes":
return await backend.get("/api/v1/nodes")
if name == "list_pending_devices":
return await backend.get("/api/v1/scan/pending")
raise ValueError(f"Unknown tool: {name}")
+2
View File
@@ -0,0 +1,2 @@
[pytest]
pythonpath = .
+5
View File
@@ -0,0 +1,5 @@
mcp[cli]>=1.0
httpx>=0.27
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic-settings>=2.0
View File
+33
View File
@@ -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}
+28
View File
@@ -0,0 +1,28 @@
import pytest
from unittest.mock import AsyncMock, patch
@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):
# Auth passes — mock handle_request so we don't need a live MCP session
with patch("app.main.session_manager.handle_request", new_callable=AsyncMock):
resp = await client.get("/mcp", headers={"X-API-Key": api_key})
assert resp.status_code != 401
+47
View File
@@ -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")
+122
View File
@@ -0,0 +1,122 @@
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={})
m.get = 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_update_node_parent_id(mock_backend):
await _dispatch("update_node", {"id": "42", "parent_id": "proxmox-1"})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
@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_get_canvas(mock_backend):
mock_backend.get = AsyncMock(return_value={
"nodes": [
{
"id": "n1",
"type": "router",
"position": {"x": 100, "y": 200},
"width": 160,
"height": 80,
"data": {"label": "Freebox", "ip": "192.168.1.1", "status": "online"},
}
],
"edges": [
{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet", "animated": True, "style": {"stroke": "#fff"}},
],
"viewport": {"x": 0, "y": 0, "zoom": 1},
})
result = await _dispatch("get_canvas", {})
mock_backend.get.assert_called_once_with("/api/v1/canvas")
# Layout/style fields stripped, only semantic data kept
assert result["nodes"] == [{"id": "n1", "node_type": "router", "label": "Freebox", "ip": "192.168.1.1", "status": "online"}]
assert result["edges"] == [{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet"}]
assert "viewport" not in result
@pytest.mark.anyio
async def test_list_nodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
result = await _dispatch("list_nodes", {})
mock_backend.get.assert_called_once_with("/api/v1/nodes")
assert result == [{"id": "1", "label": "Freebox"}]
@pytest.mark.anyio
async def test_list_pending_devices(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])
result = await _dispatch("list_pending_devices", {})
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
assert result == [{"id": "p1", "ip": "192.168.1.50"}]
@pytest.mark.anyio
async def test_unknown_tool():
with pytest.raises(ValueError, match="Unknown tool"):
await _dispatch("nonexistent", {})