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:
Pouzor
2026-03-13 16:41:34 +01:00
parent f8cadba17b
commit 7935b671d3
20 changed files with 596 additions and 3 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
+82
View File
@@ -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
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"
+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: .
+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
+21
View File
@@ -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)
+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()
+43
View File
@@ -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"}
+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)
+110
View File
@@ -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}")
+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}
+26
View File
@@ -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
+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")
+73
View File
@@ -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", {})