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
+11 -4
View File
@@ -283,6 +283,10 @@ export class SceneBridge {
// earlier-created ids and reflect earlier-deleted ids.
const simAvailable = new Set<string>(Object.keys(nodes))
const simDeleted = new Set<string>()
// Parsed create nodes keyed by patch index — so the apply phase can use the
// Zod-normalised copy (which has a generated id if the caller omitted one)
// instead of the unparsed input.
const parsedCreateNodes = new Map<number, AnyNode>()
for (let i = 0; i < patches.length; i++) {
const p = patches[i]
@@ -297,7 +301,8 @@ export class SceneBridge {
if (p.parentId !== undefined && !simAvailable.has(p.parentId)) {
throw new Error(`invalid patch: patches[${i}] create parentId "${p.parentId}" not found`)
}
simAvailable.add(p.node.id)
parsedCreateNodes.set(i, res.data)
simAvailable.add(res.data.id)
} else if (p.op === 'update') {
if (!simAvailable.has(p.id) || simDeleted.has(p.id)) {
throw new Error(`invalid patch: patches[${i}] update id "${p.id}" not found`)
@@ -353,11 +358,13 @@ export class SceneBridge {
}
}
for (const p of patches) {
for (let i = 0; i < patches.length; i++) {
const p = patches[i]!
if (p.op === 'create') {
flush('create')
createOps.push({ node: p.node, parentId: p.parentId })
createdIds.push(p.node.id as AnyNodeId)
const parsedNode = parsedCreateNodes.get(i)!
createOps.push({ node: parsedNode, parentId: p.parentId })
createdIds.push(parsedNode.id as AnyNodeId)
} else if (p.op === 'update') {
flush('update')
updateOps.push({ id: p.id, data: p.data })