Merge pull request #180 from Pouzor/feat/issue-174-mcp-full-node-schema
feat(mcp): expose full node schema in create_node/update_node (#174)
This commit is contained in:
+70
-20
@@ -4,33 +4,69 @@ from mcp.types import Tool, TextContent
|
|||||||
from .backend_client import backend
|
from .backend_client import backend
|
||||||
|
|
||||||
|
|
||||||
def register_tools(server: Server):
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
@server.list_tools()
|
|
||||||
async def list_tools():
|
|
||||||
return [
|
return [
|
||||||
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["type", "label"],
|
"required": ["type", "label"],
|
||||||
"properties": {
|
"properties": create_node_props,
|
||||||
"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={
|
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id"],
|
"required": ["id"],
|
||||||
"properties": {
|
"properties": update_node_props,
|
||||||
"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={
|
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -63,7 +99,7 @@ def register_tools(server: Server):
|
|||||||
"required": ["id"],
|
"required": ["id"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {"type": "string"},
|
"id": {"type": "string"},
|
||||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
|
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
|
||||||
"label": {"type": "string"},
|
"label": {"type": "string"},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -86,6 +122,16 @@ def register_tools(server: Server):
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = _build_tools()
|
||||||
|
|
||||||
|
|
||||||
|
def register_tools(server: Server):
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools():
|
||||||
|
return TOOLS
|
||||||
|
|
||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict):
|
async def call_tool(name: str, arguments: dict):
|
||||||
result = await _dispatch(name, arguments)
|
result = await _dispatch(name, arguments)
|
||||||
@@ -94,7 +140,11 @@ def register_tools(server: Server):
|
|||||||
|
|
||||||
def _slim_canvas(raw: dict) -> dict:
|
def _slim_canvas(raw: dict) -> dict:
|
||||||
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
|
"""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"}
|
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
||||||
|
|
||||||
def slim_node(n: dict) -> dict:
|
def slim_node(n: dict) -> dict:
|
||||||
|
|||||||
+83
-1
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from app.tools import _dispatch
|
from app.tools import TOOLS, _dispatch
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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"})
|
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
|
@pytest.mark.anyio
|
||||||
async def test_delete_node(mock_backend):
|
async def test_delete_node(mock_backend):
|
||||||
await _dispatch("delete_node", {"id": "42"})
|
await _dispatch("delete_node", {"id": "42"})
|
||||||
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
|
|||||||
assert "viewport" not in result
|
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
|
@pytest.mark.anyio
|
||||||
async def test_list_nodes(mock_backend):
|
async def test_list_nodes(mock_backend):
|
||||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
||||||
|
|||||||
Reference in New Issue
Block a user