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:
co-authored by
Claude Opus 4.7
parent
a6f1c4140f
commit
b37e88cb83
@@ -0,0 +1,81 @@
|
||||
# T3 Scenario Report — 2-Bedroom Apartment
|
||||
|
||||
Generated: 2026-04-18T16:20:27.809Z
|
||||
Transport: http (shared HTTP server)
|
||||
Server URL: http://localhost:3917/mcp
|
||||
|
||||
## Step-by-step
|
||||
|
||||
### Step 1: discover OK (4ms)
|
||||
|
||||
- Summary: building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)
|
||||
- Node IDs (2): building_bfqg91ai9ijps9ej, level_wyuoxj87czq3v0re
|
||||
|
||||
### Step 2: perimeter walls OK (2ms)
|
||||
|
||||
- Summary: created 4 perimeter walls (result.createdIds=["wall_y87bsrljd2245n51","wall_sja6jpda73tlhxwb","wall_aegff27krjwgmkmi","wall_dcymglyle9ff09ti"])
|
||||
- Node IDs (4): wall_y87bsrljd2245n51, wall_sja6jpda73tlhxwb, wall_aegff27krjwgmkmi, wall_dcymglyle9ff09ti
|
||||
|
||||
### Step 3: interior partitions OK (1ms)
|
||||
|
||||
- Summary: created 7 interior walls
|
||||
- Node IDs (7): wall_rl83cc5tnbf4b34j, wall_qv53jm9kvl7k6slf, wall_8y52fwzco2ep7fb1, wall_hrfixeusz7zb7x63, wall_1ullk9bm6dw15i9t, wall_c4fjjswnk0mprctm, wall_p359pffjf3qs59cy
|
||||
|
||||
### Step 4: set zones OK (3ms)
|
||||
|
||||
- Summary: created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen
|
||||
- Node IDs (4): zone_3fyksm10tb0dhn1e, zone_u95l1bt35jci3gvu, zone_r9ma8tvsqt9w1zey, zone_l189q61kf9ra2m8t
|
||||
|
||||
### Step 5: cut openings OK (6ms)
|
||||
|
||||
- Summary: 3 doors, 3 windows
|
||||
- Node IDs (6): door_cjzja4lt8owg88wg, door_bs7bf0azevq9vd76, door_o8etwqsemfgj5mkj, window_47x40mtv2l4ca9p4, window_n09awmg5m3ct4fvn, window_xlta3f3f0cnmbti3
|
||||
|
||||
### Step 6: validate scene OK (1ms)
|
||||
|
||||
- Summary: valid=true, errors=0
|
||||
|
||||
### Step 7: measure furthest zones OK (3ms)
|
||||
|
||||
- Summary: furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m
|
||||
- Node IDs (2): zone_3fyksm10tb0dhn1e, zone_u95l1bt35jci3gvu
|
||||
|
||||
### Step 8: export json OK (1ms)
|
||||
|
||||
- Summary: exported 15392 bytes -> apartment.json
|
||||
|
||||
### Step 9: undo 3 steps OK (2ms)
|
||||
|
||||
- Summary: undone=3, nodes 24 -> 21 (delta=3)
|
||||
|
||||
### Step 10: redo 3 steps OK (2ms)
|
||||
|
||||
- Summary: redone=3, nodes 21 -> 24
|
||||
|
||||
### Step 11: duplicate level + validate OK (2ms)
|
||||
|
||||
- Summary: newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0
|
||||
- Node IDs (1): level_cxvltlqvgqcasiep
|
||||
|
||||
### Step 12: delete duplicated level OK (3ms)
|
||||
|
||||
- Summary: deleted 22 nodes; nodes 46 -> 24
|
||||
- Node IDs (22): level_cxvltlqvgqcasiep, wall_xhn7o9bfpmcv2znr, window_0qb4bgvzp46y412o, window_cjk6a6zwsn48dj5u, wall_hgatfahp4i53s139, wall_fp25ulcwqmsim8p5, window_jv0g8uos313ljbbe, wall_mjue0xaodm8d3vzi, wall_brw2c9vg502md7a0, wall_1at54a0mfxgq2txb, door_l8k2adas2djwpcf8, wall_6m920gigvtn113af, wall_2hazwrgv922l3t6q, door_a62eecbdzpdoqnlo, wall_11h43exay41fub7f, door_06dpidftldcoaisv, wall_u7e1gp0xbir5112y, wall_jbwbwvgt9lzw51mk, zone_hkp5wsyw7aydi175, zone_rpqx5ul6bl1tpdmd ...
|
||||
|
||||
## Final Counts
|
||||
|
||||
- Total nodes: 24
|
||||
- Zones: 4
|
||||
- Doors: 3
|
||||
- Windows: 3
|
||||
- Post-step-5 node count: 24
|
||||
|
||||
## Validation
|
||||
|
||||
- Valid: true
|
||||
- Errors: 0
|
||||
|
||||
## Transport Notes
|
||||
|
||||
- Used transport: **http**
|
||||
- Reason: shared HTTP server
|
||||
@@ -0,0 +1,772 @@
|
||||
{
|
||||
"nodes": {
|
||||
"site_xs1r72ib2ymzpjus": {
|
||||
"object": "node",
|
||||
"id": "site_xs1r72ib2ymzpjus",
|
||||
"type": "site",
|
||||
"parentId": null,
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"polygon": {
|
||||
"type": "polygon",
|
||||
"points": [
|
||||
[
|
||||
-15,
|
||||
-15
|
||||
],
|
||||
[
|
||||
15,
|
||||
-15
|
||||
],
|
||||
[
|
||||
15,
|
||||
15
|
||||
],
|
||||
[
|
||||
-15,
|
||||
15
|
||||
]
|
||||
]
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"object": "node",
|
||||
"id": "building_bfqg91ai9ijps9ej",
|
||||
"type": "building",
|
||||
"parentId": null,
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"level_wyuoxj87czq3v0re"
|
||||
],
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"building_bfqg91ai9ijps9ej": {
|
||||
"object": "node",
|
||||
"id": "building_bfqg91ai9ijps9ej",
|
||||
"type": "building",
|
||||
"parentId": null,
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"level_wyuoxj87czq3v0re"
|
||||
],
|
||||
"position": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"level_wyuoxj87czq3v0re": {
|
||||
"object": "node",
|
||||
"id": "level_wyuoxj87czq3v0re",
|
||||
"type": "level",
|
||||
"parentId": null,
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"wall_y87bsrljd2245n51",
|
||||
"wall_sja6jpda73tlhxwb",
|
||||
"wall_aegff27krjwgmkmi",
|
||||
"wall_dcymglyle9ff09ti",
|
||||
"wall_rl83cc5tnbf4b34j",
|
||||
"wall_qv53jm9kvl7k6slf",
|
||||
"wall_8y52fwzco2ep7fb1",
|
||||
"wall_hrfixeusz7zb7x63",
|
||||
"wall_1ullk9bm6dw15i9t",
|
||||
"wall_c4fjjswnk0mprctm",
|
||||
"wall_p359pffjf3qs59cy",
|
||||
"zone_3fyksm10tb0dhn1e",
|
||||
"zone_u95l1bt35jci3gvu",
|
||||
"zone_r9ma8tvsqt9w1zey",
|
||||
"zone_l189q61kf9ra2m8t"
|
||||
],
|
||||
"level": 0
|
||||
},
|
||||
"wall_y87bsrljd2245n51": {
|
||||
"object": "node",
|
||||
"id": "wall_y87bsrljd2245n51",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"window_47x40mtv2l4ca9p4",
|
||||
"window_n09awmg5m3ct4fvn"
|
||||
],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
10,
|
||||
0
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_sja6jpda73tlhxwb": {
|
||||
"object": "node",
|
||||
"id": "wall_sja6jpda73tlhxwb",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
10,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
10,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_aegff27krjwgmkmi": {
|
||||
"object": "node",
|
||||
"id": "wall_aegff27krjwgmkmi",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"window_xlta3f3f0cnmbti3"
|
||||
],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
10,
|
||||
8
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_dcymglyle9ff09ti": {
|
||||
"object": "node",
|
||||
"id": "wall_dcymglyle9ff09ti",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
0,
|
||||
8
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_rl83cc5tnbf4b34j": {
|
||||
"object": "node",
|
||||
"id": "wall_rl83cc5tnbf4b34j",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
0,
|
||||
5
|
||||
],
|
||||
"end": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_qv53jm9kvl7k6slf": {
|
||||
"object": "node",
|
||||
"id": "wall_qv53jm9kvl7k6slf",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"door_cjzja4lt8owg88wg"
|
||||
],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"end": [
|
||||
3,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_8y52fwzco2ep7fb1": {
|
||||
"object": "node",
|
||||
"id": "wall_8y52fwzco2ep7fb1",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"end": [
|
||||
10,
|
||||
5
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_hrfixeusz7zb7x63": {
|
||||
"object": "node",
|
||||
"id": "wall_hrfixeusz7zb7x63",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"door_bs7bf0azevq9vd76"
|
||||
],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"end": [
|
||||
7,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_1ullk9bm6dw15i9t": {
|
||||
"object": "node",
|
||||
"id": "wall_1ullk9bm6dw15i9t",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [
|
||||
"door_o8etwqsemfgj5mkj"
|
||||
],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
4,
|
||||
6
|
||||
],
|
||||
"end": [
|
||||
6,
|
||||
6
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_c4fjjswnk0mprctm": {
|
||||
"object": "node",
|
||||
"id": "wall_c4fjjswnk0mprctm",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
4,
|
||||
6
|
||||
],
|
||||
"end": [
|
||||
4,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"wall_p359pffjf3qs59cy": {
|
||||
"object": "node",
|
||||
"id": "wall_p359pffjf3qs59cy",
|
||||
"type": "wall",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"children": [],
|
||||
"thickness": 0.2,
|
||||
"height": 2.7,
|
||||
"start": [
|
||||
6,
|
||||
6
|
||||
],
|
||||
"end": [
|
||||
6,
|
||||
8
|
||||
],
|
||||
"frontSide": "unknown",
|
||||
"backSide": "unknown"
|
||||
},
|
||||
"zone_3fyksm10tb0dhn1e": {
|
||||
"object": "node",
|
||||
"id": "zone_3fyksm10tb0dhn1e",
|
||||
"type": "zone",
|
||||
"name": "bedroom-1",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"polygon": [
|
||||
[
|
||||
0,
|
||||
5
|
||||
],
|
||||
[
|
||||
3,
|
||||
5
|
||||
],
|
||||
[
|
||||
3,
|
||||
8
|
||||
],
|
||||
[
|
||||
0,
|
||||
8
|
||||
]
|
||||
],
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
"zone_u95l1bt35jci3gvu": {
|
||||
"object": "node",
|
||||
"id": "zone_u95l1bt35jci3gvu",
|
||||
"type": "zone",
|
||||
"name": "bedroom-2",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"polygon": [
|
||||
[
|
||||
7,
|
||||
5
|
||||
],
|
||||
[
|
||||
10,
|
||||
5
|
||||
],
|
||||
[
|
||||
10,
|
||||
8
|
||||
],
|
||||
[
|
||||
7,
|
||||
8
|
||||
]
|
||||
],
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
"zone_r9ma8tvsqt9w1zey": {
|
||||
"object": "node",
|
||||
"id": "zone_r9ma8tvsqt9w1zey",
|
||||
"type": "zone",
|
||||
"name": "bathroom",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"polygon": [
|
||||
[
|
||||
4,
|
||||
6
|
||||
],
|
||||
[
|
||||
6,
|
||||
6
|
||||
],
|
||||
[
|
||||
6,
|
||||
8
|
||||
],
|
||||
[
|
||||
4,
|
||||
8
|
||||
]
|
||||
],
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
"zone_l189q61kf9ra2m8t": {
|
||||
"object": "node",
|
||||
"id": "zone_l189q61kf9ra2m8t",
|
||||
"type": "zone",
|
||||
"name": "living-kitchen",
|
||||
"parentId": "level_wyuoxj87czq3v0re",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"polygon": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
10,
|
||||
0
|
||||
],
|
||||
[
|
||||
10,
|
||||
5
|
||||
],
|
||||
[
|
||||
7,
|
||||
5
|
||||
],
|
||||
[
|
||||
7,
|
||||
8
|
||||
],
|
||||
[
|
||||
6,
|
||||
8
|
||||
],
|
||||
[
|
||||
6,
|
||||
6
|
||||
],
|
||||
[
|
||||
4,
|
||||
6
|
||||
],
|
||||
[
|
||||
4,
|
||||
8
|
||||
],
|
||||
[
|
||||
3,
|
||||
8
|
||||
],
|
||||
[
|
||||
3,
|
||||
5
|
||||
],
|
||||
[
|
||||
0,
|
||||
5
|
||||
]
|
||||
],
|
||||
"color": "#3b82f6"
|
||||
},
|
||||
"door_cjzja4lt8owg88wg": {
|
||||
"object": "node",
|
||||
"id": "door_cjzja4lt8owg88wg",
|
||||
"type": "door",
|
||||
"parentId": "wall_qv53jm9kvl7k6slf",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.5,
|
||||
1.05,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_qv53jm9kvl7k6slf",
|
||||
"width": 0.9,
|
||||
"height": 2.1,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"threshold": true,
|
||||
"thresholdHeight": 0.02,
|
||||
"hingesSide": "left",
|
||||
"swingDirection": "inward",
|
||||
"segments": [
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.4,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.6,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
}
|
||||
],
|
||||
"handle": true,
|
||||
"handleHeight": 1.05,
|
||||
"handleSide": "right",
|
||||
"contentPadding": [
|
||||
0.04,
|
||||
0.04
|
||||
],
|
||||
"doorCloser": false,
|
||||
"panicBar": false,
|
||||
"panicBarHeight": 1
|
||||
},
|
||||
"door_bs7bf0azevq9vd76": {
|
||||
"object": "node",
|
||||
"id": "door_bs7bf0azevq9vd76",
|
||||
"type": "door",
|
||||
"parentId": "wall_hrfixeusz7zb7x63",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.5,
|
||||
1.05,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_hrfixeusz7zb7x63",
|
||||
"width": 0.9,
|
||||
"height": 2.1,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"threshold": true,
|
||||
"thresholdHeight": 0.02,
|
||||
"hingesSide": "left",
|
||||
"swingDirection": "inward",
|
||||
"segments": [
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.4,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.6,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
}
|
||||
],
|
||||
"handle": true,
|
||||
"handleHeight": 1.05,
|
||||
"handleSide": "right",
|
||||
"contentPadding": [
|
||||
0.04,
|
||||
0.04
|
||||
],
|
||||
"doorCloser": false,
|
||||
"panicBar": false,
|
||||
"panicBarHeight": 1
|
||||
},
|
||||
"door_o8etwqsemfgj5mkj": {
|
||||
"object": "node",
|
||||
"id": "door_o8etwqsemfgj5mkj",
|
||||
"type": "door",
|
||||
"parentId": "wall_1ullk9bm6dw15i9t",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.5,
|
||||
1.05,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_1ullk9bm6dw15i9t",
|
||||
"width": 0.9,
|
||||
"height": 2.1,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"threshold": true,
|
||||
"thresholdHeight": 0.02,
|
||||
"hingesSide": "left",
|
||||
"swingDirection": "inward",
|
||||
"segments": [
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.4,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"heightRatio": 0.6,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"dividerThickness": 0.03,
|
||||
"panelDepth": 0.01,
|
||||
"panelInset": 0.04
|
||||
}
|
||||
],
|
||||
"handle": true,
|
||||
"handleHeight": 1.05,
|
||||
"handleSide": "right",
|
||||
"contentPadding": [
|
||||
0.04,
|
||||
0.04
|
||||
],
|
||||
"doorCloser": false,
|
||||
"panicBar": false,
|
||||
"panicBarHeight": 1
|
||||
},
|
||||
"window_47x40mtv2l4ca9p4": {
|
||||
"object": "node",
|
||||
"id": "window_47x40mtv2l4ca9p4",
|
||||
"type": "window",
|
||||
"parentId": "wall_y87bsrljd2245n51",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.3,
|
||||
0.6,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_y87bsrljd2245n51",
|
||||
"width": 1.2,
|
||||
"height": 1.2,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"rowRatios": [
|
||||
1
|
||||
],
|
||||
"columnDividerThickness": 0.03,
|
||||
"rowDividerThickness": 0.03,
|
||||
"sill": true,
|
||||
"sillDepth": 0.08,
|
||||
"sillThickness": 0.03
|
||||
},
|
||||
"window_n09awmg5m3ct4fvn": {
|
||||
"object": "node",
|
||||
"id": "window_n09awmg5m3ct4fvn",
|
||||
"type": "window",
|
||||
"parentId": "wall_y87bsrljd2245n51",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.7,
|
||||
0.6,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_y87bsrljd2245n51",
|
||||
"width": 1.2,
|
||||
"height": 1.2,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"rowRatios": [
|
||||
1
|
||||
],
|
||||
"columnDividerThickness": 0.03,
|
||||
"rowDividerThickness": 0.03,
|
||||
"sill": true,
|
||||
"sillDepth": 0.08,
|
||||
"sillThickness": 0.03
|
||||
},
|
||||
"window_xlta3f3f0cnmbti3": {
|
||||
"object": "node",
|
||||
"id": "window_xlta3f3f0cnmbti3",
|
||||
"type": "window",
|
||||
"parentId": "wall_aegff27krjwgmkmi",
|
||||
"visible": true,
|
||||
"metadata": {},
|
||||
"position": [
|
||||
0.5,
|
||||
0.6,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"wallId": "wall_aegff27krjwgmkmi",
|
||||
"width": 1.2,
|
||||
"height": 1.2,
|
||||
"frameThickness": 0.05,
|
||||
"frameDepth": 0.07,
|
||||
"columnRatios": [
|
||||
1
|
||||
],
|
||||
"rowRatios": [
|
||||
1
|
||||
],
|
||||
"columnDividerThickness": 0.03,
|
||||
"rowDividerThickness": 0.03,
|
||||
"sill": true,
|
||||
"sillDepth": 0.08,
|
||||
"sillThickness": 0.03
|
||||
}
|
||||
},
|
||||
"rootNodeIds": [
|
||||
"site_xs1r72ib2ymzpjus"
|
||||
],
|
||||
"collections": {}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"transport": {
|
||||
"kind": "http",
|
||||
"note": "shared HTTP server"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"n": 1,
|
||||
"name": "discover",
|
||||
"ok": true,
|
||||
"durationMs": 4,
|
||||
"summary": "building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)",
|
||||
"nodeIds": [
|
||||
"building_bfqg91ai9ijps9ej",
|
||||
"level_wyuoxj87czq3v0re"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 2,
|
||||
"name": "perimeter walls",
|
||||
"ok": true,
|
||||
"durationMs": 2,
|
||||
"summary": "created 4 perimeter walls (result.createdIds=[\"wall_y87bsrljd2245n51\",\"wall_sja6jpda73tlhxwb\",\"wall_aegff27krjwgmkmi\",\"wall_dcymglyle9ff09ti\"])",
|
||||
"nodeIds": [
|
||||
"wall_y87bsrljd2245n51",
|
||||
"wall_sja6jpda73tlhxwb",
|
||||
"wall_aegff27krjwgmkmi",
|
||||
"wall_dcymglyle9ff09ti"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 3,
|
||||
"name": "interior partitions",
|
||||
"ok": true,
|
||||
"durationMs": 1,
|
||||
"summary": "created 7 interior walls",
|
||||
"nodeIds": [
|
||||
"wall_rl83cc5tnbf4b34j",
|
||||
"wall_qv53jm9kvl7k6slf",
|
||||
"wall_8y52fwzco2ep7fb1",
|
||||
"wall_hrfixeusz7zb7x63",
|
||||
"wall_1ullk9bm6dw15i9t",
|
||||
"wall_c4fjjswnk0mprctm",
|
||||
"wall_p359pffjf3qs59cy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 4,
|
||||
"name": "set zones",
|
||||
"ok": true,
|
||||
"durationMs": 3,
|
||||
"summary": "created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen",
|
||||
"nodeIds": [
|
||||
"zone_3fyksm10tb0dhn1e",
|
||||
"zone_u95l1bt35jci3gvu",
|
||||
"zone_r9ma8tvsqt9w1zey",
|
||||
"zone_l189q61kf9ra2m8t"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 5,
|
||||
"name": "cut openings",
|
||||
"ok": true,
|
||||
"durationMs": 6,
|
||||
"summary": "3 doors, 3 windows",
|
||||
"nodeIds": [
|
||||
"door_cjzja4lt8owg88wg",
|
||||
"door_bs7bf0azevq9vd76",
|
||||
"door_o8etwqsemfgj5mkj",
|
||||
"window_47x40mtv2l4ca9p4",
|
||||
"window_n09awmg5m3ct4fvn",
|
||||
"window_xlta3f3f0cnmbti3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 6,
|
||||
"name": "validate scene",
|
||||
"ok": true,
|
||||
"durationMs": 1,
|
||||
"summary": "valid=true, errors=0"
|
||||
},
|
||||
{
|
||||
"n": 7,
|
||||
"name": "measure furthest zones",
|
||||
"ok": true,
|
||||
"durationMs": 3,
|
||||
"summary": "furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m",
|
||||
"nodeIds": [
|
||||
"zone_3fyksm10tb0dhn1e",
|
||||
"zone_u95l1bt35jci3gvu"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 8,
|
||||
"name": "export json",
|
||||
"ok": true,
|
||||
"durationMs": 1,
|
||||
"summary": "exported 15392 bytes -> apartment.json"
|
||||
},
|
||||
{
|
||||
"n": 9,
|
||||
"name": "undo 3 steps",
|
||||
"ok": true,
|
||||
"durationMs": 2,
|
||||
"summary": "undone=3, nodes 24 -> 21 (delta=3)"
|
||||
},
|
||||
{
|
||||
"n": 10,
|
||||
"name": "redo 3 steps",
|
||||
"ok": true,
|
||||
"durationMs": 2,
|
||||
"summary": "redone=3, nodes 21 -> 24"
|
||||
},
|
||||
{
|
||||
"n": 11,
|
||||
"name": "duplicate level + validate",
|
||||
"ok": true,
|
||||
"durationMs": 2,
|
||||
"summary": "newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0",
|
||||
"nodeIds": [
|
||||
"level_cxvltlqvgqcasiep"
|
||||
]
|
||||
},
|
||||
{
|
||||
"n": 12,
|
||||
"name": "delete duplicated level",
|
||||
"ok": true,
|
||||
"durationMs": 3,
|
||||
"summary": "deleted 22 nodes; nodes 46 -> 24",
|
||||
"nodeIds": [
|
||||
"level_cxvltlqvgqcasiep",
|
||||
"wall_xhn7o9bfpmcv2znr",
|
||||
"window_0qb4bgvzp46y412o",
|
||||
"window_cjk6a6zwsn48dj5u",
|
||||
"wall_hgatfahp4i53s139",
|
||||
"wall_fp25ulcwqmsim8p5",
|
||||
"window_jv0g8uos313ljbbe",
|
||||
"wall_mjue0xaodm8d3vzi",
|
||||
"wall_brw2c9vg502md7a0",
|
||||
"wall_1at54a0mfxgq2txb",
|
||||
"door_l8k2adas2djwpcf8",
|
||||
"wall_6m920gigvtn113af",
|
||||
"wall_2hazwrgv922l3t6q",
|
||||
"door_a62eecbdzpdoqnlo",
|
||||
"wall_11h43exay41fub7f",
|
||||
"door_06dpidftldcoaisv",
|
||||
"wall_u7e1gp0xbir5112y",
|
||||
"wall_jbwbwvgt9lzw51mk",
|
||||
"zone_hkp5wsyw7aydi175",
|
||||
"zone_rpqx5ul6bl1tpdmd",
|
||||
"zone_kohgjo47reiluhto",
|
||||
"zone_o65jm5oy5v5ej6aw"
|
||||
]
|
||||
}
|
||||
],
|
||||
"final": {
|
||||
"totalNodes": 24,
|
||||
"zones": 4,
|
||||
"doors": 3,
|
||||
"windows": 3,
|
||||
"step5NodeCount": 24,
|
||||
"validationSummary": {
|
||||
"valid": true,
|
||||
"errors": []
|
||||
},
|
||||
"undoObservation": {
|
||||
"undone": 3,
|
||||
"before": 24,
|
||||
"after": 21,
|
||||
"delta": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[t3] attempting HTTP transport at http://localhost:3917/mcp
|
||||
[t3] HTTP transport connected
|
||||
[t3] using transport: http — shared HTTP server
|
||||
[t3] OK step 1 discover (4ms): building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)
|
||||
[t3] using buildingId=building_bfqg91ai9ijps9ej levelId=level_wyuoxj87czq3v0re
|
||||
[t3] OK step 2 perimeter walls (2ms): created 4 perimeter walls (result.createdIds=["wall_y87bsrljd2245n51","wall_sja6jpda73tlhxwb","wall_aegff27krjwgmkmi","wall_dcymglyle9ff09ti"])
|
||||
[t3] OK step 3 interior partitions (1ms): created 7 interior walls
|
||||
[t3] OK step 4 set zones (3ms): created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen
|
||||
[t3] OK step 5 cut openings (6ms): 3 doors, 3 windows
|
||||
[t3] post-step-5 total node count: 24
|
||||
[t3] OK step 6 validate scene (1ms): valid=true, errors=0
|
||||
[t3] OK step 7 measure furthest zones (3ms): furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m
|
||||
[t3] OK step 8 export json (1ms): exported 15392 bytes -> apartment.json
|
||||
[t3] OK step 9 undo 3 steps (2ms): undone=3, nodes 24 -> 21 (delta=3)
|
||||
[t3] OK step 10 redo 3 steps (2ms): redone=3, nodes 21 -> 24
|
||||
[t3] OK step 11 duplicate level + validate (2ms): newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0
|
||||
[t3] OK step 12 delete duplicated level (3ms): deleted 22 nodes; nodes 46 -> 24
|
||||
[t3] done
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* T3 Scenario: End-to-end 2-bedroom apartment build via MCP HTTP server.
|
||||
*
|
||||
* Primary path: connect to the shared HTTP server at http://localhost:3917/mcp
|
||||
* using StreamableHTTPClientTransport (as mandated by the task).
|
||||
*
|
||||
* Fallback path: if the shared server is stuck (e.g. "Server already
|
||||
* initialized" because a previous client is still holding the single session
|
||||
* slot), fall back to an in-memory MCP server that still exercises the same
|
||||
* tool surface. We still emit evidence (apartment.json, REPORT.md) and the
|
||||
* bug is surfaced verbatim in the report.
|
||||
*
|
||||
* Run:
|
||||
* bun packages/mcp/test-reports/t3-scenario/run.ts
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const SERVER_URL = 'http://localhost:3917/mcp'
|
||||
|
||||
type StepResult = {
|
||||
n: number
|
||||
name: string
|
||||
ok: boolean
|
||||
durationMs: number
|
||||
summary: string
|
||||
nodeIds?: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const steps: StepResult[] = []
|
||||
|
||||
function log(msg: string): void {
|
||||
// biome-ignore lint/suspicious/noConsole: test script
|
||||
console.log(`[t3] ${msg}`)
|
||||
}
|
||||
|
||||
async function timed<T>(
|
||||
n: number,
|
||||
name: string,
|
||||
fn: () => Promise<{ summary: string; nodeIds?: string[]; result: T }>,
|
||||
): Promise<T | null> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const { summary, nodeIds, result } = await fn()
|
||||
const durationMs = Date.now() - start
|
||||
steps.push({ n, name, ok: true, durationMs, summary, nodeIds })
|
||||
log(`OK step ${n} ${name} (${durationMs}ms): ${summary}`)
|
||||
return result
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - start
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
steps.push({ n, name, ok: false, durationMs, summary: 'FAILED', error: msg })
|
||||
log(`ERR step ${n} ${name} (${durationMs}ms): ${msg}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function callTool<T = Record<string, unknown>>(
|
||||
client: Client,
|
||||
name: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const res = await client.callTool({ name, arguments: args })
|
||||
if (res.isError) {
|
||||
const text = Array.isArray(res.content)
|
||||
? res.content
|
||||
.map((c) =>
|
||||
typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '',
|
||||
)
|
||||
.join('\n')
|
||||
: ''
|
||||
throw new Error(`tool ${name} error: ${text || 'unknown'}`)
|
||||
}
|
||||
return (res.structuredContent ?? {}) as T
|
||||
}
|
||||
|
||||
type TransportKind = 'http' | 'in-memory'
|
||||
|
||||
async function connectClient(): Promise<{
|
||||
client: Client
|
||||
kind: TransportKind
|
||||
note: string
|
||||
closers: Array<() => Promise<void>>
|
||||
}> {
|
||||
// Try HTTP first.
|
||||
log(`attempting HTTP transport at ${SERVER_URL}`)
|
||||
try {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL))
|
||||
const client = new Client({ name: 't3-scenario', version: '0.1.0' })
|
||||
await client.connect(transport)
|
||||
// Smoke probe — a listTools gets the session working.
|
||||
await client.listTools()
|
||||
log(`HTTP transport connected`)
|
||||
return {
|
||||
client,
|
||||
kind: 'http',
|
||||
note: 'shared HTTP server',
|
||||
closers: [async () => client.close()],
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
log(`HTTP transport failed: ${msg}`)
|
||||
log(`FALLING BACK to in-memory MCP server`)
|
||||
|
||||
// Lazy import so we don't pay the cost when HTTP works.
|
||||
const { SceneBridge } = await import('../../src/bridge/scene-bridge')
|
||||
const { createPascalMcpServer } = await import('../../src/server')
|
||||
|
||||
const bridge = new SceneBridge()
|
||||
bridge.loadDefault()
|
||||
const server = createPascalMcpServer({ bridge })
|
||||
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
|
||||
const client = new Client({ name: 't3-scenario-inmem', version: '0.1.0' })
|
||||
await Promise.all([server.connect(srvT), client.connect(cliT)])
|
||||
return {
|
||||
client,
|
||||
kind: 'in-memory',
|
||||
note: `fallback — HTTP server returned: ${msg}`,
|
||||
closers: [async () => client.close(), async () => server.close()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
mkdirSync(HERE, { recursive: true })
|
||||
|
||||
const conn = await connectClient()
|
||||
const { client } = conn
|
||||
const transportKind = conn.kind
|
||||
const transportNote = conn.note
|
||||
log(`using transport: ${transportKind} — ${transportNote}`)
|
||||
|
||||
// ----- Step 1: Discover -----
|
||||
const discovered = await timed(1, 'discover', async () => {
|
||||
const buildings = await callTool<{ nodes: Array<{ id: string; type: string; name?: string }> }>(
|
||||
client,
|
||||
'find_nodes',
|
||||
{ type: 'building' },
|
||||
)
|
||||
const levels = await callTool<{
|
||||
nodes: Array<{ id: string; type: string; name?: string; parentId?: string }>
|
||||
}>(client, 'find_nodes', { type: 'level' })
|
||||
|
||||
if (!buildings.nodes.length) throw new Error('no building found')
|
||||
if (!levels.nodes.length) throw new Error('no level found')
|
||||
|
||||
const building = buildings.nodes[0]!
|
||||
const level = levels.nodes.find((l) => l.parentId === building.id) ?? levels.nodes[0]!
|
||||
|
||||
return {
|
||||
summary: `building=${building.id}, level=${level.id} (of ${buildings.nodes.length} buildings, ${levels.nodes.length} levels)`,
|
||||
nodeIds: [building.id, level.id],
|
||||
result: { buildingId: building.id, levelId: level.id },
|
||||
}
|
||||
})
|
||||
|
||||
if (!discovered) {
|
||||
log('cannot continue without discovered ids')
|
||||
for (const c of conn.closers) await c()
|
||||
return
|
||||
}
|
||||
const { buildingId, levelId } = discovered
|
||||
log(`using buildingId=${buildingId} levelId=${levelId}`)
|
||||
|
||||
// Helper: generate a wall id so we can reliably recover it after apply_patch.
|
||||
// Uses nanoid-like custom alphabet to match core's id generator.
|
||||
const ALPHA = '0123456789abcdefghijklmnopqrstuvwxyz'
|
||||
function genWallId(): string {
|
||||
let s = ''
|
||||
for (let i = 0; i < 16; i++) s += ALPHA[Math.floor(Math.random() * ALPHA.length)]
|
||||
return `wall_${s}`
|
||||
}
|
||||
|
||||
// ----- Step 2: Perimeter walls — 10m x 8m rectangle -----
|
||||
const perimeter = await timed(2, 'perimeter walls', async () => {
|
||||
const ids = [genWallId(), genWallId(), genWallId(), genWallId()]
|
||||
const res = await callTool<{
|
||||
appliedOps: number
|
||||
createdIds: string[]
|
||||
deletedIds: string[]
|
||||
}>(client, 'apply_patch', {
|
||||
patches: [
|
||||
{
|
||||
op: 'create',
|
||||
parentId: levelId,
|
||||
node: {
|
||||
id: ids[0],
|
||||
type: 'wall',
|
||||
start: [0, 0],
|
||||
end: [10, 0],
|
||||
thickness: 0.2,
|
||||
height: 2.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'create',
|
||||
parentId: levelId,
|
||||
node: {
|
||||
id: ids[1],
|
||||
type: 'wall',
|
||||
start: [10, 0],
|
||||
end: [10, 8],
|
||||
thickness: 0.2,
|
||||
height: 2.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'create',
|
||||
parentId: levelId,
|
||||
node: {
|
||||
id: ids[2],
|
||||
type: 'wall',
|
||||
start: [10, 8],
|
||||
end: [0, 8],
|
||||
thickness: 0.2,
|
||||
height: 2.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
op: 'create',
|
||||
parentId: levelId,
|
||||
node: {
|
||||
id: ids[3],
|
||||
type: 'wall',
|
||||
start: [0, 8],
|
||||
end: [0, 0],
|
||||
thickness: 0.2,
|
||||
height: 2.7,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
// The apply_patch tool's createdIds field is buggy (contains undefined when
|
||||
// the caller doesn't supply an id — the bridge reads p.node.id before the
|
||||
// Zod default fires). We pre-supply ids so the result is deterministic.
|
||||
const createdIds = res.createdIds.length && res.createdIds[0] ? res.createdIds : ids
|
||||
return {
|
||||
summary: `created ${createdIds.length} perimeter walls (result.createdIds=${JSON.stringify(res.createdIds)})`,
|
||||
nodeIds: createdIds,
|
||||
result: createdIds,
|
||||
}
|
||||
})
|
||||
|
||||
const [southId, eastId, northId, westId] = perimeter ?? []
|
||||
|
||||
// ----- Step 3: Interior partition walls -----
|
||||
// Layout (x=0..10 west→east, z=0..8 south→north):
|
||||
// Bedroom 1 — top-left 3x3 (x 0..3, z 5..8)
|
||||
// Bedroom 2 — top-right 3x3 (x 7..10, z 5..8)
|
||||
// Bathroom — 2x2 between them (x 4..6, z 6..8)
|
||||
// Living/Kitchen — everything else
|
||||
const interior = await timed(3, 'interior partitions', async () => {
|
||||
const walls: Array<{
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
}> = [
|
||||
{ start: [0, 5], end: [3, 5] }, // bed1 south
|
||||
{ start: [3, 5], end: [3, 8] }, // bed1 east
|
||||
{ start: [7, 5], end: [10, 5] }, // bed2 south
|
||||
{ start: [7, 5], end: [7, 8] }, // bed2 west
|
||||
{ start: [4, 6], end: [6, 6] }, // bath south
|
||||
{ start: [4, 6], end: [4, 8] }, // bath west
|
||||
{ start: [6, 6], end: [6, 8] }, // bath east
|
||||
]
|
||||
const res = await callTool<{
|
||||
appliedOps: number
|
||||
createdIds: string[]
|
||||
deletedIds: string[]
|
||||
}>(client, 'apply_patch', {
|
||||
patches: walls.map((w) => ({
|
||||
op: 'create',
|
||||
parentId: levelId,
|
||||
node: {
|
||||
type: 'wall',
|
||||
start: w.start,
|
||||
end: w.end,
|
||||
thickness: 0.2,
|
||||
height: 2.7,
|
||||
},
|
||||
})),
|
||||
})
|
||||
return {
|
||||
summary: `created ${res.createdIds.length} interior walls`,
|
||||
nodeIds: res.createdIds,
|
||||
result: res.createdIds,
|
||||
}
|
||||
})
|
||||
|
||||
const [
|
||||
bed1SouthId,
|
||||
bed1EastId,
|
||||
bed2SouthId,
|
||||
bed2WestId,
|
||||
bathSouthId,
|
||||
bathWestId,
|
||||
bathEastId,
|
||||
] = interior ?? []
|
||||
|
||||
// ----- Step 4: Set zones -----
|
||||
const zones = await timed(4, 'set zones', async () => {
|
||||
const zoneIds: Record<string, string> = {}
|
||||
const specs = [
|
||||
{
|
||||
label: 'bedroom-1',
|
||||
polygon: [
|
||||
[0, 5],
|
||||
[3, 5],
|
||||
[3, 8],
|
||||
[0, 8],
|
||||
] as Array<[number, number]>,
|
||||
},
|
||||
{
|
||||
label: 'bedroom-2',
|
||||
polygon: [
|
||||
[7, 5],
|
||||
[10, 5],
|
||||
[10, 8],
|
||||
[7, 8],
|
||||
] as Array<[number, number]>,
|
||||
},
|
||||
{
|
||||
label: 'bathroom',
|
||||
polygon: [
|
||||
[4, 6],
|
||||
[6, 6],
|
||||
[6, 8],
|
||||
[4, 8],
|
||||
] as Array<[number, number]>,
|
||||
},
|
||||
{
|
||||
label: 'living-kitchen',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[10, 5],
|
||||
[7, 5],
|
||||
[7, 8],
|
||||
[6, 8],
|
||||
[6, 6],
|
||||
[4, 6],
|
||||
[4, 8],
|
||||
[3, 8],
|
||||
[3, 5],
|
||||
[0, 5],
|
||||
] as Array<[number, number]>,
|
||||
},
|
||||
]
|
||||
for (const s of specs) {
|
||||
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
|
||||
levelId,
|
||||
label: s.label,
|
||||
polygon: s.polygon,
|
||||
})
|
||||
zoneIds[s.label] = r.zoneId
|
||||
}
|
||||
return {
|
||||
summary: `created ${Object.keys(zoneIds).length} zones: ${Object.keys(zoneIds).join(', ')}`,
|
||||
nodeIds: Object.values(zoneIds),
|
||||
result: zoneIds,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 5: Cut openings -----
|
||||
const openings = await timed(5, 'cut openings', async () => {
|
||||
const results: Array<{ wall: string; type: string; id: string }> = []
|
||||
|
||||
const doors: Array<[string | undefined, string]> = [
|
||||
[bed1EastId, 'bed1-door'],
|
||||
[bed2WestId, 'bed2-door'],
|
||||
[bathSouthId, 'bath-door'],
|
||||
]
|
||||
for (const [wallId, label] of doors) {
|
||||
if (!wallId) {
|
||||
log(`skip ${label}: no wall id`)
|
||||
continue
|
||||
}
|
||||
const r = await callTool<{ openingId: string }>(client, 'cut_opening', {
|
||||
wallId,
|
||||
type: 'door',
|
||||
position: 0.5,
|
||||
width: 0.9,
|
||||
height: 2.1,
|
||||
})
|
||||
results.push({ wall: wallId, type: 'door', id: r.openingId })
|
||||
}
|
||||
|
||||
const windows: Array<[string | undefined, number, string]> = [
|
||||
[southId, 0.3, 'south-win-1'],
|
||||
[southId, 0.7, 'south-win-2'],
|
||||
[northId, 0.5, 'north-win-1'],
|
||||
]
|
||||
for (const [wallId, pos, label] of windows) {
|
||||
if (!wallId) {
|
||||
log(`skip ${label}: no wall id`)
|
||||
continue
|
||||
}
|
||||
const r = await callTool<{ openingId: string }>(client, 'cut_opening', {
|
||||
wallId,
|
||||
type: 'window',
|
||||
position: pos,
|
||||
width: 1.2,
|
||||
height: 1.2,
|
||||
})
|
||||
results.push({ wall: wallId, type: 'window', id: r.openingId })
|
||||
}
|
||||
|
||||
const doorCount = results.filter((r) => r.type === 'door').length
|
||||
const winCount = results.filter((r) => r.type === 'window').length
|
||||
|
||||
return {
|
||||
summary: `${doorCount} doors, ${winCount} windows`,
|
||||
nodeIds: results.map((r) => r.id),
|
||||
result: results,
|
||||
}
|
||||
})
|
||||
|
||||
const step5NodeCount = openings
|
||||
? (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
: 0
|
||||
log(`post-step-5 total node count: ${step5NodeCount}`)
|
||||
|
||||
// ----- Step 6: Validate -----
|
||||
const validation = await timed(6, 'validate scene', async () => {
|
||||
const r = await callTool<{
|
||||
valid: boolean
|
||||
errors: Array<{ nodeId: string; path: string; message: string }>
|
||||
}>(client, 'validate_scene', {})
|
||||
if (!r.valid && r.errors.length) {
|
||||
log('VALIDATION ERRORS VERBATIM:')
|
||||
for (const e of r.errors) {
|
||||
log(` nodeId=${e.nodeId} path=${e.path} :: ${e.message}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
summary: `valid=${r.valid}, errors=${r.errors.length}`,
|
||||
result: r,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 7: Measure (two furthest zone centroids) -----
|
||||
await timed(7, 'measure furthest zones', async () => {
|
||||
if (!zones) throw new Error('no zones created')
|
||||
const zoneIds = Object.values(zones)
|
||||
if (zoneIds.length < 2) throw new Error('fewer than 2 zones')
|
||||
|
||||
let best: { a: string; b: string; d: number } | null = null
|
||||
for (let i = 0; i < zoneIds.length; i++) {
|
||||
for (let j = i + 1; j < zoneIds.length; j++) {
|
||||
const a = zoneIds[i]!
|
||||
const b = zoneIds[j]!
|
||||
const r = await callTool<{ distanceMeters: number }>(client, 'measure', {
|
||||
fromId: a,
|
||||
toId: b,
|
||||
})
|
||||
if (!best || r.distanceMeters > best.d) {
|
||||
best = { a, b, d: r.distanceMeters }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
summary: `furthest: ${best?.a} <-> ${best?.b} = ${best?.d.toFixed(3)}m`,
|
||||
nodeIds: best ? [best.a, best.b] : [],
|
||||
result: best,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 8: Export JSON -----
|
||||
await timed(8, 'export json', async () => {
|
||||
const r = await callTool<{ json: string }>(client, 'export_json', { pretty: true })
|
||||
writeFileSync(`${HERE}/apartment.json`, r.json, 'utf-8')
|
||||
return {
|
||||
summary: `exported ${r.json.length} bytes -> apartment.json`,
|
||||
result: r.json.length,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 9: Undo 3 steps -----
|
||||
const undoResult = await timed(9, 'undo 3 steps', async () => {
|
||||
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
const r = await callTool<{ undone: number }>(client, 'undo', { steps: 3 })
|
||||
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
const delta = before - after
|
||||
return {
|
||||
summary: `undone=${r.undone}, nodes ${before} -> ${after} (delta=${delta})`,
|
||||
result: { undone: r.undone, before, after, delta },
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 10: Redo 3 steps -----
|
||||
await timed(10, 'redo 3 steps', async () => {
|
||||
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
const r = await callTool<{ redone: number }>(client, 'redo', { steps: 3 })
|
||||
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
return {
|
||||
summary: `redone=${r.redone}, nodes ${before} -> ${after}`,
|
||||
result: { redone: r.redone, before, after },
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 11: Duplicate level + validate -----
|
||||
const dup = await timed(11, 'duplicate level + validate', async () => {
|
||||
const r = await callTool<{ newLevelId: string; newNodeIds: string[] }>(
|
||||
client,
|
||||
'duplicate_level',
|
||||
{ levelId },
|
||||
)
|
||||
const v = await callTool<{
|
||||
valid: boolean
|
||||
errors: Array<{ nodeId: string; path: string; message: string }>
|
||||
}>(client, 'validate_scene', {})
|
||||
if (!v.valid && v.errors.length) {
|
||||
log('VALIDATION ERRORS after duplicate:')
|
||||
for (const e of v.errors) {
|
||||
log(` nodeId=${e.nodeId} path=${e.path} :: ${e.message}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
summary: `newLevelId=${r.newLevelId}, cloned=${r.newNodeIds.length}, valid=${v.valid}, errors=${v.errors.length}`,
|
||||
nodeIds: [r.newLevelId],
|
||||
result: r,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Step 12: Delete duplicated level cascade -----
|
||||
await timed(12, 'delete duplicated level', async () => {
|
||||
if (!dup) throw new Error('no duplicated level id')
|
||||
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
const r = await callTool<{ deletedIds: string[] }>(client, 'delete_node', {
|
||||
id: dup.newLevelId,
|
||||
cascade: true,
|
||||
})
|
||||
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
|
||||
return {
|
||||
summary: `deleted ${r.deletedIds.length} nodes; nodes ${before} -> ${after}`,
|
||||
nodeIds: r.deletedIds,
|
||||
result: r,
|
||||
}
|
||||
})
|
||||
|
||||
// ----- Final summary -----
|
||||
const allNodes = (await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {}))
|
||||
.nodes
|
||||
const zoneNodes = allNodes.filter((n) => n.type === 'zone')
|
||||
const doorNodes = allNodes.filter((n) => n.type === 'door')
|
||||
const windowNodes = allNodes.filter((n) => n.type === 'window')
|
||||
|
||||
const report = {
|
||||
transport: { kind: transportKind, note: transportNote },
|
||||
steps,
|
||||
final: {
|
||||
totalNodes: allNodes.length,
|
||||
zones: zoneNodes.length,
|
||||
doors: doorNodes.length,
|
||||
windows: windowNodes.length,
|
||||
step5NodeCount,
|
||||
validationSummary: validation ?? null,
|
||||
undoObservation: undoResult ?? null,
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync(`${HERE}/run-summary.json`, JSON.stringify(report, null, 2), 'utf-8')
|
||||
|
||||
// Build REPORT.md
|
||||
const lines: string[] = []
|
||||
lines.push('# T3 Scenario Report — 2-Bedroom Apartment')
|
||||
lines.push('')
|
||||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||||
lines.push(`Transport: ${transportKind} (${transportNote})`)
|
||||
lines.push(`Server URL: ${SERVER_URL}`)
|
||||
lines.push('')
|
||||
lines.push('## Step-by-step')
|
||||
lines.push('')
|
||||
for (const s of steps) {
|
||||
lines.push(`### Step ${s.n}: ${s.name} ${s.ok ? 'OK' : 'FAIL'} (${s.durationMs}ms)`)
|
||||
lines.push('')
|
||||
lines.push(`- Summary: ${s.summary}`)
|
||||
if (s.nodeIds?.length) {
|
||||
lines.push(
|
||||
`- Node IDs (${s.nodeIds.length}): ${s.nodeIds.slice(0, 20).join(', ')}${s.nodeIds.length > 20 ? ' ...' : ''}`,
|
||||
)
|
||||
}
|
||||
if (s.error) lines.push(`- Error: \`${s.error}\``)
|
||||
lines.push('')
|
||||
}
|
||||
lines.push('## Final Counts')
|
||||
lines.push('')
|
||||
lines.push(`- Total nodes: ${allNodes.length}`)
|
||||
lines.push(`- Zones: ${zoneNodes.length}`)
|
||||
lines.push(`- Doors: ${doorNodes.length}`)
|
||||
lines.push(`- Windows: ${windowNodes.length}`)
|
||||
lines.push(`- Post-step-5 node count: ${step5NodeCount}`)
|
||||
lines.push('')
|
||||
lines.push('## Validation')
|
||||
lines.push('')
|
||||
if (validation) {
|
||||
lines.push(`- Valid: ${validation.valid}`)
|
||||
lines.push(`- Errors: ${validation.errors.length}`)
|
||||
if (!validation.valid && validation.errors.length) {
|
||||
lines.push('')
|
||||
lines.push('Verbatim errors:')
|
||||
lines.push('')
|
||||
for (const e of validation.errors) {
|
||||
lines.push(`- nodeId=\`${e.nodeId}\` path=\`${e.path}\` :: ${e.message}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push('- (validation step failed)')
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('## Transport Notes')
|
||||
lines.push('')
|
||||
lines.push(`- Used transport: **${transportKind}**`)
|
||||
lines.push(`- Reason: ${transportNote}`)
|
||||
if (transportKind === 'in-memory') {
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'The shared HTTP server at :3917 returned "Server already initialized" — a known bug where the SDK\'s `StreamableHTTPServerTransport` in stateful mode accepts only a single session across the process lifetime. Subsequent clients cannot initialize. Falling back to an in-memory MCP server that exercises the same tools end-to-end.',
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
writeFileSync(`${HERE}/REPORT.md`, lines.join('\n'), 'utf-8')
|
||||
|
||||
log('done')
|
||||
for (const c of conn.closers) await c()
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// biome-ignore lint/suspicious/noConsole: test script
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user