Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/core/src/agent-tools/clear-scene.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod'

export const clearSceneTool = {
name: 'clear_scene',
title: 'Clear the project',
description:
'Empty the project on purpose, back to the default scaffold: a site, a building and one level of the standard storey height. It is the only way to empty a project: a write that would empty it is refused (scene_wipe_blocked), since that is far more often an accident. Call it only when the person asked to start over, and say what they asked in reason.',
input: {
reason: z.string().min(1).describe('What the person asked for, in their words.'),
},
}
3 changes: 3 additions & 0 deletions packages/core/src/agent-tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { addObjectTool, getSourceTool } from './add-object'
import { clearSceneTool } from './clear-scene'
import { editCollectionTool, listCollectionsTool } from './collections'
import { addColumnTool } from './columns'
import { findByTypeTool } from './find-by-type'
Expand All @@ -15,6 +16,7 @@ import { fitStairTool, measureStairTool } from './stairs'
import { addDoorTool, addWindowTool } from './wall-openings'

export * from './add-object'
export * from './clear-scene'
export * from './collections'
export * from './columns'
export * from './find-by-type'
Expand Down Expand Up @@ -51,4 +53,5 @@ export const AGENT_TOOL_CONTRACTS = [
findByTypeTool,
editCollectionTool,
listCollectionsTool,
clearSceneTool,
] as const
1 change: 1 addition & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ captured by Zundo's temporal middleware as a single undoable step.
| `set_zone` | Create a zone/room polygon on a level. | `{ levelId, polygon, label, properties? }` | `{ zoneId }` |
| `duplicate_level` | Copy a level as the editor does (units whose rooms are all on it included; plan references, scans and spawns left behind), above or below, shifting the floors past it. | `{ levelId, position?, name?, preset? }` | `{ newLevelId, name, floorIndex, shiftedLevelIds, copied, skipped, newNodeIds[] }` |
| `delete_node` | Delete a node with everything under it, as the editor's Delete does. | `{ id }` | `{ deletedIds: [] }` |
| `clear_scene` | Empty the project on purpose, back to the default site, building and level. The only way to empty a project: a write that would leave it empty is refused (`scene_wipe_blocked`) by a store that guards against accidental wipes, and the session keeps the project as stored. | `{ reason }` | `{ cleared: { removed }, version, graphHash }` |
| `undo` | Step back through temporal history. | `{ steps? }` | `{ undone: number }` |
| `redo` | Step forward through temporal history. | `{ steps? }` | `{ redone: number }` |
| `export_json` | Serialize the scene graph as JSON. | `{ pretty? }` | `{ json: string }` |
Expand Down
18 changes: 18 additions & 0 deletions packages/mcp/src/storage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export interface SceneSaveOptions {
agentSessionId?: string
/** Optional high-level operation name for presence/debug metadata. */
operation?: string
/**
* The write empties a populated project on purpose (clear_scene). A store may refuse a write that
* empties the project unless this is set, with `SceneWipeBlockedError`.
*/
allowSceneWipe?: boolean
}

export type SceneSaveMode = 'draft' | 'checkpoint'
Expand Down Expand Up @@ -171,6 +176,19 @@ export class SceneInvalidError extends Error {
}
}

/**
* A store refused a write that would empty a populated project without `allowSceneWipe`: far more
* often an accident (a client that never finished loading, a deletion of everything) than an intent.
* Still an invalid write to a caller that only knows `SceneInvalidError`; the MCP answers it
* `scene_wipe_blocked`, naming clear_scene.
*/
export class SceneWipeBlockedError extends SceneInvalidError {
constructor(message = 'Scene wipe blocked') {
super(message)
this.name = 'SceneWipeBlockedError'
}
}

export class SceneTooLargeError extends Error {
readonly code = 'too_large' as const
constructor(message = 'Scene too large') {
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/src/tools/apply-patch.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { isAgentRefusal } from '@pascal-app/core/agent-tools'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { DESTRUCTIVE_TOOL_ANNOTATIONS } from './annotations'
import { ErrorCode, throwMcpError } from './errors'
import { ErrorCode, refusalResult, throwMcpError } from './errors'
import { liveSyncOutput, persistencePayload, publishLiveSceneSnapshot } from './live-sync'
import { assertPatchKeepsIdentity, PatchRefusedError } from './patch-guards'
import { PatchSchema } from './schemas'
Expand Down Expand Up @@ -90,6 +91,8 @@ export function registerApplyPatch(server: McpServer, bridge: SceneOperations):
isError: true,
}
}
// A refusal the store made (an accidental wipe) answers with its code, as the shared tools'.
if (isAgentRefusal(err)) return refusalResult(err)
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InvalidParams, msg)
}
Expand Down
255 changes: 255 additions & 0 deletions packages/mcp/src/tools/clear-scene.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
import { describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { AGENT_TOOL_CONTRACTS } from '@pascal-app/core/agent-tools'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { createSceneOperations } from '../operations'
import { type SceneMeta, type SceneStore, SceneWipeBlockedError } from '../storage/types'
import { registerApplyPatch } from './apply-patch'
import { registerClearScene } from './clear-scene'
import { registerCreateWall } from './create-wall'
import { registerCreateRoom } from './room-tools'
import { registerStructureTools } from './structure-tools'
import { registerUndo } from './undo'

// An agent that starts over clears the project on purpose; a write that would empty it by
// accident is refused, as the hosted store refuses it (the scaffold: a site, a building, a level).
const SCAFFOLD_NODE_COUNT = 4
const isWipe = (previous: number, next: number) =>
previous > SCAFFOLD_NODE_COUNT && next <= SCAFFOLD_NODE_COUNT && previous - next > 1

/** A store keeping one scene, which refuses an accidental wipe as a hosted store does. */
function guardedStore(meta: SceneMeta) {
let stored: SceneGraph | null = null
const saves: { nodeCount: number; allowSceneWipe?: boolean }[] = []
const store: SceneStore = {
backend: 'sqlite',
async save(opts) {
const previous = Object.keys(stored?.nodes ?? {}).length
const next = Object.keys(opts.graph.nodes).length
if (stored && !opts.allowSceneWipe && isWipe(previous, next))
throw new SceneWipeBlockedError()
stored = opts.graph
saves.push({ nodeCount: next, ...(opts.allowSceneWipe ? { allowSceneWipe: true } : {}) })
return { ...meta, version: meta.version + saves.length, nodeCount: next }
},
async load() {
return stored ? { ...meta, graph: stored } : null
},
async list() {
return []
},
async delete() {
return { deleted: false, hidden: false }
},
async rename() {
return meta
},
async appendSceneEvent(opts) {
return {
eventId: 1,
sceneId: opts.sceneId,
version: opts.version,
kind: opts.kind,
createdAt: new Date().toISOString(),
graph: opts.graph,
}
},
}
return { store, saves, seed: (graph: SceneGraph) => (stored = graph) }
}

async function houseSession() {
const bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')!
for (const [start, end] of [
[
[0, 0],
[5, 0],
],
[
[5, 0],
[5, 4],
],
[
[5, 4],
[0, 4],
],
[
[0, 4],
[0, 0],
],
] as const)
bridge.createNode(WallNode.parse({ start: [...start], end: [...end] }), level.id)
const meta: SceneMeta = {
id: 'scene_house',
name: 'House',
projectId: 'project_house',
thumbnailUrl: null,
version: 1,
createdAt: '2026-10-06T00:00:00Z',
updatedAt: '2026-10-06T00:00:00Z',
ownerId: null,
sizeBytes: 0,
nodeCount: Object.keys(bridge.getNodes()).length,
}
const guarded = guardedStore(meta)
guarded.seed(bridge.exportJSON())
const operations = createSceneOperations({ bridge, store: guarded.store })
operations.setActiveScene(meta)
const server = new McpServer({ name: 'clear', version: '0.0.0' })
registerClearScene(server, operations)
registerApplyPatch(server, operations)
registerCreateWall(server, operations)
registerUndo(server, operations)
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'clear-client', version: '0.0.0' })
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
const call = async (name: string, args: Record<string, unknown>) => {
const result = await client.callTool({ name, arguments: args })
const text = (result.content as { type: string; text: string }[])[0]!.text
return { isError: !!result.isError, body: JSON.parse(text) as Record<string, unknown> }
}
return { bridge, call, guarded, meta }
}

describe('clear_scene', () => {
test('is one of the shared agent tools, named for a person', () => {
const contract = AGENT_TOOL_CONTRACTS.find((tool) => tool.name === 'clear_scene')
expect(contract?.title).toBe('Clear the project')
expect(contract?.description).toContain('scene_wipe_blocked')
})

test('empties the project on purpose; the next write lands on the scaffold', async () => {
const { bridge, call, guarded, meta } = await houseSession()
// The walls and what they derived (the room, its floor and ceiling) go; the scaffold stays.
const before = Object.keys(bridge.getNodes()).length
const cleared = await call('clear_scene', { reason: 'The person asked to start over.' })
expect(cleared.isError).toBe(false)
expect(cleared.body.cleared).toEqual({ removed: before - 3 })
expect(guarded.saves.at(-1)).toEqual({ nodeCount: 3, allowSceneWipe: true })

const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')!
expect((level as { height?: number }).height).toBe(2.5)
const wall = await call('create_wall', { levelId: level.id, start: [0, 0], end: [3, 0] })
expect(wall.isError).toBe(false)

// A fresh session loads what was stored: the scaffold and the one new wall.
const stored = await guarded.store.load(meta.id)
const types = Object.values(stored!.graph.nodes).map((node) => (node as { type: string }).type)
expect(types.sort()).toEqual(['building', 'level', 'site', 'wall'])
})

test('leaves nothing to undo: an undo after a clear does not bring back an empty or old scene', async () => {
const { bridge, call, guarded } = await houseSession()
const cleared = await call('clear_scene', { reason: 'The person asked to start over.' })
expect(cleared.isError).toBe(false)
const saves = guarded.saves.length
await call('undo', { steps: 1 })
const types = Object.values(bridge.getNodes()).map((node) => node.type)
expect(types.sort()).toEqual(['building', 'level', 'site'])
expect(guarded.saves.length).toBe(saves)
})

test("keeps the project's installed plugins, in the session and in the saved scene", async () => {
const { bridge, call, guarded, meta } = await houseSession()
bridge.loadJSON({ ...bridge.exportJSON(), installedPlugins: ['pascal:sheets'] })
const cleared = await call('clear_scene', { reason: 'The person asked to start over.' })
expect(cleared.isError).toBe(false)
expect(bridge.exportJSON().installedPlugins).toEqual(['pascal:sheets'])
const stored = await guarded.store.load(meta.id)
expect(stored!.graph.installedPlugins).toEqual(['pascal:sheets'])
const types = Object.values(stored!.graph.nodes).map((node) => (node as { type: string }).type)
expect(types.sort()).toEqual(['building', 'level', 'site'])
})

test('a write that would empty the project by accident is refused, and nothing changed', async () => {
const { bridge, call, guarded } = await houseSession()
const before = Object.keys(bridge.getNodes()).sort()
const site = bridge.getRootNodeIds()[0]!
const refused = await call('apply_patch', { patches: [{ op: 'delete', id: site }] })
expect(refused.isError).toBe(true)
expect(refused.body.code).toBe('scene_wipe_blocked')
expect(String(refused.body.error)).toContain('nothing changed')
expect(String(refused.body.error)).toContain('clear_scene')
expect(guarded.saves).toEqual([])
// The session holds the project as stored, not the refused write.
expect(Object.keys(bridge.getNodes()).sort()).toEqual(before)
})
})

// An agent deletes the only room it built: the store refuses the write as a wipe, and the session
// must not keep the deletion, or its next writes build on a scene the project does not hold.
describe('deleting the only room', () => {
async function oneRoomSession() {
const bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const meta: SceneMeta = {
id: 'scene_room',
name: 'Room',
projectId: 'project_room',
thumbnailUrl: null,
version: 1,
createdAt: '2026-10-06T00:00:00Z',
updatedAt: '2026-10-06T00:00:00Z',
ownerId: null,
sizeBytes: 0,
nodeCount: Object.keys(bridge.getNodes()).length,
}
const guarded = guardedStore(meta)
guarded.seed(bridge.exportJSON())
const operations = createSceneOperations({ bridge, store: guarded.store })
operations.setActiveScene(meta)
const server = new McpServer({ name: 'room', version: '0.0.0' })
registerCreateRoom(server, operations)
registerStructureTools(server, operations)
registerCreateWall(server, operations)
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'room-client', version: '0.0.0' })
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
const call = async (name: string, args: Record<string, unknown>) => {
const result = await client.callTool({ name, arguments: args })
return {
isError: !!result.isError,
text: (result.content as { type: string; text: string }[])[0]!.text,
}
}
const level = Object.values(bridge.getNodes()).find((node) => node.type === 'level')!
const room = await call('create_room', {
levelId: level.id,
name: 'Studio',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
expect(room.isError).toBe(false)
return { bridge, call, guarded, meta, levelId: level.id, zoneId: JSON.parse(room.text).zoneId }
}

test('is refused as a wipe, the session keeps the room, and the next write matches the store', async () => {
const { bridge, call, guarded, meta, levelId, zoneId } = await oneRoomSession()
const before = Object.keys(bridge.getNodes()).sort()
const saves = guarded.saves.length
const refused = await call('delete_zone', { zoneId, contents: 'delete' })
expect(refused.isError).toBe(true)
expect(refused.text).toContain('nothing changed')
expect(refused.text).toContain('only room')
expect(guarded.saves.length).toBe(saves)
expect(Object.keys(bridge.getNodes()).sort()).toEqual(before)

const wall = await call('create_wall', { levelId, start: [6, 0], end: [8, 0] })
expect(wall.isError).toBe(false)
const stored = await guarded.store.load(meta.id)
expect(Object.keys(stored!.graph.nodes).sort()).toEqual(Object.keys(bridge.getNodes()).sort())
expect(Object.keys(stored!.graph.nodes)).toContain(zoneId)
})
})
Loading
Loading