Fixes breakage introduced by #320 which changed `SiteNode.children` from embedded `BuildingNode | ItemNode` objects to flat `string[]` IDs. - `packages/mcp/src/lib/rehydrate-site-children.ts`: replace now-obsolete re-embedding logic with a no-op passthrough (call-site compatible) - `packages/mcp/src/tools/variants/generate-variants.ts`: drop the inline copy of the same function and its call - `packages/nodes/src/site/renderer.tsx`: cast `childId as AnyNodeId` since `SiteNode.children` is now `string[]`, not `AnyNodeId[]` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
149 lines
4.8 KiB
TypeScript
149 lines
4.8 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
|
import { AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
|
|
import { z } from 'zod'
|
|
import type { SceneOperations } from '../../operations'
|
|
import { ErrorCode, throwMcpError } from '../errors'
|
|
import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations'
|
|
|
|
const MUTATION_KINDS = [
|
|
'wall-thickness',
|
|
'wall-height',
|
|
'zone-labels',
|
|
'room-proportions',
|
|
'open-plan',
|
|
'door-positions',
|
|
'fence-style',
|
|
] as const
|
|
|
|
export const generateVariantsInput = {
|
|
baseSceneId: z
|
|
.string()
|
|
.optional()
|
|
.describe('If set, fork from this saved scene; else fork from current bridge state.'),
|
|
count: z.number().int().min(1).max(10).default(3),
|
|
vary: z.array(z.enum(MUTATION_KINDS)).min(1).default(['wall-thickness', 'wall-height']),
|
|
seed: z.number().int().optional().describe('Deterministic RNG seed.'),
|
|
save: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe('If true, also save each variant via SceneStore and return ids.'),
|
|
}
|
|
|
|
export const generateVariantsOutput = {
|
|
variants: z.array(
|
|
z.object({
|
|
index: z.number(),
|
|
description: z.string(),
|
|
nodeCount: z.number(),
|
|
sceneId: z.string().optional(),
|
|
url: z.string().optional(),
|
|
graph: z.any().optional(),
|
|
}),
|
|
),
|
|
}
|
|
|
|
/**
|
|
* Count how many nodes in a graph fail `AnyNode` validation. Used to keep the
|
|
* tool from returning silently corrupt variants.
|
|
*/
|
|
function countInvalidNodes(graph: SceneGraph): number {
|
|
let invalid = 0
|
|
for (const node of Object.values(graph.nodes)) {
|
|
if (!AnyNodeSchema.safeParse(node).success) invalid++
|
|
}
|
|
return invalid
|
|
}
|
|
|
|
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 scene operations and returned with an id + URL; otherwise the graph is returned inline.',
|
|
inputSchema: generateVariantsInput,
|
|
outputSchema: generateVariantsOutput,
|
|
},
|
|
async ({ baseSceneId, count, vary, seed, save }) => {
|
|
// 1. Obtain the base SceneGraph.
|
|
let base: SceneGraph
|
|
let baseName = 'scene'
|
|
if (baseSceneId) {
|
|
const loaded = await bridge.loadStoredScene(baseSceneId)
|
|
if (!loaded) {
|
|
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId })
|
|
}
|
|
base = loaded.graph
|
|
baseName = loaded.name
|
|
} else {
|
|
base = bridge.exportSceneGraph()
|
|
}
|
|
|
|
// 2. Seed the RNG. Default seed is a time-ish number so runs vary, but
|
|
// tests always pass a fixed seed for determinism.
|
|
const initialSeed = seed ?? Math.floor(Math.random() * 0xff_ff_ff_ff)
|
|
|
|
const mutations = vary as MutationKind[]
|
|
const variants: Array<{
|
|
index: number
|
|
description: string
|
|
nodeCount: number
|
|
sceneId?: string
|
|
url?: string
|
|
graph?: SceneGraph
|
|
}> = []
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
// Each variant gets its own RNG stream derived from (seed + i) so
|
|
// results are deterministic per-index.
|
|
const rng = mulberry32(initialSeed + i)
|
|
|
|
let forked: SceneGraph = forkSceneGraph(base)
|
|
for (const kind of mutations) {
|
|
forked = applyMutation(forked, rng, kind)
|
|
}
|
|
|
|
const invalidCount = countInvalidNodes(forked)
|
|
if (invalidCount > 0) {
|
|
throwMcpError(
|
|
ErrorCode.InternalError,
|
|
`variant_invalid: variant ${i} produced ${invalidCount} invalid node(s)`,
|
|
{ index: i },
|
|
)
|
|
}
|
|
|
|
const nodeCount = Object.keys(forked.nodes).length
|
|
const description = describeVariant(forked, mutations)
|
|
|
|
if (save) {
|
|
try {
|
|
const meta = await bridge.saveScene({
|
|
name: `${baseName}-variant-${i + 1}`,
|
|
graph: forked,
|
|
})
|
|
variants.push({
|
|
index: i,
|
|
description,
|
|
nodeCount,
|
|
sceneId: meta.id,
|
|
url: `/scene/${meta.id}`,
|
|
})
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
throwMcpError(ErrorCode.InternalError, `save_failed: ${msg}`, { index: i })
|
|
}
|
|
} else {
|
|
variants.push({ index: i, description, nodeCount, graph: forked })
|
|
}
|
|
}
|
|
|
|
const payload = { variants }
|
|
return {
|
|
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
|
structuredContent: payload,
|
|
}
|
|
},
|
|
)
|
|
}
|