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:
co-authored by
Claude Opus 4.7
parent
07ed429d58
commit
58ad89e80b
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user