feat(mcp): implement 19 scene query and mutation tools

Scene querying: get_scene, get_node, describe_node, find_nodes, measure.
Scene mutation (undoable, atomic): apply_patch, create_level, create_wall,
place_item, cut_opening, set_zone, duplicate_level, delete_node.
Undo/redo: undo, redo.
Export: export_json, export_glb (not_implemented stub).
Validation: validate_scene, check_collisions.

Each tool has:
- Exported Zod input + output schemas
- register<Tool>(server, bridge) wiring function
- Bun test with happy-path + error-path coverage via InMemoryTransport

59 tool tests, all passing end-to-end through MCP protocol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adrian Perez
2026-04-18 17:51:08 +02:00
co-authored by Claude Opus 4.7
parent 07ed429d58
commit 58ad89e80b
41 changed files with 2584 additions and 0 deletions
@@ -0,0 +1,67 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerApplyPatch } from './apply-patch'
describe('apply_patch', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
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('applies a batch of create + update', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [
{ op: 'create', node: wall, parentId: level.id },
{ op: 'update', id: wall.id, data: { thickness: 0.2 } },
],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.appliedOps).toBe(2)
expect(parsed.createdIds).toContain(wall.id)
// Wait a tick for RAF-scheduled dirty-marking to settle.
await new Promise((r) => setTimeout(r, 10))
const stored = bridge.getNode(wall.id)
expect(stored).not.toBeNull()
expect((stored as { thickness?: number }).thickness).toBe(0.2)
})
test('rejects update to a non-existent node', async () => {
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [{ op: 'update', id: 'wall_none', data: { thickness: 0.1 } }],
},
})
expect(result.isError).toBe(true)
})
test('rejects malformed patch shape', async () => {
const result = await client.callTool({
name: 'apply_patch',
arguments: {
patches: [{ op: 'nope', garbage: true } as unknown as object],
},
})
expect(result.isError).toBe(true)
})
})
+68
View File
@@ -0,0 +1,68 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { PatchSchema } from './schemas'
export const applyPatchInput = {
patches: z.array(PatchSchema),
}
export const applyPatchOutput = {
appliedOps: z.number(),
deletedIds: z.array(z.string()),
createdIds: z.array(z.string()),
}
export function registerApplyPatch(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'apply_patch',
{
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.',
inputSchema: applyPatchInput,
outputSchema: applyPatchOutput,
},
async ({ patches }) => {
const bridgePatches: BridgePatch[] = patches.map((p) => {
if (p.op === 'create') {
return {
op: 'create',
node: p.node as unknown as AnyNode,
...(p.parentId !== undefined ? { parentId: p.parentId as AnyNodeId } : {}),
}
}
if (p.op === 'update') {
return {
op: 'update',
id: p.id as AnyNodeId,
data: p.data as Partial<AnyNode>,
}
}
return {
op: 'delete',
id: p.id as AnyNodeId,
...(p.cascade !== undefined ? { cascade: p.cascade } : {}),
}
})
try {
const result = bridge.applyPatch(bridgePatches)
const payload = {
appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[],
createdIds: result.createdIds as unknown as string[],
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InvalidParams, msg)
}
},
)
}
@@ -0,0 +1,88 @@
import { beforeEach, 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 { ItemNode, WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerCheckCollisions } from './check-collisions'
function makeItem(position: [number, number, number], dims: [number, number, number] = [1, 1, 1]) {
return ItemNode.parse({
position,
asset: {
id: 'x',
name: 'x',
category: 'x',
thumbnail: '',
src: 'asset://x',
dimensions: dims,
},
})
}
describe('check_collisions', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCheckCollisions(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('detects overlapping item AABBs', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [10, 0] })
bridge.createNode(wall, level.id)
const a = makeItem([0, 0, 0])
const b = makeItem([0.5, 0, 0.5])
;(a as { wallId?: string }).wallId = wall.id
;(b as { wallId?: string }).wallId = wall.id
bridge.createNode(a, wall.id)
bridge.createNode(b, wall.id)
const result = await client.callTool({
name: 'check_collisions',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.collisions.length).toBeGreaterThanOrEqual(1)
const ids = parsed.collisions.flatMap((c: { aId: string; bId: string }) => [c.aId, c.bId])
expect(ids).toContain(a.id)
expect(ids).toContain(b.id)
})
test('returns empty array when items do not overlap', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [10, 0] })
bridge.createNode(wall, level.id)
const a = makeItem([-10, 0, -10])
const b = makeItem([10, 0, 10])
;(a as { wallId?: string }).wallId = wall.id
;(b as { wallId?: string }).wallId = wall.id
bridge.createNode(a, wall.id)
bridge.createNode(b, wall.id)
const result = await client.callTool({
name: 'check_collisions',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.collisions.length).toBe(0)
})
test('scopes to levelId', async () => {
const result = await client.callTool({
name: 'check_collisions',
arguments: { levelId: 'level_missing' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(Array.isArray(parsed.collisions)).toBe(true)
})
})
@@ -0,0 +1,79 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema'
import { getScaledDimensions } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { NodeIdSchema } from './schemas'
export const checkCollisionsInput = {
levelId: NodeIdSchema.optional(),
}
export const checkCollisionsOutput = {
collisions: z.array(
z.object({
aId: z.string(),
bId: z.string(),
kind: z.string(),
}),
),
}
type AABB = { minX: number; maxX: number; minZ: number; maxZ: number }
function itemAabb(item: ItemNode): AABB {
const [x, , z] = item.position
const [w, , d] = getScaledDimensions(item)
const halfW = w / 2
const halfD = d / 2
return {
minX: x - halfW,
maxX: x + halfW,
minZ: z - halfD,
maxZ: z + halfD,
}
}
function aabbOverlap(a: AABB, b: AABB): boolean {
return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ
}
export function registerCheckCollisions(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'check_collisions',
{
title: 'Check collisions',
description:
'Detect overlapping item footprints via an axis-aligned 2D bounding-box test. Optionally scoped to a single level.',
inputSchema: checkCollisionsInput,
outputSchema: checkCollisionsOutput,
},
async ({ levelId }) => {
const filter: { type: 'item'; levelId?: AnyNodeId } = { type: 'item' }
if (levelId) filter.levelId = levelId as AnyNodeId
const items = bridge.findNodes(filter) as ItemNode[]
const boxes = items.map((i) => ({ item: i, aabb: itemAabb(i) }))
const collisions: { aId: string; bId: string; kind: string }[] = []
for (let i = 0; i < boxes.length; i++) {
for (let j = i + 1; j < boxes.length; j++) {
const a = boxes[i]!
const b = boxes[j]!
if (aabbOverlap(a.aabb, b.aabb)) {
collisions.push({
aId: a.item.id as string,
bId: b.item.id as string,
kind: 'item-aabb',
})
}
}
}
const payload = { collisions }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,54 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerCreateLevel } from './create-level'
describe('create_level', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateLevel(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('creates a level on a building', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: building.id, elevation: 3, label: 'Second' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.levelId).toMatch(/^level_/)
const created = bridge.getNode(parsed.levelId)
expect(created).not.toBeNull()
expect(created!.type).toBe('level')
expect((created as { level: number }).level).toBe(3)
})
test('rejects unknown building id', async () => {
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: 'building_nope' },
})
expect(result.isError).toBe(true)
})
test('rejects non-building parent', async () => {
const wallLike = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_level',
arguments: { buildingId: wallLike.id },
})
expect(result.isError).toBe(true)
})
})
+61
View File
@@ -0,0 +1,61 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { LevelNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const createLevelInput = {
buildingId: NodeIdSchema,
elevation: z.number().optional(),
height: z.number().optional(),
label: z.string().optional(),
}
export const createLevelOutput = {
levelId: z.string(),
}
export function registerCreateLevel(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'create_level',
{
title: 'Create level',
description:
'Create a new level node attached to the given building. height and label are stored in metadata.',
inputSchema: createLevelInput,
outputSchema: createLevelOutput,
},
async ({ buildingId, elevation, height, label }) => {
const parent = bridge.getNode(buildingId as AnyNodeId)
if (!parent) {
throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`)
}
if (parent.type !== 'building') {
throwMcpError(
ErrorCode.InvalidParams,
`Node ${buildingId} is a ${parent.type}, expected building`,
)
}
const metadata: Record<string, unknown> = {}
if (height !== undefined) metadata.height = height
if (label !== undefined) metadata.label = label
const levelNode = LevelNode.parse({
level: elevation ?? 0,
children: [],
...(Object.keys(metadata).length > 0 ? { metadata } : {}),
...(label !== undefined ? { name: label } : {}),
})
const id = bridge.createNode(levelNode, buildingId as AnyNodeId)
const payload = { levelId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,66 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerCreateWall } from './create-wall'
describe('create_wall', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateWall(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('creates a wall with custom thickness', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_wall',
arguments: {
levelId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.15,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.wallId).toMatch(/^wall_/)
const created = bridge.getNode(parsed.wallId)
expect(created).not.toBeNull()
expect((created as { thickness?: number }).thickness).toBe(0.15)
})
test('rejects unknown level id', async () => {
const result = await client.callTool({
name: 'create_wall',
arguments: {
levelId: 'level_nope',
start: [0, 0],
end: [1, 0],
},
})
expect(result.isError).toBe(true)
})
test('rejects invalid start tuple', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'create_wall',
arguments: {
levelId: level.id,
start: [0],
end: [1, 0],
},
})
expect(result.isError).toBe(true)
})
})
+57
View File
@@ -0,0 +1,57 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { WallNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema, Vec2Schema } from './schemas'
export const createWallInput = {
levelId: NodeIdSchema,
start: Vec2Schema,
end: Vec2Schema,
thickness: z.number().positive().optional(),
height: z.number().positive().optional(),
}
export const createWallOutput = {
wallId: z.string(),
}
export function registerCreateWall(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'create_wall',
{
title: 'Create wall',
description:
'Create a new wall on the given level between two 2D points. Thickness and height default to the core library defaults when omitted.',
inputSchema: createWallInput,
outputSchema: createWallOutput,
},
async ({ levelId, start, end, thickness, height }) => {
const parent = bridge.getNode(levelId as AnyNodeId)
if (!parent) {
throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
}
if (parent.type !== 'level') {
throwMcpError(
ErrorCode.InvalidParams,
`Node ${levelId} is a ${parent.type}, expected level`,
)
}
const wall = WallNode.parse({
start,
end,
...(thickness !== undefined ? { thickness } : {}),
...(height !== undefined ? { height } : {}),
})
const id = bridge.createNode(wall, levelId as AnyNodeId)
const payload = { wallId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,97 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerCutOpening } from './cut-opening'
describe('cut_opening', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCutOpening(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('creates a door opening on a wall', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'door',
position: 0.5,
width: 0.9,
height: 2.1,
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.openingId).toMatch(/^door_/)
const created = bridge.getNode(parsed.openingId)
expect((created as { wallId?: string }).wallId).toBe(wall.id)
expect((created as { width: number }).width).toBe(0.9)
})
test('creates a window opening on a wall', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'window',
position: 0.25,
width: 1.2,
height: 1.2,
},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.openingId).toMatch(/^window_/)
})
test('rejects unknown wall id', async () => {
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: 'wall_nope',
type: 'door',
position: 0.5,
width: 1,
height: 2,
},
})
expect(result.isError).toBe(true)
})
test('rejects out-of-range position', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'cut_opening',
arguments: {
wallId: wall.id,
type: 'door',
position: 1.5,
width: 1,
height: 2,
},
})
expect(result.isError).toBe(true)
})
})
+61
View File
@@ -0,0 +1,61 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { DoorNode, WindowNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const cutOpeningInput = {
wallId: NodeIdSchema,
type: z.enum(['door', 'window']),
position: z.number().min(0).max(1),
width: z.number().positive(),
height: z.number().positive(),
}
export const cutOpeningOutput = {
openingId: z.string(),
}
export function registerCutOpening(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'cut_opening',
{
title: 'Cut opening',
description:
'Cut a door or window opening into an existing wall. position is a parametric 0..1 offset along the wall centreline.',
inputSchema: cutOpeningInput,
outputSchema: cutOpeningOutput,
},
async ({ wallId, type, position, width, height }) => {
const wall = bridge.getNode(wallId as AnyNodeId)
if (!wall) {
throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`)
}
if (wall.type !== 'wall') {
throwMcpError(ErrorCode.InvalidParams, `Node ${wallId} is a ${wall.type}, expected wall`)
}
// wallT is stored on door/window children via position in the schema;
// the core systems look up wallId and derive placement from `position[0]`
// being on the wall-local axis. We set wallId explicitly so the runtime
// can associate the opening with its parent wall.
const base = {
wallId,
width,
height,
position: [position, height / 2, 0] as [number, number, number],
}
const opening = type === 'door' ? DoorNode.parse(base) : WindowNode.parse(base)
const id = bridge.createNode(opening, wallId as AnyNodeId)
const payload = { openingId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,67 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerDeleteNode } from './delete-node'
describe('delete_node', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDeleteNode(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('deletes a leaf node', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [2, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'delete_node',
arguments: { id: wall.id },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.deletedIds).toContain(wall.id)
expect(bridge.getNode(wall.id)).toBeNull()
})
test('refuses to delete a node with children without cascade', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'delete_node',
arguments: { id: building.id },
})
expect(result.isError).toBe(true)
})
test('cascades when cascade=true', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'delete_node',
arguments: { id: building.id, cascade: true },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.deletedIds.length).toBeGreaterThanOrEqual(1)
expect(bridge.getNode(building.id)).toBeNull()
})
test('errors on unknown id', async () => {
const result = await client.callTool({
name: 'delete_node',
arguments: { id: 'wall_nope' },
})
expect(result.isError).toBe(true)
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const deleteNodeInput = {
id: NodeIdSchema,
cascade: z.boolean().optional(),
}
export const deleteNodeOutput = {
deletedIds: z.array(z.string()),
}
export function registerDeleteNode(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'delete_node',
{
title: 'Delete node',
description:
'Delete a node. If it has children, pass `cascade: true` to delete descendants recursively.',
inputSchema: deleteNodeInput,
outputSchema: deleteNodeOutput,
},
async ({ id, cascade }) => {
const node = bridge.getNode(id as AnyNodeId)
if (!node) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${id}`)
}
try {
const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false)
const payload = { deletedIds: removed }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InvalidRequest, msg)
}
},
)
}
@@ -0,0 +1,64 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerDescribeNode } from './describe-node'
describe('describe_node', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDescribeNode(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('describes a wall with human sentence', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')
expect(level).toBeDefined()
const wall = WallNode.parse({ start: [0, 0], end: [5, 0] })
bridge.createNode(wall, level!.id)
const result = await client.callTool({
name: 'describe_node',
arguments: { id: wall.id },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.type).toBe('wall')
expect(parsed.parentId).toBe(level!.id)
expect(typeof parsed.description).toBe('string')
expect(parsed.description).toContain('Wall from')
expect(Array.isArray(parsed.ancestryIds)).toBe(true)
expect(Array.isArray(parsed.childrenIds)).toBe(true)
expect(typeof parsed.properties).toBe('object')
})
test('ancestry for wall includes level and building', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [3, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'describe_node',
arguments: { id: wall.id },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.ancestryIds).toContain(level.id)
})
test('errors on unknown id', async () => {
const result = await client.callTool({
name: 'describe_node',
arguments: { id: 'wall_nope' },
})
expect(result.isError).toBe(true)
})
})
+102
View File
@@ -0,0 +1,102 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const describeNodeInput = {
id: NodeIdSchema,
}
export const describeNodeOutput = {
id: z.string(),
type: z.string(),
parentId: z.string().nullable(),
ancestryIds: z.array(z.string()),
childrenIds: z.array(z.string()),
properties: z.record(z.string(), z.unknown()),
description: z.string(),
}
/**
* Build a short, human-readable one-liner describing the node.
* Covers the common shapes; falls back to a generic sentence otherwise.
*/
function describe(node: AnyNode): string {
switch (node.type) {
case 'wall': {
const [x1, z1] = node.start
const [x2, z2] = node.end
const t = node.thickness ?? 0.1
const h = node.height ?? 2.5
return `Wall from (${x1},${z1}) to (${x2},${z2}), thickness ${t.toFixed(2)}m, height ${h.toFixed(2)}m`
}
case 'level':
return `Level ${node.level}`
case 'building': {
const [x, y, z] = node.position
return `Building at (${x},${y},${z})`
}
case 'site':
return `Site with ${node.polygon?.points.length ?? 0}-sided property line`
case 'zone':
return `Zone "${node.name}" with ${node.polygon.length} vertices`
case 'slab':
return `Slab with ${node.polygon.length} vertices`
case 'ceiling':
return `Ceiling with ${node.polygon.length} vertices, height ${node.height.toFixed(2)}m`
case 'door':
return `Door (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)`
case 'window':
return `Window (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)`
case 'item': {
const [x, y, z] = node.position
return `Item "${node.asset.name}" at (${x},${y},${z})`
}
default:
return `${node.type} node`
}
}
export function registerDescribeNode(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'describe_node',
{
title: 'Describe node',
description:
'Return a structured summary of a node including its ancestry, children IDs, key properties, and a short human description.',
inputSchema: describeNodeInput,
outputSchema: describeNodeOutput,
},
async ({ id }) => {
const node = bridge.getNode(id as AnyNodeId)
if (!node) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${id}`)
}
// Ancestry minus self.
const ancestry = bridge.getAncestry(id as AnyNodeId)
const ancestryIds = ancestry.slice(1).map((n) => n.id as string)
const children = bridge.getChildren(id as AnyNodeId)
const childrenIds = children.map((n) => n.id as string)
const n = node as AnyNode
const payload = {
id: n.id as string,
type: n.type as string,
parentId: (n.parentId ?? null) as string | null,
ancestryIds,
childrenIds,
properties: n as unknown as Record<string, unknown>,
description: describe(n),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,60 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerDuplicateLevel } from './duplicate-level'
describe('duplicate_level', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDuplicateLevel(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('duplicates a level with its wall descendants', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [3, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'duplicate_level',
arguments: { levelId: level.id },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.newLevelId).toMatch(/^level_/)
expect(parsed.newLevelId).not.toBe(level.id)
expect(parsed.newNodeIds.length).toBeGreaterThanOrEqual(2)
const newLevel = bridge.getNode(parsed.newLevelId)
expect(newLevel).not.toBeNull()
expect(newLevel!.type).toBe('level')
})
test('rejects unknown id', async () => {
const result = await client.callTool({
name: 'duplicate_level',
arguments: { levelId: 'level_nope' },
})
expect(result.isError).toBe(true)
})
test('rejects non-level target', async () => {
const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')!
const result = await client.callTool({
name: 'duplicate_level',
arguments: { levelId: building.id },
})
expect(result.isError).toBe(true)
})
})
+71
View File
@@ -0,0 +1,71 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const duplicateLevelInput = {
levelId: NodeIdSchema,
}
export const duplicateLevelOutput = {
newLevelId: z.string(),
newNodeIds: z.array(z.string()),
}
export function registerDuplicateLevel(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'duplicate_level',
{
title: 'Duplicate level',
description:
'Clone a level and all its descendants into a new subtree attached to the same building.',
inputSchema: duplicateLevelInput,
outputSchema: duplicateLevelOutput,
},
async ({ levelId }) => {
const node = bridge.getNode(levelId as AnyNodeId)
if (!node) {
throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
}
if (node.type !== 'level') {
throwMcpError(ErrorCode.InvalidParams, `Node ${levelId} is a ${node.type}, expected level`)
}
// cloneLevelSubtree(nodes, levelId) — returns { clonedNodes, newLevelId, idMap }.
const { clonedNodes, newLevelId } = cloneLevelSubtree(bridge.getNodes(), levelId as AnyNodeId)
const buildingId = (node.parentId as AnyNodeId | null) ?? undefined
// Flatten cloned subtree into create patches. The level node itself
// attaches to the original building; descendants attach to their
// already-remapped parent (encoded in `parentId`).
const patches: BridgePatch[] = clonedNodes.map((n) => {
const isRoot = (n.id as AnyNodeId) === newLevelId
const parentIdForBridge = isRoot
? buildingId
: ((n.parentId as AnyNodeId | null) ?? undefined)
const createOp: BridgePatch = {
op: 'create',
node: n as AnyNode,
...(parentIdForBridge !== undefined ? { parentId: parentIdForBridge } : {}),
}
return createOp
})
const result = bridge.applyPatch(patches)
const payload = {
newLevelId: newLevelId as string,
newNodeIds: result.createdIds as unknown as string[],
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+34
View File
@@ -0,0 +1,34 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
/**
* Throw a structured MCP error. The SDK translates `McpError` into a
* JSON-RPC error response automatically.
*/
export function throwMcpError(code: ErrorCode, message: string, data?: unknown): never {
throw new McpError(code, message, data)
}
/**
* Return a non-throwing tool error payload — used for structured failures that
* we want the client to see inline in `content` rather than as a protocol
* error. Sets `isError: true` so SDK clients treat it as a failure.
*/
export function toolError(
message: string,
data?: Record<string, unknown>,
): {
content: { type: 'text'; text: string }[]
isError: true
} {
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: message, ...(data ?? {}) }),
},
],
isError: true,
}
}
export { ErrorCode, McpError }
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerExportGlb } from './export-glb'
describe('export_glb', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerExportGlb(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('returns not_implemented structurally (not an error)', async () => {
const result = await client.callTool({
name: 'export_glb',
arguments: {},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.status).toBe('not_implemented')
expect(typeof parsed.reason).toBe('string')
})
test('structuredContent exposes the status', async () => {
const result = await client.callTool({
name: 'export_glb',
arguments: {},
})
expect((result.structuredContent as { status: string }).status).toBe('not_implemented')
})
})
+34
View File
@@ -0,0 +1,34 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const exportGlbInput = {}
export const exportGlbOutput = {
status: z.literal('not_implemented'),
reason: z.string(),
}
export function registerExportGlb(server: McpServer, _bridge: SceneBridge): void {
server.registerTool(
'export_glb',
{
title: 'Export GLB',
description:
'GLB export is not available in headless mode — it requires the Three.js renderer, which is browser-only. Returns a structured `not_implemented` response.',
inputSchema: exportGlbInput,
outputSchema: exportGlbOutput,
},
async () => {
const payload = {
status: 'not_implemented' as const,
reason: 'GLB export requires the Three.js renderer, which is browser-only',
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
isError: false,
}
},
)
}
@@ -0,0 +1,52 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerExportJson } from './export-json'
describe('export_json', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerExportJson(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('returns serialisable JSON', async () => {
const result = await client.callTool({
name: 'export_json',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(typeof parsed.json).toBe('string')
const reparsed = JSON.parse(parsed.json)
expect(Array.isArray(reparsed.rootNodeIds)).toBe(true)
expect(typeof reparsed.nodes).toBe('object')
})
test('pretty=true produces indented output', async () => {
const result = await client.callTool({
name: 'export_json',
arguments: { pretty: true },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.json.includes('\n')).toBe(true)
})
test('pretty=false (default) produces compact output', async () => {
const result = await client.callTool({
name: 'export_json',
arguments: { pretty: false },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.json.includes('\n')).toBe(false)
})
})
+33
View File
@@ -0,0 +1,33 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const exportJsonInput = {
pretty: z.boolean().optional(),
}
export const exportJsonOutput = {
json: z.string(),
}
export function registerExportJson(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'export_json',
{
title: 'Export JSON',
description:
'Return the scene as a serialized JSON string. Pass `pretty: true` to indent with 2 spaces.',
inputSchema: exportJsonInput,
outputSchema: exportJsonOutput,
},
async ({ pretty }) => {
const scene = bridge.exportJSON()
const json = JSON.stringify(scene, null, pretty ? 2 : 0)
const payload = { json }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, 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 { WallNode, ZoneNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerFindNodes } from './find-nodes'
describe('find_nodes', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerFindNodes(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('filters by type', async () => {
const result = await client.callTool({
name: 'find_nodes',
arguments: { type: 'level' },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.nodes.length).toBeGreaterThan(0)
for (const n of parsed.nodes) {
expect(n.type).toBe('level')
}
})
test('returns empty list for unused type', async () => {
const result = await client.callTool({
name: 'find_nodes',
arguments: { type: 'roof' },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(Array.isArray(parsed.nodes)).toBe(true)
expect(parsed.nodes.length).toBe(0)
})
test('zoneId filters walls whose midpoint falls in the zone polygon', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const zone = ZoneNode.parse({
name: 'Kitchen',
polygon: [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
],
})
bridge.createNode(zone, level.id)
const inWall = WallNode.parse({ start: [-2, -2], end: [2, 2] })
bridge.createNode(inWall, level.id)
const outWall = WallNode.parse({ start: [50, 50], end: [60, 60] })
bridge.createNode(outWall, level.id)
const result = await client.callTool({
name: 'find_nodes',
arguments: { type: 'wall', zoneId: zone.id },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
const ids: string[] = parsed.nodes.map((n: { id: string }) => n.id)
expect(ids).toContain(inWall.id)
expect(ids).not.toContain(outWall.id)
})
test('invalid type is rejected', async () => {
const result = await client.callTool({
name: 'find_nodes',
arguments: { type: 'not-a-type' },
})
expect(result.isError).toBe(true)
})
})
+126
View File
@@ -0,0 +1,126 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema'
import { pointInPolygon } from '@pascal-app/core/spatial-grid'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { NodeIdSchema } from './schemas'
const ALL_NODE_TYPES = [
'site',
'building',
'level',
'wall',
'fence',
'zone',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'item',
'door',
'window',
'scan',
'guide',
] as const
export const findNodesInput = {
type: z.enum(ALL_NODE_TYPES).optional(),
parentId: NodeIdSchema.optional(),
levelId: NodeIdSchema.optional(),
zoneId: NodeIdSchema.optional(),
}
export const findNodesOutput = {
nodes: z.array(z.record(z.string(), z.unknown())),
}
/** Compute a representative 2D point (x, z) for zone-filtering. */
function getPointForZoneFilter(node: AnyNode): [number, number] | null {
if (node.type === 'wall' || node.type === 'fence') {
const [x1, z1] = node.start
const [x2, z2] = node.end
return [(x1 + x2) / 2, (z1 + z2) / 2]
}
if (
node.type === 'item' ||
node.type === 'door' ||
node.type === 'window' ||
node.type === 'building' ||
node.type === 'stair' ||
node.type === 'roof'
) {
const [x, , z] = node.position
return [x, z]
}
if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const poly = node.polygon as Array<[number, number]> | undefined
if (!poly || poly.length === 0) return null
let cx = 0
let cz = 0
for (const [x, z] of poly) {
cx += x
cz += z
}
return [cx / poly.length, cz / poly.length]
}
return null
}
export function registerFindNodes(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'find_nodes',
{
title: 'Find nodes',
description:
'Find nodes matching any combination of type, parentId, levelId, or zoneId filters.',
inputSchema: findNodesInput,
outputSchema: findNodesOutput,
},
async (args) => {
const { type, parentId, levelId, zoneId } = args as {
type?: AnyNodeType
parentId?: string
levelId?: string
zoneId?: string
}
// Delegate type/parent/level filtering to the bridge.
const baseFilter: {
type?: AnyNodeType
parentId?: AnyNodeId
levelId?: AnyNodeId
} = {}
if (type !== undefined) baseFilter.type = type
if (parentId !== undefined) baseFilter.parentId = parentId as AnyNodeId
if (levelId !== undefined) baseFilter.levelId = levelId as AnyNodeId
let results = bridge.findNodes(baseFilter)
// Zone-polygon filter: point-in-polygon on a representative 2D point.
if (zoneId) {
const zone = bridge.getNode(zoneId as AnyNodeId)
if (!zone || zone.type !== 'zone') {
// Unknown zoneId → return empty list rather than throw; matches
// typical "filter" semantics.
results = []
} else {
const poly = zone.polygon
results = results.filter((n) => {
const pt = getPointForZoneFilter(n)
if (!pt) return false
return pointInPolygon(pt[0], pt[1], poly)
})
}
}
const payload = {
nodes: results as unknown as Array<Record<string, unknown>>,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+49
View File
@@ -0,0 +1,49 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerGetNode } from './get-node'
describe('get_node', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerGetNode(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('returns node by id', async () => {
const rootId = bridge.getRootNodeIds()[0]!
const result = await client.callTool({
name: 'get_node',
arguments: { id: rootId },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.node.id).toBe(rootId)
})
test('errors on unknown id', async () => {
const result = await client.callTool({
name: 'get_node',
arguments: { id: 'wall_doesnotexist' },
})
expect(result.isError).toBe(true)
})
test('rejects missing id argument', async () => {
const result = await client.callTool({
name: 'get_node',
arguments: {},
})
expect(result.isError).toBe(true)
})
})
+37
View File
@@ -0,0 +1,37 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const getNodeInput = {
id: NodeIdSchema,
}
export const getNodeOutput = {
node: z.record(z.string(), z.unknown()),
}
export function registerGetNode(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'get_node',
{
title: 'Get node',
description: 'Return the full node payload for the given ID.',
inputSchema: getNodeInput,
outputSchema: getNodeOutput,
},
async ({ id }) => {
const node = bridge.getNode(id as AnyNodeId)
if (!node) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${id}`)
}
const payload = { node: node as unknown as Record<string, unknown> }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+51
View File
@@ -0,0 +1,51 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerGetScene } from './get-scene'
describe('get_scene', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerGetScene(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('returns scene with nodes and rootNodeIds', async () => {
const result = await client.callTool({ name: 'get_scene', arguments: {} })
expect(result.isError).toBeFalsy()
const text = (result.content as Array<{ type: string; text: string }>)[0]!.text
const parsed = JSON.parse(text)
expect(Array.isArray(parsed.rootNodeIds)).toBe(true)
expect(parsed.rootNodeIds.length).toBeGreaterThan(0)
expect(typeof parsed.nodes).toBe('object')
expect(Object.keys(parsed.nodes).length).toBeGreaterThan(0)
})
test('reflects mutations to the bridge', async () => {
const beforeCount = Object.keys(bridge.getNodes()).length
bridge.setScene({}, [])
const result = await client.callTool({ name: 'get_scene', arguments: {} })
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.rootNodeIds.length).toBe(0)
expect(Object.keys(parsed.nodes).length).toBe(0)
expect(beforeCount).toBeGreaterThan(0)
})
test('returns structured content', async () => {
const result = await client.callTool({ name: 'get_scene', arguments: {} })
expect(result.structuredContent).toBeDefined()
expect(
Array.isArray((result.structuredContent as { rootNodeIds: unknown[] }).rootNodeIds),
).toBe(true)
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const getSceneInput = {}
export const getSceneOutput = {
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.record(z.string(), z.unknown()).optional(),
}
export function registerGetScene(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'get_scene',
{
title: 'Get scene',
description:
'Returns the full scene graph: flat node dictionary, root node IDs, and collections.',
inputSchema: getSceneInput,
outputSchema: getSceneOutput,
},
async () => {
const scene = bridge.exportJSON()
const payload = {
nodes: scene.nodes as Record<string, unknown>,
rootNodeIds: scene.rootNodeIds,
collections: (scene.collections ?? {}) as Record<string, unknown>,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+48
View File
@@ -0,0 +1,48 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { registerApplyPatch } from './apply-patch'
import { registerCheckCollisions } from './check-collisions'
import { registerCreateLevel } from './create-level'
import { registerCreateWall } from './create-wall'
import { registerCutOpening } from './cut-opening'
import { registerDeleteNode } from './delete-node'
import { registerDescribeNode } from './describe-node'
import { registerDuplicateLevel } from './duplicate-level'
import { registerExportGlb } from './export-glb'
import { registerExportJson } from './export-json'
import { registerFindNodes } from './find-nodes'
import { registerGetNode } from './get-node'
import { registerGetScene } from './get-scene'
import { registerMeasure } from './measure'
import { registerPlaceItem } from './place-item'
import { registerRedo } from './redo'
import { registerSetZone } from './set-zone'
import { registerUndo } from './undo'
import { registerValidateScene } from './validate-scene'
/**
* Register every non-vision MCP tool against the given server.
* Vision tools (analyze_floorplan_image, analyze_room_photo) are registered
* separately via `registerVisionTools` (Agent E).
*/
export function registerTools(server: McpServer, bridge: SceneBridge): void {
registerGetScene(server, bridge)
registerGetNode(server, bridge)
registerDescribeNode(server, bridge)
registerFindNodes(server, bridge)
registerMeasure(server, bridge)
registerApplyPatch(server, bridge)
registerCreateLevel(server, bridge)
registerCreateWall(server, bridge)
registerPlaceItem(server, bridge)
registerCutOpening(server, bridge)
registerSetZone(server, bridge)
registerDuplicateLevel(server, bridge)
registerDeleteNode(server, bridge)
registerUndo(server, bridge)
registerRedo(server, bridge)
registerExportJson(server, bridge)
registerExportGlb(server, bridge)
registerValidateScene(server, bridge)
registerCheckCollisions(server, bridge)
}
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, 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 { WallNode, ZoneNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerMeasure } from './measure'
describe('measure', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerMeasure(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('computes distance between two wall midpoints', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const a = WallNode.parse({ start: [0, 0], end: [2, 0] })
const b = WallNode.parse({ start: [10, 0], end: [12, 0] })
bridge.createNode(a, level.id)
bridge.createNode(b, level.id)
const result = await client.callTool({
name: 'measure',
arguments: { fromId: a.id, toId: b.id },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
// Midpoint a = (1,0,0); midpoint b = (11,0,0) — distance 10.
expect(parsed.distanceMeters).toBeCloseTo(10, 5)
expect(parsed.units).toBe('meters')
})
test('computes zone area via shoelace for self-measurement', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
// 4x4 square centred at origin — area 16.
const zone = ZoneNode.parse({
name: 'Kitchen',
polygon: [
[-2, -2],
[2, -2],
[2, 2],
[-2, 2],
],
})
bridge.createNode(zone, level.id)
const result = await client.callTool({
name: 'measure',
arguments: { fromId: zone.id, toId: zone.id },
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.distanceMeters).toBe(0)
expect(parsed.areaSqMeters).toBeCloseTo(16, 5)
})
test('errors on unknown id', async () => {
const result = await client.callTool({
name: 'measure',
arguments: { fromId: 'wall_nope', toId: 'wall_nope2' },
})
expect(result.isError).toBe(true)
})
})
+154
View File
@@ -0,0 +1,154 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
export const measureInput = {
fromId: NodeIdSchema,
toId: NodeIdSchema,
}
export const measureOutput = {
distanceMeters: z.number(),
areaSqMeters: z.number().optional(),
units: z.literal('meters'),
}
/**
* Compute a 3D centre point in level-coordinate space for distance measurement.
*
* For walls / fences: midpoint of the start/end segment at Y=0.
* For positioned nodes (item, door, window, building, stair, roof): `position`.
* For polygon nodes (slab, ceiling, zone): 2D centroid lifted to Y=0.
* For site: centroid of property line at Y=0 if available.
*
* Returns null if no representative centre can be derived (e.g. level node
* has no position of its own).
*/
function getCentre(node: AnyNode): [number, number, number] | null {
switch (node.type) {
case 'wall':
case 'fence': {
const [x1, z1] = node.start
const [x2, z2] = node.end
return [(x1 + x2) / 2, 0, (z1 + z2) / 2]
}
case 'item':
case 'door':
case 'window':
case 'building':
case 'stair':
case 'roof':
return node.position
case 'slab':
case 'ceiling':
case 'zone': {
const poly = node.polygon as Array<[number, number]> | undefined
if (!poly || poly.length === 0) return null
let cx = 0
let cz = 0
for (const [x, z] of poly) {
cx += x
cz += z
}
return [cx / poly.length, 0, cz / poly.length]
}
case 'site': {
const pts = node.polygon?.points ?? []
if (pts.length === 0) return [0, 0, 0]
let cx = 0
let cz = 0
for (const [x, z] of pts) {
cx += x
cz += z
}
return [cx / pts.length, 0, cz / pts.length]
}
default:
return null
}
}
/** Compute polygon area via the shoelace formula. */
function shoelaceArea(polygon: Array<[number, number]>): number {
if (polygon.length < 3) return 0
let sum = 0
const n = polygon.length
for (let i = 0; i < n; i++) {
const [x1, z1] = polygon[i]!
const [x2, z2] = polygon[(i + 1) % n]!
sum += x1 * z2 - x2 * z1
}
return Math.abs(sum) / 2
}
export function registerMeasure(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'measure',
{
title: 'Measure',
description:
'Measure distance (in meters) between two nodes, or the area of a polygon node when fromId === toId.',
inputSchema: measureInput,
outputSchema: measureOutput,
},
async ({ fromId, toId }) => {
const from = bridge.getNode(fromId as AnyNodeId)
if (!from) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${fromId}`)
}
const to = bridge.getNode(toId as AnyNodeId)
if (!to) {
throwMcpError(ErrorCode.InvalidParams, `Node not found: ${toId}`)
}
// Self-measurement: compute area for polygon-bearing nodes.
if (fromId === toId) {
const n = from as AnyNode
if (n.type === 'zone' || n.type === 'slab' || n.type === 'ceiling') {
const area = shoelaceArea(n.polygon as Array<[number, number]>)
const payload = {
distanceMeters: 0,
areaSqMeters: area,
units: 'meters' as const,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
// For non-polygon self, distance is 0 and no area.
const payload = { distanceMeters: 0, units: 'meters' as const }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
}
const fromCentre = getCentre(from as AnyNode)
const toCentre = getCentre(to as AnyNode)
if (!fromCentre || !toCentre) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot derive centre for measurement between ${from.type} and ${to.type}`,
)
}
const dx = fromCentre[0] - toCentre[0]
const dy = fromCentre[1] - toCentre[1]
const dz = fromCentre[2] - toCentre[2]
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
const payload = {
distanceMeters: distance,
units: 'meters' as const,
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+71
View File
@@ -0,0 +1,71 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerPlaceItem } from './place-item'
describe('place_item', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerPlaceItem(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('places an item on a wall and derives wallT', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [10, 0] })
bridge.createNode(wall, level.id)
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'chair:basic',
targetNodeId: wall.id,
position: [5, 0, 0],
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.itemId).toMatch(/^item_/)
expect(parsed.status).toBe('catalog_unavailable')
const item = bridge.getNode(parsed.itemId)
expect(item).not.toBeNull()
// Midpoint of a [0..10] wall at x=5 → wallT = 0.5.
expect((item as { wallT?: number }).wallT).toBeCloseTo(0.5, 3)
})
test('rejects placement on a level', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'foo',
targetNodeId: level.id,
position: [0, 0, 0],
},
})
expect(result.isError).toBe(true)
})
test('rejects unknown target', async () => {
const result = await client.callTool({
name: 'place_item',
arguments: {
catalogItemId: 'foo',
targetNodeId: 'wall_nope',
position: [0, 0, 0],
},
})
expect(result.isError).toBe(true)
})
})
+103
View File
@@ -0,0 +1,103 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { ItemNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema, Vec3Schema } from './schemas'
export const placeItemInput = {
catalogItemId: z.string().min(1),
targetNodeId: NodeIdSchema,
position: Vec3Schema,
rotation: z.number().optional(),
}
export const placeItemOutput = {
itemId: z.string(),
status: z.string().optional(),
}
/** Compute wallT (0..1) from a 3D position projected onto the wall centreline. */
function computeWallT(
start: [number, number],
end: [number, number],
position: [number, number, number],
): number {
const [sx, sz] = start
const [ex, ez] = end
const dx = ex - sx
const dz = ez - sz
const lenSq = dx * dx + dz * dz
if (lenSq === 0) return 0
const px = position[0] - sx
const pz = position[2] - sz
const t = (px * dx + pz * dz) / lenSq
return Math.max(0, Math.min(1, t))
}
export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'place_item',
{
title: 'Place item',
description:
'Place a catalog item into the scene, attaching it to a wall, ceiling, or site. In headless mode the catalog is unavailable, so the asset payload is a placeholder — `status: "catalog_unavailable"` indicates this.',
inputSchema: placeItemInput,
outputSchema: placeItemOutput,
},
async ({ catalogItemId, targetNodeId, position, rotation }) => {
const target = bridge.getNode(targetNodeId as AnyNodeId)
if (!target) {
throwMcpError(ErrorCode.InvalidParams, `Target node not found: ${targetNodeId}`)
}
const targetType = target.type
if (targetType !== 'wall' && targetType !== 'ceiling' && targetType !== 'site') {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot place item on ${targetType}; target must be a wall, ceiling, or site`,
)
}
const baseAsset = {
id: catalogItemId,
name: catalogItemId,
category: 'unknown',
thumbnail: '',
src: 'asset://placeholder',
dimensions: [0.5, 0.5, 0.5] as [number, number, number],
offset: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
scale: [1, 1, 1] as [number, number, number],
}
const wallExtras: { wallId: string; wallT: number } | Record<string, never> =
targetType === 'wall'
? {
wallId: targetNodeId,
wallT: computeWallT(
(target as { start: [number, number] }).start,
(target as { end: [number, number] }).end,
position as [number, number, number],
),
}
: {}
const item = ItemNode.parse({
position,
rotation: [0, rotation ?? 0, 0],
asset: baseAsset,
...wallExtras,
})
const id = bridge.createNode(item, targetNodeId as AnyNodeId)
const payload = {
itemId: id as string,
status: 'catalog_unavailable',
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+62
View File
@@ -0,0 +1,62 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerRedo } from './redo'
describe('redo', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
bridge.clearHistory()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerRedo(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('redoes a previously undone create', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await new Promise((r) => setTimeout(r, 5))
bridge.undo(1)
await new Promise((r) => setTimeout(r, 5))
expect(bridge.getNode(wall.id)).toBeNull()
const result = await client.callTool({
name: 'redo',
arguments: { steps: 1 },
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.redone).toBe(1)
expect(bridge.getNode(wall.id)).not.toBeNull()
})
test('returns 0 when nothing to redo', async () => {
const result = await client.callTool({
name: 'redo',
arguments: {},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.redone).toBe(0)
})
test('rejects non-positive steps', async () => {
const result = await client.callTool({
name: 'redo',
arguments: { steps: -1 },
})
expect(result.isError).toBe(true)
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const redoInput = {
steps: z.number().int().positive().optional(),
}
export const redoOutput = {
redone: z.number(),
}
export function registerRedo(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'redo',
{
title: 'Redo',
description:
'Redo the next N previously-undone steps (default 1). Returns the number of steps actually redone.',
inputSchema: redoInput,
outputSchema: redoOutput,
},
async ({ steps }) => {
const redone = bridge.redo(steps ?? 1)
const payload = { redone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod'
/**
* Shared Zod schemas used by multiple MCP tools. Keep DRY — if a shape is
* referenced by more than one tool, define it here.
*/
/** A node identifier — non-empty string. The core uses `${prefix}_${nanoid}`. */
export const NodeIdSchema = z.string().min(1)
/** 2D point as [x, z] (floor plane). Matches core's tuple convention. */
export const Vec2Schema = z.tuple([z.number(), z.number()])
/** 3D point as [x, y, z]. */
export const Vec3Schema = z.tuple([z.number(), z.number(), z.number()])
/**
* A single patch operation. Union of create / update / delete.
*
* For `create`, the node object must include `type` so Zod can discriminate at
* the bridge layer — we accept a plain object here and let the bridge's Zod
* re-parse catch structural issues. For `update`, `data` is a partial merge.
*/
export const CreatePatchSchema = z.object({
op: z.literal('create'),
node: z.record(z.string(), z.unknown()),
parentId: NodeIdSchema.optional(),
})
export const UpdatePatchSchema = z.object({
op: z.literal('update'),
id: NodeIdSchema,
data: z.record(z.string(), z.unknown()),
})
export const DeletePatchSchema = z.object({
op: z.literal('delete'),
id: NodeIdSchema,
cascade: z.boolean().optional(),
})
export const PatchSchema = z.discriminatedUnion('op', [
CreatePatchSchema,
UpdatePatchSchema,
DeletePatchSchema,
])
export type Patch = z.infer<typeof PatchSchema>
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerSetZone } from './set-zone'
describe('set_zone', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerSetZone(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('creates a zone on a level', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'set_zone',
arguments: {
levelId: level.id,
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
label: 'Kitchen',
properties: { primary: true },
},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.zoneId).toMatch(/^zone_/)
const zone = bridge.getNode(parsed.zoneId)
expect((zone as { name: string }).name).toBe('Kitchen')
})
test('rejects polygon with <3 vertices', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const result = await client.callTool({
name: 'set_zone',
arguments: {
levelId: level.id,
polygon: [
[0, 0],
[1, 1],
],
label: 'X',
},
})
expect(result.isError).toBe(true)
})
test('rejects unknown level id', async () => {
const result = await client.callTool({
name: 'set_zone',
arguments: {
levelId: 'level_nope',
polygon: [
[0, 0],
[1, 0],
[0, 1],
],
label: 'X',
},
})
expect(result.isError).toBe(true)
})
})
+56
View File
@@ -0,0 +1,56 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { ZoneNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema, Vec2Schema } from './schemas'
export const setZoneInput = {
levelId: NodeIdSchema,
polygon: z.array(Vec2Schema).min(3),
label: z.string().min(1),
properties: z.record(z.string(), z.unknown()).optional(),
}
export const setZoneOutput = {
zoneId: z.string(),
}
export function registerSetZone(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'set_zone',
{
title: 'Set zone',
description:
'Create a polygonal zone on the given level. label is stored as the zone name and properties are merged into metadata.',
inputSchema: setZoneInput,
outputSchema: setZoneOutput,
},
async ({ levelId, polygon, label, properties }) => {
const parent = bridge.getNode(levelId as AnyNodeId)
if (!parent) {
throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
}
if (parent.type !== 'level') {
throwMcpError(
ErrorCode.InvalidParams,
`Node ${levelId} is a ${parent.type}, expected level`,
)
}
const zone = ZoneNode.parse({
name: label,
polygon,
metadata: properties ?? {},
})
const id = bridge.createNode(zone, levelId as AnyNodeId)
const payload = { zoneId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, 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 { WallNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerUndo } from './undo'
describe('undo', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
bridge.clearHistory()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerUndo(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('undoes one step', async () => {
const level = Object.values(bridge.getNodes()).find((n) => n.type === 'level')!
const wall = WallNode.parse({ start: [0, 0], end: [1, 0] })
bridge.createNode(wall, level.id)
await new Promise((r) => setTimeout(r, 5))
const result = await client.callTool({
name: 'undo',
arguments: {},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.undone).toBe(1)
expect(bridge.getNode(wall.id)).toBeNull()
})
test('returns 0 when nothing to undo', async () => {
const result = await client.callTool({
name: 'undo',
arguments: {},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.undone).toBe(0)
})
test('rejects non-positive steps', async () => {
const result = await client.callTool({
name: 'undo',
arguments: { steps: 0 },
})
expect(result.isError).toBe(true)
})
})
+32
View File
@@ -0,0 +1,32 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const undoInput = {
steps: z.number().int().positive().optional(),
}
export const undoOutput = {
undone: z.number(),
}
export function registerUndo(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'undo',
{
title: 'Undo',
description:
'Undo the most recent N steps in the scene history (default 1). Returns the number of steps actually undone.',
inputSchema: undoInput,
outputSchema: undoOutput,
},
async ({ steps }) => {
const undone = bridge.undo(steps ?? 1)
const payload = { undone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
},
)
}
@@ -0,0 +1,55 @@
import { beforeEach, 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 { SceneBridge } from '../bridge/scene-bridge'
import { registerValidateScene } from './validate-scene'
describe('validate_scene', () => {
let client: Client
let bridge: SceneBridge
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
bridge.loadDefault()
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerValidateScene(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('default scene is valid', async () => {
const result = await client.callTool({
name: 'validate_scene',
arguments: {},
})
expect(result.isError).toBeFalsy()
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
expect(parsed.valid).toBe(true)
expect(Array.isArray(parsed.errors)).toBe(true)
})
test('reports structured errors', async () => {
const result = await client.callTool({
name: 'validate_scene',
arguments: {},
})
const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text)
for (const err of parsed.errors) {
expect(typeof err.nodeId).toBe('string')
expect(typeof err.path).toBe('string')
expect(typeof err.message).toBe('string')
}
})
test('returns structuredContent', async () => {
const result = await client.callTool({
name: 'validate_scene',
arguments: {},
})
expect(result.structuredContent).toBeDefined()
expect((result.structuredContent as { valid: boolean }).valid).toBe(true)
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
export const validateSceneInput = {}
export const validateSceneOutput = {
valid: z.boolean(),
errors: z.array(
z.object({
nodeId: z.string(),
path: z.string(),
message: z.string(),
}),
),
}
export function registerValidateScene(server: McpServer, bridge: SceneBridge): void {
server.registerTool(
'validate_scene',
{
title: 'Validate scene',
description:
'Run Zod validation against every node in the scene. Returns `{ valid, errors }` where each error has `{ nodeId, path, message }`.',
inputSchema: validateSceneInput,
outputSchema: validateSceneOutput,
},
async () => {
const result = bridge.validateScene()
return {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
structuredContent: result,
}
},
)
}