fix(mcp): add shared operations and secure scene APIs

This commit is contained in:
Aymeric Rabot
2026-04-27 17:10:25 -04:00
parent 058ee747c9
commit 8dfd1a9429
81 changed files with 1332 additions and 1786 deletions
+4 -8
View File
@@ -1,8 +1,8 @@
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 type { SceneStore } from '../storage/types'
import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { PatchSchema } from './schemas'
@@ -17,11 +17,7 @@ export const applyPatchOutput = {
createdIds: z.array(z.string()),
}
export function registerApplyPatch(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerApplyPatch(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'apply_patch',
{
@@ -56,7 +52,7 @@ export function registerApplyPatch(
try {
const result = bridge.applyPatch(bridgePatches)
await publishLiveSceneSnapshot(bridge, store, 'apply_patch')
await publishLiveSceneSnapshot(bridge, 'apply_patch')
const payload = {
appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[],
+2 -2
View File
@@ -2,7 +2,7 @@ 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 type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas'
export const checkCollisionsInput = {
@@ -38,7 +38,7 @@ 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 {
export function registerCheckCollisions(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'check_collisions',
{
@@ -4,8 +4,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LevelNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerSceneQueryTools } from './scene-query'
import { registerConstructionTools } from './construction-tools'
import { registerSceneQueryTools } from './scene-query'
describe('construction tools', () => {
let client: Client
+15 -18
View File
@@ -11,8 +11,7 @@ import {
WallNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
@@ -107,17 +106,15 @@ function textResult<T extends Record<string, unknown>>(payload: T) {
}
}
function assertNode(bridge: SceneBridge, id: string, type: AnyNode['type']): AnyNode {
function assertNode(bridge: SceneOperations, id: string, type: AnyNode['type']): AnyNode {
const node = bridge.getNode(id as AnyNodeId)
if (!node) throw new Error(`${type} not found: ${id}`)
if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`)
return node
}
function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId {
const building = bridge
.getAncestry(levelId as AnyNodeId)
.find((node) => node.type === 'building')
function getBuildingIdForLevel(bridge: SceneOperations, levelId: string): AnyNodeId {
const building = bridge.getAncestry(levelId as AnyNodeId).find((node) => node.type === 'building')
if (!building) {
throw new Error(`Building ancestor not found for level: ${levelId}`)
}
@@ -134,7 +131,11 @@ function isRoofLevel(level: AnyNode): boolean {
)
}
function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLevel: AnyNode): number {
function nextLevelIndex(
bridge: SceneOperations,
buildingId: AnyNodeId,
referenceLevel: AnyNode,
): number {
const existing = bridge
.getChildren(buildingId)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level')
@@ -144,14 +145,14 @@ function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLev
return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate
}
function nodesOnLevel(bridge: SceneBridge, levelId: string): AnyNode[] {
function nodesOnLevel(bridge: SceneOperations, levelId: string): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
}
function firstNodeOnLevel(
bridge: SceneBridge,
bridge: SceneOperations,
levelId: string,
type: 'slab' | 'ceiling',
): AnyNode | null {
@@ -204,11 +205,7 @@ function withHole(
} as Partial<AnyNode>
}
export function registerConstructionTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerConstructionTools(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_story_shell',
{
@@ -285,7 +282,7 @@ export function registerConstructionTools(
}
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_story_shell')
await publishLiveSceneSnapshot(bridge, 'create_story_shell')
return textResult({
levelId,
wallIds,
@@ -379,7 +376,7 @@ export function registerConstructionTools(
{ op: 'create', node: roof, parentId: targetRoofLevelId },
{ op: 'create', node: segment, parentId: roof.id as AnyNodeId },
])
await publishLiveSceneSnapshot(bridge, store, 'create_roof')
await publishLiveSceneSnapshot(bridge, 'create_roof')
return textResult({
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
@@ -500,7 +497,7 @@ export function registerConstructionTools(
}
bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels')
await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels')
return textResult({
stairId: stair.id,
stairSegmentId: segment.id,
+3 -8
View File
@@ -2,8 +2,7 @@ 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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -19,11 +18,7 @@ export const createLevelOutput = {
levelId: z.string(),
}
export function registerCreateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_level',
{
@@ -57,7 +52,7 @@ export function registerCreateLevel(
})
const id = bridge.createNode(levelNode, buildingId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_level')
await publishLiveSceneSnapshot(bridge, 'create_level')
const payload = { levelId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+4 -2
View File
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../bridge/scene-bridge'
import { createSceneOperations } from '../operations'
import type { SceneMeta, SceneStore } from '../storage/types'
import { registerCreateWall } from './create-wall'
@@ -97,8 +98,9 @@ describe('create_wall', () => {
}
const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' })
const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' })
bridge.setActiveScene(savedMeta)
registerCreateWall(liveServer, bridge, store)
const operations = createSceneOperations({ bridge, store })
operations.setActiveScene(savedMeta)
registerCreateWall(liveServer, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)])
+3 -8
View File
@@ -2,8 +2,7 @@ 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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -20,11 +19,7 @@ export const createWallOutput = {
wallId: z.string(),
}
export function registerCreateWall(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateWall(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_wall',
{
@@ -64,7 +59,7 @@ export function registerCreateWall(
...(height !== undefined ? { height } : {}),
})
const id = bridge.createNode(wall, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_wall')
await publishLiveSceneSnapshot(bridge, 'create_wall')
const payload = { wallId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+3 -8
View File
@@ -2,8 +2,7 @@ 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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { wallLength, wallLocalXFromT } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
@@ -21,11 +20,7 @@ export const cutOpeningOutput = {
openingId: z.string(),
}
export function registerCutOpening(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCutOpening(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'cut_opening',
{
@@ -73,7 +68,7 @@ export function registerCutOpening(
position: [base.position[0], 0.9 + height / 2, 0],
})
const id = bridge.createNode(opening, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'cut_opening')
await publishLiveSceneSnapshot(bridge, 'cut_opening')
const payload = { openingId: id as string }
return {
+3 -8
View File
@@ -1,8 +1,7 @@
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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -16,11 +15,7 @@ export const deleteNodeOutput = {
deletedIds: z.array(z.string()),
}
export function registerDeleteNode(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerDeleteNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'delete_node',
{
@@ -37,7 +32,7 @@ export function registerDeleteNode(
}
try {
const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false)
await publishLiveSceneSnapshot(bridge, store, 'delete_node')
await publishLiveSceneSnapshot(bridge, 'delete_node')
const payload = { deletedIds: removed }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,7 +1,7 @@
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 type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -59,7 +59,7 @@ function describe(node: AnyNode): string {
}
}
export function registerDescribeNode(server: McpServer, bridge: SceneBridge): void {
export function registerDescribeNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'describe_node',
{
+4 -8
View File
@@ -2,8 +2,8 @@ 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 type { SceneStore } from '../storage/types'
import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -17,11 +17,7 @@ export const duplicateLevelOutput = {
newNodeIds: z.array(z.string()),
}
export function registerDuplicateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerDuplicateLevel(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'duplicate_level',
{
@@ -63,7 +59,7 @@ export function registerDuplicateLevel(
})
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'duplicate_level')
await publishLiveSceneSnapshot(bridge, 'duplicate_level')
const payload = {
newLevelId: newLevelId as string,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const exportGlbInput = {}
@@ -9,7 +9,7 @@ export const exportGlbOutput = {
reason: z.string(),
}
export function registerExportGlb(server: McpServer, _bridge: SceneBridge): void {
export function registerExportGlb(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'export_glb',
{
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const exportJsonInput = {
pretty: z.boolean().optional(),
@@ -10,7 +10,7 @@ export const exportJsonOutput = {
json: z.string(),
}
export function registerExportJson(server: McpServer, bridge: SceneBridge): void {
export function registerExportJson(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'export_json',
{
+2 -2
View File
@@ -2,7 +2,7 @@ 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 type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas'
const ALL_NODE_TYPES = [
@@ -68,7 +68,7 @@ function getPointForZoneFilter(node: AnyNode): [number, number] | null {
return null
}
export function registerFindNodes(server: McpServer, bridge: SceneBridge): void {
export function registerFindNodes(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'find_nodes',
{
+2 -2
View File
@@ -1,7 +1,7 @@
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 type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -13,7 +13,7 @@ export const getNodeOutput = {
node: z.record(z.string(), z.unknown()),
}
export function registerGetNode(server: McpServer, bridge: SceneBridge): void {
export function registerGetNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_node',
{
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const getSceneInput = {}
@@ -10,7 +10,7 @@ export const getSceneOutput = {
collections: z.record(z.string(), z.unknown()).optional(),
}
export function registerGetScene(server: McpServer, bridge: SceneBridge): void {
export function registerGetScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_scene',
{
+30 -31
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { registerApplyPatch } from './apply-patch'
import { registerCheckCollisions } from './check-collisions'
import { registerConstructionTools } from './construction-tools'
@@ -34,35 +33,35 @@ import { registerVariantTools } from './variants'
* separately via `registerVisionTools` (Agent E).
*
* Scene-lifecycle tools (save/load/list/delete/rename scene) are registered
* when a `store` is provided; callers that pass `undefined` skip them.
* when persistence operations are available.
*/
export function registerTools(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
registerGetScene(server, bridge)
registerGetNode(server, bridge)
registerDescribeNode(server, bridge)
registerFindNodes(server, bridge)
registerSceneQueryTools(server, bridge)
registerMeasure(server, bridge)
registerConstructionTools(server, bridge, store)
registerRoomTools(server, bridge, store)
registerApplyPatch(server, bridge, store)
registerCreateLevel(server, bridge, store)
registerCreateWall(server, bridge, store)
registerPlaceItem(server, bridge, store)
registerCutOpening(server, bridge, store)
registerSetZone(server, bridge, store)
registerDuplicateLevel(server, bridge, store)
registerDeleteNode(server, bridge, store)
registerUndo(server, bridge, store)
registerRedo(server, bridge, store)
registerExportJson(server, bridge)
registerExportGlb(server, bridge)
registerValidateScene(server, bridge)
registerCheckCollisions(server, bridge)
registerTemplateTools(server, bridge, store)
if (store) {
registerSceneLifecycleTools(server, bridge, store)
registerVariantTools(server, bridge, store)
registerPhotoToSceneTool(server, bridge, store)
export function registerTools(server: McpServer, operations: SceneOperations): void {
registerGetScene(server, operations)
registerGetNode(server, operations)
registerDescribeNode(server, operations)
registerFindNodes(server, operations)
registerSceneQueryTools(server, operations)
registerMeasure(server, operations)
registerConstructionTools(server, operations)
registerRoomTools(server, operations)
registerApplyPatch(server, operations)
registerCreateLevel(server, operations)
registerCreateWall(server, operations)
registerPlaceItem(server, operations)
registerCutOpening(server, operations)
registerSetZone(server, operations)
registerDuplicateLevel(server, operations)
registerDeleteNode(server, operations)
registerUndo(server, operations)
registerRedo(server, operations)
registerExportJson(server, operations)
registerExportGlb(server, operations)
registerValidateScene(server, operations)
registerCheckCollisions(server, operations)
registerTemplateTools(server, operations)
if (operations.hasStore) {
registerSceneLifecycleTools(server, operations)
registerVariantTools(server, operations)
registerPhotoToSceneTool(server, operations)
}
}
+16 -22
View File
@@ -1,13 +1,13 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings'
import type { SceneBridge } from '../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../storage/types'
import type { SceneOperations } from '../operations'
import { SceneVersionConflictError } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
export function syncDerivedStairOpenings(bridge: SceneBridge): number {
const updates = syncAutoStairOpenings(bridge.getNodes())
export function syncDerivedStairOpenings(operations: SceneOperations): number {
const updates = syncAutoStairOpenings(operations.getNodes())
if (updates.length === 0) return 0
bridge.applyPatch(
operations.applyPatch(
updates.map((update) => ({
op: 'update' as const,
id: update.id,
@@ -23,24 +23,18 @@ export function syncDerivedStairOpenings(bridge: SceneBridge): number {
* bound to a saved scene.
*/
export async function publishLiveSceneSnapshot(
bridge: SceneBridge,
store: SceneStore | undefined,
operations: SceneOperations,
kind: string,
): Promise<void> {
syncDerivedStairOpenings(bridge)
syncDerivedStairOpenings(operations)
const active = bridge.getActiveScene()
if (!active || !store?.appendSceneEvent) return
const active = operations.getActiveScene()
if (!active || !operations.canAppendSceneEvents) return
const exported = bridge.exportJSON()
const graph: SceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
const graph = operations.exportSceneGraph()
try {
const meta = await store.save({
const meta = await operations.saveScene({
id: active.id,
name: active.name,
projectId: active.projectId,
@@ -49,8 +43,8 @@ export async function publishLiveSceneSnapshot(
graph,
expectedVersion: active.version,
})
bridge.setActiveScene(meta)
await store.appendSceneEvent({
operations.setActiveScene(meta)
await operations.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind,
@@ -69,12 +63,12 @@ export async function publishLiveSceneSnapshot(
}
export async function appendLiveSceneEvent(
store: SceneStore,
operations: SceneOperations,
sceneId: string,
version: number,
kind: string,
graph: SceneGraph,
): Promise<void> {
if (!store.appendSceneEvent) return
await store.appendSceneEvent({ sceneId, version, kind, graph })
if (!operations.canAppendSceneEvents) return
await operations.appendSceneEvent({ sceneId, version, kind, graph })
}
+2 -2
View File
@@ -1,7 +1,7 @@
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 type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -84,7 +84,7 @@ function shoelaceArea(polygon: Array<[number, number]>): number {
return Math.abs(sum) / 2
}
export function registerMeasure(server: McpServer, bridge: SceneBridge): void {
export function registerMeasure(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'measure',
{
+5 -10
View File
@@ -1,20 +1,15 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerPhotoToScene } from './photo-to-scene'
/**
* Register the `photo_to_scene` orchestrator tool. Chains the vision
* (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph
* synthesis → optional `SceneStore.save``bridge.setScene` so callers get a
* navigable Pascal scene from a single photo upload.
* synthesis → optional scene save → bridge setScene so callers get a navigable
* Pascal scene from a single photo upload.
*/
export function registerPhotoToSceneTool(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerPhotoToScene(server, bridge, store)
export function registerPhotoToSceneTool(server: McpServer, bridge: SceneOperations): void {
registerPhotoToScene(server, bridge)
}
export {
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import { InMemorySceneStore } from '../scene-lifecycle/test-utils'
import { registerPhotoToScene } from './photo-to-scene'
@@ -22,8 +23,9 @@ async function makeWiredPair(opts: { withSampling: boolean; samplingHandler?: Ha
const bridge = new SceneBridge()
bridge.setScene({}, [])
const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerPhotoToScene(server, bridge, store)
registerPhotoToScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
@@ -11,8 +11,7 @@ import {
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { appendLiveSceneEvent } from '../live-sync'
/**
@@ -341,11 +340,7 @@ function buildSceneGraphFromVision(
}
}
export function registerPhotoToScene(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
export function registerPhotoToScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'photo_to_scene',
{
@@ -375,12 +370,12 @@ export function registerPhotoToScene(
// 4. Save or return inline.
if (save) {
const meta = await store.save({
const meta = await bridge.saveScene({
name,
graph,
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'photo_to_scene', graph)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'photo_to_scene', graph)
const payload: {
sceneId: string
url: string
+3 -8
View File
@@ -2,8 +2,7 @@ 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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { findCatalogItem } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
@@ -22,11 +21,7 @@ export const placeItemOutput = {
status: z.string().optional(),
}
export function registerPlaceItem(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerPlaceItem(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'place_item',
{
@@ -102,7 +97,7 @@ export function registerPlaceItem(
...wallExtras,
})
const id = bridge.createNode(item, parentId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'place_item')
await publishLiveSceneSnapshot(bridge, 'place_item')
const payload = {
itemId: id as string,
status: catalogAsset ? 'ok' : 'catalog_unavailable',
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
export const redoInput = {
@@ -12,7 +11,7 @@ export const redoOutput = {
redone: z.number(),
}
export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerRedo(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'redo',
{
@@ -24,7 +23,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge, store?: Sce
},
async ({ steps }) => {
const redone = bridge.redo(steps ?? 1)
if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo')
if (redone > 0) await publishLiveSceneSnapshot(bridge, 'redo')
const payload = { redone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+17 -34
View File
@@ -10,8 +10,7 @@ import {
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { findCatalogItem, searchCatalogItems } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import {
@@ -118,7 +117,7 @@ function textResult<T extends Record<string, unknown>>(payload: T) {
}
}
function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
function assertLevel(bridge: SceneOperations, levelId: string): AnyNode {
const level = bridge.getNode(levelId as AnyNodeId)
if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
if (level.type !== 'level') {
@@ -138,7 +137,7 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
return level
}
function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wall' } {
function assertWall(bridge: SceneOperations, wallId: string): AnyNode & { type: 'wall' } {
const wall = bridge.getNode(wallId as AnyNodeId)
if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`)
if (wall.type !== 'wall') {
@@ -148,7 +147,7 @@ function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wal
}
function inferRoomGeometry(
bridge: SceneBridge,
bridge: SceneOperations,
levelId: string | undefined,
polygon: Vec2[] | undefined,
zoneId: string | undefined,
@@ -385,11 +384,7 @@ export function registerSearchAssets(server: McpServer): void {
)
}
export function registerCreateRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateRoom(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_room',
{
@@ -431,7 +426,7 @@ export function registerCreateRoom(
parentId: levelId as AnyNodeId,
})),
])
await publishLiveSceneSnapshot(bridge, store, 'create_room')
await publishLiveSceneSnapshot(bridge, 'create_room')
return textResult({
zoneId: zone.id,
@@ -444,7 +439,7 @@ export function registerCreateRoom(
)
}
export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerAddDoor(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'add_door',
{
@@ -475,17 +470,13 @@ export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?:
...(swingDirection ? { swingDirection } : {}),
})
const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_door')
await publishLiveSceneSnapshot(bridge, 'add_door')
return textResult({ doorId: id, localX })
},
)
}
export function registerAddWindow(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerAddWindow(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'add_window',
{
@@ -514,17 +505,13 @@ export function registerAddWindow(
height,
})
const id = bridge.createNode(windowNode, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_window')
await publishLiveSceneSnapshot(bridge, 'add_window')
return textResult({ windowId: id, localX, sillHeight })
},
)
}
export function registerFurnishRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'furnish_room',
{
@@ -582,7 +569,7 @@ export function registerFurnishRoom(
parentId: room.levelId as AnyNodeId,
})),
)
await publishLiveSceneSnapshot(bridge, store, 'furnish_room')
await publishLiveSceneSnapshot(bridge, 'furnish_room')
}
return textResult({
@@ -594,14 +581,10 @@ export function registerFurnishRoom(
)
}
export function registerRoomTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerRoomTools(server: McpServer, bridge: SceneOperations): void {
registerSearchAssets(server)
registerCreateRoom(server, bridge, store)
registerAddDoor(server, bridge, store)
registerAddWindow(server, bridge, store)
registerFurnishRoom(server, bridge, store)
registerCreateRoom(server, bridge)
registerAddDoor(server, bridge)
registerAddWindow(server, bridge)
registerFurnishRoom(server, bridge)
}
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerDeleteScene } from './delete-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('delete_scene', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDeleteScene(server, store)
registerDeleteScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
export const deleteSceneInput = {
@@ -12,7 +13,7 @@ export const deleteSceneOutput = {
deleted: z.boolean(),
}
export function registerDeleteScene(server: McpServer, store: SceneStore): void {
export function registerDeleteScene(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'delete_scene',
{
@@ -24,7 +25,7 @@ export function registerDeleteScene(server: McpServer, store: SceneStore): void
},
async ({ id, expectedVersion }) => {
try {
const deleted = await store.delete(id, {
const deleted = await operations.deleteStoredScene(id, {
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
const payload = { deleted }
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerDeleteScene } from './delete-scene'
import { registerListScenes } from './list-scenes'
import { registerLoadScene } from './load-scene'
@@ -10,19 +9,15 @@ import { registerSaveScene } from './save-scene'
/**
* Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`,
* `list_scenes`, `delete_scene`, `rename_scene`) against the given server.
* All tools operate against the supplied `SceneStore` so tests can inject an
* in-memory implementation.
* All tools operate against shared scene operations so MCP, REST, and future CLI
* entry points share the same storage boundary.
*/
export function registerSceneLifecycleTools(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerSaveScene(server, bridge, store)
registerLoadScene(server, bridge, store)
registerListScenes(server, store)
registerDeleteScene(server, store)
registerRenameScene(server, store)
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
registerSaveScene(server, operations)
registerLoadScene(server, operations)
registerListScenes(server, operations)
registerDeleteScene(server, operations)
registerRenameScene(server, operations)
}
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerListScenes } from './list-scenes'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('list_scenes', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerListScenes(server, store)
registerListScenes(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
const DEFAULT_LIMIT = 100
@@ -27,7 +27,7 @@ export const listScenesOutput = {
),
}
export function registerListScenes(server: McpServer, store: SceneStore): void {
export function registerListScenes(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'list_scenes',
{
@@ -39,7 +39,7 @@ export function registerListScenes(server: McpServer, store: SceneStore): void {
},
async ({ projectId, limit }) => {
try {
const scenes = await store.list({
const scenes = await operations.listScenes({
...(projectId !== undefined ? { projectId } : {}),
limit: limit ?? DEFAULT_LIMIT,
})
@@ -5,7 +5,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerLoadScene } from './load-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('load_scene', () => {
let client: Client
@@ -17,8 +22,9 @@ describe('load_scene', () => {
bridge.setScene({}, [])
bridge.loadDefault()
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerLoadScene(server, bridge, store)
registerLoadScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
export const loadSceneInput = {
@@ -21,7 +20,7 @@ export const loadSceneOutput = {
nodeCount: z.number(),
}
export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'load_scene',
{
@@ -32,7 +31,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store:
outputSchema: loadSceneOutput,
},
async ({ id }) => {
const result = await store.load(id)
const result = await bridge.loadStoredScene(id)
if (!result) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id })
}
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerRenameScene } from './rename-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('rename_scene', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerRenameScene(server, store)
registerRenameScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
export const renameSceneInput = {
@@ -22,7 +23,7 @@ export const renameSceneOutput = {
nodeCount: z.number(),
}
export function registerRenameScene(server: McpServer, store: SceneStore): void {
export function registerRenameScene(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'rename_scene',
{
@@ -34,7 +35,7 @@ export function registerRenameScene(server: McpServer, store: SceneStore): void
},
async ({ id, newName, expectedVersion }) => {
try {
const meta = await store.rename(id, newName, {
const meta = await operations.renameStoredScene(id, newName, {
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
const payload = {
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerSaveScene } from './save-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('save_scene', () => {
let client: Client
@@ -16,8 +21,9 @@ describe('save_scene', () => {
bridge.setScene({}, [])
bridge.loadDefault()
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerSaveScene(server, bridge, store)
registerSaveScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { AnyNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
@@ -39,7 +39,7 @@ export const saveSceneOutput = {
url: z.string(),
}
export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'save_scene',
{
@@ -56,12 +56,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
if (!validation.valid) {
throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors })
}
const exported = bridge.exportJSON()
sceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
sceneGraph = bridge.exportSceneGraph()
} else {
if (!graph) {
throwMcpError(
@@ -96,7 +91,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
}
try {
const meta = await store.save({
const meta = await bridge.saveScene({
...(id !== undefined ? { id } : {}),
name,
...(projectId !== undefined ? { projectId } : {}),
@@ -104,7 +99,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
if (includeCurrentScene) {
bridge.setActiveScene(meta)
}
@@ -1,3 +1,5 @@
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from '../../operations'
import {
type SceneListOptions,
type SceneMeta,
@@ -15,6 +17,20 @@ export function parseToolText(content: StoredTextContent[]): Record<string, unkn
return JSON.parse(content[0]!.text) as Record<string, unknown>
}
export function createTestSceneOperations(options?: {
bridge?: SceneBridge
store?: InMemorySceneStore
}): {
bridge: SceneBridge
store: InMemorySceneStore
operations: SceneOperations
} {
const bridge = options?.bridge ?? new SceneBridge()
const store = options?.store ?? new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
return { bridge, store, operations }
}
/**
* In-memory `SceneStore` for tests. Backed by a plain `Map` keyed by id.
* Implements the full interface including optimistic concurrency via
+3 -1
View File
@@ -159,7 +159,9 @@ describe('scene query tools', () => {
)
expect(listPayload.occupiedStoryCount).toBe(2)
expect(listPayload.roofLevelIds).toEqual([roofLevel.id])
expect(listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id)).toMatchObject({
expect(
listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id),
).toMatchObject({
role: 'roof',
isSupportLevel: true,
referenceLevelId: upper.id,
+21 -15
View File
@@ -1,7 +1,7 @@
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 type { SceneOperations } from '../operations'
import {
distance2D,
pointInPolygon,
@@ -88,7 +88,7 @@ function textResult<T extends Record<string, unknown>>(payload: T) {
}
}
function getLevels(bridge: SceneBridge): AnyNode[] {
function getLevels(bridge: SceneOperations): AnyNode[] {
return bridge.findNodes({ type: 'level' }).sort((a, b) => {
const aa = a.type === 'level' ? a.level : 0
const bb = b.type === 'level' ? b.level : 0
@@ -96,13 +96,16 @@ function getLevels(bridge: SceneBridge): AnyNode[] {
})
}
function getDefaultLevelId(bridge: SceneBridge, requested?: string | undefined): AnyNodeId | null {
function getDefaultLevelId(
bridge: SceneOperations,
requested?: string | undefined,
): AnyNodeId | null {
if (requested) return requested as AnyNodeId
const level = getLevels(bridge)[0]
return (level?.id as AnyNodeId | undefined) ?? null
}
function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] {
function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
@@ -140,7 +143,7 @@ function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole {
return 'occupied'
}
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) {
return bridge
.getChildren(wallId)
.filter((child) => child.type === 'door' || child.type === 'window')
@@ -153,7 +156,7 @@ function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
}))
}
function wallSummary(bridge: SceneBridge, wall: AnyNode) {
function wallSummary(bridge: SceneOperations, wall: AnyNode) {
if (wall.type !== 'wall') return null
const length = distance2D(wall.start, wall.end)
return {
@@ -280,7 +283,10 @@ function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransfor
return transforms
}
function stairFootprintPolygons(bridge: SceneBridge, stair: AnyNode & { type: 'stair' }): Vec2[][] {
function stairFootprintPolygons(
bridge: SceneOperations,
stair: AnyNode & { type: 'stair' },
): Vec2[][] {
if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
return [
@@ -345,7 +351,7 @@ function getLevelNumber(
}
function targetLevelIdsForStair(
bridge: SceneBridge,
bridge: SceneOperations,
stair: AnyNode & { type: 'stair' },
): AnyNodeId[] {
const nodes = bridge.getNodes()
@@ -388,7 +394,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean {
})
}
function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
function levelSummary(bridge: SceneOperations, levelId: AnyNodeId) {
const level = bridge.getNode(levelId)
if (!level || level.type !== 'level') {
throw new Error(`Level not found: ${levelId}`)
@@ -454,7 +460,7 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
}
}
export function registerListLevels(server: McpServer, bridge: SceneBridge): void {
export function registerListLevels(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'list_levels',
{
@@ -497,7 +503,7 @@ export function registerListLevels(server: McpServer, bridge: SceneBridge): void
)
}
export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): void {
export function registerGetLevelSummary(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_level_summary',
{
@@ -515,7 +521,7 @@ export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge):
)
}
export function registerGetWalls(server: McpServer, bridge: SceneBridge): void {
export function registerGetWalls(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_walls',
{
@@ -536,7 +542,7 @@ export function registerGetWalls(server: McpServer, bridge: SceneBridge): void {
)
}
export function registerGetZones(server: McpServer, bridge: SceneBridge): void {
export function registerGetZones(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_zones',
{
@@ -557,7 +563,7 @@ export function registerGetZones(server: McpServer, bridge: SceneBridge): void {
)
}
export function registerVerifyScene(server: McpServer, bridge: SceneBridge): void {
export function registerVerifyScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'verify_scene',
{
@@ -817,7 +823,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
)
}
export function registerSceneQueryTools(server: McpServer, bridge: SceneBridge): void {
export function registerSceneQueryTools(server: McpServer, bridge: SceneOperations): void {
registerListLevels(server, bridge)
registerGetLevelSummary(server, bridge)
registerGetWalls(server, bridge)
+3 -4
View File
@@ -2,8 +2,7 @@ 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 type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -19,7 +18,7 @@ export const setZoneOutput = {
zoneId: z.string(),
}
export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerSetZone(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'set_zone',
{
@@ -58,7 +57,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?:
metadata: properties ?? {},
})
const id = bridge.createNode(zone, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'set_zone')
await publishLiveSceneSnapshot(bridge, 'set_zone')
const payload = { zoneId: id as string }
return {
@@ -2,9 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
@@ -22,9 +21,10 @@ export const createFromTemplateInput = {
.optional()
.describe('Optional display name for the saved scene. Defaults to the template name.'),
/**
* When a `SceneStore` is wired into the MCP server, set this flag to `true`
* to immediately save the instantiated template and return its `SceneMeta`.
* When `false` (default) the template is applied to the bridge only.
* When persistence operations are wired into the MCP server, set this flag to
* `true` to immediately save the instantiated template and return its
* `SceneMeta`. When `false` (default) the template is applied to the bridge
* only.
*/
save: z.boolean().default(false),
projectId: z.string().optional(),
@@ -54,17 +54,13 @@ export const createFromTemplateOutput = {
/**
* `create_from_template` — instantiate a seed template into the bridge, and
* optionally persist it via the attached `SceneStore`.
* optionally persist it via the attached scene operations.
*
* The source template is cloned with fresh ids (`cloneSceneGraph`) so the
* deterministic placeholders (`site_empty`, `wall_n`, …) don't collide
* across repeated calls or with other scenes.
*/
export function registerCreateFromTemplate(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateFromTemplate(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_from_template',
{
@@ -112,7 +108,7 @@ export function registerCreateFromTemplate(
}
}
if (!store) {
if (!bridge.hasStore) {
// Graceful no-store mode: report that save was skipped rather than
// erroring — this makes the tool usable in headless bridge-only
// deployments (tests, smoke scripts) without crashing.
@@ -125,13 +121,13 @@ export function registerCreateFromTemplate(
}
try {
const meta = await store.save({
const meta = await bridge.saveScene({
name: name ?? entry.name,
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', {
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
nodes,
rootNodeIds,
})
+5 -11
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerCreateFromTemplate } from './create-from-template'
import { registerListTemplates } from './list-templates'
@@ -8,17 +7,12 @@ import { registerListTemplates } from './list-templates'
* Register the template MCP tools (`list_templates`, `create_from_template`)
* against the given server.
*
* `store` is optional: when omitted, `create_from_template` still applies the
* template to the bridge but skips the save step. This makes the tool safe
* to wire into headless bridge-only deployments.
* When persistence operations are unavailable, `create_from_template` still
* applies the template to the bridge but skips the save step.
*/
export function registerTemplateTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
registerListTemplates(server)
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, bridge)
}
export {
@@ -3,6 +3,7 @@ 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 { createSceneOperations } from '../../operations'
import {
InMemorySceneStore,
parseToolText,
@@ -59,8 +60,9 @@ describe('create_from_template', () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -138,9 +140,10 @@ describe('create_from_template without a store', () => {
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
const operations = createSceneOperations({ bridge })
const server = new McpServer({ name: 'test', version: '0.0.0' })
// No store passed → save should be gracefully skipped.
registerCreateFromTemplate(server, bridge)
// No store in operations → save should be gracefully skipped.
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
export const undoInput = {
@@ -12,7 +11,7 @@ export const undoOutput = {
undone: z.number(),
}
export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerUndo(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'undo',
{
@@ -24,7 +23,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge, store?: Sce
},
async ({ steps }) => {
const undone = bridge.undo(steps ?? 1)
if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo')
if (undone > 0) await publishLiveSceneSnapshot(bridge, 'undo')
const payload = { undone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const validateSceneInput = {}
@@ -15,7 +15,7 @@ export const validateSceneOutput = {
),
}
export function registerValidateScene(server: McpServer, bridge: SceneBridge): void {
export function registerValidateScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'validate_scene',
{
@@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import {
InMemorySceneStore,
parseToolText,
@@ -56,8 +57,9 @@ async function setup(): Promise<{
bridge.setScene({}, [])
bridge.loadDefault()
const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerGenerateVariants(server, bridge, store)
registerGenerateVariants(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations'
@@ -93,17 +92,13 @@ function countInvalidNodes(graph: SceneGraph): number {
return invalid
}
export function registerGenerateVariants(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
export function registerGenerateVariants(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'generate_variants',
{
title: 'Generate variants',
description:
'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via the SceneStore and returned with an id + URL; otherwise the graph is returned inline.',
'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via scene operations and returned with an id + URL; otherwise the graph is returned inline.',
inputSchema: generateVariantsInput,
outputSchema: generateVariantsOutput,
},
@@ -112,19 +107,14 @@ export function registerGenerateVariants(
let base: SceneGraph
let baseName = 'scene'
if (baseSceneId) {
const loaded = await store.load(baseSceneId)
const loaded = await bridge.loadStoredScene(baseSceneId)
if (!loaded) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId })
}
base = loaded.graph
baseName = loaded.name
} else {
const exported = bridge.exportJSON()
base = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
base = bridge.exportSceneGraph()
}
// 2. Seed the RNG. Default seed is a time-ish number so runs vary, but
@@ -167,7 +157,7 @@ export function registerGenerateVariants(
if (save) {
try {
const meta = await store.save({
const meta = await bridge.saveScene({
name: `${baseName}-variant-${i + 1}`,
graph: forked,
})
+4 -11
View File
@@ -1,19 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerGenerateVariants } from './generate-variants'
/**
* Register the variant-generation MCP tools against the given server. Uses the
* supplied `SceneStore` both to load a `baseSceneId` (when provided) and to
* persist variants when `save=true`.
* Register the variant-generation MCP tools against shared scene operations.
*/
export function registerVariantTools(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerGenerateVariants(server, bridge, store)
export function registerVariantTools(server: McpServer, bridge: SceneOperations): void {
registerGenerateVariants(server, bridge)
}
export {
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
/**
* Input shape for `analyze_floorplan_image`.
@@ -118,7 +118,7 @@ function extractText(
return texts.join('\n').trim()
}
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneBridge): void {
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'analyze_floorplan_image',
{
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
/**
* Input shape for `analyze_room_photo`.
@@ -99,7 +99,7 @@ function extractText(
return texts.join('\n').trim()
}
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneBridge): void {
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'analyze_room_photo',
{
+4 -4
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
import { registerAnalyzeFloorplanImage } from './analyze-floorplan-image'
import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
@@ -9,9 +9,9 @@ import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
* not advertise `sampling` support, calling either tool returns
* `sampling_unavailable`.
*/
export function registerVisionTools(server: McpServer, bridge: SceneBridge): void {
registerAnalyzeFloorplanImage(server, bridge)
registerAnalyzeRoomPhoto(server, bridge)
export function registerVisionTools(server: McpServer, operations: SceneOperations): void {
registerAnalyzeFloorplanImage(server, operations)
registerAnalyzeRoomPhoto(server, operations)
}
export {