fix(mcp): apply_patch preserves schema-defaulted ids in multi-op batches

SceneBridge.applyPatch now threads the Zod-parsed node through to the
apply phase instead of the raw input. Previously, when a caller sent a
create patch without an `id` field, the schema's objectId default ran
during dry-run parsing but only in `res.data`; the apply phase pushed
the unparsed `p.node` (no id) to the store, so subsequent tools that
walked `level.children` crashed on undefined entries (e.g.
duplicate_level -> cloneLevelSubtree -> extractIdPrefix(undefined)).

Also adds test-reports/ artefacts from live end-to-end testing:
- t1-stdio: 21/21 tools pass via stdio (~106ms)
- t2-http: connect/single-session behaviour (HTTP transport quirk
  documented)
- t3-scenario: 2-bedroom apartment built end-to-end — 12/12 steps
  after this fix (24 final nodes, validate=true, apartment.json
  exported)
- t4-errors: 24/24 invalid-input cases rejected with proper MCP errors
- t5-resources-prompts: 4/4 resources, 3/3 prompts, dev server
  /api/health 200 OK

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adrian Perez
2026-04-18 18:21:00 +02:00
co-authored by Claude Opus 4.7
parent a6f1c4140f
commit b37e88cb83
19 changed files with 5884 additions and 4 deletions
@@ -0,0 +1,728 @@
# T4 — Error Contract Verification Report
Server: `http://localhost:3917/`
Run date: 2026-04-18T16:17:28.757Z
## Summary
- PASS: 24
- WARN: 0
- FAIL: 0
- Total cases: 24
Baseline node count: 3
Final node count: 3 (delta=0)
Final validation: valid=true, errors=0
## Cases
### T4-01 — `get_node` — nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "node_doesnotexist_xyz"
}
```
**Expected:** McpError InvalidParams (-32602) "Node not found" OR structured tool error
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_doesnotexist_xyz",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_doesnotexist_xyz"
}
]
}
```
### T4-02 — `describe_node` — nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "node_missing_123"
}
```
**Expected:** McpError InvalidParams (-32602) "Node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_missing_123",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_missing_123"
}
]
}
```
### T4-03 — `find_nodes` — invalid type enum "hamster"
**Verdict:** ✅ PASS
**Input:**
```json
{
"type": "hamster"
}
```
**Expected:** Zod validation error (MCP InvalidParams -32602)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool find_nodes: [\n {\n \"code\": \"invalid_value\",\n \"values\": [\n \"site\",\n \"building\",\n \"level\",\n \"wall\",\n \"fence\",\n \"zone\",\n \"slab\",\n \"ceiling\",\n \"roof\",\n \"roof-segment\",\n \"stair\",\n \"stair-segment\",\n \"item\",\n \"door\",\n \"window\",\n \"scan\",\n \"guide\"\n ],\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid option: expected one of \\\"site\\\"|\\\"building\\\"|\\\"level\\\"|\\\"wall\\\"|\\\"fence\\\"|\\\"zone\\\"|\\\"slab\\\"|\\\"ceiling\\\"|\\\"roof\\\"|\\\"roof-segment\\\"|\\\"stair\\\"|\\\"stair-segment\\\"|\\\"item\\\"|\\\"door\\\"|\\\"window\\\"|\\\"scan\\\"|\\\"guide\\\"\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool find_nodes: [\n {\n \"code\": \"invalid_value\",\n \"values\": [\n \"site\",\n \"building\",\n \"level\",\n \"wall\",\n \"fence\",\n \"zone\",\n \"slab\",\n \"ceiling\",\n \"roof\",\n \"roof-segment\",\n \"stair\",\n \"stair-segment\",\n \"item\",\n \"door\",\n \"window\",\n \"scan\",\n \"guide\"\n ],\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid option: expected one of \\\"site\\\"|\\\"building\\\"|\\\"level\\\"|\\\"wall\\\"|\\\"fence\\\"|\\\"zone\\\"|\\\"slab\\\"|\\\"ceiling\\\"|\\\"roof\\\"|\\\"roof-segment\\\"|\\\"stair\\\"|\\\"stair-segment\\\"|\\\"item\\\"|\\\"door\\\"|\\\"window\\\"|\\\"scan\\\"|\\\"guide\\\"\"\n }\n]"
}
]
}
```
### T4-04 — `measure` — nonexistent fromId
**Verdict:** ✅ PASS
**Input:**
```json
{
"fromId": "node_nosuch_f",
"toId": "site_watn4a0qt2xpgri7"
}
```
**Expected:** McpError InvalidParams "Node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_nosuch_f",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_nosuch_f"
}
]
}
```
### T4-05 — `apply_patch` — patches with one invalid node (missing type)
**Verdict:** ✅ PASS
**Input:**
```json
{
"patches": [
{
"op": "create",
"node": {
"foo": "bar"
},
"parentId": "level_tl2aravmn2u9afft"
}
]
}
```
**Expected:** McpError InvalidParams, all-or-nothing rollback (no partial state change)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: invalid patch: patches[0] create node failed schema: [\n {\n \"code\": \"invalid_union\",\n \"errors\": [],\n \"note\": \"No matching discriminator\",\n \"discriminator\": \"type\",\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid input\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: invalid patch: patches[0] create node failed schema: [\n {\n \"code\": \"invalid_union\",\n \"errors\": [],\n \"note\": \"No matching discriminator\",\n \"discriminator\": \"type\",\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid input\"\n }\n]"
}
]
}
```
### T4-06 — `apply_patch` — delete nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"patches": [
{
"op": "delete",
"id": "node_nonexistent_delete_xyz"
}
]
}
```
**Expected:** McpError InvalidParams, no state change
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: invalid patch: patches[0] delete id \"node_nonexistent_delete_xyz\" not found",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: invalid patch: patches[0] delete id \"node_nonexistent_delete_xyz\" not found"
}
]
}
```
### T4-07 — `create_level` — buildingId is not a building (passed a wall/level/site id)
**Verdict:** ✅ PASS
**Input:**
```json
{
"buildingId": "level_tl2aravmn2u9afft",
"elevation": 0
}
```
**Expected:** McpError InvalidParams "expected building"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node level_tl2aravmn2u9afft is a level, expected building",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node level_tl2aravmn2u9afft is a level, expected building"
}
]
}
```
### T4-08 — `create_wall` — levelId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_nosuch_999",
"start": [
0,
0
],
"end": [
5,
0
]
}
```
**Expected:** McpError InvalidParams "Level not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Level not found: level_nosuch_999",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Level not found: level_nosuch_999"
}
]
}
```
### T4-09 — `create_wall` — start not a tuple
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_tl2aravmn2u9afft",
"start": "not-a-tuple",
"end": [
5,
0
]
}
```
**Expected:** Zod validation error (MCP InvalidParams -32602)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool create_wall: [\n {\n \"expected\": \"tuple\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"start\"\n ],\n \"message\": \"Invalid input: expected tuple, received string\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool create_wall: [\n {\n \"expected\": \"tuple\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"start\"\n ],\n \"message\": \"Invalid input: expected tuple, received string\"\n }\n]"
}
]
}
```
### T4-10 — `place_item` — targetNodeId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"catalogItemId": "chair-1",
"targetNodeId": "node_nosuch_target",
"position": [
0,
0,
0
]
}
```
**Expected:** McpError InvalidParams "Target node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Target node not found: node_nosuch_target",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Target node not found: node_nosuch_target"
}
]
}
```
### T4-11 — `cut_opening` — wallId is not a wall
**Verdict:** ✅ PASS
**Input:**
```json
{
"wallId": "site_watn4a0qt2xpgri7",
"type": "door",
"position": 0.5,
"width": 0.8,
"height": 2
}
```
**Expected:** McpError InvalidParams "expected wall"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected wall",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected wall"
}
]
}
```
### T4-12 — `cut_opening` — position out of [0,1]
**Verdict:** ✅ PASS
**Input:**
```json
{
"wallId": "missing_wall",
"type": "door",
"position": 2.5,
"width": 0.8,
"height": 2
}
```
**Expected:** Zod validation error (MCP InvalidParams) — position must be <= 1
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool cut_opening: [\n {\n \"origin\": \"number\",\n \"code\": \"too_big\",\n \"maximum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"position\"\n ],\n \"message\": \"Too big: expected number to be <=1\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool cut_opening: [\n {\n \"origin\": \"number\",\n \"code\": \"too_big\",\n \"maximum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"position\"\n ],\n \"message\": \"Too big: expected number to be <=1\"\n }\n]"
}
]
}
```
### T4-13 — `set_zone` — polygon with < 3 points
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_tl2aravmn2u9afft",
"polygon": [
[
0,
0
],
[
5,
0
]
],
"label": "Tiny"
}
```
**Expected:** Zod validation error (MCP InvalidParams) — polygon must have >= 3 points
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool set_zone: [\n {\n \"origin\": \"array\",\n \"code\": \"too_small\",\n \"minimum\": 3,\n \"inclusive\": true,\n \"path\": [\n \"polygon\"\n ],\n \"message\": \"Too small: expected array to have >=3 items\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool set_zone: [\n {\n \"origin\": \"array\",\n \"code\": \"too_small\",\n \"minimum\": 3,\n \"inclusive\": true,\n \"path\": [\n \"polygon\"\n ],\n \"message\": \"Too small: expected array to have >=3 items\"\n }\n]"
}
]
}
```
### T4-14 — `duplicate_level` — levelId is not a level
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "site_watn4a0qt2xpgri7"
}
```
**Expected:** McpError InvalidParams "expected level"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected level",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected level"
}
]
}
```
### T4-15 — `delete_node` — cascade=false with children (target site site_watn4a0qt2xpgri7 children=1)
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "site_watn4a0qt2xpgri7",
"cascade": false
}
```
**Expected:** McpError InvalidRequest "node has children" (no delete)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: node has 2 descendant(s); pass cascade: true to delete recursively",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: node has 2 descendant(s); pass cascade: true to delete recursively"
}
]
}
```
### T4-16a — `undo` — negative steps
**Verdict:** ✅ PASS
**Input:**
```json
{
"steps": -1
}
```
**Expected:** Zod validation error (MCP InvalidParams) — steps must be positive int
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool undo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool undo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]"
}
]
}
```
### T4-16b — `redo` — negative steps
**Verdict:** ✅ PASS
**Input:**
```json
{
"steps": -2
}
```
**Expected:** Zod validation error (MCP InvalidParams) — steps must be positive int
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool redo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool redo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]"
}
]
}
```
### T4-17 — `export_json` — pretty='yes' (string not bool)
**Verdict:** ✅ PASS
**Input:**
```json
{
"pretty": "yes"
}
```
**Expected:** Zod validation error (MCP InvalidParams) — pretty must be boolean
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool export_json: [\n {\n \"expected\": \"boolean\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"pretty\"\n ],\n \"message\": \"Invalid input: expected boolean, received string\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool export_json: [\n {\n \"expected\": \"boolean\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"pretty\"\n ],\n \"message\": \"Invalid input: expected boolean, received string\"\n }\n]"
}
]
}
```
### T4-18 — `check_collisions` — levelId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_nosuch_zzz"
}
```
**Expected:** Empty collisions result OR graceful error
**Actual:**
```json
{
"kind": "unexpected_success",
"message": "tool returned successfully with no isError flag",
"structuredContent": {
"collisions": []
},
"rawContent": [
{
"type": "text",
"text": "{\"collisions\":[]}"
}
]
}
```
**Note:** returned empty collisions (graceful)
### T4-19 — `validate_scene` — baseline: no args
**Verdict:** ✅ PASS
**Input:**
```json
{}
```
**Expected:** Success — structured { valid, errors[] }
**Actual:**
```json
{
"kind": "unexpected_success",
"message": "tool returned successfully with no isError flag",
"structuredContent": {
"valid": true,
"errors": []
},
"rawContent": [
{
"type": "text",
"text": "{\"valid\":true,\"errors\":[]}"
}
]
}
```
**Note:** baseline passed
### T4-20a — `analyze_floorplan_image` — image: '' (empty string)
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": ""
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-20b — `analyze_floorplan_image` — image: 'not-a-url-or-base64'
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": "not-a-url-or-base64"
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-21a — `analyze_room_photo` — image: '' (empty string)
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": ""
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-21b — `analyze_room_photo` — image: 'not-a-url-or-base64'
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": "not-a-url-or-base64"
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```