From 7935b671d38c6aa2c2fd6868d3af5d64dcba9d01 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:41:34 +0100 Subject: [PATCH 1/8] 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 --- .env.example | 7 +++ README.md | 82 +++++++++++++++++++++++++++ backend/app/api/deps.py | 24 +++++++- backend/app/core/config.py | 5 ++ docker-compose.yml | 16 ++++++ mcp/Dockerfile.mcp | 10 ++++ mcp/app/__init__.py | 0 mcp/app/auth.py | 21 +++++++ mcp/app/backend_client.py | 40 +++++++++++++ mcp/app/config.py | 12 ++++ mcp/app/main.py | 43 ++++++++++++++ mcp/app/resources.py | 43 ++++++++++++++ mcp/app/tools.py | 110 ++++++++++++++++++++++++++++++++++++ mcp/pytest.ini | 2 + mcp/requirements.txt | 5 ++ mcp/tests/__init__.py | 0 mcp/tests/conftest.py | 33 +++++++++++ mcp/tests/test_auth.py | 26 +++++++++ mcp/tests/test_resources.py | 47 +++++++++++++++ mcp/tests/test_tools.py | 73 ++++++++++++++++++++++++ 20 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 mcp/Dockerfile.mcp create mode 100644 mcp/app/__init__.py create mode 100644 mcp/app/auth.py create mode 100644 mcp/app/backend_client.py create mode 100644 mcp/app/config.py create mode 100644 mcp/app/main.py create mode 100644 mcp/app/resources.py create mode 100644 mcp/app/tools.py create mode 100644 mcp/pytest.ini create mode 100644 mcp/requirements.txt create mode 100644 mcp/tests/__init__.py create mode 100644 mcp/tests/conftest.py create mode 100644 mcp/tests/test_auth.py create mode 100644 mcp/tests/test_resources.py create mode 100644 mcp/tests/test_tools.py diff --git a/.env.example b/.env.example index 767cdca..11a238b 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ SCANNER_RANGES=["192.168.1.0/24"] # Status checker interval in seconds 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 diff --git a/README.md b/README.md index 62d00a6..9497e30 100644 --- a/README.md +++ b/README.md @@ -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://: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://: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://: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 **Backend (Python 3.13):** diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index d398576..260c5d4 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 app.core.config import settings 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) if not username: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index cf8cf0c..80d51a2 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -25,6 +25,11 @@ class Settings(BaseSettings): # Status checker 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: return Path(self.sqlite_path).parent / "scan_config.json" diff --git a/docker-compose.yml b/docker-compose.yml index 9cd9a06..631125c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,22 @@ services: cap_add: - 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: build: context: . diff --git a/mcp/Dockerfile.mcp b/mcp/Dockerfile.mcp new file mode 100644 index 0000000..6065594 --- /dev/null +++ b/mcp/Dockerfile.mcp @@ -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"] diff --git a/mcp/app/__init__.py b/mcp/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/app/auth.py b/mcp/app/auth.py new file mode 100644 index 0000000..4633ef2 --- /dev/null +++ b/mcp/app/auth.py @@ -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) diff --git a/mcp/app/backend_client.py b/mcp/app/backend_client.py new file mode 100644 index 0000000..e260c39 --- /dev/null +++ b/mcp/app/backend_client.py @@ -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() diff --git a/mcp/app/config.py b/mcp/app/config.py new file mode 100644 index 0000000..f6adf81 --- /dev/null +++ b/mcp/app/config.py @@ -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() diff --git a/mcp/app/main.py b/mcp/app/main.py new file mode 100644 index 0000000..c55019d --- /dev/null +++ b/mcp/app/main.py @@ -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"} diff --git a/mcp/app/resources.py b/mcp/app/resources.py new file mode 100644 index 0000000..e14f1ed --- /dev/null +++ b/mcp/app/resources.py @@ -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) diff --git a/mcp/app/tools.py b/mcp/app/tools.py new file mode 100644 index 0000000..3bf3225 --- /dev/null +++ b/mcp/app/tools.py @@ -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}") diff --git a/mcp/pytest.ini b/mcp/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/mcp/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/mcp/requirements.txt b/mcp/requirements.txt new file mode 100644 index 0000000..8d843d7 --- /dev/null +++ b/mcp/requirements.txt @@ -0,0 +1,5 @@ +mcp[cli]>=1.0 +httpx>=0.27 +fastapi>=0.115 +uvicorn[standard]>=0.30 +pydantic-settings>=2.0 diff --git a/mcp/tests/__init__.py b/mcp/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp/tests/conftest.py b/mcp/tests/conftest.py new file mode 100644 index 0000000..68e9038 --- /dev/null +++ b/mcp/tests/conftest.py @@ -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} diff --git a/mcp/tests/test_auth.py b/mcp/tests/test_auth.py new file mode 100644 index 0000000..c34e8ad --- /dev/null +++ b/mcp/tests/test_auth.py @@ -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 diff --git a/mcp/tests/test_resources.py b/mcp/tests/test_resources.py new file mode 100644 index 0000000..36f5d7f --- /dev/null +++ b/mcp/tests/test_resources.py @@ -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") diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py new file mode 100644 index 0000000..a97ea1a --- /dev/null +++ b/mcp/tests/test_tools.py @@ -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", {}) From ff69856d31c243cc90408a5cdc8b986d312c9950 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:42:13 +0100 Subject: [PATCH 2/8] chore: ignore Ideas.md --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3856de0..3ab8a23 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ htmlcov/ # Docker .docker/ +Ideas.md From e3f8c27a04bc93aefdac5066b9d4ed9a442110d4 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:42:48 +0100 Subject: [PATCH 3/8] test: add backend tests for MCP service key auth in deps.py --- backend/tests/test_auth.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 95422eb..7f3f001 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,3 +1,4 @@ +import pytest from httpx import AsyncClient @@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient): res = await client.get("/api/v1/health") assert res.status_code == 200 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 From 593335648fbaa1cc5d3d262a78b0014e524f9725 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:45:51 +0100 Subject: [PATCH 4/8] chore: add mcp/.env.example --- mcp/.env.example | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 mcp/.env.example diff --git a/mcp/.env.example b/mcp/.env.example new file mode 100644 index 0000000..104ba2e --- /dev/null +++ b/mcp/.env.example @@ -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 From e1d16b86e361528bddf76ffa92f0edfb23124703 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:52:36 +0100 Subject: [PATCH 5/8] docs: fix Claude Code MCP setup instructions (use claude mcp add CLI command) --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9497e30..c3a5e45 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,13 @@ docker compose up -d mcp **3. Configure your AI client:** -**Claude Code** (`~/.claude/claude_desktop_config.json` or via `/mcp` in the CLI): +**Claude Code** — run this command in your terminal: +```bash +claude mcp add --transport sse homelable http://:8001/mcp \ + --header "X-API-Key: mcp_sk_yourkey" +``` + +Or add it manually to `~/.claude.json`: ```json { "mcpServers": { @@ -214,7 +220,7 @@ docker compose up -d mcp } ``` -**Claude Desktop** (same config file, usually `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): +**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): ```json { "mcpServers": { From e41dbe579c8725d6c886207104fd7ba720132535 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 17:28:38 +0100 Subject: [PATCH 6/8] fix(mcp): fix SSE streaming crash and reduce get_canvas token usage - Replace BaseHTTPMiddleware with pure ASGI middleware in auth.py to fix the "Unexpected message: http.response.start" crash on SSE streams - Migrate from SseServerTransport to StreamableHTTPSessionManager in main.py - Add _slim_canvas() in tools.py to strip React Flow layout fields from get_canvas responses (60-80% payload reduction) - Update tests: assert canvas slimming, mock session_manager.handle_request in auth tests to avoid uninitialized task group errors --- mcp/app/auth.py | 45 +++++++++++++++++++++++++++++++---------- mcp/app/main.py | 27 ++++++++++++------------- mcp/app/tools.py | 43 +++++++++++++++++++++++++++++++++++++++ mcp/tests/test_auth.py | 6 ++++-- mcp/tests/test_tools.py | 43 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 137 insertions(+), 27 deletions(-) diff --git a/mcp/app/auth.py b/mcp/app/auth.py index 4633ef2..786751c 100644 --- a/mcp/app/auth.py +++ b/mcp/app/auth.py @@ -1,21 +1,44 @@ import hmac -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import JSONResponse +import json +from starlette.types import ASGIApp, Receive, Scope, Send from .config import settings +_BYPASS_PATHS = {"/health", "/register"} -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", "") +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()): - return JSONResponse({"detail": "Invalid or missing X-API-Key"}, status_code=401) + 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 - return await call_next(request) + await self.app(scope, receive, send) diff --git a/mcp/app/main.py b/mcp/app/main.py index c55019d..ca40953 100644 --- a/mcp/app/main.py +++ b/mcp/app/main.py @@ -1,7 +1,7 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from mcp.server import Server -from mcp.server.sse import SseServerTransport +from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from .auth import ApiKeyMiddleware from .backend_client import backend @@ -13,29 +13,28 @@ 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() - yield + async with session_manager.run(): + 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.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") diff --git a/mcp/app/tools.py b/mcp/app/tools.py index 3bf3225..56fb933 100644 --- a/mcp/app/tools.py +++ b/mcp/app/tools.py @@ -71,6 +71,18 @@ def register_tools(server: Server): "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() @@ -79,6 +91,27 @@ def register_tools(server: Server): 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) @@ -107,4 +140,14 @@ async def _dispatch(name: str, args: dict) -> dict: 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}") diff --git a/mcp/tests/test_auth.py b/mcp/tests/test_auth.py index c34e8ad..29cdfc2 100644 --- a/mcp/tests/test_auth.py +++ b/mcp/tests/test_auth.py @@ -1,4 +1,5 @@ import pytest +from unittest.mock import AsyncMock, patch @pytest.mark.anyio @@ -21,6 +22,7 @@ async def test_wrong_api_key(client): @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}) + # 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 diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index a97ea1a..80a6cee 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -9,6 +9,7 @@ def mock_backend(): 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 @@ -67,6 +68,48 @@ async def test_hide_device(mock_backend): 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"): From 300567c88da261d81206a89a25c9e6a443520ec0 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 21:29:27 +0100 Subject: [PATCH 7/8] feat(mcp): expose parent_id in update_node tool - Add parent_id to NodeUpdate schema in backend so PATCH /nodes/{id} accepts it (was silently ignored before) - Expose parent_id in update_node MCP tool schema so the MCP SDK forwards it to the backend instead of stripping it - Add regression tests in both backend and MCP layers --- backend/app/schemas/nodes.py | 1 + backend/tests/test_nodes.py | 10 ++++++++++ mcp/app/tools.py | 11 ++++++----- mcp/tests/test_tools.py | 6 ++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index bd3cad3..9f71db3 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -42,6 +42,7 @@ class NodeUpdate(BaseModel): notes: str | None = None pos_x: float | None = None pos_y: float | None = None + parent_id: str | None = None container_mode: bool | None = None custom_colors: dict[str, Any] | None = None custom_icon: str | None = None diff --git a/backend/tests/test_nodes.py b/backend/tests/test_nodes.py index eafff2c..db54305 100644 --- a/backend/tests/test_nodes.py +++ b/backend/tests/test_nodes.py @@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict): 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): res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) assert res.status_code == 401 diff --git a/mcp/app/tools.py b/mcp/app/tools.py index 56fb933..b9b934c 100644 --- a/mcp/app/tools.py +++ b/mcp/app/tools.py @@ -24,11 +24,12 @@ def register_tools(server: Server): "type": "object", "required": ["id"], "properties": { - "id": {"type": "string"}, - "label": {"type": "string"}, - "ip": {"type": "string"}, - "hostname": {"type": "string"}, - "status": {"type": "string"}, + "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={ diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index 80a6cee..c0b4bcf 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -26,6 +26,12 @@ async def test_update_node(mock_backend): 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"}) From f36bdfe878c5df7a8411d5fef370d0f8487d23a9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 16 Mar 2026 00:51:12 +0100 Subject: [PATCH 8/8] update package --- frontend/package-lock.json | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54244b7..3d7279c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@base-ui/react": "^1.2.0", "@dagrejs/dagre": "^2.0.4", @@ -5474,9 +5474,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -5826,9 +5826,9 @@ } }, "node_modules/hono": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", - "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", + "version": "4.12.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz", + "integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -8770,9 +8770,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz", + "integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==", "dev": true, "license": "MIT", "engines": {