Reset core/viewer/editor/mcp packages and apps/editor from private-editor

Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
This commit is contained in:
Wawa
2026-05-09 20:52:26 +00:00
parent 146f0a846f
commit e618175bba
196 changed files with 5899 additions and 3966 deletions
+4 -2
View File
@@ -7,6 +7,7 @@ import { parseArgs } from 'node:util'
import { SceneBridge } from '../bridge/scene-bridge'
import { version } from '../index'
import { createPascalMcpServer } from '../server'
import { createSceneStore } from '../storage'
import { connectHttp } from '../transports/http'
import { connectStdio } from '../transports/stdio'
@@ -60,11 +61,12 @@ async function main(): Promise<void> {
bridge.loadDefault()
}
const server = createPascalMcpServer({ bridge })
const store = await createSceneStore()
const server = createPascalMcpServer({ bridge, store })
if (values.http) {
const portNum = Number.parseInt(values.port ?? '3917', 10)
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65_535) {
throw new Error(`invalid --port value: ${values.port}`)
}
const handle = await connectHttp(server, portNum, {
+10
View File
@@ -0,0 +1,10 @@
export type {
ActiveSceneMeta,
CreatePatch,
DeletePatch,
Patch,
UpdatePatch,
ValidationError,
ValidationResult,
} from './scene-bridge'
export { SceneBridge } from './scene-bridge'
@@ -401,27 +401,6 @@ describe('SceneBridge', () => {
})
describe('setScene / exportJSON / loadJSON', () => {
test('setScene prunes duplicated levels that were accidentally saved as roots', () => {
const level0 = LevelNode.parse({ level: 0, children: [] })
const building = BuildingNode.parse({ children: [level0.id] })
const site = SiteNode.parse({ children: [building] })
const orphanLevel = LevelNode.parse({ level: 1, children: [] })
bridge.setScene(
{
[site.id]: site,
[building.id]: building,
[level0.id]: level0,
[orphanLevel.id]: orphanLevel,
} as any,
[site.id, orphanLevel.id] as any,
)
expect(bridge.getRootNodeIds()).toEqual([site.id])
expect(bridge.getNode(orphanLevel.id)).toBeNull()
expect(bridge.findNodes({ type: 'level' }).map((node) => node.id)).toEqual([level0.id])
})
test('exportJSON returns the scene shape', () => {
const exp = bridge.exportJSON()
expect(typeof exp.nodes).toBe('object')
+1 -1
View File
@@ -493,7 +493,7 @@ export class SceneBridge {
private _findParentByChildrenScan(id: AnyNodeId): AnyNode | null {
const nodes = useScene.getState().nodes
for (const candidate of Object.values(nodes)) {
if (!('children' in candidate) || !Array.isArray(candidate.children)) continue
if (!('children' in candidate && Array.isArray(candidate.children))) continue
for (const child of candidate.children as unknown[]) {
let childId: string | null = null
if (typeof child === 'string') childId = child
@@ -2,6 +2,8 @@ import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema'
import type { ActiveSceneMeta, Patch, SceneBridge, ValidationResult } from '../bridge/scene-bridge'
import type {
ProjectCreateOptions,
ProjectStatus,
SceneEvent,
SceneEventAppendOptions,
SceneEventListOptions,
@@ -24,6 +26,8 @@ export interface SceneOperations {
readonly hasSceneEvents: boolean
readonly canAppendSceneEvents: boolean
readonly canListSceneEvents: boolean
readonly canCreateProject: boolean
readonly canGetProjectStatus: boolean
readonly storeBackend: SceneStore['backend'] | null
setActiveScene(meta: ActiveSceneMeta): void
@@ -60,6 +64,8 @@ export interface SceneOperations {
getHistory(): { pastCount: number; futureCount: number }
clearHistory(): void
createProject(options: ProjectCreateOptions): Promise<ProjectStatus>
getProjectStatus(id: string): Promise<ProjectStatus | null>
saveScene(options: SceneSaveOptions): Promise<SceneMeta>
loadStoredScene(id: string): Promise<SceneWithGraph | null>
listScenes(options?: SceneListOptions): Promise<SceneMeta[]>
@@ -102,6 +108,14 @@ class SceneOperationsFacade implements SceneOperations {
return typeof this.#store?.listSceneEvents === 'function'
}
get canCreateProject(): boolean {
return typeof this.#store?.createProject === 'function'
}
get canGetProjectStatus(): boolean {
return typeof this.#store?.getProjectStatus === 'function'
}
get storeBackend(): SceneStore['backend'] | null {
return this.#store?.backend ?? null
}
@@ -219,6 +233,44 @@ class SceneOperationsFacade implements SceneOperations {
this.requireBridge().clearHistory()
}
async createProject(options: ProjectCreateOptions): Promise<ProjectStatus> {
const store = this.requireStore()
if (!store.createProject) {
throw new Error('create_project_unavailable')
}
return store.createProject(options)
}
async getProjectStatus(id: string): Promise<ProjectStatus | null> {
const store = this.requireStore()
if (store.getProjectStatus) {
return store.getProjectStatus(id)
}
const scene = await store.load(id)
if (!scene) return null
const editorUrl = scene.editorUrl ?? `/editor/${scene.id}`
return {
id: scene.id,
projectId: scene.projectId ?? scene.id,
name: scene.name,
editorUrl,
url: editorUrl,
ownerId: scene.ownerId,
thumbnailUrl: scene.thumbnailUrl,
publishedVersion: scene.published === false ? null : scene.version,
latestVersion: scene.version,
draftVersion: null,
browserVisibleVersion: scene.version,
version: scene.version,
isEmpty: scene.nodeCount === 0,
sizeBytes: scene.sizeBytes,
nodeCount: scene.nodeCount,
graphHash: scene.graphHash ?? null,
createdAt: scene.createdAt,
updatedAt: scene.updatedAt,
}
}
async saveScene(options: SceneSaveOptions): Promise<SceneMeta> {
return this.requireStore().save(options)
}
+3 -2
View File
@@ -6,13 +6,14 @@ import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
'You are a Pascal 3D scene designer.',
'You have access to semantic scene tools and the lower-level `apply_patch` tool. Prefer semantic construction/room/opening/furnishing tools for architectural work, and use `apply_patch` for bulk graph edits that need exact control.',
'If the user asks for a new project, call `create_project` before building. Use `create_house_from_brief` for a fast starter, then refine with semantic tools. Semantic tools update the browser-visible draft; call `save_scene` with `saveMode: "checkpoint"` only for meaningful milestones, then call `verify_scene` and `get_project_status`, and return the final `editorUrl`.',
'Build incrementally with visible progress. Starting from an empty scene, first create/load a Site and Building, then create occupied Levels and `create_story_shell` once per story before detailed rooms, openings, furniture, a dedicated roof level via `create_roof`, and landscaping.',
'Respect these invariants:',
' - Levels live under a Building.',
' - Walls, fences, zones, slabs, ceilings, roofs, stairs live under a Level.',
' - Multi-story exterior walls are per-level story walls; never make lower-level walls taller to stand in for upper-level walls.',
' - Doors and windows live under a Wall (parentId = wallId).',
' - Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling; outdoor items can live under a Site.',
' - Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling. Do not place items directly under the Site node.',
'Use realistic dimensions in meters. Keep wall thickness small (0.10.3 m) and ceiling height 2.43.0 m unless the brief dictates otherwise.',
SCENE_DESIGN_GUIDANCE,
'Respond ONLY with tool calls. Do not produce verbose narrative or prose; keep any explanations in short tool-call arguments.',
@@ -32,7 +33,7 @@ export function buildFromBriefPrompt(args: {
parts.push(
'',
'## Task',
'Produce tool calls that realise the brief within the stated constraints. Start from an empty site. Prefer create_story_shell/create_room/add_door/add_window/create_stair_between_levels/create_roof/furnish_room for architectural layout, use apply_patch for exact bulk graph work, and call validate_scene plus verify_scene after complex layouts.',
'Produce tool calls that realise the brief within the stated constraints. For a new project, call create_project first. Use create_house_from_brief for a fast starter or prefer create_story_shell/create_room/add_door/add_window/create_stair_between_levels/create_roof/furnish_room for architectural layout, use apply_patch for exact bulk graph work, call save_scene with saveMode: "checkpoint" only when the design reaches a meaningful milestone, then call validate_scene, verify_scene, and get_project_status.',
)
return parts.join('\n')
}
+66 -34
View File
@@ -2,58 +2,90 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../operations'
export const AGENT_GUIDE = [
'# Pascal MCP agent guide',
'# Pascal MCP Agent Guide',
'',
'Use this guide before inspecting application source code. The MCP surface is intended to expose the construction contract an agent needs for normal scene editing.',
'You are editing Pascal architectural projects. Use MCP tools only; do not inspect the Pascal repository unless the user explicitly asks.',
'',
'## Fast visible-progress workflow',
'## Standard Workflow',
'',
'1. Query `pascal://scene/current/summary` or `list_levels` to orient yourself.',
'2. Create visible massing first: `create_level` as needed, then `create_story_shell` once per story.',
'3. Add room semantics next: zones/rooms, interior walls, slabs, and ceilings. Prefer `create_room` for simple rooms and `apply_patch` only for exact multi-room partitions.',
'4. Add circulation and envelope details: `create_stair_between_levels`, then `add_door` and `add_window`.',
'5. Add `create_roof`, furniture with `furnish_room`/`place_item`, and exterior features such as fences, patios, driveways, lawns, and garden zones.',
'6. Run `validate_scene` and `verify_scene`; fix issues before handing off.',
'1. Read this guide or call `get_capabilities` if available.',
'2. If the user asks for a new project, call `create_project` first.',
'3. For quick starts, call `create_house_from_brief`. For precise edits, build with semantic tools: `create_story_shell`, `create_room`, `add_door`, `add_window`, `furnish_room`, `create_roof`, `place_item`.',
'4. Let semantic tools update the browser-visible draft. Call `save_scene` with `saveMode: "draft"` for autosave-style progress, or `saveMode: "checkpoint"` only for meaningful milestones.',
'5. Call `validate_scene`, `verify_scene`, then `get_project_status`.',
'6. Return the final `editorUrl` from tool output. Do not infer routes.',
'',
'This sequence lets users see a recognizable building quickly instead of waiting for one large hidden planning pass.',
'## Important Concepts',
'',
'## Construction rules',
'- A project is the browser-visible container.',
'- A scene graph is the architectural model.',
'- A draft is the browser-visible working model and may be overwritten many times.',
'- A version/checkpoint is a meaningful saved model revision.',
'- The browser editor opens the current draft when one exists, otherwise the published version.',
'- Always return the `editorUrl`, not an internal API URL.',
'',
'- Levels live under a Building.',
'- Walls, fences, zones, slabs, ceilings, roofs, and stairs live under a Level.',
'- Doors and windows live under their Wall. Use `add_door`/`add_window`; their `t` or `position` is 0..1 along the wall.',
'- Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling.',
'- For multi-story buildings, create separate level-owned exterior walls for each story. Do not make first-story walls taller to represent upper-story bearing walls.',
'- Use `create_story_shell` once per floor/story to avoid cross-level wall ownership mistakes.',
'- Use `create_stair_between_levels` for stairs. It creates a straight stair and one rectangular manual slab/ceiling opening while disabling automatic stair-opening mode, avoiding duplicate or irregular holes.',
'- Roofs are containers with roof segments and should be isolated on a dedicated roof level for solo/exploded level views. Use `create_roof`; by default it creates a roof level above the reference occupied level. Do not attach roofs directly to the top occupied floor unless explicitly requested.',
'- Story count means occupied stories, not raw level count. A two-story house may correctly have three levels when the third level has metadata role `roof`; do not delete roof/support levels to satisfy a requested story count.',
'- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.',
'## URLs',
'',
'## Scene model facts exposed here so agents do not need repo inspection',
'- Use `editorUrl` returned by tools.',
'- If a tool returns only an id, call `get_project_status` to get the browser URL.',
'- Hosted editor URLs use `/editor/<projectId>`.',
'',
'## Scene Creation Rules',
'',
'- Prefer semantic tools over raw graph patches.',
'- Do not hand-write node graphs unless no semantic tool exists.',
'- For rooms, use `create_room` -> `add_door` -> `add_window` -> `furnish_room`.',
'- For complete homes, create exterior shell, interior rooms, openings, roof, furniture, then landscaping.',
'- For doors/windows, use `t` or `position` from 0 to 1 along the wall unless a tool explicitly says otherwise.',
'- X/Z are floor-plan axes and Y is vertical; dimensions are meters.',
'- A story wall height is normally 2.4-3.0m; wall thickness is normally 0.1-0.3m.',
'- Slab and ceiling holes are polygon arrays. Manual stair openings should have `holeMetadata` with source `manual` and a single rectangular polygon.',
'- Dedicated roof levels use metadata role `roof` and normally contain the roof only; the top occupied level keeps its own walls, rooms, slabs, and ceiling.',
'- `verify_scene` reports `occupiedStoryCount`, `supportLevelCount`, and `roofLevelIds`; use those fields instead of `levelCount` when checking story-count requirements.',
'- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.',
'- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.',
'- Use `create_roof` for roofs. A dedicated roof support level is valid and should not count as an occupied story.',
'',
'## Tool preference',
'## Required Final Checks',
'',
'- Prefer semantic tools first: `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_stair_between_levels`, `create_roof`, `furnish_room`, `place_item`.',
'- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.',
'- `save_scene` must succeed. Use `saveMode: "checkpoint"` before final handoff only if the user asked for a durable version.',
'- `verify_scene.hasIssues` should be false; otherwise explain remaining issues.',
'- `get_project_status.nodeCount` must be greater than 0 for a non-empty design.',
'- Return `editorUrl` in the final user response.',
'',
'## If Something Looks Empty',
'',
'1. Call `get_project_status`.',
'2. Compare `publishedVersion`, `latestVersion`, `browserVisibleVersion`, `nodeCount`, and `graphHash`.',
'3. If the graph is non-empty but the browser appears empty, call `get_project_status` to re-bind the session, then `save_scene` with `saveMode: "draft"`.',
'4. Re-run `verify_scene`.',
'',
'## Output Contract',
'',
'Final user response should include: project name, `editorUrl`, version, node/room summary, and any known limitations.',
].join('\n')
export function registerAgentGuide(server: McpServer, _bridge: SceneOperations): void {
server.registerResource(
'agent-guide',
'pascal://agent-guide',
{
title: 'Pascal MCP agent guide',
description:
'Short MCP-first project creation, save/publish, validation, and output workflow for external agents.',
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: AGENT_GUIDE,
},
],
}),
)
server.registerResource(
'agent-guide-legacy',
'pascal://agent/guide',
{
title: 'Agent construction guide',
description:
'MCP-first construction workflow, scene invariants, and tool preferences so agents do not need to inspect the Pascal codebase.',
title: 'Pascal MCP agent guide',
description: 'Legacy URI for the Pascal MCP agent guide. Prefer pascal://agent-guide.',
mimeType: 'text/markdown',
},
async (uri) => ({
+2 -1
View File
@@ -14,7 +14,8 @@ import { registerSceneSummary } from './scene-summary'
* - `pascal://scene/current/summary` — text/markdown, human summary
* - `pascal://catalog/items` — application/json, host-supplied catalog
* - `pascal://constraints/{levelId}` — application/json, per-level constraints
* - `pascal://agent/guide` — text/markdown, MCP-first construction guide
* - `pascal://agent-guide` — text/markdown, MCP-first agent guide
* - `pascal://agent/guide` — text/markdown, legacy alias
*/
export function registerResources(server: McpServer, operations: SceneOperations): void {
registerAgentGuide(server, operations)
+19 -8
View File
@@ -201,21 +201,32 @@ describe('pascal://catalog/items', () => {
})
})
describe('pascal://agent/guide', () => {
describe('pascal://agent-guide', () => {
beforeEach(() => resetScene())
test('returns MCP-first construction guidance', async () => {
test('returns MCP-first project guidance', async () => {
const pair = await spinUp(registerAgentGuide)
try {
const res = await pair.client.readResource({ uri: 'pascal://agent/guide' })
const res = await pair.client.readResource({ uri: 'pascal://agent-guide' })
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('text/markdown')
const text = content.text ?? ''
expect(text).toContain('create_story_shell')
expect(text).toContain('create_stair_between_levels')
expect(text).toContain('dedicated roof level')
expect(text).toContain('Do not make first-story walls taller')
expect(text).toContain('Run `validate_scene` and `verify_scene`')
expect(text).toContain('create_project')
expect(text).toContain('save_scene')
expect(text).toContain('get_project_status')
expect(text).toContain('editorUrl')
expect(text).toContain('0 to 1 along the wall')
} finally {
await pair.close()
}
})
test('keeps the legacy agent guide URI as an alias', async () => {
const pair = await spinUp(registerAgentGuide)
try {
const res = await pair.client.readResource({ uri: 'pascal://agent/guide' })
const text = (res.contents[0] as { text?: string }).text ?? ''
expect(text).toContain('Pascal MCP Agent Guide')
} finally {
await pair.close()
}
+2 -2
View File
@@ -11,7 +11,7 @@ function polygonArea(poly: Poly2D): number {
for (let i = 0; i < poly.length; i++) {
const a = poly[i]
const b = poly[(i + 1) % poly.length]
if (!a || !b) continue
if (!(a && b)) continue
sum += a[0] * b[1] - b[0] * a[1]
}
return Math.abs(sum) / 2
@@ -32,7 +32,7 @@ function emptyBBox(): BBox {
}
function expandBBox(bbox: BBox, x: number, y: number, z: number): void {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return
if (!(Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z))) return
bbox.empty = false
if (x < bbox.min[0]) bbox.min[0] = x
if (y < bbox.min[1]) bbox.min[1] = y
+4 -67
View File
@@ -3,25 +3,14 @@ import type { SceneBridge } from './bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from './operations'
import { registerPrompts } from './prompts'
import { registerResources } from './resources'
import { createSceneStore } from './storage'
import type {
SceneEvent,
SceneEventAppendOptions,
SceneEventListOptions,
SceneListOptions,
SceneMeta,
SceneMutateOptions,
SceneSaveOptions,
SceneStore,
SceneWithGraph,
} from './storage/types'
import type { SceneStore } from './storage/types'
import { registerTools } from './tools'
import { registerVisionTools } from './tools/vision'
export type CreatePascalMcpServerOptions = {
bridge: SceneBridge
operations?: SceneOperations
/** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */
/** Required for persistence tools. Hosted apps and CLIs inject their own store. */
store?: SceneStore
name?: string
version?: string
@@ -32,63 +21,11 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe
name: opts.name ?? 'pascal-mcp',
version: opts.version ?? '0.1.0',
})
const store = opts.store ?? createLazySceneStore()
const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store })
const operations =
opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store })
registerTools(server, operations)
registerVisionTools(server, operations)
registerResources(server, operations)
registerPrompts(server, operations)
return server
}
/**
* Wrap `createSceneStore()` (which is async) behind a synchronous `SceneStore`
* facade so that `createPascalMcpServer` can remain synchronous. Each method
* resolves the underlying store on first use and memoizes it afterwards.
*/
function createLazySceneStore(): SceneStore {
let cached: Promise<SceneStore> | null = null
const resolve = (): Promise<SceneStore> => {
if (!cached) cached = createSceneStore()
return cached
}
return {
get backend(): 'sqlite' {
return 'sqlite'
},
async save(options: SceneSaveOptions): Promise<SceneMeta> {
const real = await resolve()
return real.save(options)
},
async load(id: string): Promise<SceneWithGraph | null> {
const real = await resolve()
return real.load(id)
},
async list(options?: SceneListOptions): Promise<SceneMeta[]> {
const real = await resolve()
return real.list(options)
},
async delete(id: string, options?: SceneMutateOptions): Promise<boolean> {
const real = await resolve()
return real.delete(id, options)
},
async rename(id: string, newName: string, options?: SceneMutateOptions): Promise<SceneMeta> {
const real = await resolve()
return real.rename(id, newName, options)
},
async appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent> {
const real = await resolve()
if (!real.appendSceneEvent) {
throw new Error('scene_events_unavailable')
}
return real.appendSceneEvent(options)
},
async listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]> {
const real = await resolve()
if (!real.listSceneEvents) {
throw new Error('scene_events_unavailable')
}
return real.listSceneEvents(id, options)
},
}
}
+128 -11
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import { mkdirSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
@@ -6,6 +7,8 @@ import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver'
import {
type ProjectCreateOptions,
type ProjectStatus,
type SceneEvent,
type SceneEventAppendOptions,
type SceneEventListOptions,
@@ -58,6 +61,15 @@ interface SceneEventRow {
graph_json: string
}
interface ProjectPlaceholder {
id: string
name: string
ownerId: string | null
thumbnailUrl: string | null
createdAt: string
updatedAt: string
}
const GraphSchema = z.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
@@ -116,6 +128,7 @@ function resolveMaxSceneBytes(
}
function rowToMeta(row: SceneRow): SceneMeta {
const editorUrl = editorUrlForScene(row.id)
return {
id: row.id,
name: row.name,
@@ -127,6 +140,66 @@ function rowToMeta(row: SceneRow): SceneMeta {
updatedAt: row.updated_at,
sizeBytes: row.size_bytes,
nodeCount: row.node_count,
editorUrl,
url: editorUrl,
published: true,
graphHash: hashGraphJson(row.graph_json),
}
}
function editorUrlForScene(id: string): string {
return `/editor/${id}`
}
function hashGraphJson(graphJson: string): string {
return createHash('sha256').update(graphJson).digest('hex')
}
function rowToProjectStatus(row: SceneRow): ProjectStatus {
const editorUrl = editorUrlForScene(row.id)
return {
id: row.id,
projectId: row.project_id ?? row.id,
name: row.name,
editorUrl,
url: editorUrl,
ownerId: row.owner_id,
thumbnailUrl: row.thumbnail_url,
publishedVersion: row.version,
latestVersion: row.version,
draftVersion: null,
browserVisibleVersion: row.version,
version: row.version,
isEmpty: row.node_count === 0,
sizeBytes: row.size_bytes,
nodeCount: row.node_count,
graphHash: hashGraphJson(row.graph_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
function placeholderToProjectStatus(project: ProjectPlaceholder): ProjectStatus {
const editorUrl = editorUrlForScene(project.id)
return {
id: project.id,
projectId: project.id,
name: project.name,
editorUrl,
url: editorUrl,
ownerId: project.ownerId,
thumbnailUrl: project.thumbnailUrl,
publishedVersion: null,
latestVersion: null,
draftVersion: null,
browserVisibleVersion: null,
version: 0,
isEmpty: true,
sizeBytes: 0,
nodeCount: 0,
graphHash: null,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
}
}
@@ -205,6 +278,7 @@ export class SqliteSceneStore implements SceneStore {
readonly databasePath: string
private readonly maxSceneBytes: number
private readonly projectPlaceholders = new Map<string, ProjectPlaceholder>()
private db: SqliteDatabase | null = null
private dbPromise: Promise<SqliteDatabase> | null = null
@@ -214,6 +288,38 @@ export class SqliteSceneStore implements SceneStore {
this.maxSceneBytes = resolveMaxSceneBytes(env, opts.maxSceneBytes)
}
async createProject(opts: ProjectCreateOptions): Promise<ProjectStatus> {
const db = await this.database()
assertValidName(opts.name)
const id = opts.id ? sanitizeSlug(opts.id) : this.generateUniqueId(db)
if (!isValidSlug(id)) {
throw new SceneInvalidError(`Invalid project id after sanitization: "${id}"`)
}
if (this.getRow(db, id)) {
throw new SceneInvalidError(`Project with id "${id}" already exists`)
}
const now = new Date().toISOString()
const project: ProjectPlaceholder = {
id,
name: opts.name,
ownerId: opts.ownerId ?? null,
thumbnailUrl: null,
createdAt: now,
updatedAt: now,
}
this.projectPlaceholders.set(id, project)
return placeholderToProjectStatus(project)
}
async getProjectStatus(id: string): Promise<ProjectStatus | null> {
const db = await this.database()
const safeId = sanitizeSlug(id)
const row = this.getRow(db, safeId)
if (row) return rowToProjectStatus(row)
const placeholder = this.projectPlaceholders.get(safeId)
return placeholder ? placeholderToProjectStatus(placeholder) : null
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
return this.withWriteTransaction((db) => {
assertValidName(opts.name)
@@ -228,6 +334,7 @@ export class SqliteSceneStore implements SceneStore {
}
const existing = this.getRow(db, id)
const placeholder = this.projectPlaceholders.get(id)
if (existing && providedId !== undefined && opts.expectedVersion === undefined) {
throw new SceneInvalidError(
@@ -254,8 +361,12 @@ export class SqliteSceneStore implements SceneStore {
const now = new Date().toISOString()
const version = (existing?.version ?? 0) + 1
const createdAt = existing?.created_at ?? now
const createdAt = existing?.created_at ?? placeholder?.createdAt ?? now
const nodeCount = Object.keys(opts.graph.nodes ?? {}).length
const projectId = opts.projectId ?? existing?.project_id ?? (placeholder ? id : null)
const ownerId = opts.ownerId ?? existing?.owner_id ?? placeholder?.ownerId ?? null
const thumbnailUrl =
opts.thumbnailUrl ?? existing?.thumbnail_url ?? placeholder?.thumbnailUrl ?? null
if (existing) {
db.query(
@@ -272,9 +383,9 @@ export class SqliteSceneStore implements SceneStore {
WHERE id = ?`,
).run(
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
projectId,
ownerId,
thumbnailUrl,
version,
now,
sizeBytes,
@@ -291,9 +402,9 @@ export class SqliteSceneStore implements SceneStore {
).run(
id,
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
projectId,
ownerId,
thumbnailUrl,
version,
createdAt,
now,
@@ -307,19 +418,25 @@ export class SqliteSceneStore implements SceneStore {
`INSERT INTO scene_revisions (
scene_id, version, graph_json, author_kind, author_id, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, version, graphJson, 'mcp', opts.ownerId ?? null, now)
).run(id, version, graphJson, 'mcp', ownerId, now)
this.projectPlaceholders.delete(id)
return {
id,
name: opts.name,
projectId: opts.projectId ?? null,
ownerId: opts.ownerId ?? null,
thumbnailUrl: opts.thumbnailUrl ?? null,
projectId,
ownerId,
thumbnailUrl,
version,
createdAt,
updatedAt: now,
sizeBytes,
nodeCount,
editorUrl: editorUrlForScene(id),
url: editorUrlForScene(id),
published: true,
graphHash: hashGraphJson(graphJson),
}
})
}
+55 -2
View File
@@ -10,7 +10,7 @@ export interface SceneMeta {
name: string
projectId: string | null
thumbnailUrl: string | null
/** Monotonic, incremented on every save. */
/** Browser-visible model version. Draft saves may update the same version repeatedly. */
version: number
/** ISO 8601 timestamp. */
createdAt: string
@@ -19,6 +19,18 @@ export interface SceneMeta {
ownerId: string | null
sizeBytes: number
nodeCount: number
/** Browser route agents should return to users. Hosted apps should prefer /editor/<projectId>. */
editorUrl?: string
/** Backward-compatible alias for clients that still read url. */
url?: string
/** True when this save is browser-visible without a separate publish call. */
published?: boolean
/** True when the saved graph is still the mutable browser-visible draft. */
isDraft?: boolean
/** How the scene was saved. Draft saves should not create meaningful history versions. */
saveMode?: SceneSaveMode
/** Stable hash of the graph payload used for save/load/status matching. */
graphHash?: string
}
export interface SceneWithGraph extends SceneMeta {
@@ -43,8 +55,18 @@ export interface SceneSaveOptions {
thumbnailUrl?: string | null
/** When set, save fails with `SceneVersionConflictError` on mismatch. */
expectedVersion?: number
/** `draft` updates the browser-visible working model; `checkpoint` records version history. */
saveMode?: SceneSaveMode
/** Whether a checkpoint should become the published/browser-visible head. */
publish?: boolean
/** Optional hosted MCP session id for project presence/debug metadata. */
agentSessionId?: string
/** Optional high-level operation name for presence/debug metadata. */
operation?: string
}
export type SceneSaveMode = 'draft' | 'checkpoint'
export interface SceneListOptions {
projectId?: string
ownerId?: string
@@ -67,8 +89,39 @@ export interface SceneEventListOptions {
limit?: number
}
export interface ProjectCreateOptions {
id?: SceneId
name: string
ownerId?: string | null
isPrivate?: boolean
}
export interface ProjectStatus {
id: SceneId
projectId: string
name: string
editorUrl: string
url: string
ownerId: string | null
thumbnailUrl: string | null
publishedVersion: number | null
latestVersion: number | null
draftVersion: number | null
browserVisibleVersion: number | null
/** Alias for the browser-visible/latest meaningful version. */
version: number
isEmpty: boolean
sizeBytes: number
nodeCount: number
graphHash: string | null
createdAt: string
updatedAt: string
}
export interface SceneStore {
readonly backend: 'sqlite'
readonly backend: 'sqlite' | 'supabase'
createProject?(opts: ProjectCreateOptions): Promise<ProjectStatus>
getProjectStatus?(id: SceneId): Promise<ProjectStatus | null>
save(opts: SceneSaveOptions): Promise<SceneMeta>
load(id: SceneId): Promise<SceneWithGraph | null>
list(opts?: SceneListOptions): Promise<SceneMeta[]>
+4 -1
View File
@@ -29,7 +29,7 @@ export async function publishLiveSceneSnapshot(
syncDerivedStairOpenings(operations)
const active = operations.getActiveScene()
if (!active || !operations.canAppendSceneEvents) return
if (!(active && operations.canAppendSceneEvents)) return
const graph = operations.exportSceneGraph()
@@ -42,6 +42,9 @@ export async function publishLiveSceneSnapshot(
thumbnailUrl: active.thumbnailUrl,
graph,
expectedVersion: active.version,
saveMode: 'draft',
publish: false,
operation: kind,
})
operations.setActiveScene(meta)
await operations.appendSceneEvent({
+1 -1
View File
@@ -129,7 +129,7 @@ export function registerMeasure(server: McpServer, bridge: SceneOperations): voi
const fromCentre = getCentre(from as AnyNode)
const toCentre = getCentre(to as AnyNode)
if (!fromCentre || !toCentre) {
if (!(fromCentre && toCentre)) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot derive centre for measurement between ${from.type} and ${to.type}`,
+3 -4
View File
@@ -27,7 +27,7 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v
{
title: 'Place item',
description:
'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, a ceiling for ceiling-attached items, or the site for outdoor items.',
'Place a catalog item into the scene. Target a level/slab/zone for floor items, a wall for wall-attached items, or a ceiling for ceiling-attached items. Do not target the site node directly.',
inputSchema: placeItemInput,
outputSchema: placeItemOutput,
},
@@ -42,12 +42,11 @@ export function registerPlaceItem(server: McpServer, bridge: SceneOperations): v
targetType !== 'slab' &&
targetType !== 'zone' &&
targetType !== 'wall' &&
targetType !== 'ceiling' &&
targetType !== 'site'
targetType !== 'ceiling'
) {
throwMcpError(
ErrorCode.InvalidRequest,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, ceiling, or site`,
`Cannot place item on ${targetType}; target must be a level, slab, zone, wall, or ceiling. Site-level placement is not supported yet because site.children is reserved for buildings.`,
)
}
+10
View File
@@ -107,6 +107,10 @@ describe('room tools', () => {
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(2.5, 3)
expect(door.t).toBe(0.5)
expect(door.position).toBe(0.5)
expect(door.wallLength).toBeCloseTo(5, 3)
expect(door.coordinateSystem).toBe('wall-local-meters')
expect(
(bridge.getNode(door.doorId) as { position: [number, number, number] }).position[0],
).toBeCloseTo(2.5, 3)
@@ -117,6 +121,10 @@ describe('room tools', () => {
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(1.25, 3)
expect(win.t).toBe(0.25)
expect(win.position).toBe(0.25)
expect(win.wallLength).toBeCloseTo(5, 3)
expect(win.coordinateSystem).toBe('wall-local-meters')
expect(
(bridge.getNode(win.windowId) as { position: [number, number, number] }).position[1],
).toBe(1.5)
@@ -146,6 +154,7 @@ describe('room tools', () => {
})
const door = JSON.parse((doorResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(door.localX).toBeCloseTo(1.5, 3)
expect(door.t).toBe(0.25)
const windowResult = await client.callTool({
name: 'add_window',
@@ -153,6 +162,7 @@ describe('room tools', () => {
})
const win = JSON.parse((windowResult.content as Array<{ type: string; text: string }>)[0]!.text)
expect(win.localX).toBeCloseTo(4.5, 3)
expect(win.t).toBe(0.75)
expect(bridge.validateScene().valid).toBe(true)
})
+33 -4
View File
@@ -76,6 +76,11 @@ export const addDoorInput = {
export const addDoorOutput = {
doorId: z.string(),
localX: z.number(),
t: z.number(),
position: z.number(),
wallLength: z.number(),
clamped: z.boolean(),
coordinateSystem: z.literal('wall-local-meters'),
}
export const addWindowInput = {
@@ -90,6 +95,11 @@ export const addWindowInput = {
export const addWindowOutput = {
windowId: z.string(),
localX: z.number(),
t: z.number(),
position: z.number(),
wallLength: z.number(),
clamped: z.boolean(),
coordinateSystem: z.literal('wall-local-meters'),
sillHeight: z.number(),
}
@@ -471,7 +481,15 @@ export function registerAddDoor(server: McpServer, bridge: SceneOperations): voi
})
const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'add_door')
return textResult({ doorId: id, localX })
return textResult({
doorId: id,
localX,
t: wallT,
position: wallT,
wallLength: length,
clamped: Math.abs(localX - wallT * length) > 1e-9,
coordinateSystem: 'wall-local-meters',
})
},
)
}
@@ -506,7 +524,16 @@ export function registerAddWindow(server: McpServer, bridge: SceneOperations): v
})
const id = bridge.createNode(windowNode, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, 'add_window')
return textResult({ windowId: id, localX, sillHeight })
return textResult({
windowId: id,
localX,
t: wallT,
position: wallT,
wallLength: length,
clamped: Math.abs(localX - wallT * length) > 1e-9,
coordinateSystem: 'wall-local-meters',
sillHeight,
})
},
)
}
@@ -539,8 +566,10 @@ export function registerFurnishRoom(server: McpServer, bridge: SceneOperations):
const fp = itemFootprint(asset, placement.x, placement.z, placement.rotationDeg ?? 0)
const padding = 0.05
if (
!pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) ||
!pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding)
!(
pointInBoundsWithPadding(fp.minX, fp.minZ, bounds, -padding) &&
pointInBoundsWithPadding(fp.maxX, fp.maxZ, bounds, -padding)
)
) {
skipped.push(`${asset.id}: outside room bounds`)
continue
@@ -0,0 +1,85 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { currentLevelContext, projectStatusPayload } from './metadata'
export const createProjectInput = {
name: z.string().min(1).max(200),
id: z.string().min(1).max(64).optional(),
isPrivate: z.boolean().default(true),
}
export const createProjectOutput = {
id: z.string(),
projectId: z.string(),
name: z.string(),
editorUrl: z.string(),
url: z.string(),
ownerId: z.string().nullable(),
thumbnailUrl: z.string().nullable(),
publishedVersion: z.number().nullable(),
latestVersion: z.number().nullable(),
draftVersion: z.number().nullable(),
browserVisibleVersion: z.number().nullable(),
version: z.number(),
isEmpty: z.boolean(),
sizeBytes: z.number(),
nodeCount: z.number(),
graphHash: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
nextStep: z.string(),
}
export function registerCreateProject(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'create_project',
{
title: 'Create project',
description:
'Create a browser-visible Pascal project for the authenticated user. Use this before save_scene when the user asks for a new project.',
inputSchema: createProjectInput,
outputSchema: createProjectOutput,
},
async ({ name, id, isPrivate }) => {
if (!operations.canCreateProject) {
throwMcpError(
ErrorCode.InvalidRequest,
'create_project_unavailable: this MCP store cannot create hosted projects',
)
}
try {
const status = await operations.createProject({
name,
...(id !== undefined ? { id } : {}),
isPrivate,
})
operations.setActiveScene({
id: status.id,
name: status.name,
projectId: status.projectId,
ownerId: status.ownerId,
thumbnailUrl: status.thumbnailUrl,
version: status.version,
})
const payload = {
...projectStatusPayload(
status,
'The project is now bound to this MCP session. Open editorUrl now; semantic tools will update the browser-visible draft. Call save_scene with saveMode: "checkpoint" only when you want a meaningful version.',
),
...currentLevelContext(operations),
}
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)
}
},
)
}
@@ -0,0 +1,78 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, McpError, throwMcpError } from '../errors'
import { currentLevelContext, projectStatusPayload } from './metadata'
export const getProjectStatusInput = {
id: z.string().min(1).max(64),
}
export const getProjectStatusOutput = {
id: z.string(),
projectId: z.string(),
name: z.string(),
editorUrl: z.string(),
url: z.string(),
ownerId: z.string().nullable(),
thumbnailUrl: z.string().nullable(),
publishedVersion: z.number().nullable(),
latestVersion: z.number().nullable(),
draftVersion: z.number().nullable(),
browserVisibleVersion: z.number().nullable(),
version: z.number(),
isEmpty: z.boolean(),
sizeBytes: z.number(),
nodeCount: z.number(),
graphHash: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
nextStep: z.string(),
}
export function registerGetProjectStatus(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'get_project_status',
{
title: 'Get project status',
description:
'Authoritative status/debug call for a Pascal project: editor URL, browser-visible version, latest saved version, published version, node count, and graph hash.',
inputSchema: getProjectStatusInput,
outputSchema: getProjectStatusOutput,
},
async ({ id }) => {
try {
const status = await operations.getProjectStatus(id)
if (!status) {
throwMcpError(ErrorCode.InvalidParams, 'project_not_found', { id })
}
const activeScene = operations.getActiveScene()
if (activeScene?.id !== status.id) {
const scene = await operations.loadStoredScene(status.id)
if (scene) {
operations.loadJSON(scene.graph)
operations.setActiveScene(scene)
}
}
const nextStep =
status.nodeCount > 0
? 'Open editorUrl or continue editing, then save_scene again.'
: 'Project is empty. Build a scene with semantic tools or create_from_template, then save_scene.'
const payload = {
...projectStatusPayload(status, nextStep),
...currentLevelContext(operations),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
structuredContent: payload,
}
} catch (err) {
if (err instanceof McpError) throw err
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InternalError, msg)
}
},
)
}
@@ -1,6 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../../operations'
import { registerCreateProject } from './create-project'
import { registerDeleteScene } from './delete-scene'
import { registerGetProjectStatus } from './get-project-status'
import { registerListScenes } from './list-scenes'
import { registerLoadScene } from './load-scene'
import { registerRenameScene } from './rename-scene'
@@ -13,6 +15,8 @@ import { registerSaveScene } from './save-scene'
* entry points share the same storage boundary.
*/
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
registerCreateProject(server, operations)
registerGetProjectStatus(server, operations)
registerSaveScene(server, operations)
registerLoadScene(server, operations)
registerListScenes(server, operations)
@@ -20,7 +24,13 @@ export function registerSceneLifecycleTools(server: McpServer, operations: Scene
registerRenameScene(server, operations)
}
export { createProjectInput, createProjectOutput, registerCreateProject } from './create-project'
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
export {
getProjectStatusInput,
getProjectStatusOutput,
registerGetProjectStatus,
} from './get-project-status'
export { listScenesInput, listScenesOutput, registerListScenes } from './list-scenes'
export { loadSceneInput, loadSceneOutput, registerLoadScene } from './load-scene'
export { registerRenameScene, renameSceneInput, renameSceneOutput } from './rename-scene'
@@ -23,6 +23,10 @@ export const listScenesOutput = {
ownerId: z.string().nullable(),
sizeBytes: z.number(),
nodeCount: z.number(),
editorUrl: z.string().optional(),
url: z.string().optional(),
published: z.boolean().optional(),
graphHash: z.string().optional(),
}),
),
}
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { currentLevelContext, sceneMetaPayload } from './metadata'
export const loadSceneInput = {
id: z.string().min(1).max(64),
@@ -18,6 +19,14 @@ export const loadSceneOutput = {
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(),
}
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
@@ -43,16 +52,8 @@ export function registerLoadScene(server: McpServer, bridge: SceneOperations): v
throwMcpError(ErrorCode.InvalidRequest, `load_failed: ${msg}`, { id })
}
const payload = {
id: result.id,
name: result.name,
projectId: result.projectId,
thumbnailUrl: result.thumbnailUrl,
version: result.version,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
ownerId: result.ownerId,
sizeBytes: result.sizeBytes,
nodeCount: result.nodeCount,
...sceneMetaPayload(result, result.graph),
...currentLevelContext(bridge),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -0,0 +1,77 @@
import { createHash } from 'node:crypto'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNodeId } from '@pascal-app/core/schema'
import type { SceneOperations } from '../../operations'
import type { ProjectStatus, SceneMeta } from '../../storage/types'
export function computeGraphHash(graph: SceneGraph): string {
const normalized = JSON.stringify({
nodes: graph.nodes ?? {},
rootNodeIds: graph.rootNodeIds ?? [],
collections: (graph as Record<string, unknown>).collections ?? {},
})
return createHash('sha256').update(normalized).digest('hex')
}
export function editorUrlFor(meta: Pick<SceneMeta, 'id' | 'editorUrl' | 'url'>): string {
return meta.editorUrl ?? meta.url ?? `/editor/${meta.id}`
}
export function sceneMetaPayload(meta: SceneMeta, graph?: SceneGraph) {
const editorUrl = editorUrlFor(meta)
return {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
editorUrl,
url: editorUrl,
published: meta.published ?? true,
isDraft: meta.isDraft ?? false,
saveMode: meta.saveMode ?? (meta.isDraft ? 'draft' : 'checkpoint'),
graphHash: meta.graphHash ?? (graph ? computeGraphHash(graph) : undefined),
}
}
export function projectStatusPayload(status: ProjectStatus, nextStep?: string) {
return {
id: status.id,
projectId: status.projectId,
name: status.name,
editorUrl: status.editorUrl,
url: status.url,
ownerId: status.ownerId,
thumbnailUrl: status.thumbnailUrl,
publishedVersion: status.publishedVersion,
latestVersion: status.latestVersion,
draftVersion: status.draftVersion,
browserVisibleVersion: status.browserVisibleVersion,
version: status.version,
isEmpty: status.isEmpty,
sizeBytes: status.sizeBytes,
nodeCount: status.nodeCount,
graphHash: status.graphHash,
createdAt: status.createdAt,
updatedAt: status.updatedAt,
...(nextStep ? { nextStep } : {}),
}
}
export function currentLevelContext(operations: SceneOperations) {
const levels = operations.findNodes({ type: 'level' }).sort((a, b) => {
const aa = a.type === 'level' ? a.level : 0
const bb = b.type === 'level' ? b.level : 0
return aa - bb
})
const levelIds = levels.map((level) => level.id as AnyNodeId as string)
return {
levelIds,
defaultLevelId: levelIds[0] ?? null,
}
}
@@ -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 { registerCreateProject } from './create-project'
import { registerGetProjectStatus } from './get-project-status'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('project lifecycle tools', () => {
let client: Client
let store: InMemorySceneStore
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateProject(server, operations)
registerGetProjectStatus(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)])
})
test('creates a project and returns an editor URL', async () => {
const result = await client.callTool({
name: 'create_project',
arguments: { name: 'Dogfood house' },
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.name).toBe('Dogfood house')
expect(typeof parsed.projectId).toBe('string')
expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`)
expect(parsed.nodeCount).toBe(0)
expect(parsed.nextStep).toContain('save_scene')
})
test('reports status for an existing project', async () => {
const project = await store.createProject({ name: 'Status house' })
const result = await client.callTool({
name: 'get_project_status',
arguments: { id: project.projectId },
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.projectId).toBe(project.projectId)
expect(parsed.editorUrl).toBe(`/editor/${project.projectId}`)
expect(parsed.nodeCount).toBe(0)
})
})
@@ -29,7 +29,7 @@ describe('save_scene', () => {
await Promise.all([server.connect(srvT), client.connect(cliT)])
})
test('saves the current scene and returns SceneMeta with url', async () => {
test('saves the current scene and returns SceneMeta with editorUrl', async () => {
const result = await client.callTool({
name: 'save_scene',
arguments: { name: 'My Scene' },
@@ -39,7 +39,10 @@ describe('save_scene', () => {
expect(parsed.name).toBe('My Scene')
expect(typeof parsed.id).toBe('string')
expect(parsed.version).toBe(1)
expect(parsed.url).toBe(`/scene/${parsed.id}`)
expect(parsed.url).toBe(`/editor/${parsed.id}`)
expect(parsed.editorUrl).toBe(`/editor/${parsed.id}`)
expect(parsed.published).toBe(true)
expect(typeof parsed.graphHash).toBe('string')
expect(parsed.nodeCount).toBeGreaterThan(0)
})
@@ -6,12 +6,23 @@ import type { SceneOperations } from '../../operations'
import { SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
import { currentLevelContext, sceneMetaPayload } from './metadata'
export const saveSceneInput = {
id: z.string().min(1).max(64).optional(),
name: z.string().min(1).max(200),
projectId: z.string().optional(),
expectedVersion: z.number().int().positive().optional(),
saveMode: z
.enum(['draft', 'checkpoint'])
.default('draft')
.describe(
'`draft` updates the browser-visible working model without polluting version history. `checkpoint` creates a meaningful saved version.',
),
publish: z
.boolean()
.optional()
.describe('For checkpoint saves, publish the checkpoint as the browser-visible version.'),
thumbnail: z.string().url().optional(),
includeCurrentScene: z
.boolean()
@@ -37,6 +48,13 @@ export const saveSceneOutput = {
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(),
}
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
@@ -45,11 +63,21 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
{
title: 'Save scene',
description:
'Persist the current scene (or a provided graph) to the SceneStore. Returns the SceneMeta along with a `url` pointing to `/scene/<id>`.',
'Save the current scene (or a provided graph) to the SceneStore. Defaults to a browser-visible draft save so agents can iterate without creating many project versions. Use saveMode: "checkpoint" for meaningful version history.',
inputSchema: saveSceneInput,
outputSchema: saveSceneOutput,
},
async ({ id, name, projectId, expectedVersion, thumbnail, includeCurrentScene, graph }) => {
async ({
id,
name,
projectId,
expectedVersion,
saveMode,
publish,
thumbnail,
includeCurrentScene,
graph,
}) => {
let sceneGraph: SceneGraph
if (includeCurrentScene) {
const validation = bridge.validateScene()
@@ -98,23 +126,17 @@ export function registerSaveScene(server: McpServer, bridge: SceneOperations): v
graph: sceneGraph,
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}),
saveMode,
...(publish !== undefined ? { publish } : {}),
operation: 'save_scene',
})
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
if (includeCurrentScene) {
bridge.setActiveScene(meta)
}
const payload = {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
url: `/scene/${meta.id}`,
...sceneMetaPayload(meta, sceneGraph),
...currentLevelContext(bridge),
}
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
@@ -1,6 +1,8 @@
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from '../../operations'
import {
type ProjectCreateOptions,
type ProjectStatus,
type SceneListOptions,
type SceneMeta,
type SceneMutateOptions,
@@ -10,6 +12,7 @@ import {
SceneVersionConflictError,
type SceneWithGraph,
} from '../../storage/types'
import { computeGraphHash, editorUrlFor } from './metadata'
export type StoredTextContent = { type: string; text: string }
@@ -39,7 +42,40 @@ export function createTestSceneOperations(options?: {
export class InMemorySceneStore implements SceneStore {
readonly backend = 'sqlite' as const
private readonly data = new Map<string, SceneWithGraph>()
private readonly projects = new Map<
string,
{
id: string
name: string
ownerId: string | null
isPrivate: boolean
thumbnailUrl: string | null
createdAt: string
updatedAt: string
}
>()
private idCounter = 0
private projectCounter = 0
async createProject(opts: ProjectCreateOptions): Promise<ProjectStatus> {
const id = opts.id ?? `project_${++this.projectCounter}`
const now = new Date().toISOString()
this.projects.set(id, {
id,
name: opts.name,
ownerId: opts.ownerId ?? null,
isPrivate: opts.isPrivate ?? true,
thumbnailUrl: null,
createdAt: now,
updatedAt: now,
})
return this.toProjectStatus(id)
}
async getProjectStatus(id: string): Promise<ProjectStatus | null> {
if (!(this.projects.has(id) || this.data.has(id))) return null
return this.toProjectStatus(id)
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
const existing = opts.id ? this.data.get(opts.id) : undefined
@@ -63,9 +99,14 @@ export class InMemorySceneStore implements SceneStore {
ownerId: opts.ownerId ?? existing.ownerId,
sizeBytes: serialized.length,
nodeCount,
editorUrl: existing.editorUrl ?? `/editor/${existing.id}`,
url: existing.url ?? `/editor/${existing.id}`,
published: true,
graphHash: computeGraphHash(opts.graph),
graph: opts.graph,
}
this.data.set(existing.id, updated)
this.touchProject(existing.id, opts.name, updated.updatedAt)
return this.toMeta(updated)
}
@@ -80,7 +121,7 @@ export class InMemorySceneStore implements SceneStore {
const record: SceneWithGraph = {
id,
name: opts.name,
projectId: opts.projectId ?? null,
projectId: opts.projectId ?? (this.projects.has(id) ? id : null),
thumbnailUrl: opts.thumbnailUrl ?? null,
version: 1,
createdAt: now,
@@ -88,9 +129,14 @@ export class InMemorySceneStore implements SceneStore {
ownerId: opts.ownerId ?? null,
sizeBytes: serialized.length,
nodeCount,
editorUrl: `/editor/${id}`,
url: `/editor/${id}`,
published: true,
graphHash: computeGraphHash(opts.graph),
graph: opts.graph,
}
this.data.set(id, record)
this.touchProject(id, opts.name, now)
return this.toMeta(record)
}
@@ -142,10 +188,29 @@ export class InMemorySceneStore implements SceneStore {
updatedAt: new Date().toISOString(),
}
this.data.set(id, updated)
this.touchProject(id, newName, updated.updatedAt)
return this.toMeta(updated)
}
private touchProject(id: string, name: string, updatedAt: string): void {
const existing = this.projects.get(id)
if (existing) {
this.projects.set(id, { ...existing, name, updatedAt })
return
}
this.projects.set(id, {
id,
name,
ownerId: null,
isPrivate: true,
thumbnailUrl: null,
createdAt: updatedAt,
updatedAt,
})
}
private toMeta(rec: SceneWithGraph): SceneMeta {
const editorUrl = editorUrlFor(rec)
return {
id: rec.id,
name: rec.name,
@@ -157,6 +222,37 @@ export class InMemorySceneStore implements SceneStore {
ownerId: rec.ownerId,
sizeBytes: rec.sizeBytes,
nodeCount: rec.nodeCount,
editorUrl,
url: editorUrl,
published: rec.published ?? true,
graphHash: rec.graphHash ?? computeGraphHash(rec.graph),
}
}
private toProjectStatus(id: string): ProjectStatus {
const project = this.projects.get(id)
const scene = this.data.get(id)
const now = new Date().toISOString()
const editorUrl = `/editor/${id}`
return {
id,
projectId: id,
name: scene?.name ?? project?.name ?? id,
editorUrl,
url: editorUrl,
ownerId: scene?.ownerId ?? project?.ownerId ?? null,
thumbnailUrl: scene?.thumbnailUrl ?? project?.thumbnailUrl ?? null,
publishedVersion: scene?.version ?? null,
latestVersion: scene?.version ?? null,
draftVersion: null,
browserVisibleVersion: scene?.version ?? null,
version: scene?.version ?? 0,
isEmpty: !scene || scene.nodeCount === 0,
sizeBytes: scene?.sizeBytes ?? 0,
nodeCount: scene?.nodeCount ?? 0,
graphHash: scene?.graphHash ?? (scene ? computeGraphHash(scene.graph) : null),
createdAt: scene?.createdAt ?? project?.createdAt ?? now,
updatedAt: scene?.updatedAt ?? project?.updatedAt ?? now,
}
}
}
+1 -1
View File
@@ -382,7 +382,7 @@ function holeBelongsToStair(
}
function parentListsChild(parent: AnyNode, childId: string): boolean {
if (!('children' in parent) || !Array.isArray(parent.children)) return false
if (!('children' in parent && Array.isArray(parent.children))) return false
return parent.children.some((child) => {
if (typeof child === 'string') return child === childId
return (
@@ -7,6 +7,7 @@ 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
@@ -48,6 +49,13 @@ export const createFromTemplateOutput = {
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(),
}
@@ -121,10 +129,18 @@ export function registerCreateFromTemplate(server: McpServer, bridge: SceneOpera
}
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,
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
saveMode: 'draft',
publish: false,
operation: 'create_from_template',
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
@@ -132,17 +148,8 @@ export function registerCreateFromTemplate(server: McpServer, bridge: SceneOpera
rootNodeIds,
})
const scene = {
id: meta.id,
name: meta.name,
projectId: meta.projectId,
thumbnailUrl: meta.thumbnailUrl,
version: meta.version,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
ownerId: meta.ownerId,
sizeBytes: meta.sizeBytes,
nodeCount: meta.nodeCount,
url: `/scene/${meta.id}`,
...sceneMetaPayload(meta, { nodes, rootNodeIds }),
...currentLevelContext(bridge),
}
const payload = { ...basePayload, scene }
return {
@@ -0,0 +1,210 @@
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 createHouseFromBriefInput = {
brief: z.string().min(1),
projectId: z.string().optional(),
projectName: z.string().min(1).max(200).optional(),
bedroomCount: z.number().int().min(0).max(12).optional(),
rooms: z.array(z.string().min(1)).optional(),
style: z.string().optional(),
landscaping: z.boolean().optional(),
constraints: z.string().optional(),
}
export const createHouseFromBriefOutput = {
projectId: z.string().nullable(),
editorUrl: z.string().nullable(),
url: z.string().nullable(),
version: z.number().nullable(),
published: z.boolean(),
isDraft: z.boolean(),
templateId: z.string(),
nodeCount: z.number(),
roomCount: z.number(),
levelIds: z.array(z.string()),
defaultLevelId: z.string().nullable(),
validation: z.object({
valid: z.boolean(),
errors: z.array(z.string()),
}),
summary: z.string(),
limitations: z.array(z.string()),
nextStep: z.string(),
}
function chooseTemplate(args: {
bedroomCount?: number
rooms?: string[]
landscaping?: boolean
}): TemplateId {
const requested = new Set((args.rooms ?? []).map((room) => room.toLowerCase()))
if (
args.landscaping ||
requested.has('garden') ||
requested.has('patio') ||
requested.has('yard')
) {
return 'garden-house'
}
if ((args.bedroomCount ?? 0) <= 1) return 'empty-studio'
if ((args.bedroomCount ?? 0) <= 2) return 'two-bedroom'
return 'garden-house'
}
function countNodeTypes(nodes: Record<AnyNodeId, AnyNode>): {
nodeCount: number
roomCount: number
} {
let roomCount = 0
for (const node of Object.values(nodes)) {
if (node.type === 'zone') roomCount++
}
return {
nodeCount: Object.keys(nodes).length,
roomCount,
}
}
export function registerCreateHouseFromBrief(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_house_from_brief',
{
title: 'Create house from brief',
description:
'High-level hosted workflow for external agents: choose a starter house from a brief, create/save/publish it, and return the editor URL. Use semantic tools afterward for exact customization.',
inputSchema: createHouseFromBriefInput,
outputSchema: createHouseFromBriefOutput,
},
async ({
brief,
projectId,
projectName,
bedroomCount,
rooms,
style,
landscaping,
constraints,
}) => {
const templateId = chooseTemplate({
...(bedroomCount !== undefined ? { bedroomCount } : {}),
...(rooms !== undefined ? { rooms } : {}),
...(landscaping !== undefined ? { landscaping } : {}),
})
if (!isTemplateId(templateId)) {
throwMcpError(ErrorCode.InternalError, `unknown_template: ${templateId}`)
}
const entry = TEMPLATES[templateId]
const cloned = rehydrateSiteChildren(cloneSceneGraph(entry.template))
const nodes = cloned.nodes as Record<AnyNodeId, AnyNode>
const rootNodeIds = cloned.rootNodeIds as AnyNodeId[]
const counts = countNodeTypes(nodes)
try {
bridge.setScene(nodes, rootNodeIds)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throwMcpError(ErrorCode.InternalError, `apply_failed: ${msg}`)
}
const rawValidation = bridge.validateScene()
const validation = {
valid: rawValidation.valid,
errors: rawValidation.errors.map(
(error) => `${error.nodeId}:${error.path}: ${error.message}`,
),
}
const limitations: string[] = []
if ((bedroomCount ?? 0) > 2) {
limitations.push(
'MVP create_house_from_brief uses the closest built-in template for 3+ bedroom requests; refine with create_room/add_door/add_window for exact room count.',
)
}
if (style || constraints) {
limitations.push(
'Style and constraints are recorded in the summary but not yet fully synthesized into custom geometry.',
)
}
if (!bridge.hasStore) {
const payload = {
projectId: null,
editorUrl: null,
url: null,
version: null,
published: false,
isDraft: false,
templateId,
nodeCount: counts.nodeCount,
roomCount: counts.roomCount,
...currentLevelContext(bridge),
validation,
summary: `Applied ${entry.name} starter scene from brief: ${brief}`,
limitations,
nextStep:
'No SceneStore is attached. Call save_scene later in a hosted MCP session to publish.',
}
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: projectName ?? entry.name })
saveProjectId = project.projectId
}
const meta = await bridge.saveScene({
...(saveProjectId !== undefined ? { id: saveProjectId, projectId: saveProjectId } : {}),
name: projectName ?? entry.name,
graph: { nodes, rootNodeIds },
saveMode: 'draft',
publish: false,
operation: 'create_house_from_brief',
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_house_from_brief', {
nodes,
rootNodeIds,
})
const scene = sceneMetaPayload(meta, { nodes, rootNodeIds })
const payload = {
projectId: scene.projectId ?? scene.id,
editorUrl: scene.editorUrl,
url: scene.url,
version: scene.version,
published: scene.published,
isDraft: scene.isDraft,
templateId,
nodeCount: scene.nodeCount,
roomCount: counts.roomCount,
...currentLevelContext(bridge),
validation,
summary: `Created ${scene.name} from ${entry.name}. Brief: ${brief}`,
limitations,
nextStep:
'Call verify_scene and get_project_status. If the brief needs more specificity, refine with semantic tools and save_scene again.',
}
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}`)
}
},
)
}
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneOperations } from '../../operations'
import { registerCreateFromTemplate } from './create-from-template'
import { registerCreateHouseFromBrief } from './create-house-from-brief'
import { registerListTemplates } from './list-templates'
/**
@@ -13,6 +14,7 @@ import { registerListTemplates } from './list-templates'
export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
registerListTemplates(server)
registerCreateFromTemplate(server, bridge)
registerCreateHouseFromBrief(server, bridge)
}
export {
@@ -20,6 +22,11 @@ export {
createFromTemplateOutput,
registerCreateFromTemplate,
} from './create-from-template'
export {
createHouseFromBriefInput,
createHouseFromBriefOutput,
registerCreateHouseFromBrief,
} from './create-house-from-brief'
export {
listTemplatesInput,
listTemplatesOutput,
@@ -10,6 +10,7 @@ import {
type StoredTextContent,
} from '../scene-lifecycle/test-utils'
import { registerCreateFromTemplate } from './create-from-template'
import { registerCreateHouseFromBrief } from './create-house-from-brief'
import { registerListTemplates } from './list-templates'
describe('list_templates', () => {
@@ -63,6 +64,7 @@ describe('create_from_template', () => {
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, operations)
registerCreateHouseFromBrief(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)])
@@ -106,9 +108,16 @@ describe('create_from_template', () => {
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(parsed.scene).toBeDefined()
const scene = parsed.scene as { id: string; name: string; url: string; nodeCount: number }
const scene = parsed.scene as {
id: string
name: string
url: string
editorUrl: string
nodeCount: number
}
expect(scene.name).toBe('My flat')
expect(scene.url).toBe(`/scene/${scene.id}`)
expect(scene.url).toBe(`/editor/${scene.id}`)
expect(scene.editorUrl).toBe(`/editor/${scene.id}`)
expect(scene.nodeCount).toBeGreaterThan(0)
// Confirm the store actually holds it.
@@ -131,6 +140,26 @@ describe('create_from_template', () => {
expect(idsB).not.toContain(id)
}
})
test('create_house_from_brief creates, saves, and returns an editor URL', async () => {
const result = await client.callTool({
name: 'create_house_from_brief',
arguments: {
brief: 'Create a compact modern two-bedroom home with a small patio.',
projectName: 'Brief house',
bedroomCount: 2,
landscaping: true,
},
})
expect(result.isError).toBeFalsy()
const parsed = parseToolText(result.content as StoredTextContent[])
expect(typeof parsed.projectId).toBe('string')
expect(parsed.editorUrl).toBe(`/editor/${parsed.projectId}`)
expect(parsed.version).toBe(1)
expect(parsed.published).toBe(true)
expect(parsed.nodeCount as number).toBeGreaterThan(0)
expect(parsed.nextStep as string).toContain('get_project_status')
})
})
describe('create_from_template without a store', () => {
@@ -119,7 +119,7 @@ export function registerGenerateVariants(server: McpServer, bridge: SceneOperati
// 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() * 0xffffffff)
const initialSeed = seed ?? Math.floor(Math.random() * 0xff_ff_ff_ff)
const mutations = vary as MutationKind[]
const variants: Array<{
+14 -16
View File
@@ -11,21 +11,19 @@ export type MutationKind =
| 'door-positions'
| 'fence-style'
/** Deterministic 32-bit RNG. */
/** Deterministic seeded RNG. */
export type Rng = () => number
/**
* Tiny PRNG. Returns a function that produces uniformly distributed floats in
* [0, 1). Source: https://stackoverflow.com/a/47593316/17118
* Tiny Park-Miller PRNG. Returns a function that produces uniformly
* distributed floats in [0, 1) without bitwise operators.
*/
export function mulberry32(seed: number): Rng {
let state = seed | 0
let state = Math.trunc(Math.abs(seed)) % 2_147_483_647
if (state === 0) state = 1
return () => {
state = (state + 0x6d2b79f5) | 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
state = (state * 16_807) % 2_147_483_647
return (state - 1) / 2_147_483_646
}
}
@@ -74,10 +72,10 @@ function siteBounds(
if (node.type !== 'site') continue
const pts = (node as { polygon?: { points?: Array<[number, number]> } }).polygon?.points
if (!pts || pts.length === 0) continue
let minX = Infinity
let maxX = -Infinity
let minZ = Infinity
let maxZ = -Infinity
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [x, z] of pts) {
if (x < minX) minX = x
if (x > maxX) maxX = x
@@ -101,7 +99,7 @@ function isPerimeterWall(
bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null,
epsilon = 0.01,
): boolean {
if (!bounds || !wall.start || !wall.end) return false
if (!(bounds && wall.start && wall.end)) return false
const onBound = (x: number, z: number): boolean =>
Math.abs(x - bounds.minX) <= epsilon ||
Math.abs(x - bounds.maxX) <= epsilon ||
@@ -154,7 +152,7 @@ function applyRoomProportions(graph: SceneGraph, rng: Rng): SceneGraph {
start?: [number, number]
end?: [number, number]
}
if (!wall.start || !wall.end) continue
if (!(wall.start && wall.end)) continue
if (isPerimeterWall(wall, bounds)) continue
// Nudge each endpoint by ±10% of its current value.
const nudge = (v: number): number => v * (1 + (rng() * 2 - 1) * 0.1)
@@ -193,7 +191,7 @@ function applyOpenPlan(graph: SceneGraph, rng: Rng): SceneGraph {
out.rootNodeIds = out.rootNodeIds.filter((id) => !removal.has(id))
// Drop references from any parent's `children` array.
for (const parent of Object.values(out.nodes)) {
if (!('children' in parent) || !Array.isArray((parent as { children?: unknown[] }).children)) {
if (!('children' in parent && Array.isArray((parent as { children?: unknown[] }).children))) {
continue
}
const children = (parent as { children: unknown[] }).children
+2 -2
View File
@@ -52,7 +52,7 @@ export async function connectHttp(
): Promise<HttpTransportHandle> {
const host = options.host ?? DEFAULT_HOST
const authToken = options.authToken ?? process.env.PASCAL_MCP_HTTP_TOKEN
if (!isLoopbackHost(host) && !authToken) {
if (!(isLoopbackHost(host) || authToken)) {
throw new Error(
'HTTP transport on a non-loopback host requires PASCAL_MCP_HTTP_TOKEN or authToken',
)
@@ -149,7 +149,7 @@ function createHttpGuard(options: {
if (options.authToken) {
const supplied = bearerToken(req) ?? headerValue(req.headers['x-pascal-mcp-token'])
if (!supplied || !safeEqual(supplied, options.authToken)) {
if (!(supplied && safeEqual(supplied, options.authToken))) {
sendJson(res, 401, { error: 'unauthorized' })
return false
}