feat(mcp): expose full node schema in create_node/update_node (#174)

The MCP create_node/update_node inputSchema only advertised
type/label/ip/hostname/status (plus parent_id on update), so LLM
clients could not set documentation/hardware fields the backend
already validates and stores.

Mirror NodeBase/NodeUpdate by spreading a shared _NODE_FIELDS schema
into both tools: os, notes, mac, check_method, check_target, services,
cpu_count, cpu_model, ram_gb, disk_gb, show_hardware, container_mode,
custom_icon, properties. type stays an enum of canonical node types.
_dispatch already forwarded args verbatim, so no dispatch change.

Also extend _slim_canvas NODE_KEEP so get_canvas round-trips the new
documentation fields the LLM can now write.

Refactor tool definitions into a module-level TOOLS list for direct
schema assertions in tests.

ha-relevant: no
This commit is contained in:
Pouzor
2026-05-31 22:53:00 +02:00
parent aac6c09a04
commit 23a0a47a7f
2 changed files with 211 additions and 79 deletions
+83 -1
View File
@@ -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"}])