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:
@@ -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}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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", {})
|
||||
Reference in New Issue
Block a user