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