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