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 { rehydrateSiteChildren } from '../../lib/rehydrate-site-children' import type { SceneOperations } from '../../operations' import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates' import { ErrorCode, throwMcpError } from '../errors' import { appendLiveSceneEvent } from '../live-sync' import { currentLevelContext, sceneMetaPayload } from '../scene-lifecycle/metadata' export const createFromTemplateInput = { id: z .string() .describe( 'Template id (see `list_templates`). Currently one of: "empty-studio", "two-bedroom", "garden-house".', ), name: z .string() .min(1) .max(200) .optional() .describe('Optional display name for the saved scene. Defaults to the template name.'), /** * 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(), } export const createFromTemplateOutput = { templateId: z.string(), rootNodeIds: z.array(z.string()), nodeCount: z.number(), /** Present when `save: true` (and a store was available). */ scene: z .object({ id: z.string(), name: z.string(), projectId: z.string().nullable(), thumbnailUrl: z.string().nullable(), version: z.number(), createdAt: z.string(), updatedAt: z.string(), ownerId: z.string().nullable(), sizeBytes: z.number(), nodeCount: z.number(), url: z.string(), editorUrl: z.string(), published: z.boolean(), isDraft: z.boolean(), saveMode: z.enum(['draft', 'checkpoint']), graphHash: z.string().optional(), levelIds: z.array(z.string()), defaultLevelId: z.string().nullable(), }) .optional(), } /** * `create_from_template` — instantiate a seed template into the bridge, and * 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: SceneOperations): void { server.registerTool( 'create_from_template', { title: 'Create scene from template', description: 'Instantiate a seed Pascal scene template into the bridge. Regenerates all ids before applying. When `save: true` and a SceneStore is wired, also persists the new scene and returns the SceneMeta.', inputSchema: createFromTemplateInput, outputSchema: createFromTemplateOutput, }, async ({ id, name, save, projectId }) => { if (!isTemplateId(id)) { throwMcpError( ErrorCode.InvalidParams, `unknown_template: ${id}. Call list_templates for the set of valid ids.`, ) } const entry = TEMPLATES[id as TemplateId] // Clone: regenerate ids so each instantiation is independent. // `cloneSceneGraph` flattens SiteNode.children to string ids; rehydrate // them back to embedded objects to satisfy the SiteNode schema (see // CROSS_CUTTING §2). const cloned = rehydrateSiteChildren(cloneSceneGraph(entry.template)) const nodes = cloned.nodes as Record const rootNodeIds = cloned.rootNodeIds as AnyNodeId[] try { bridge.setScene(nodes, rootNodeIds) } catch (err) { const msg = err instanceof Error ? err.message : String(err) throwMcpError(ErrorCode.InternalError, `apply_failed: ${msg}`) } const basePayload = { templateId: entry.id, rootNodeIds: rootNodeIds as string[], nodeCount: Object.keys(nodes).length, } if (!save) { bridge.clearActiveScene() return { content: [{ type: 'text' as const, text: JSON.stringify(basePayload) }], structuredContent: basePayload, } } 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. bridge.clearActiveScene() const payload = { ...basePayload, saveSkipped: true } as const return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], structuredContent: payload, } } try { let saveProjectId = projectId if (!saveProjectId && bridge.canCreateProject) { const project = await bridge.createProject({ name: name ?? entry.name }) saveProjectId = project.projectId } const meta = await bridge.saveScene({ ...(saveProjectId !== undefined ? { id: saveProjectId, projectId: saveProjectId } : {}), name: name ?? entry.name, graph: { nodes, rootNodeIds }, saveMode: 'draft', publish: false, operation: 'create_from_template', }) bridge.setActiveScene(meta) await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', { nodes, rootNodeIds, }) const scene = { ...sceneMetaPayload(meta, { nodes, rootNodeIds }), ...currentLevelContext(bridge), } const payload = { ...basePayload, scene } 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.InternalError, `save_failed: ${msg}`) } }, ) }