Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ captured by Zundo's temporal middleware as a single undoable step.
| `add_door` | Add a door to a wall using parametric placement. | `{ wallId, t, width?, height?, hingesSide?, swingDirection? }` | `{ doorId, localX }` |
| `add_window` | Add a window to a wall using parametric placement and sill height. | `{ wallId, t, width?, height?, sillHeight? }` | `{ windowId, localX, sillHeight }` |
| `furnish_room` | Place realistic furniture for a room type inside a polygon. | `{ levelId, roomType, polygon, doorWallIndex? }` | `{ placed, itemIds, skipped }` |
| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. | `{ patches: Patch[] }` | `{ applied: number }` |
| `apply_patch` | Batched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. | `{ patches: Patch[] }` | `{ applied: number }` |
| `create_level` | Add a new level to a building. | `{ buildingId, elevation, height, label? }` | `{ levelId }` |
| `create_wall` | Add a wall to a level. | `{ levelId, start, end, thickness?, height? }` | `{ wallId }` |
| `place_item` | Place a catalog item on a level/slab/zone, ceiling, wall, or site. Slab/zone targets resolve to the parent level so floor items render and validate. | `{ catalogItemId, targetNodeId, position, rotation? }` | `{ itemId, status }` |
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/prompts/from-brief.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are a Pascal 3D scene designer.',
'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.',
'One patch per phase: when a phase needs many graph edits, send all of them in a single `apply_patch` call in stable order (later ops may reference ids created by earlier ops) instead of looping one-op calls; a single call is atomic and pays the snapshot cost once.',
'Bind an active scene before mutating anything. Call `create_project` for a new project, `list_scenes` then `load_scene` for an existing one, or `create_house_from_brief` to create and load a starter in one step. Without a bound scene, mutations apply in memory only — they are not persisted and never appear in the browser.',
'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.',
'Semantic tools update the browser-visible draft. Call `save_scene` with `saveMode: "checkpoint"` only for meaningful milestones, then call `verify_scene` and `get_project_status`, and return the final `editorUrl`.',
Expand Down
1 change: 1 addition & 0 deletions packages/mcp/src/resources/agent-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const AGENT_GUIDE = [
'',
'- Prefer semantic tools over raw graph patches.',
'- Do not hand-write node graphs unless no semantic tool exists.',
'- When you do use `apply_patch` for bulk graph edits, batch-first is the default: one call containing all create/update/delete ops for the phase, in stable order so later ops can reference ids created earlier. A single call is atomic (all or nothing); do not loop one-op `apply_patch` calls.',
'- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.',
'- `furnish_room` skips or nudges poses that block door clear zones or overlap other items; `verify_scene` and `check_collisions` report remaining issues.',
'- Between adjacent rooms, prefer one shared wall (or only cut openings that line up). Leave ~0.65 m clear on both sides of each door; do not stack furniture footprints.',
Expand Down
53 changes: 53 additions & 0 deletions packages/mcp/src/tools/apply-patch-batch-first.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../bridge/scene-bridge'
import { buildFromBriefPrompt } from '../prompts/from-brief'
import { AGENT_GUIDE } from '../resources/agent-guide'
import { registerApplyPatch } from './apply-patch'

describe('apply_patch batch-first guidance', () => {
let client: Client

beforeEach(async () => {
const bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerApplyPatch(server, bridge)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
})

test('tool description states batch-first as the default', async () => {
const { tools } = await client.listTools()
const applyPatch = tools.find((tool) => tool.name === 'apply_patch')
expect(applyPatch).toBeDefined()
const description = applyPatch!.description ?? ''
expect(description.toLowerCase()).toContain('batch-first')
expect(description.toLowerCase()).toContain('do not loop one-op')
expect(description.toLowerCase()).toContain('validated before any are applied')
})

test('agent guide tells agents to batch apply_patch per phase', () => {
expect(AGENT_GUIDE).toContain('batch-first is the default')
expect(AGENT_GUIDE).toContain('do not loop one-op `apply_patch` calls')
})

test('from_brief preamble says one patch per phase', () => {
const text = buildFromBriefPrompt({ brief: 'Studio loft' })
expect(text).toContain('One patch per phase')
expect(text).toContain('single `apply_patch` call')
expect(text.toLowerCase()).toContain('instead of looping one-op')
})

test('MCP README apply_patch row documents batch-first default', () => {
const readme = readFileSync(join(import.meta.dir, '../../README.md'), 'utf8')
expect(readme).toContain('Batch-first is the default')
expect(readme).toContain('do not loop one-op calls')
})
})
2 changes: 1 addition & 1 deletion packages/mcp/src/tools/apply-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function registerApplyPatch(server: McpServer, bridge: SceneOperations):
{
title: 'Apply patch',
description:
'Apply a batch of create/update/delete operations atomically. All patches are validated before any are applied; the entire batch forms a single undo step.',
'Apply a batch of create/update/delete operations atomically. All patches are validated before any are applied; the entire batch forms a single undo step. Batch-first is the default: prefer one apply_patch call containing all create/update/delete ops for a build step, in stable order so later ops can reference ids created by earlier ops. A single call is atomic (all or nothing) and pays the snapshot and save cost once; do not loop one-op calls.',
inputSchema: applyPatchInput,
outputSchema: applyPatchOutput,
},
Expand Down
Loading