Files
homelable/mcp/app/tools.py
T
Pouzor ac8aea7467 feat(mcp): expose edge styling on create_edge and update_edge
The backend has always accepted these on EdgeCreate/EdgeUpdate; only the canvas
UI could set them, so an edge created over MCP came out with the edge type's
preset and no way to change it — the animation, colour and markers other edges
in the same canvas already use were unreachable.

Adds animated, custom_color, path_style, line_style, width_mult, marker_start,
marker_end, vlan_id and speed to both tools. The enums match what
normalize_animated / normalize_marker accept, so an advertised value can't
silently coerce to 'none'.

ha-relevant: no
2026-09-09 01:19:19 +02:00

577 lines
31 KiB
Python

import json
from urllib.parse import quote
from mcp.server import Server
from mcp.types import Tool, TextContent
from .backend_client import backend
from .devices import DEVICE_TOOL_NAMES, DEVICE_TOOLS, dispatch_device
from .racks import RACK_TOOL_NAMES, RACK_TOOLS, dispatch_rack
# Kept in sync manually with frontend/src/types/index.ts NodeType (device types only —
# group/groupRect/text are canvas annotations created via dedicated UI actions, not create_node).
# Sync is enforced automatically by test_node_types_in_sync_with_frontend (mcp/tests/test_node_types_sync.py).
NODE_TYPES = [
"isp", "router", "firewall", "switch", "server", "proxmox", "vm", "lxc", "nas", "kvm", "iot", "ap",
"camera", "printer", "computer", "laptop", "mobile", "cpl", "docker_host", "docker_container",
"generic", "zigbee_coordinator", "zigbee_router", "zigbee_enddevice",
"zwave_coordinator", "zwave_router", "zwave_enddevice", "grid", "ups", "battery", "generator",
"solar_panel", "inverter", "circuit_breaker", "contactor", "electrical_switch", "socket",
"light", "meter", "transformer", "load",
]
# Kept in sync manually with frontend/src/types/index.ts EdgeType.
# Sync is enforced automatically by test_edge_types_in_sync_with_frontend (mcp/tests/test_edge_types_sync.py).
EDGE_TYPES = [
"ethernet", "wifi", "iot", "vlan", "virtual", "cluster", "fibre", "electrical",
]
# 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."},
# Live observation, not a user setting: the inventory row keeps the freshest
# one rather than the last writer, so an update only lands while the device's
# status is still unknown (see link_facts in inventory_sync.py). Set it on
# create; to change it later, point check_method/check_target at the device
# and let the status checker observe it.
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"], "description": "Live status. Honoured on create; on update it is ignored unless the device's status is still unknown — the status checker owns it."},
"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."},
"pos_x": {"type": "number", "description": "X position on the canvas. Omit on create to auto-place (root nodes only)."},
"pos_y": {"type": "number", "description": "Y position on the canvas. Omit on create to auto-place (root nodes only). For child nodes this is relative to the parent container."},
"width": {"type": "number", "description": "Width of the node card in pixels. Mainly useful for container nodes."},
"height": {"type": "number", "description": "Height of the node card in pixels. Mainly useful for container nodes."},
"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."},
# The field is `key`, not `name`: the backend keys properties on it, both to
# merge them into the inventory row and to order them in the node's view
# (`merge_properties` / `apply_view` in backend/app/services/inventory_sync.py).
# A property with no `key` collapses with every other keyless one — send two
# and the node draws one.
# Connection points. The count is per side; the IDs an edge references are
# derived from it — slot 0 is the bare side name, slot N >= 1 is
# '{side}-{N + 1}' (frontend/src/utils/handleUtils.ts). Lowering a count
# moves the edges that used the dropped handles back to the side's slot 0.
"top_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the top side (default 1). Their IDs are 'top', 'top-2', 'top-3', ..."},
"bottom_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the bottom side (default 1). Their IDs are 'bottom', 'bottom-2', 'bottom-3', ..."},
"left_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the left side (default 0). Their IDs are 'left', 'left-2', 'left-3', ..."},
"right_handles": {"type": "integer", "minimum": 0, "maximum": 64, "description": "Connection points on the right side (default 0). Their IDs are 'right', 'right-2', 'right-3', ..."},
"show_port_numbers": {"type": "boolean", "description": "Number each connection point on the node card."},
"properties": {
"type": "array",
"description": "Key/value metadata shown on the node. `key` is the identity: two properties sharing one are the same property.",
"items": {
"type": "object",
"required": ["key", "value"],
"properties": {
"key": {"type": "string"},
"value": {"type": "string"},
"icon": {"type": "string", "description": "Lucide icon name shown beside the value."},
"visible": {"type": "boolean", "description": "Draw it on the node card. Default true."},
},
},
},
}
# 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'."},
}
# Which connection point each end of an edge attaches to. The IDs are the node's
# per-side handle IDs (see the *_handles fields above): 'bottom', 'bottom-2',
# 'left-3', ... Omit both on create and the backend picks them from the two nodes'
# relative positions (_auto_handles in backend/app/api/routes/edges.py).
#
# The node must already have the connection point — left/right default to none —
# or the backend answers 422 rather than storing an edge the canvas cannot draw.
# Presentation, mirroring the backend EdgeBase (backend/app/schemas/edges.py) and
# the EdgeData types the canvas draws from (frontend/src/types/index.ts). Every one
# is optional: an omitted field keeps the edge type's own preset.
_EDGE_STYLE_FIELDS = {
"animated": {"type": "string", "enum": ["none", "basic", "snake", "flow"], "description": "Line animation. 'basic' is the dashed march, 'snake' a travelling segment, 'flow' a continuous drift."},
"custom_color": {"type": "string", "description": "Line colour override, e.g. '#00d4ff'. Omit to use the edge type's colour."},
"path_style": {"type": "string", "enum": ["bezier", "smooth"], "description": "How the line is routed between the two connection points."},
"line_style": {"type": "string", "enum": ["solid", "dashed", "dotted"], "description": "How the line itself is stroked."},
"width_mult": {"type": "number", "minimum": 1, "maximum": 4, "description": "Stroke-width multiplier (1-4x) on the edge type's base width."},
"marker_start": {"type": "string", "enum": ["none", "arrow", "arrow-open", "circle", "diamond", "square"], "description": "Marker drawn at the source end."},
"marker_end": {"type": "string", "enum": ["none", "arrow", "arrow-open", "circle", "diamond", "square"], "description": "Marker drawn at the target end."},
"vlan_id": {"type": "integer", "description": "VLAN tag carried by the link. Shown on the edge for a 'vlan' type."},
"speed": {"type": "string", "description": "Link speed as it should read on the canvas, e.g. '10G' or '2.5 Gbps'."},
}
_EDGE_HANDLE_FIELDS = {
"source_handle": {"type": "string", "description": "Connection point on the source node, e.g. 'bottom' or 'right-2'. The node must have it already — raise that side's *_handles first, or the call is rejected. Omit to let the server pick."},
"target_handle": {"type": "string", "description": "Connection point on the target node, e.g. 'top' or 'left-3'. The node must have it already — raise that side's *_handles first, or the call is rejected. Omit to let the server pick."},
}
# 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.
# Use list_designs to discover the available IDs.
_DESIGN_ID_FIELD = {
"design_id": {"type": "string", "description": "Target design/canvas ID. Omit to use the default (first) canvas; call list_designs to discover IDs."},
}
def _build_tools() -> list[Tool]:
create_node_props = {
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
**_DESIGN_ID_FIELD,
}
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. Pass source_handle/target_handle to choose which connection points it attaches to; omit them and the server picks from the nodes' relative positions.", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": EDGE_TYPES, "default": "ethernet"},
"label": {"type": "string"},
**_EDGE_STYLE_FIELDS,
**_EDGE_HANDLE_FIELDS,
**_DESIGN_ID_FIELD,
},
}),
Tool(name="update_edge", description="Update an existing link: its type, label, styling (animation, colour, line/path style, width, endpoint markers), or which connection points it attaches to. Call get_canvas to discover edge ids.", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string", "description": "Edge id."},
"type": {"type": "string", "enum": EDGE_TYPES},
"label": {"type": "string"},
**_EDGE_STYLE_FIELDS,
**_EDGE_HANDLE_FIELDS,
},
}),
Tool(name="list_edges", description="List every link with its full detail — type, label, waypoints, and the source_handle/target_handle each end attaches to. get_canvas slims edges down to id/source/target/type/label; use this when the connection points or styling matter.", inputSchema={
"type": "object",
"properties": {},
}),
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. A device created from a rack canvas (discovery_source 'rack') is refused with a 409 — passive rack gear never goes on a logical canvas.", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
"label": {"type": "string"},
**_DESIGN_ID_FIELD,
},
}),
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": {
"design_id": {"type": "string", "description": "Only list zones on this design/canvas. Omit to list every design's zones; call list_designs to discover IDs."},
},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_node_summaries", description="List all nodes with only id/label/type/status — a lighter, lower-token alternative to list_nodes or get_canvas for when full node detail (ip, services, notes, hardware, properties, ...) isn't needed.", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="get_node", description="Get node(s) by id or by label. Provide exactly one of the two. Lookup by 'id' returns a single full node object. Lookup by 'label' is a case-insensitive substring match and returns a list of full node objects, since labels are not unique.", inputSchema={
"type": "object",
"properties": {
"id": {"type": "string", "description": "Node id. Returns a single node object."},
"label": {"type": "string", "description": "Case-insensitive substring match on label. Returns a list of matching node objects."},
},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_inventory", description="List the full device inventory (everything scanned except user-hidden devices): both pending devices awaiting triage and already-approved devices. Each row carries a 'status' field. Use the optional 'status' filter to narrow the result. Gear created from a rack canvas is listed here too, tagged discovery_source 'rack'.", inputSchema={
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["all", "pending", "approved"], "default": "all", "description": "Filter inventory by status. 'all' returns pending + approved."},
},
}),
Tool(name="list_hidden_devices", description="List devices the user has hidden from the inventory. Hidden devices are excluded from list_pending_devices and list_inventory; use restore_device to bring one back to pending.", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="restore_device", description="Restore (un-hide) a previously hidden device, returning it to pending status so it reappears in the triage list. Use this to undo a hide_device action.", inputSchema={
"type": "object",
"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. Lists every design's zones unless design_id narrows it.", inputSchema={
"type": "object",
"properties": {
"design_id": {"type": "string", "description": "Only list zones on this design/canvas. Omit to list every design's zones; call list_designs to discover IDs."},
},
}),
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, a node on another design, 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": {},
}),
Tool(name="create_design", description="Create a new design (canvas) and return it, including its id for use as design_id", inputSchema={
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Name of the new canvas."},
"icon": {"type": "string", "description": "Icon name for the canvas (default: dashboard)."},
"design_type": {"type": "string", "description": "Design type (default: network)."},
},
}),
Tool(name="delete_design", description="Delete a design (canvas) and all its nodes and edges. The last remaining design cannot be deleted.", inputSchema={
"type": "object",
"required": ["design_id"],
"properties": {
"design_id": {"type": "string", "description": "ID of the design to delete. Call list_designs to discover IDs."},
},
}),
]
# The rack canvas and the inventory write routes live in their own modules —
# both are big enough to bury the logical-canvas tools this file is about.
TOOLS = _build_tools() + RACK_TOOLS + DEVICE_TOOLS
def register_tools(server: Server):
@server.list_tools()
async def list_tools():
return TOOLS
@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 layout/style fields — keep only semantic data for AI use.
`GET /api/v1/canvas` reports a node flat (`NodeResponse`): the device facts
sit beside `id` and `type`, not under a React Flow `data` key. A payload that
does carry `data` — the shape the frontend store holds — is folded in too, so
either form slims to the same thing.
"""
NODE_KEEP = {
"label", "ip", "hostname", "mac", "os", "status", "services",
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
"disk_gb", "parent_id",
}
EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict:
fields = {**n, **n.get("data", {})}
out = {k: v for k, v in fields.items() if k in NODE_KEEP and v not in (None, "", [])}
out["id"] = n.get("id")
# `type` on the wire is the node type (router, proxmox, ...); it is
# reported under its own key so it cannot collide with a device fact.
out["node_type"] = n.get("type")
# Nesting is `parent_id` on the API and `parentId` in a React Flow payload.
parent = out.pop("parent_id", None) or n.get("parentId")
if parent:
out["parent_id"] = parent
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", [])],
}
def _slim_node_summary(n: dict) -> dict:
"""Only the fields needed to identify a node and reference it in later calls."""
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"),
"design_id": z.get("design_id"),
"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 in RACK_TOOL_NAMES:
return await dispatch_rack(name, args)
if name in DEVICE_TOOL_NAMES:
return await dispatch_device(name, args)
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 == "update_edge":
edge_id = args.pop("id")
return await backend.patch(f"/api/v1/edges/{edge_id}", args)
if name == "list_edges":
return await backend.get("/api/v1/edges")
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":
design_id = args.get("design_id")
path = f"/api/v1/canvas?design_id={design_id}" if design_id else "/api/v1/canvas"
raw = await backend.get(path)
return _slim_canvas(raw)
if name == "list_nodes":
return await backend.get("/api/v1/nodes")
if name == "list_node_summaries":
nodes = await backend.get("/api/v1/nodes")
return [_slim_node_summary(n) for n in nodes]
if name == "get_node":
node_id = args.get("id")
label = args.get("label")
if node_id:
return await backend.get(f"/api/v1/nodes/{node_id}")
if label:
return await backend.get(f"/api/v1/nodes?label={quote(label)}")
raise ValueError("get_node requires either 'id' or 'label'")
if name == "list_pending_devices":
# Backend /scan/pending returns the whole inventory: approved rows stay
# listed so the frontend can show a canvas-presence badge. This tool
# promises only devices "not yet approved or hidden", so filter to
# actual pending rows (legacy rows without a status count as pending).
devices = await backend.get("/api/v1/scan/pending")
return [d for d in devices if d.get("status", "pending") == "pending"]
if name == "list_inventory":
# /scan/pending returns the whole inventory minus hidden rows (pending +
# approved). Legacy rows without a status field count as pending.
devices = await backend.get("/api/v1/scan/pending")
wanted = args.get("status", "all")
if wanted == "all":
return devices
return [d for d in devices if d.get("status", "pending") == wanted]
if name == "list_hidden_devices":
return await backend.get("/api/v1/scan/hidden")
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":
# /api/v1/nodes has no design filter — it returns every design's nodes,
# so narrow here when the caller named one.
design_id = args.get("design_id")
nodes = await backend.get("/api/v1/nodes")
return [
_slim_zone(n, nodes)
for n in nodes
if n.get("type") == ZONE_TYPE and (design_id is None or n.get("design_id") == design_id)
]
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
# A zone only groups its own canvas: parenting across designs
# would hide the node on the design it belongs to.
or node.get("design_id") != zone.get("design_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")
if name == "create_design":
return await backend.post("/api/v1/designs", args)
if name == "delete_design":
return await backend.delete(f"/api/v1/designs/{args['design_id']}")
raise ValueError(f"Unknown tool: {name}")