diff --git a/backend/tests/test_inventory_sync.py b/backend/tests/test_inventory_sync.py index ac3a61b..0d03860 100644 --- a/backend/tests/test_inventory_sync.py +++ b/backend/tests/test_inventory_sync.py @@ -435,6 +435,77 @@ class TestRoutesKeepTheLinkInStep: canvas_b = (await client.get(f"/api/v1/canvas?design_id={design_b}", headers=headers)).json() assert canvas_b["nodes"][0]["ip"] == "10.0.0.9" + @pytest.mark.asyncio + async def test_the_wire_shape_the_mcp_server_reads_is_unchanged( + self, client: AsyncClient, headers, db_session + ): + """`mcp/app/tools.py` reads the facts flat on the node — keep it that way. + + The MCP server is a thin proxy over these routes: `_slim_canvas` keeps + `NODE_KEEP`, `_slim_node_summary` keeps label/type/status, and `get_node` + looks a node up by label. None of it knows the inventory row exists, so + the split must not show on the wire. + """ + db_session.add( + InventoryDevice( + id="d-1", + ip="10.0.0.5", + hostname="nas.lan", + mac="aa:bb:cc:dd:ee:ff", + os="TrueNAS", + notes="in the garage", + cpu_count=8, + cpu_model="Xeon", + ram_gb=32.0, + disk_gb=4000.0, + services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}], + properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}], + status_live="online", + ) + ) + await db_session.commit() + design_id = (await client.post("/api/v1/designs", json={"name": "Net"}, headers=headers)).json()["id"] + created = ( + await client.post( + "/api/v1/nodes", + json={ + "type": "nas", "label": "NAS", "ip": "10.0.0.5", + "design_id": design_id, "force": True, + }, + headers=headers, + ) + ).json() + + # mcp `_slim_canvas` NODE_KEEP, plus what `_slim_node_summary` reads. + expected = { + "type": "nas", + "label": "NAS", + "ip": "10.0.0.5", + "hostname": "nas.lan", + "mac": "aa:bb:cc:dd:ee:ff", + "os": "TrueNAS", + "status": "online", + "notes": "in the garage", + "cpu_count": 8, + "cpu_model": "Xeon", + "ram_gb": 32.0, + "disk_gb": 4000.0, + } + for source in ( + created, + (await client.get(f"/api/v1/nodes/{created['id']}", headers=headers)).json(), + (await client.get("/api/v1/nodes", headers=headers)).json()[0], + (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0], + # mcp `get_node` falls back to a label lookup when given no id. + (await client.get("/api/v1/nodes?label=NAS", headers=headers)).json()[0], + ): + assert {k: source[k] for k in expected} == expected + assert source["services"] == [{"port": 22, "protocol": "tcp", "service_name": "ssh"}] + assert source["properties"] == [ + {"key": "Rack", "value": "A1", "icon": None, "visible": True} + ] + assert "parent_id" in source + @pytest.mark.asyncio async def test_deleting_a_node_keeps_the_device(self, client: AsyncClient, headers, db_session): db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5")) diff --git a/mcp/app/tools.py b/mcp/app/tools.py index 1128629..9114006 100644 --- a/mcp/app/tools.py +++ b/mcp/app/tools.py @@ -217,19 +217,31 @@ def register_tools(server: Server): def _slim_canvas(raw: dict) -> dict: - """Strip React Flow layout/style fields — keep only semantic data for AI use.""" + """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 = { - "id", "type", "label", "ip", "hostname", "mac", "os", "status", "services", + "label", "ip", "hostname", "mac", "os", "status", "services", "notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb", - "disk_gb", "parentId", + "disk_gb", "parent_id", } EDGE_KEEP = {"id", "source", "target", "type", "label"} def slim_node(n: dict) -> dict: - data = n.get("data", {}) - out = {k: v for k, v in data.items() if k in NODE_KEEP and v not in (None, "", [])} + 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: diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index b7d3676..a8d1c56 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -197,6 +197,113 @@ async def test_get_canvas_keeps_documentation_fields(mock_backend): assert node["properties"] == [{"name": "rack", "value": "A1"}] +# A node as `GET /api/v1/canvas` actually reports it: `NodeResponse`, flat, with +# the device facts beside `id` and `type` rather than under a React Flow `data` +# key. Copied from a real response — the backend pins this shape in +# `backend/tests/test_inventory_sync.py:: +# TestRoutesKeepTheLinkInStep::test_the_wire_shape_the_mcp_server_reads_is_unchanged`. +_API_CANVAS_NODE = { + "id": "n1", + "type": "proxmox", + "label": "pve1", + "design_id": "d-1", + "device_id": "dev-1", + "ip": "192.168.1.10", + "hostname": "pve1.lan", + "mac": "aa:bb:cc:dd:ee:ff", + "os": "Proxmox VE 8", + "status": "online", + "notes": "Main hypervisor", + "services": [{"port": 8006, "protocol": "tcp", "service_name": "proxmox"}], + "properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}], + "cpu_count": 16, + "cpu_model": "Xeon E5", + "ram_gb": 64.0, + "disk_gb": 4000.0, + "show_hardware": True, + "check_method": "https", + "check_target": "https://192.168.1.10:8006", + "parent_id": None, + "pos_x": 120.0, + "pos_y": 240.0, + "width": 160.0, + "height": 80.0, + "container_mode": False, + "custom_colors": None, + "custom_icon": None, + "show_port_numbers": False, + "bottom_handles": 1, + "top_handles": 1, + "left_handles": 0, + "right_handles": 0, + "ieee_address": None, + "last_seen": "2026-08-14T10:00:00Z", + "last_scan": None, + "response_time_ms": 12, + "created_at": "2026-08-01T10:00:00Z", + "updated_at": "2026-08-14T10:00:00Z", +} + + +@pytest.mark.anyio +async def test_get_canvas_keeps_the_facts_of_a_flat_api_node(mock_backend): + """The API reports a node flat; slimming must not drop everything but the id.""" + mock_backend.get = AsyncMock(return_value={"nodes": [_API_CANVAS_NODE], "edges": []}) + + node = (await _dispatch("get_canvas", {}))["nodes"][0] + + assert node == { + "id": "n1", + "node_type": "proxmox", + "label": "pve1", + "ip": "192.168.1.10", + "hostname": "pve1.lan", + "mac": "aa:bb:cc:dd:ee:ff", + "os": "Proxmox VE 8", + "status": "online", + "notes": "Main hypervisor", + "services": [{"port": 8006, "protocol": "tcp", "service_name": "proxmox"}], + "properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}], + "cpu_count": 16, + "cpu_model": "Xeon E5", + "ram_gb": 64.0, + "disk_gb": 4000.0, + } + + +@pytest.mark.anyio +async def test_get_canvas_drops_the_layout_fields_of_a_flat_api_node(mock_backend): + mock_backend.get = AsyncMock(return_value={"nodes": [_API_CANVAS_NODE], "edges": []}) + + node = (await _dispatch("get_canvas", {}))["nodes"][0] + + for dropped in ( + "pos_x", "pos_y", "width", "height", "custom_colors", "custom_icon", + "bottom_handles", "top_handles", "left_handles", "right_handles", + "created_at", "updated_at", "design_id", "device_id", "type", + ): + assert dropped not in node + + +@pytest.mark.anyio +async def test_get_canvas_reports_nesting_from_either_shape(mock_backend): + mock_backend.get = AsyncMock( + return_value={ + "nodes": [ + {**_API_CANVAS_NODE, "id": "child", "parent_id": "n1"}, + {"id": "rf-child", "type": "vm", "parentId": "n1", "data": {"label": "vm1"}}, + ], + "edges": [], + } + ) + + nodes = (await _dispatch("get_canvas", {}))["nodes"] + + assert nodes[0]["parent_id"] == "n1" + assert nodes[1]["parent_id"] == "n1" + assert nodes[1]["label"] == "vm1" + + @pytest.mark.anyio async def test_get_canvas_with_design_id(mock_backend): mock_backend.get = AsyncMock(return_value={"nodes": [], "edges": []})