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
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", {})