feat(mcp): expose zones to the MCP server
An AI client could create nodes but had no way to create the areas that organize them, or to move a node into one (#365). Four tools, all on the existing /api/v1/nodes endpoints: create_zone (a groupRect node, with size and border/text colours), list_zones (each zone plus the ids it contains), add_to_zone and remove_from_zone, both taking a list of node ids. The canvas stores a child's position relative to its parent, so add_to_zone rebases each node on the zone and remove_from_zone restores absolute coordinates — otherwise a node jumps by the zone's offset the next time the canvas loads. add_to_zone skips what it cannot move (the zone itself, a node already inside it, an unknown id, and any node the zone descends from, which would build a cycle) and reports them back rather than failing the batch. groupRect stays out of NODE_TYPES: create_node remains a device tool, and the enum sync tests keep passing. ha-relevant: no
This commit is contained in:
committed by
Pouzor - Rémy Jardient
parent
3301f00e57
commit
1ec7ea7e8e
@@ -64,6 +64,18 @@ _NODE_FIELDS = {
|
||||
},
|
||||
}
|
||||
|
||||
# A zone is a groupRect node: canvas furniture that visually groups the nodes
|
||||
# parented to it (a VLAN, a site, a rack room). It is deliberately kept out of
|
||||
# NODE_TYPES — create_node makes devices, these tools make and fill zones.
|
||||
ZONE_TYPE = "groupRect"
|
||||
|
||||
_ZONE_COLOR_FIELDS = {
|
||||
"border": {"type": "string", "description": "Border/label colour, e.g. '#00d4ff'."},
|
||||
"border_style": {"type": "string", "enum": ["solid", "dashed", "dotted"]},
|
||||
"border_width": {"type": "number"},
|
||||
"text_color": {"type": "string", "description": "Label colour, e.g. '#e6edf3'."},
|
||||
}
|
||||
|
||||
# Optional design/canvas selector. The backend attaches nodes/edges to the first
|
||||
# design when design_id is omitted (see backend nodes.py / edges.py), so these
|
||||
# tools stay backward compatible; pass design_id to target a specific canvas.
|
||||
@@ -178,6 +190,38 @@ def _build_tools() -> list[Tool]:
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="create_zone", description="Create a zone (a groupRect area) on the canvas. A zone visually groups the nodes parented to it — a VLAN, a site, a room. Use add_to_zone to fill it.", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["label"],
|
||||
"properties": {
|
||||
"label": {"type": "string", "description": "Zone name, shown in its top-left corner."},
|
||||
"pos_x": {"type": "number", "description": "X position on the canvas. Omit to auto-place."},
|
||||
"pos_y": {"type": "number", "description": "Y position on the canvas. Omit to auto-place."},
|
||||
"width": {"type": "number", "description": "Zone width in pixels (default 360)."},
|
||||
"height": {"type": "number", "description": "Zone height in pixels (default 240)."},
|
||||
**_ZONE_COLOR_FIELDS,
|
||||
**_DESIGN_ID_FIELD,
|
||||
},
|
||||
}),
|
||||
Tool(name="list_zones", description="List the zones (groupRect areas) on the canvas with their id, label, position, size and the nodes each one contains.", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="add_to_zone", description="Move one or more nodes into a zone. Positions are rebased on the zone so the nodes keep their place on screen. A node already in the zone, the zone itself, and any node the zone is nested in are skipped.", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["zone_id", "node_ids"],
|
||||
"properties": {
|
||||
"zone_id": {"type": "string", "description": "Target zone id — call list_zones to discover it."},
|
||||
"node_ids": {"type": "array", "items": {"type": "string"}, "description": "Ids of the nodes to move in."},
|
||||
},
|
||||
}),
|
||||
Tool(name="remove_from_zone", description="Take nodes back out of the zone they are in, restoring their absolute canvas position. Nodes that are not in a zone are left alone.", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["node_ids"],
|
||||
"properties": {
|
||||
"node_ids": {"type": "array", "items": {"type": "string"}, "description": "Ids of the nodes to detach."},
|
||||
},
|
||||
}),
|
||||
Tool(name="list_designs", description="List all designs (canvases) with their IDs and node/group/text counts", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
@@ -258,6 +302,34 @@ def _slim_node_summary(n: dict) -> dict:
|
||||
return {"id": n.get("id"), "label": n.get("label"), "type": n.get("type"), "status": n.get("status")}
|
||||
|
||||
|
||||
def _slim_zone(z: dict, nodes: list[dict]) -> dict:
|
||||
"""A zone plus the ids of the nodes parented to it."""
|
||||
return {
|
||||
"id": z.get("id"),
|
||||
"label": z.get("label"),
|
||||
"pos_x": z.get("pos_x"),
|
||||
"pos_y": z.get("pos_y"),
|
||||
"width": z.get("width"),
|
||||
"height": z.get("height"),
|
||||
"node_ids": [n["id"] for n in nodes if n.get("parent_id") == z.get("id")],
|
||||
}
|
||||
|
||||
|
||||
def _is_ancestor(nodes_by_id: dict[str, dict], maybe_ancestor_id: str, node_id: str) -> bool:
|
||||
"""True when `maybe_ancestor_id` sits on `node_id`'s parent chain. Cycle-safe."""
|
||||
seen = {node_id}
|
||||
current = nodes_by_id.get(node_id)
|
||||
while current:
|
||||
pid = current.get("parent_id")
|
||||
if not pid or pid in seen:
|
||||
return False
|
||||
if pid == maybe_ancestor_id:
|
||||
return True
|
||||
seen.add(pid)
|
||||
current = nodes_by_id.get(pid)
|
||||
return False
|
||||
|
||||
|
||||
async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "create_node":
|
||||
return await backend.post("/api/v1/nodes", args)
|
||||
@@ -331,6 +403,70 @@ async def _dispatch(name: str, args: dict) -> dict:
|
||||
if name == "restore_device":
|
||||
return await backend.post(f"/api/v1/scan/pending/{args['id']}/restore", {})
|
||||
|
||||
if name == "create_zone":
|
||||
colors = {k: args.pop(k) for k in list(_ZONE_COLOR_FIELDS) if k in args}
|
||||
body = {
|
||||
"type": ZONE_TYPE,
|
||||
"status": "unknown",
|
||||
"width": args.pop("width", 360),
|
||||
"height": args.pop("height", 240),
|
||||
**args,
|
||||
}
|
||||
if colors:
|
||||
body["custom_colors"] = colors
|
||||
return await backend.post("/api/v1/nodes", body)
|
||||
|
||||
if name == "list_zones":
|
||||
nodes = await backend.get("/api/v1/nodes")
|
||||
return [_slim_zone(n, nodes) for n in nodes if n.get("type") == ZONE_TYPE]
|
||||
|
||||
if name == "add_to_zone":
|
||||
zone_id = args["zone_id"]
|
||||
nodes = await backend.get("/api/v1/nodes")
|
||||
by_id = {n["id"]: n for n in nodes}
|
||||
zone = by_id.get(zone_id)
|
||||
if zone is None or zone.get("type") != ZONE_TYPE:
|
||||
raise ValueError(f"add_to_zone: {zone_id} is not a zone")
|
||||
moved, skipped = [], []
|
||||
for node_id in args["node_ids"]:
|
||||
node = by_id.get(node_id)
|
||||
if (
|
||||
node is None
|
||||
or node_id == zone_id
|
||||
or node.get("parent_id") == zone_id
|
||||
or _is_ancestor(by_id, node_id, zone_id)
|
||||
):
|
||||
skipped.append(node_id)
|
||||
continue
|
||||
# The canvas stores a child's position relative to its parent, so
|
||||
# rebase or the node jumps by the zone's offset on the next load.
|
||||
await backend.patch(f"/api/v1/nodes/{node_id}", {
|
||||
"parent_id": zone_id,
|
||||
"pos_x": (node.get("pos_x") or 0) - (zone.get("pos_x") or 0),
|
||||
"pos_y": (node.get("pos_y") or 0) - (zone.get("pos_y") or 0),
|
||||
})
|
||||
moved.append(node_id)
|
||||
return {"zone_id": zone_id, "moved": moved, "skipped": skipped}
|
||||
|
||||
if name == "remove_from_zone":
|
||||
nodes = await backend.get("/api/v1/nodes")
|
||||
by_id = {n["id"]: n for n in nodes}
|
||||
detached, skipped = [], []
|
||||
for node_id in args["node_ids"]:
|
||||
node = by_id.get(node_id)
|
||||
zone = by_id.get(node.get("parent_id") or "") if node else None
|
||||
if node is None or zone is None or zone.get("type") != ZONE_TYPE:
|
||||
skipped.append(node_id)
|
||||
continue
|
||||
# Zone-relative → absolute, so the node stays where it looks.
|
||||
await backend.patch(f"/api/v1/nodes/{node_id}", {
|
||||
"parent_id": None,
|
||||
"pos_x": (node.get("pos_x") or 0) + (zone.get("pos_x") or 0),
|
||||
"pos_y": (node.get("pos_y") or 0) + (zone.get("pos_y") or 0),
|
||||
})
|
||||
detached.append(node_id)
|
||||
return {"detached": detached, "skipped": skipped}
|
||||
|
||||
if name == "list_designs":
|
||||
return await backend.get("/api/v1/designs")
|
||||
|
||||
|
||||
@@ -530,3 +530,105 @@ async def test_list_hidden_devices(mock_backend):
|
||||
async def test_restore_device(mock_backend):
|
||||
await _dispatch("restore_device", {"id": "5"})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/restore", {})
|
||||
|
||||
|
||||
# ── Zones (#365) ─────────────────────────────────────────────────────────────
|
||||
|
||||
_ZONE_NODES = [
|
||||
{"id": "z1", "type": "groupRect", "label": "DMZ", "pos_x": 100, "pos_y": 100, "width": 400, "height": 300},
|
||||
{"id": "n1", "type": "router", "label": "Router", "pos_x": 160, "pos_y": 220},
|
||||
{"id": "n2", "type": "server", "label": "NAS", "pos_x": 200, "pos_y": 260, "parent_id": "z1"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_zone_posts_a_grouprect_node(mock_backend):
|
||||
await _dispatch("create_zone", {"label": "DMZ", "pos_x": 10, "pos_y": 20})
|
||||
mock_backend.post.assert_called_once_with("/api/v1/nodes", {
|
||||
"type": "groupRect", "status": "unknown", "width": 360, "height": 240,
|
||||
"label": "DMZ", "pos_x": 10, "pos_y": 20,
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_zone_folds_colors_into_custom_colors(mock_backend):
|
||||
await _dispatch("create_zone", {"label": "VLAN 10", "border": "#39d353", "border_style": "dashed", "width": 500})
|
||||
body = mock_backend.post.call_args[0][1]
|
||||
assert body["custom_colors"] == {"border": "#39d353", "border_style": "dashed"}
|
||||
assert body["width"] == 500
|
||||
assert "border" not in body
|
||||
|
||||
|
||||
def test_create_zone_schema_requires_label():
|
||||
tool = next(t for t in TOOLS if t.name == "create_zone")
|
||||
assert tool.inputSchema["required"] == ["label"]
|
||||
|
||||
|
||||
def test_zone_type_is_not_offered_by_create_node():
|
||||
"""A zone is canvas furniture — create_node stays a device tool."""
|
||||
tool = next(t for t in TOOLS if t.name == "create_node")
|
||||
assert "groupRect" not in tool.inputSchema["properties"]["type"]["enum"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_zones_returns_only_zones_with_their_children(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=list(_ZONE_NODES))
|
||||
result = await _dispatch("list_zones", {})
|
||||
assert result == [{
|
||||
"id": "z1", "label": "DMZ", "pos_x": 100, "pos_y": 100,
|
||||
"width": 400, "height": 300, "node_ids": ["n2"],
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_zone_rebases_positions_on_the_zone(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=list(_ZONE_NODES))
|
||||
result = await _dispatch("add_to_zone", {"zone_id": "z1", "node_ids": ["n1"]})
|
||||
mock_backend.patch.assert_called_once_with(
|
||||
"/api/v1/nodes/n1", {"parent_id": "z1", "pos_x": 60, "pos_y": 120}
|
||||
)
|
||||
assert result == {"zone_id": "z1", "moved": ["n1"], "skipped": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_zone_skips_the_zone_itself_and_nodes_already_in_it(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=list(_ZONE_NODES))
|
||||
result = await _dispatch("add_to_zone", {"zone_id": "z1", "node_ids": ["z1", "n2", "ghost"]})
|
||||
mock_backend.patch.assert_not_called()
|
||||
assert result["moved"] == []
|
||||
assert result["skipped"] == ["z1", "n2", "ghost"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_zone_refuses_a_node_the_zone_descends_from(mock_backend):
|
||||
"""Parenting a zone's own ancestor under it would build a cycle."""
|
||||
mock_backend.get = AsyncMock(return_value=[
|
||||
{"id": "outer", "type": "groupRect", "pos_x": 0, "pos_y": 0},
|
||||
{"id": "z1", "type": "groupRect", "pos_x": 100, "pos_y": 100, "parent_id": "outer"},
|
||||
])
|
||||
result = await _dispatch("add_to_zone", {"zone_id": "z1", "node_ids": ["outer"]})
|
||||
mock_backend.patch.assert_not_called()
|
||||
assert result["skipped"] == ["outer"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_to_zone_rejects_a_target_that_is_not_a_zone(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=list(_ZONE_NODES))
|
||||
with pytest.raises(ValueError, match="is not a zone"):
|
||||
await _dispatch("add_to_zone", {"zone_id": "n1", "node_ids": ["n2"]})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_remove_from_zone_restores_absolute_positions(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=list(_ZONE_NODES))
|
||||
result = await _dispatch("remove_from_zone", {"node_ids": ["n2", "n1"]})
|
||||
mock_backend.patch.assert_called_once_with(
|
||||
"/api/v1/nodes/n2", {"parent_id": None, "pos_x": 300, "pos_y": 360}
|
||||
)
|
||||
# n1 has no parent — nothing to detach.
|
||||
assert result == {"detached": ["n2"], "skipped": ["n1"]}
|
||||
|
||||
|
||||
def test_zone_tools_are_registered():
|
||||
names = {t.name for t in TOOLS}
|
||||
assert {"create_zone", "list_zones", "add_to_zone", "remove_from_zone"} <= names
|
||||
|
||||
Reference in New Issue
Block a user