300567c88d
- 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
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
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"},
|
|
"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={
|
|
"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"}},
|
|
}),
|
|
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()
|
|
async def call_tool(name: str, arguments: dict):
|
|
result = await _dispatch(name, arguments)
|
|
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)
|
|
|
|
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", {})
|
|
|
|
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}")
|