From c14952f69e3fb999423c117a947553b9b82e4f3b Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:09:55 +0400 Subject: [PATCH 1/2] docs(mcp): make batch-first apply_patch usage the stated default Tool description, agent guide, from-brief preamble, and README now instruct agents to compose one atomic apply_patch batch per phase instead of looping single-op calls. The tool already validates all ops before applying any; only the guidance was missing. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/mcp/README.md | 2 +- packages/mcp/src/prompts/from-brief.ts | 1 + packages/mcp/src/resources/agent-guide.ts | 1 + packages/mcp/src/tools/apply-patch.ts | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1331dea070..0292ffd5fd 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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 }` | diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index eec0a223fe..2062930100 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -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`.', diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 6fa5dec066..2ceec36de1 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -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.', diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts index 767d51bddf..a9ee5bf893 100644 --- a/packages/mcp/src/tools/apply-patch.ts +++ b/packages/mcp/src/tools/apply-patch.ts @@ -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, }, From d9a6310026553841ab0a0a63b574c8e673e6d9e7 Mon Sep 17 00:00:00 2001 From: ActArtech <123718991+ActArtech@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:23:41 +0400 Subject: [PATCH 2/2] test(mcp): assert batch-first apply_patch guidance surfaces Lock the tool description, agent guide, from_brief preamble, and README row that state batch-first as the default without changing apply_patch runtime behavior. --- .../src/tools/apply-patch-batch-first.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/mcp/src/tools/apply-patch-batch-first.test.ts diff --git a/packages/mcp/src/tools/apply-patch-batch-first.test.ts b/packages/mcp/src/tools/apply-patch-batch-first.test.ts new file mode 100644 index 0000000000..883dbefa49 --- /dev/null +++ b/packages/mcp/src/tools/apply-patch-batch-first.test.ts @@ -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') + }) +})