166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
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<AnyNodeId, AnyNode>
|
|
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}`)
|
|
}
|
|
},
|
|
)
|
|
}
|