diff --git a/mcp/app/tools.py b/mcp/app/tools.py index b9b934c..dbddb27 100644 --- a/mcp/app/tools.py +++ b/mcp/app/tools.py @@ -4,87 +4,133 @@ from mcp.types import Tool, TextContent from .backend_client import backend +NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"] + +# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py). +# create_node and update_node both expose these so the MCP is symmetric with what the +# backend already validates and stores. _dispatch forwards args verbatim, so any field +# advertised here is accepted by the backend. +_NODE_FIELDS = { + "label": {"type": "string"}, + "ip": {"type": "string"}, + "hostname": {"type": "string"}, + "mac": {"type": "string", "description": "MAC address."}, + "os": {"type": "string", "description": "Operating system / distribution."}, + "status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]}, + "check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."}, + "check_target": {"type": "string", "description": "Target host/URL used by the status check."}, + "services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."}, + "notes": {"type": "string", "description": "Free-text notes / documentation for the node."}, + "parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."}, + "container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."}, + "custom_icon": {"type": "string", "description": "Override icon name for the node."}, + "cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."}, + "cpu_model": {"type": "string", "description": "CPU model name."}, + "ram_gb": {"type": "number", "description": "RAM in gigabytes."}, + "disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."}, + "show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."}, + "properties": { + "type": "array", + "description": "Arbitrary key/value metadata shown on the node.", + "items": { + "type": "object", + "required": ["name", "value"], + "properties": { + "name": {"type": "string"}, + "value": {"type": "string"}, + }, + }, + }, +} + + +def _build_tools() -> list[Tool]: + create_node_props = { + "type": {"type": "string", "enum": NODE_TYPES}, + **_NODE_FIELDS, + } + create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"} + + update_node_props = { + "id": {"type": "string"}, + "type": {"type": "string", "enum": NODE_TYPES}, + **_NODE_FIELDS, + } + + return [ + Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={ + "type": "object", + "required": ["type", "label"], + "properties": create_node_props, + }), + Tool(name="update_node", description="Update an existing node", inputSchema={ + "type": "object", + "required": ["id"], + "properties": update_node_props, + }), + 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": NODE_TYPES, "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": {}, + }), + ] + + +TOOLS = _build_tools() + + 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": {}, - }), - ] + return TOOLS @server.call_tool() async def call_tool(name: str, arguments: dict): @@ -94,7 +140,11 @@ def register_tools(server: Server): 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"} + NODE_KEEP = { + "id", "type", "label", "ip", "hostname", "mac", "os", "status", "services", + "notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb", + "disk_gb", "parentId", + } EDGE_KEEP = {"id", "source", "target", "type", "label"} def slim_node(n: dict) -> dict: diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index c0b4bcf..be1f7b3 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch -from app.tools import _dispatch +from app.tools import TOOLS, _dispatch @pytest.fixture @@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend): mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"}) +@pytest.mark.anyio +async def test_create_node_full_properties(mock_backend): + args = { + "type": "proxmox", + "label": "pve1", + "os": "Proxmox VE 8", + "notes": "Main hypervisor", + "services": [{"name": "ssh", "port": 22}], + "cpu_count": 16, + "cpu_model": "Ryzen 9 5950X", + "ram_gb": 64, + "disk_gb": 2000, + "show_hardware": True, + "properties": [{"name": "rack", "value": "A1"}], + } + await _dispatch("create_node", dict(args)) + # All extra fields forwarded to the backend unchanged. + mock_backend.post.assert_called_once_with("/api/v1/nodes", args) + + +@pytest.mark.anyio +async def test_update_node_properties(mock_backend): + await _dispatch("update_node", { + "id": "42", + "os": "Debian 12", + "properties": [{"name": "role", "value": "db"}], + }) + mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", { + "os": "Debian 12", + "properties": [{"name": "role", "value": "db"}], + }) + + @pytest.mark.anyio async def test_delete_node(mock_backend): await _dispatch("delete_node", {"id": "42"}) @@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend): assert "viewport" not in result +@pytest.mark.anyio +async def test_get_canvas_keeps_documentation_fields(mock_backend): + mock_backend.get = AsyncMock(return_value={ + "nodes": [ + { + "id": "n1", + "type": "proxmox", + "position": {"x": 0, "y": 0}, + "data": { + "label": "pve1", + "os": "Proxmox VE 8", + "notes": "Main hypervisor", + "cpu_count": 16, + "ram_gb": 64, + "properties": [{"name": "rack", "value": "A1"}], + }, + } + ], + "edges": [], + }) + result = await _dispatch("get_canvas", {}) + node = result["nodes"][0] + assert node["os"] == "Proxmox VE 8" + assert node["notes"] == "Main hypervisor" + assert node["cpu_count"] == 16 + assert node["ram_gb"] == 64 + assert node["properties"] == [{"name": "rack", "value": "A1"}] + + +def _tool_schema(name: str) -> dict: + tool = next(t for t in TOOLS if t.name == name) + return tool.inputSchema["properties"] + + +def test_create_node_schema_exposes_full_node_fields(): + props = _tool_schema("create_node") + for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"): + assert field in props, f"create_node schema missing {field}" + # type stays an enum of the canonical node types + assert "enum" in props["type"] + + +def test_update_node_schema_exposes_full_node_fields(): + props = _tool_schema("update_node") + for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"): + assert field in props, f"update_node schema missing {field}" + assert "id" in props + + @pytest.mark.anyio async def test_list_nodes(mock_backend): mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])