From 7935b671d38c6aa2c2fd6868d3af5d64dcba9d01 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 13 Mar 2026 16:41:34 +0100 Subject: [PATCH] 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", {})