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:
@@ -11,15 +11,30 @@
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"import": "./dist/server.js",
|
||||
"default": "./dist/server.js"
|
||||
},
|
||||
"./storage": {
|
||||
"types": "./dist/storage/index.d.ts",
|
||||
"import": "./dist/storage/index.js",
|
||||
"default": "./dist/storage/index.js"
|
||||
},
|
||||
"./storage/types": {
|
||||
"types": "./dist/storage/types.d.ts",
|
||||
"import": "./dist/storage/types.js",
|
||||
"default": "./dist/storage/types.js"
|
||||
},
|
||||
"./operations": {
|
||||
"types": "./dist/operations/index.d.ts",
|
||||
"import": "./dist/operations/index.js",
|
||||
"default": "./dist/operations/index.js"
|
||||
},
|
||||
"./bridge": {
|
||||
"types": "./dist/bridge/index.d.ts",
|
||||
"import": "./dist/bridge/index.js",
|
||||
"default": "./dist/bridge/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
@@ -39,14 +54,14 @@
|
||||
"prepublishOnly": "bun run build && bun test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "workspace:*"
|
||||
"@pascal-app/core": "^0.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "workspace:*",
|
||||
"@pascal-app/core": "^0.7.0",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.1–0.3 m) and ceiling height 2.4–3.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')
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user