diff --git a/packages/mcp/src/tools/apply-patch.test.ts b/packages/mcp/src/tools/apply-patch.test.ts new file mode 100644 index 00000000..f834224a --- /dev/null +++ b/packages/mcp/src/tools/apply-patch.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts new file mode 100644 index 00000000..76ac54c5 --- /dev/null +++ b/packages/mcp/src/tools/apply-patch.ts @@ -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, + } + } + 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) + } + }, + ) +} diff --git a/packages/mcp/src/tools/check-collisions.test.ts b/packages/mcp/src/tools/check-collisions.test.ts new file mode 100644 index 00000000..1a159b4a --- /dev/null +++ b/packages/mcp/src/tools/check-collisions.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/check-collisions.ts b/packages/mcp/src/tools/check-collisions.ts new file mode 100644 index 00000000..c1b5da4f --- /dev/null +++ b/packages/mcp/src/tools/check-collisions.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/create-level.test.ts b/packages/mcp/src/tools/create-level.test.ts new file mode 100644 index 00000000..cf548daa --- /dev/null +++ b/packages/mcp/src/tools/create-level.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts new file mode 100644 index 00000000..c9722118 --- /dev/null +++ b/packages/mcp/src/tools/create-level.ts @@ -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 = {} + 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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts new file mode 100644 index 00000000..23dee6b0 --- /dev/null +++ b/packages/mcp/src/tools/create-wall.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts new file mode 100644 index 00000000..005f8b27 --- /dev/null +++ b/packages/mcp/src/tools/create-wall.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/cut-opening.test.ts b/packages/mcp/src/tools/cut-opening.test.ts new file mode 100644 index 00000000..7fbbd6b7 --- /dev/null +++ b/packages/mcp/src/tools/cut-opening.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts new file mode 100644 index 00000000..85f73797 --- /dev/null +++ b/packages/mcp/src/tools/cut-opening.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/delete-node.test.ts b/packages/mcp/src/tools/delete-node.test.ts new file mode 100644 index 00000000..1eae1ba4 --- /dev/null +++ b/packages/mcp/src/tools/delete-node.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/delete-node.ts b/packages/mcp/src/tools/delete-node.ts new file mode 100644 index 00000000..528ee643 --- /dev/null +++ b/packages/mcp/src/tools/delete-node.ts @@ -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) + } + }, + ) +} diff --git a/packages/mcp/src/tools/describe-node.test.ts b/packages/mcp/src/tools/describe-node.test.ts new file mode 100644 index 00000000..a75e7527 --- /dev/null +++ b/packages/mcp/src/tools/describe-node.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts new file mode 100644 index 00000000..319f386d --- /dev/null +++ b/packages/mcp/src/tools/describe-node.ts @@ -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, + description: describe(n), + } + + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/duplicate-level.test.ts b/packages/mcp/src/tools/duplicate-level.test.ts new file mode 100644 index 00000000..1552d4d2 --- /dev/null +++ b/packages/mcp/src/tools/duplicate-level.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/duplicate-level.ts b/packages/mcp/src/tools/duplicate-level.ts new file mode 100644 index 00000000..d50878b0 --- /dev/null +++ b/packages/mcp/src/tools/duplicate-level.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/errors.ts b/packages/mcp/src/tools/errors.ts new file mode 100644 index 00000000..9ec31fb8 --- /dev/null +++ b/packages/mcp/src/tools/errors.ts @@ -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, +): { + content: { type: 'text'; text: string }[] + isError: true +} { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ error: message, ...(data ?? {}) }), + }, + ], + isError: true, + } +} + +export { ErrorCode, McpError } diff --git a/packages/mcp/src/tools/export-glb.test.ts b/packages/mcp/src/tools/export-glb.test.ts new file mode 100644 index 00000000..398620be --- /dev/null +++ b/packages/mcp/src/tools/export-glb.test.ts @@ -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') + }) +}) diff --git a/packages/mcp/src/tools/export-glb.ts b/packages/mcp/src/tools/export-glb.ts new file mode 100644 index 00000000..bce005c6 --- /dev/null +++ b/packages/mcp/src/tools/export-glb.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/export-json.test.ts b/packages/mcp/src/tools/export-json.test.ts new file mode 100644 index 00000000..10429fa3 --- /dev/null +++ b/packages/mcp/src/tools/export-json.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/export-json.ts b/packages/mcp/src/tools/export-json.ts new file mode 100644 index 00000000..8fd8026e --- /dev/null +++ b/packages/mcp/src/tools/export-json.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/find-nodes.test.ts b/packages/mcp/src/tools/find-nodes.test.ts new file mode 100644 index 00000000..1b373100 --- /dev/null +++ b/packages/mcp/src/tools/find-nodes.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/find-nodes.ts b/packages/mcp/src/tools/find-nodes.ts new file mode 100644 index 00000000..fa20e8e6 --- /dev/null +++ b/packages/mcp/src/tools/find-nodes.ts @@ -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>, + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/get-node.test.ts b/packages/mcp/src/tools/get-node.test.ts new file mode 100644 index 00000000..f64a4360 --- /dev/null +++ b/packages/mcp/src/tools/get-node.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/get-node.ts b/packages/mcp/src/tools/get-node.ts new file mode 100644 index 00000000..cdd9af59 --- /dev/null +++ b/packages/mcp/src/tools/get-node.ts @@ -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 } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/get-scene.test.ts b/packages/mcp/src/tools/get-scene.test.ts new file mode 100644 index 00000000..b80794e6 --- /dev/null +++ b/packages/mcp/src/tools/get-scene.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/get-scene.ts b/packages/mcp/src/tools/get-scene.ts new file mode 100644 index 00000000..c6dcd7c5 --- /dev/null +++ b/packages/mcp/src/tools/get-scene.ts @@ -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, + rootNodeIds: scene.rootNodeIds, + collections: (scene.collections ?? {}) as Record, + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 00000000..f0682b5c --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -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) +} diff --git a/packages/mcp/src/tools/measure.test.ts b/packages/mcp/src/tools/measure.test.ts new file mode 100644 index 00000000..dd489d66 --- /dev/null +++ b/packages/mcp/src/tools/measure.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/measure.ts b/packages/mcp/src/tools/measure.ts new file mode 100644 index 00000000..f6668bea --- /dev/null +++ b/packages/mcp/src/tools/measure.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/place-item.test.ts b/packages/mcp/src/tools/place-item.test.ts new file mode 100644 index 00000000..bb79c65d --- /dev/null +++ b/packages/mcp/src/tools/place-item.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts new file mode 100644 index 00000000..94cf41a6 --- /dev/null +++ b/packages/mcp/src/tools/place-item.ts @@ -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 = + 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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/redo.test.ts b/packages/mcp/src/tools/redo.test.ts new file mode 100644 index 00000000..6954ac06 --- /dev/null +++ b/packages/mcp/src/tools/redo.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/redo.ts b/packages/mcp/src/tools/redo.ts new file mode 100644 index 00000000..859486b2 --- /dev/null +++ b/packages/mcp/src/tools/redo.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts new file mode 100644 index 00000000..138555b1 --- /dev/null +++ b/packages/mcp/src/tools/schemas.ts @@ -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 diff --git a/packages/mcp/src/tools/set-zone.test.ts b/packages/mcp/src/tools/set-zone.test.ts new file mode 100644 index 00000000..b50ef24c --- /dev/null +++ b/packages/mcp/src/tools/set-zone.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts new file mode 100644 index 00000000..d69b1889 --- /dev/null +++ b/packages/mcp/src/tools/set-zone.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/undo.test.ts b/packages/mcp/src/tools/undo.test.ts new file mode 100644 index 00000000..edc5d3ba --- /dev/null +++ b/packages/mcp/src/tools/undo.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/undo.ts b/packages/mcp/src/tools/undo.ts new file mode 100644 index 00000000..bb195886 --- /dev/null +++ b/packages/mcp/src/tools/undo.ts @@ -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, + } + }, + ) +} diff --git a/packages/mcp/src/tools/validate-scene.test.ts b/packages/mcp/src/tools/validate-scene.test.ts new file mode 100644 index 00000000..aac1a3bd --- /dev/null +++ b/packages/mcp/src/tools/validate-scene.test.ts @@ -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) + }) +}) diff --git a/packages/mcp/src/tools/validate-scene.ts b/packages/mcp/src/tools/validate-scene.ts new file mode 100644 index 00000000..1a095aa2 --- /dev/null +++ b/packages/mcp/src/tools/validate-scene.ts @@ -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, + } + }, + ) +}