From e8d0b13ff5e41903506e56eba57a146ddbf82378 Mon Sep 17 00:00:00 2001 From: Adrian Perez Date: Sat, 18 Apr 2026 19:29:28 +0200 Subject: [PATCH] feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the combined filesystem/Supabase storage adapter + MCP scene lifecycle tools + Next.js API routes + editor /scene/[id] route, so an MCP save is directly openable at /scene/ without any injection hack. End-to-end verified: 10/10 e2e steps pass. Storage (A1/A2/A3): - SceneStore interface + error classes + slug helpers - FilesystemSceneStore at $PASCAL_DATA_DIR (defaults XDG/~/.pascal) with atomic writes, .index sidecar, optimistic locking - SupabaseSceneStore with scenes + scene_revisions tables, RLS migration SQL, mock-backed unit tests - createSceneStore(env) auto-selects based on SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY MCP tools (A4, A8, A9, A10): - save_scene / load_scene / list_scenes / delete_scene / rename_scene - list_templates / create_from_template (3 seed templates: empty-studio, two-bedroom, garden-house) - generate_variants (7 mutation kinds, seeded RNG, save=true|false) - photo_to_scene (vision sampling → scene graph → save) Editor (A5, A6): - /api/scenes + /api/scenes/[id] with RFC 7232 If-Match locking - /scene/[id] and /scenes route pages with save button, SceneLoader - Removed the window.__pascalScene dev injection hack Security + UX edges (A7, A8): - AssetUrl Zod validator: asset:// blob: data:image/ /path https: (http://localhost for dev) + PASCAL_ALLOWED_ASSET_ORIGINS env allowlist. Hardens scan.url, guide.url, item.asset.src, material.texture.url, MaterialMaps.*Map - Auto-frame camera on empty→non-empty scene transition (camera-controls:fit-scene emitter event) Shared utilities: - rehydrateSiteChildren() extracted to packages/mcp/src/lib/ and used by both create-from-template and generate-variants to work around the SiteNode.children-as-objects vs. ids inconsistency (CROSS_CUTTING §2) - Storage + MCP subpath exports added to packages/mcp/package.json (CROSS_CUTTING §4) Tests: 293 pass / 0 fail across 40 files (was 142 pre-Phase-7). Biome: clean. Phase-7 e2e script at packages/mcp/test-reports/phase7-e2e.ts: MCP HTTP + editor Next.js both point at $PASCAL_DATA_DIR = /tmp/pascal-e2e, save_scene from MCP, GET /api/scenes/ from editor server, /scenes list page renders all saved scenes, scene page renders SceneLoader, delete_scene works. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/editor/app/api/scenes/[id]/route.ts | 191 +++++ apps/editor/app/api/scenes/route.ts | 105 +++ apps/editor/app/page.tsx | 37 +- apps/editor/app/scene/[id]/page.tsx | 73 ++ apps/editor/app/scenes/page.tsx | 117 +++ apps/editor/components/save-button.tsx | 157 ++++ apps/editor/components/scene-loader.tsx | 145 ++++ apps/editor/lib/scene-store-server.test.ts | 58 ++ apps/editor/lib/scene-store-server.ts | 91 +++ apps/editor/package.json | 4 +- bun.lock | 27 + packages/core/src/events/bus.ts | 16 + packages/core/src/index.ts | 19 +- packages/core/src/schema/asset-url.test.ts | 141 ++++ packages/core/src/schema/asset-url.ts | 79 ++ packages/core/src/schema/index.ts | 16 +- packages/core/src/schema/material.ts | 23 +- packages/core/src/schema/nodes/guide.ts | 3 +- packages/core/src/schema/nodes/item.ts | 3 +- packages/core/src/schema/nodes/scan.ts | 3 +- .../editor/custom-camera-controls.tsx | 29 +- .../editor/src/components/editor/index.tsx | 4 +- packages/editor/src/hooks/use-auto-frame.ts | 45 ++ packages/editor/src/lib/scene-bounds.test.ts | 183 +++++ packages/editor/src/lib/scene-bounds.ts | 169 +++++ packages/mcp/CROSS_CUTTING.md | 104 +++ packages/mcp/examples/photo-to-scene.md | 119 +++ packages/mcp/package.json | 6 + packages/mcp/sql/README.md | 71 ++ packages/mcp/sql/migrations/0001_scenes.sql | 76 ++ packages/mcp/src/bridge/scene-bridge.test.ts | 5 +- .../mcp/src/lib/rehydrate-site-children.ts | 40 + packages/mcp/src/server.ts | 52 +- .../storage/filesystem-scene-store.test.ts | 599 +++++++++++++++ .../mcp/src/storage/filesystem-scene-store.ts | 388 ++++++++++ packages/mcp/src/storage/index.ts | 29 + packages/mcp/src/storage/slug.ts | 66 ++ packages/mcp/src/storage/store.test.ts | 125 ++++ .../src/storage/supabase-scene-store.test.ts | 333 +++++++++ .../mcp/src/storage/supabase-scene-store.ts | 414 +++++++++++ packages/mcp/src/storage/types.ts | 88 +++ packages/mcp/src/templates/empty-studio.ts | 264 +++++++ packages/mcp/src/templates/garden-house.ts | 283 ++++++++ packages/mcp/src/templates/index.ts | 41 ++ packages/mcp/src/templates/templates.test.ts | 85 +++ packages/mcp/src/templates/two-bedroom.ts | 311 ++++++++ packages/mcp/src/tools/index.ts | 16 +- .../mcp/src/tools/photo-to-scene/index.ts | 24 + .../photo-to-scene/photo-to-scene.test.ts | 194 +++++ .../tools/photo-to-scene/photo-to-scene.ts | 427 +++++++++++ .../scene-lifecycle/delete-scene.test.ts | 56 ++ .../src/tools/scene-lifecycle/delete-scene.ts | 50 ++ .../mcp/src/tools/scene-lifecycle/index.ts | 32 + .../tools/scene-lifecycle/list-scenes.test.ts | 73 ++ .../src/tools/scene-lifecycle/list-scenes.ts | 57 ++ .../tools/scene-lifecycle/load-scene.test.ts | 64 ++ .../src/tools/scene-lifecycle/load-scene.ts | 63 ++ .../scene-lifecycle/rename-scene.test.ts | 59 ++ .../src/tools/scene-lifecycle/rename-scene.ts | 71 ++ .../tools/scene-lifecycle/save-scene.test.ts | 83 +++ .../src/tools/scene-lifecycle/save-scene.ts | 111 +++ .../src/tools/scene-lifecycle/test-utils.ts | 146 ++++ .../tools/templates/create-from-template.ts | 154 ++++ packages/mcp/src/tools/templates/index.ts | 33 + .../mcp/src/tools/templates/list-templates.ts | 47 ++ .../mcp/src/tools/templates/templates.test.ts | 169 +++++ .../tools/variants/generate-variants.test.ts | 295 ++++++++ .../src/tools/variants/generate-variants.ts | 197 +++++ packages/mcp/src/tools/variants/index.ts | 30 + .../mcp/src/tools/variants/mutations.test.ts | 409 +++++++++++ packages/mcp/src/tools/variants/mutations.ts | 331 +++++++++ packages/mcp/test-reports/casa-sol/build.ts | 36 +- packages/mcp/test-reports/casa-sol/scene.json | 683 +++--------------- packages/mcp/test-reports/phase7-e2e.ts | 95 +++ .../mcp/test-reports/research/PHASE7_PLAN.md | 82 +++ packages/mcp/test-reports/t1-stdio/run.ts | 120 +-- packages/mcp/test-reports/t2-http/run.ts | 143 ++-- .../test-reports/t3-scenario/apartment.json | 440 ++--------- .../test-reports/t3-scenario/run-summary.json | 16 +- packages/mcp/test-reports/t3-scenario/run.ts | 17 +- .../test-reports/t5-resources-prompts/run.ts | 86 +-- 81 files changed, 8933 insertions(+), 1213 deletions(-) create mode 100644 apps/editor/app/api/scenes/[id]/route.ts create mode 100644 apps/editor/app/api/scenes/route.ts create mode 100644 apps/editor/app/scene/[id]/page.tsx create mode 100644 apps/editor/app/scenes/page.tsx create mode 100644 apps/editor/components/save-button.tsx create mode 100644 apps/editor/components/scene-loader.tsx create mode 100644 apps/editor/lib/scene-store-server.test.ts create mode 100644 apps/editor/lib/scene-store-server.ts create mode 100644 packages/core/src/schema/asset-url.test.ts create mode 100644 packages/core/src/schema/asset-url.ts create mode 100644 packages/editor/src/hooks/use-auto-frame.ts create mode 100644 packages/editor/src/lib/scene-bounds.test.ts create mode 100644 packages/editor/src/lib/scene-bounds.ts create mode 100644 packages/mcp/examples/photo-to-scene.md create mode 100644 packages/mcp/sql/README.md create mode 100644 packages/mcp/sql/migrations/0001_scenes.sql create mode 100644 packages/mcp/src/lib/rehydrate-site-children.ts create mode 100644 packages/mcp/src/storage/filesystem-scene-store.test.ts create mode 100644 packages/mcp/src/storage/filesystem-scene-store.ts create mode 100644 packages/mcp/src/storage/index.ts create mode 100644 packages/mcp/src/storage/slug.ts create mode 100644 packages/mcp/src/storage/store.test.ts create mode 100644 packages/mcp/src/storage/supabase-scene-store.test.ts create mode 100644 packages/mcp/src/storage/supabase-scene-store.ts create mode 100644 packages/mcp/src/storage/types.ts create mode 100644 packages/mcp/src/templates/empty-studio.ts create mode 100644 packages/mcp/src/templates/garden-house.ts create mode 100644 packages/mcp/src/templates/index.ts create mode 100644 packages/mcp/src/templates/templates.test.ts create mode 100644 packages/mcp/src/templates/two-bedroom.ts create mode 100644 packages/mcp/src/tools/photo-to-scene/index.ts create mode 100644 packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts create mode 100644 packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/delete-scene.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/index.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/list-scenes.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/load-scene.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/rename-scene.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/save-scene.ts create mode 100644 packages/mcp/src/tools/scene-lifecycle/test-utils.ts create mode 100644 packages/mcp/src/tools/templates/create-from-template.ts create mode 100644 packages/mcp/src/tools/templates/index.ts create mode 100644 packages/mcp/src/tools/templates/list-templates.ts create mode 100644 packages/mcp/src/tools/templates/templates.test.ts create mode 100644 packages/mcp/src/tools/variants/generate-variants.test.ts create mode 100644 packages/mcp/src/tools/variants/generate-variants.ts create mode 100644 packages/mcp/src/tools/variants/index.ts create mode 100644 packages/mcp/src/tools/variants/mutations.test.ts create mode 100644 packages/mcp/src/tools/variants/mutations.ts create mode 100644 packages/mcp/test-reports/phase7-e2e.ts create mode 100644 packages/mcp/test-reports/research/PHASE7_PLAN.md diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts new file mode 100644 index 00000000..33d82633 --- /dev/null +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -0,0 +1,191 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSceneStore } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +type RouteParams = { params: Promise<{ id: string }> } + +const graphSchema = z.unknown().refine((v: unknown) => v !== null && typeof v === 'object', { + message: 'graph must be an object', +}) + +const putSceneSchema = z.object({ + name: z.string().min(1).max(200).optional(), + graph: graphSchema, + thumbnailUrl: z.string().url().nullable().optional(), + expectedVersion: z.number().int().nonnegative().optional(), +}) + +const patchSceneSchema = z.object({ + name: z.string().min(1).max(200), + expectedVersion: z.number().int().nonnegative().optional(), +}) + +export async function GET(_request: NextRequest, { params }: RouteParams) { + const { id } = await params + const store = await getSceneStore() + try { + const scene = await store.load(id) + if (!scene) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + return NextResponse.json(scene, { + headers: { ETag: `"${scene.version}"` }, + }) + } catch (error) { + return handleStoreError(error) + } +} + +export async function PUT(request: NextRequest, { params }: RouteParams) { + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = putSceneSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + const expectedVersion = ifMatch ?? parsed.data.expectedVersion + + const store = await getSceneStore() + try { + const existing = await store.load(id) + if (!existing) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + const meta = await store.save({ + id, + name: parsed.data.name ?? existing.name, + projectId: existing.projectId, + ownerId: existing.ownerId, + graph: parsed.data.graph as never, + thumbnailUrl: + parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl, + expectedVersion, + }) + return NextResponse.json(meta, { + headers: { ETag: `"${meta.version}"` }, + }) + } catch (error) { + return handleStoreError(error, { includeCurrentVersionFor: id }) + } +} + +export async function DELETE(request: NextRequest, { params }: RouteParams) { + const { id } = await params + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + + const store = await getSceneStore() + try { + const removed = await store.delete(id, { expectedVersion: ifMatch }) + if (!removed) { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + return new NextResponse(null, { status: 204 }) + } catch (error) { + return handleStoreError(error, { includeCurrentVersionFor: id }) + } +} + +export async function PATCH(request: NextRequest, { params }: RouteParams) { + const { id } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = patchSceneSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const ifMatch = parseIfMatch(request.headers.get('If-Match')) + const expectedVersion = ifMatch ?? parsed.data.expectedVersion + + const store = await getSceneStore() + try { + const meta = await store.rename(id, parsed.data.name, { expectedVersion }) + return NextResponse.json(meta, { + headers: { ETag: `"${meta.version}"` }, + }) + } catch (error) { + return handleStoreError(error, { includeCurrentVersionFor: id }) + } +} + +/** + * Parses an `If-Match` header value per RFC 7232. Accepts `""` or + * weak `W/""` forms. Returns `undefined` when the header is absent, + * the wildcard `*`, or unparseable as a non-negative integer. + */ +function parseIfMatch(raw: string | null): number | undefined { + if (!raw) return undefined + const trimmed = raw.trim() + if (trimmed === '*') return undefined + const match = trimmed.match(/^(?:W\/)?"([^"]+)"$/) + const inner = match ? match[1] : trimmed + if (!inner) return undefined + const n = Number(inner) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return undefined + return n +} + +async function handleStoreError( + error: unknown, + opts: { includeCurrentVersionFor?: string } = {}, +): Promise { + const code = (error as { code?: string })?.code + if (code === 'version_conflict') { + let currentVersion: number | undefined + if (opts.includeCurrentVersionFor) { + try { + const store = await getSceneStore() + const current = await store.load(opts.includeCurrentVersionFor) + currentVersion = current?.version + } catch { + // Best-effort; skip reporting currentVersion on secondary failure. + } + } + return NextResponse.json( + currentVersion === undefined + ? { error: 'version_conflict' } + : { error: 'version_conflict', currentVersion }, + { status: 409 }, + ) + } + if (code === 'not_found') { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + if (code === 'too_large') { + return NextResponse.json({ error: 'too_large' }, { status: 413 }) + } + if (code === 'invalid') { + return NextResponse.json({ error: 'invalid' }, { status: 400 }) + } + const message = error instanceof Error ? error.message : 'unexpected_error' + return NextResponse.json({ error: 'internal_error', message }, { status: 500 }) +} diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts new file mode 100644 index 00000000..0f098807 --- /dev/null +++ b/apps/editor/app/api/scenes/route.ts @@ -0,0 +1,105 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSceneStore } from '@/lib/scene-store-server' + +export const dynamic = 'force-dynamic' + +/** + * The `graph` payload is an opaque `SceneGraph` — we don't re-validate the + * full Zod schema here to keep the route lean. The storage layer performs + * size checks, and consumers supply graphs they built with the editor/core + * schema already. Passing through as `unknown` keeps the API contract + * honest without duplicating the core schema surface. + */ +const graphSchema = z.unknown().refine((v: unknown) => v !== null && typeof v === 'object', { + message: 'graph must be an object', +}) + +const createSceneSchema = z.object({ + id: z.string().min(1).max(64).optional(), + name: z.string().min(1).max(200), + projectId: z.string().min(1).max(200).nullable().optional(), + graph: graphSchema, + thumbnailUrl: z.string().url().nullable().optional(), +}) + +const listQuerySchema = z.object({ + projectId: z.string().min(1).max(200).optional(), + limit: z.coerce.number().int().positive().max(500).optional(), +}) + +export async function GET(request: NextRequest) { + const url = new URL(request.url) + const parsed = listQuerySchema.safeParse({ + projectId: url.searchParams.get('projectId') ?? undefined, + limit: url.searchParams.get('limit') ?? undefined, + }) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const store = await getSceneStore() + const scenes = await store.list({ + projectId: parsed.data.projectId, + limit: parsed.data.limit, + }) + return NextResponse.json({ scenes }) +} + +export async function POST(request: NextRequest) { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'invalid_request', details: 'body must be valid JSON' }, + { status: 400 }, + ) + } + + const parsed = createSceneSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request', details: parsed.error.issues }, + { status: 400 }, + ) + } + + const store = await getSceneStore() + try { + const meta = await store.save({ + id: parsed.data.id, + name: parsed.data.name, + projectId: parsed.data.projectId ?? null, + graph: parsed.data.graph as never, + thumbnailUrl: parsed.data.thumbnailUrl ?? null, + }) + return NextResponse.json(meta, { + status: 201, + headers: { Location: `/scene/${meta.id}` }, + }) + } catch (error) { + return handleStoreError(error) + } +} + +function handleStoreError(error: unknown): NextResponse { + const code = (error as { code?: string })?.code + if (code === 'version_conflict') { + return NextResponse.json({ error: 'version_conflict' }, { status: 409 }) + } + if (code === 'not_found') { + return NextResponse.json({ error: 'not_found' }, { status: 404 }) + } + if (code === 'too_large') { + return NextResponse.json({ error: 'too_large' }, { status: 413 }) + } + if (code === 'invalid') { + return NextResponse.json({ error: 'invalid' }, { status: 400 }) + } + const message = error instanceof Error ? error.message : 'unexpected_error' + return NextResponse.json({ error: 'internal_error', message }, { status: 500 }) +} diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index ee705ac8..72a45c41 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,18 +1,7 @@ 'use client' -import { - Editor, - type SidebarTab, - ViewerToolbarLeft, - ViewerToolbarRight, -} from '@pascal-app/editor' -import { useScene } from '@pascal-app/core' - -// Dev-only: expose the scene store on window so MCP can inject a built -// scene into the running editor for visual verification. No-op in prod. -if (typeof window !== 'undefined' && process.env.NODE_ENV !== 'production') { - ;(window as unknown as { __pascalScene?: typeof useScene }).__pascalScene = useScene -} +import { Editor, type SidebarTab, ViewerToolbarLeft, ViewerToolbarRight } from '@pascal-app/editor' +import Link from 'next/link' const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ { @@ -22,12 +11,30 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ }, ] +const PROJECT_ID = 'local-editor' + export default function Home() { return ( -
+
+ {PROJECT_ID === 'local-editor' && ( +
+
+ Local editor — scenes are not saved. + + Open recent scenes + + + · + + + Create new + +
+
+ )} } viewerToolbarRight={} diff --git a/apps/editor/app/scene/[id]/page.tsx b/apps/editor/app/scene/[id]/page.tsx new file mode 100644 index 00000000..95df49f3 --- /dev/null +++ b/apps/editor/app/scene/[id]/page.tsx @@ -0,0 +1,73 @@ +import type { SceneGraph } from '@pascal-app/editor' +import { headers } from 'next/headers' +import Link from 'next/link' +import { SceneLoader, type SceneMeta } from '@/components/scene-loader' + +export const dynamic = 'force-dynamic' + +interface SceneWithGraph extends SceneMeta { + graph: SceneGraph +} + +async function resolveBaseUrl(): Promise { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL + } + const h = await headers() + const host = h.get('x-forwarded-host') ?? h.get('host') + const proto = h.get('x-forwarded-proto') ?? 'http' + if (!host) { + return 'http://localhost:3000' + } + return `${proto}://${host}` +} + +async function fetchScene(id: string): Promise { + const base = await resolveBaseUrl() + const response = await fetch(`${base}/api/scenes/${encodeURIComponent(id)}`, { + cache: 'no-store', + }) + if (response.status === 404) { + return null + } + if (!response.ok) { + throw new Error(`Failed to load scene: ${response.status}`) + } + return (await response.json()) as SceneWithGraph +} + +export default async function ScenePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const scene = await fetchScene(id) + + if (!scene) { + return ( +
+
+

404

+

Scene not found

+

+ We couldn't find a scene with id {id}. +

+
+ + Browse scenes + + + Back to editor + +
+
+
+ ) + } + + const { graph, ...meta } = scene + return +} diff --git a/apps/editor/app/scenes/page.tsx b/apps/editor/app/scenes/page.tsx new file mode 100644 index 00000000..39b8dbf0 --- /dev/null +++ b/apps/editor/app/scenes/page.tsx @@ -0,0 +1,117 @@ +import { headers } from 'next/headers' +import Link from 'next/link' +import { CreateSceneButton } from '@/components/save-button' +import type { SceneMeta } from '@/components/scene-loader' + +export const dynamic = 'force-dynamic' + +async function resolveBaseUrl(): Promise { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL + } + const h = await headers() + const host = h.get('x-forwarded-host') ?? h.get('host') + const proto = h.get('x-forwarded-proto') ?? 'http' + if (!host) { + return 'http://localhost:3000' + } + return `${proto}://${host}` +} + +async function fetchScenes(): Promise { + const base = await resolveBaseUrl() + const response = await fetch(`${base}/api/scenes?limit=50`, { + cache: 'no-store', + }) + if (!response.ok) { + return [] + } + const payload = (await response.json()) as { scenes?: SceneMeta[] } | SceneMeta[] + if (Array.isArray(payload)) { + return payload + } + return payload.scenes ?? [] +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString() + } catch { + return iso + } +} + +export default async function ScenesPage() { + const scenes = await fetchScenes() + + return ( +
+
+
+ + +
+
+ +
+

Your scenes

+

+ {scenes.length === 0 + ? 'No scenes yet. Create one to get started.' + : `${scenes.length} scene${scenes.length === 1 ? '' : 's'}.`} +

+ + {scenes.length === 0 ? ( +
+

You haven't saved any scenes yet.

+
+ +
+
+ ) : ( +
    + {scenes.map((scene) => ( +
  • + +
    + {scene.thumbnailUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {scene.name} + ) : ( + No thumbnail + )} +
    +
    +

    + {scene.name} +

    +
    + {scene.nodeCount} nodes + +
    +
    + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/apps/editor/components/save-button.tsx b/apps/editor/components/save-button.tsx new file mode 100644 index 00000000..bd227b83 --- /dev/null +++ b/apps/editor/components/save-button.tsx @@ -0,0 +1,157 @@ +'use client' + +import type { SceneGraph } from '@pascal-app/editor' +import { useRouter } from 'next/navigation' +import { useCallback, useState } from 'react' + +const EMPTY_GRAPH: SceneGraph = { + nodes: {}, + rootNodeIds: [], +} + +interface SaveButtonProps { + sceneId: string + name: string + version: number + getGraph: () => SceneGraph | null +} + +/** + * Creates a new empty scene and navigates the user to it. + */ +export function CreateSceneButton({ label = 'Create new scene' }: { label?: string } = {}) { + const router = useRouter() + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + + const handleCreate = useCallback(async () => { + setIsCreating(true) + setError(null) + try { + const response = await fetch('/api/scenes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Untitled scene', graph: EMPTY_GRAPH }), + }) + if (!response.ok) { + setError(`Failed to create scene (${response.status})`) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/scene/${meta.id}`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create scene') + } finally { + setIsCreating(false) + } + }, [router]) + + return ( +
+ {error && {error}} + +
+ ) +} + +/** + * Save + Save-as buttons that call the scenes API directly. + * Used for UIs that want explicit save controls outside of the Editor's + * built-in autosave plumbing. + */ +export function SaveButton({ sceneId, name, version, getGraph }: SaveButtonProps) { + const router = useRouter() + const [isSaving, setIsSaving] = useState(false) + const [status, setStatus] = useState(null) + + const handleSave = useCallback(async () => { + const graph = getGraph() + if (!graph) { + setStatus('No scene to save') + return + } + setIsSaving(true) + setStatus(null) + try { + const response = await fetch(`/api/scenes/${sceneId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'If-Match': String(version), + }, + body: JSON.stringify({ name, graph }), + }) + if (response.status === 409) { + setStatus('Conflict — reload to continue') + return + } + if (!response.ok) { + setStatus(`Save failed (${response.status})`) + return + } + setStatus('Saved') + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Save failed') + } finally { + setIsSaving(false) + } + }, [getGraph, name, sceneId, version]) + + const handleSaveAs = useCallback(async () => { + const graph = getGraph() + if (!graph) { + setStatus('No scene to save') + return + } + const newName = typeof window !== 'undefined' ? window.prompt('New scene name', name) : null + if (!newName) return + setIsSaving(true) + setStatus(null) + try { + const response = await fetch('/api/scenes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName, graph }), + }) + if (!response.ok) { + setStatus(`Save-as failed (${response.status})`) + return + } + const meta = (await response.json()) as { id: string } + router.push(`/scene/${meta.id}`) + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Save-as failed') + } finally { + setIsSaving(false) + } + }, [getGraph, name, router]) + + return ( +
+ + + {status && {status}} +
+ ) +} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx new file mode 100644 index 00000000..6ae6962a --- /dev/null +++ b/apps/editor/components/scene-loader.tsx @@ -0,0 +1,145 @@ +'use client' + +import { + Editor, + type SceneGraph, + type SidebarTab, + ViewerToolbarLeft, + ViewerToolbarRight, +} from '@pascal-app/editor' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useCallback, useRef, useState } from 'react' + +export interface SceneMeta { + id: string + name: string + projectId: string | null + thumbnailUrl: string | null + version: number + createdAt: string + updatedAt: string + ownerId: string | null + sizeBytes: number + nodeCount: number +} + +const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ + { + id: 'site', + label: 'Scene', + component: () => null, // Built-in SitePanel handles this + }, +] + +interface SceneLoaderProps { + initialScene: SceneGraph + meta: SceneMeta +} + +export function SceneLoader({ initialScene, meta }: SceneLoaderProps) { + const router = useRouter() + const versionRef = useRef(meta.version) + const [conflict, setConflict] = useState(false) + const [saveError, setSaveError] = useState(null) + + const handleLoad = useCallback(async () => initialScene, [initialScene]) + + const handleSave = useCallback( + async (graph: SceneGraph) => { + try { + const response = await fetch(`/api/scenes/${meta.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'If-Match': String(versionRef.current), + }, + body: JSON.stringify({ name: meta.name, graph }), + }) + + if (response.status === 409) { + setConflict(true) + return + } + + if (!response.ok) { + setSaveError(`Save failed (${response.status})`) + return + } + + const next = (await response.json()) as SceneMeta + versionRef.current = next.version + setSaveError(null) + } catch (error) { + setSaveError(error instanceof Error ? error.message : 'Save failed') + } + }, + [meta.id, meta.name], + ) + + const handleThumb = useCallback( + async (_blob: Blob) => { + // TODO(phase7): upload thumbnail via POST /api/scenes/[id]/thumbnail. + // Stub endpoint is not yet implemented in v0.1 — skip upload for now. + await fetch(`/api/scenes/${meta.id}/thumbnail`, { + method: 'POST', + // Intentionally no body — endpoint is a stub. + }).catch(() => { + // Swallow errors silently; thumbnail upload is best-effort. + }) + }, + [meta.id], + ) + + return ( +
+ {conflict && ( +
+

Another session saved first — refresh?

+

+ Your changes haven't been saved. Reload to pick up the latest version. +

+
+ + +
+
+ )} + {saveError && !conflict && ( +
+

{saveError}

+
+ )} +
+ + All scenes + +
+ } + viewerToolbarRight={} + /> +
+ ) +} diff --git a/apps/editor/lib/scene-store-server.test.ts b/apps/editor/lib/scene-store-server.test.ts new file mode 100644 index 00000000..90d11d2a --- /dev/null +++ b/apps/editor/lib/scene-store-server.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' + +describe('getSceneStore', () => { + beforeEach(() => { + mock.module('@pascal-app/mcp/storage', () => { + let callCount = 0 + return { + createSceneStore: async (_env?: NodeJS.ProcessEnv) => { + callCount++ + return { + backend: 'filesystem' as const, + __instanceNumber: callCount, + save: async () => ({}) as never, + load: async () => null, + list: async () => [], + delete: async () => false, + rename: async () => ({}) as never, + } + }, + } + }) + }) + + test('returns the same promise on repeated calls', async () => { + const mod = await import('./scene-store-server') + mod.__resetSceneStoreForTests() + + const a = mod.getSceneStore() + const b = mod.getSceneStore() + + expect(a).toBe(b) + }) + + test('resolves to the same store instance across calls', async () => { + const mod = await import('./scene-store-server') + mod.__resetSceneStoreForTests() + + const storeA = await mod.getSceneStore() + const storeB = await mod.getSceneStore() + + expect(storeA).toBe(storeB) + // Factory should have been invoked exactly once — asserted indirectly via + // our mock's instance counter. + expect((storeA as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1) + expect((storeB as unknown as { __instanceNumber: number }).__instanceNumber).toBe(1) + }) + + test('reset helper clears the cached singleton', async () => { + const mod = await import('./scene-store-server') + mod.__resetSceneStoreForTests() + + const first = await mod.getSceneStore() + mod.__resetSceneStoreForTests() + const second = await mod.getSceneStore() + + expect(first).not.toBe(second) + }) +}) diff --git a/apps/editor/lib/scene-store-server.ts b/apps/editor/lib/scene-store-server.ts new file mode 100644 index 00000000..3eb61add --- /dev/null +++ b/apps/editor/lib/scene-store-server.ts @@ -0,0 +1,91 @@ +// TODO: auth — every call in this module currently runs unauthenticated. +// v0.1 skips auth; the factory should eventually receive a user context from +// middleware / a request-scoped session and propagate it into SceneStore. +// Only import this module from server code (route handlers, server components, +// server actions). Importing from client code will leak the Supabase service +// role key into the browser bundle. + +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' + +/** + * Inlined copies of the shared storage contract. The canonical source lives in + * `packages/mcp/src/storage/types.ts`; re-declared here so the editor only + * needs the runtime factory from `@pascal-app/mcp/storage` and type-checks + * without a hard compile-time dependency on the MCP package's source tree. + * + * Keep this file in sync whenever the MCP storage types change. + */ +export type SceneId = string + +export interface SceneMeta { + id: SceneId + name: string + projectId: string | null + thumbnailUrl: string | null + version: number + createdAt: string + updatedAt: string + ownerId: string | null + sizeBytes: number + nodeCount: number +} + +export interface SceneWithGraph extends SceneMeta { + graph: SceneGraph +} + +export interface SceneSaveOptions { + id?: SceneId + name: string + projectId?: string | null + ownerId?: string | null + graph: SceneGraph + thumbnailUrl?: string | null + expectedVersion?: number +} + +export interface SceneListOptions { + projectId?: string + ownerId?: string + limit?: number +} + +export interface SceneMutateOptions { + expectedVersion?: number +} + +export interface SceneStore { + readonly backend: 'filesystem' | 'supabase' + save(opts: SceneSaveOptions): Promise + load(id: SceneId): Promise + list(opts?: SceneListOptions): Promise + delete(id: SceneId, opts?: SceneMutateOptions): Promise + rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise +} + +/** + * Per-process singleton. The factory is async because backend modules are + * dynamically imported — we cache the in-flight promise so concurrent calls + * during a cold start share a single instantiation. + */ +let cached: Promise | null = null + +export function getSceneStore(): Promise { + if (!cached) { + cached = (async () => { + const mod = (await import('@pascal-app/mcp/storage')) as { + createSceneStore: (env?: NodeJS.ProcessEnv) => Promise + } + return mod.createSceneStore(process.env) + })() + } + return cached +} + +/** + * Test-only helper to reset the cached singleton. Not exported for production + * callers. + */ +export function __resetSceneStoreForTests(): void { + cached = null +} diff --git a/apps/editor/package.json b/apps/editor/package.json index 518447af..e8b71334 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -14,6 +14,7 @@ "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", + "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", @@ -26,7 +27,8 @@ "react-dom": "^19.2.4", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", - "three": "^0.184.0" + "three": "^0.184.0", + "zod": "^4.3.5" }, "devDependencies": { "@pascal/typescript-config": "*", diff --git a/bun.lock b/bun.lock index bffe453b..7d9ff1c0 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@number-flow/react": "^0.5.14", "@pascal-app/core": "*", "@pascal-app/editor": "*", + "@pascal-app/mcp": "*", "@pascal-app/viewer": "*", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", @@ -31,6 +32,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "three": "^0.184.0", + "zod": "^4.3.5", }, "devDependencies": { "@pascal/typescript-config": "*", @@ -151,6 +153,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@supabase/supabase-js": "^2", "zod": "^4.3.5", }, "devDependencies": { @@ -584,6 +587,20 @@ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + "@supabase/auth-js": ["@supabase/auth-js@2.103.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-SMDJ4vg5jLXNEHdhN4J4ujSb203WangbDw1n3VaARH0ZqM51E6lJnoUAHlpQU9N7SzP0hfgghA9IvT8c7tGRfg=="], + + "@supabase/functions-js": ["@supabase/functions-js@2.103.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-A2ZHi95GIRRlN9LGOSa/zGEIPg9taR1giDI9Gkfkgrcz0YmKV8ShiAplIrKsHQFdkzKxtsO3maJF0efL+i31mg=="], + + "@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="], + + "@supabase/postgrest-js": ["@supabase/postgrest-js@2.103.3", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-S0k/9FJVXDeejNfQLCJwRlm4IH8Wet/HEEdBTBpX6/G2o1eU/6CjQop/hJPZIwlQkI6D/zbHH8KymuCsBgy6jA=="], + + "@supabase/realtime-js": ["@supabase/realtime-js@2.103.3", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-fUvKtSXMUk1BkApVwAurWtHF4Vzbb0UB9aC/fQXrRBek7Ta3Kaora+wHf/fGwFNQs7uRz+mvjIVpzLfpR32VXA=="], + + "@supabase/storage-js": ["@supabase/storage-js@2.103.3", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-5bAIEubrw5keHcdKR2RTois0O1M2Ilx4UYuzOzc07G6mLGCPS/8t1nbC6Vq451pnxR3sK+rmtFHWb9CY/OPjAw=="], + + "@supabase/supabase-js": ["@supabase/supabase-js@2.103.3", "", { "dependencies": { "@supabase/auth-js": "2.103.3", "@supabase/functions-js": "2.103.3", "@supabase/postgrest-js": "2.103.3", "@supabase/realtime-js": "2.103.3", "@supabase/storage-js": "2.103.3" } }, "sha512-DuPiAz5pIJsTAQCt7B6bDZrnLzlq9+/5bta/GWTsgpLn6AkuZQcmYsQHYplv4skQ8U2raKY5HASQOu4KtYq9Qw=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], @@ -654,6 +671,8 @@ "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="], @@ -1012,6 +1031,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "idb-keyval": ["idb-keyval@6.2.2", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="], @@ -1528,6 +1549,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1594,6 +1617,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], @@ -1648,6 +1673,8 @@ "@pascal-app/viewer/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index bb6d2b7b..7d2d9be2 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -97,6 +97,21 @@ export interface ThumbnailGenerateEvent { snapLevels?: boolean } +export interface CameraControlFitSceneEvent { + /** + * XZ-plane axis-aligned bounds of the scene's geometry, computed from the + * scene graph (see `@pascal-app/editor`'s `computeSceneBoundsXZ`). The + * viewer's camera-controls listener frames the camera onto this box. + * Omitted values fall back to the camera's default pose. + */ + bounds?: { + min: [number, number] + max: [number, number] + center: [number, number] + size: [number, number] + } +} + type CameraControlEvents = { 'camera-controls:view': CameraControlEvent 'camera-controls:focus': CameraControlEvent @@ -104,6 +119,7 @@ type CameraControlEvents = { 'camera-controls:top-view': undefined 'camera-controls:orbit-cw': undefined 'camera-controls:orbit-ccw': undefined + 'camera-controls:fit-scene': CameraControlFitSceneEvent 'camera-controls:generate-thumbnail': ThumbnailGenerateEvent } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 134770e2..ba55d0e3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export type { BuildingEvent, CameraControlEvent, + CameraControlFitSceneEvent, CeilingEvent, DoorEvent, EventSuffix, @@ -37,7 +38,6 @@ export { type Space, wallTouchesOthers, } from './lib/space-detection' -export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, @@ -48,6 +48,7 @@ export { type MaterialCatalogItem, toLibraryMaterialRef, } from './material-library' +export { baseMaterial, glassMaterial } from './materials' export * from './schema' export { type ControlValue, @@ -55,20 +56,14 @@ export { useInteractive, } from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' -export { FenceSystem } from './systems/fence/fence-system' export { clearSceneHistory, default as useScene } from './store/use-scene' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' +export { FenceSystem } from './systems/fence/fence-system' export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' export { StairSystem } from './systems/stair/stair-system' -export { - DEFAULT_WALL_HEIGHT, - DEFAULT_WALL_THICKNESS, - getWallPlanFootprint, - getWallThickness, -} from './systems/wall/wall-footprint' export { getClampedWallCurveOffset, getMaxWallCurveOffset, @@ -82,12 +77,18 @@ export { normalizeWallCurveOffset, sampleWallCenterline, } from './systems/wall/wall-curve' +export { + DEFAULT_WALL_HEIGHT, + DEFAULT_WALL_THICKNESS, + getWallPlanFootprint, + getWallThickness, +} from './systems/wall/wall-footprint' export { calculateLevelMiters, getWallMiterBoundaryPoints, type Point2D, - type WallMiterBoundaryPoints, pointToKey, + type WallMiterBoundaryPoints, type WallMiterData, } from './systems/wall/wall-mitering' export { WallSystem } from './systems/wall/wall-system' diff --git a/packages/core/src/schema/asset-url.test.ts b/packages/core/src/schema/asset-url.test.ts new file mode 100644 index 00000000..85facfc7 --- /dev/null +++ b/packages/core/src/schema/asset-url.test.ts @@ -0,0 +1,141 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; core does not +// depend on @types/bun so the import type is unresolved at compile time. +// The tsconfig in packages/core still emits this file; the @ts-expect-error +// keeps the build green while letting `bun test` pick it up normally. +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { ALLOWED_ORIGINS_ENV, AssetUrl } from './asset-url' + +function isValid(url: string): boolean { + return AssetUrl.safeParse(url).success +} + +describe('AssetUrl', () => { + describe('allowed URLs', () => { + const cases: Array<[string, string]> = [ + ['asset://abc', 'internal asset handle'], + ['asset://catalog/items/chair-1', 'nested asset handle'], + ['blob:http://example.com/uuid-1234', 'blob URL with http inner'], + ['blob:https://example.com/uuid-5678', 'blob URL with https inner'], + ['https://cdn.example.com/a.glb', 'https CDN URL'], + ['https://cdn.example.com/models/chair.glb?v=2', 'https URL with query string'], + ['http://localhost:3000/x', 'http localhost with port'], + ['http://localhost/x', 'http localhost without port'], + ['http://127.0.0.1:8080/texture.png', 'http 127.0.0.1 loopback'], + ['/public/a.glb', 'app-relative path'], + ['/material/wood1/albedoMap_basecolor.jpg', 'relative path deep'], + ['data:image/png;base64,AAA', 'inline PNG data URL'], + ['data:image/jpeg;base64,/9j/', 'inline JPEG data URL'], + ['data:image/webp;base64,UklGR', 'inline WebP data URL'], + ['data:image/svg+xml,%3Csvg%3E', 'inline SVG data URL'], + ] + for (const [url, label] of cases) { + test(`accepts ${label}: ${url}`, () => { + expect(isValid(url)).toBe(true) + }) + } + }) + + describe('rejected URLs', () => { + const cases: Array<[string, string]> = [ + ['javascript:alert(1)', 'javascript scheme'], + ['JAVASCRIPT:alert(1)', 'javascript scheme uppercase'], + ['file:///etc/passwd', 'file scheme'], + ['file://C:/Windows/System32/config', 'file scheme Windows'], + ['http://evil.com/', 'non-loopback http'], + ['http://example.com:3000/x', 'http on non-loopback host'], + ['http://169.254.169.254/latest/meta-data/', 'http on link-local (cloud metadata)'], + ['data:text/html,', 'data text/html'], + ['data:application/javascript,alert(1)', 'data application/javascript'], + ['data:text/plain,hi', 'data text/plain'], + ['ftp://a.b.com', 'ftp scheme'], + ['ws://example.com/', 'websocket scheme'], + ['vbscript:msgbox', 'vbscript scheme'], + ['', 'empty string'], + ['not a url at all', 'non-url string'], + ['://missing-scheme', 'malformed'], + ] + for (const [url, label] of cases) { + test(`rejects ${label}: ${url}`, () => { + expect(isValid(url)).toBe(false) + }) + } + }) + + describe(`env allowlist via ${ALLOWED_ORIGINS_ENV}`, () => { + const g = globalThis as { process?: { env?: Record } } + const original = g.process?.env?.[ALLOWED_ORIGINS_ENV] + + beforeEach(() => { + if (g.process?.env) delete g.process.env[ALLOWED_ORIGINS_ENV] + }) + + afterEach(() => { + if (!g.process?.env) return + if (original === undefined) { + delete g.process.env[ALLOWED_ORIGINS_ENV] + } else { + g.process.env[ALLOWED_ORIGINS_ENV] = original + } + }) + + test('single origin allowlist accepts matching https URL', () => { + if (!g.process?.env) return // browser-only runtime + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true) + expect(isValid('https://cdn.pascal.app/deep/path?q=1')).toBe(true) + }) + + test('single origin allowlist rejects non-matching https URL', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.other.com/a.glb')).toBe(false) + expect(isValid('https://attacker.example.com/x')).toBe(false) + }) + + test('multi-origin allowlist accepts any listed origin', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app, https://assets.pascal.app' + expect(isValid('https://cdn.pascal.app/a.glb')).toBe(true) + expect(isValid('https://assets.pascal.app/tex.webp')).toBe(true) + expect(isValid('https://third.example.com/x')).toBe(false) + }) + + test('allowlist ignores trailing / in URL path (origin match only)', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app/')).toBe(true) + expect(isValid('https://cdn.pascal.app')).toBe(true) + }) + + test('empty allowlist behaves like unset', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = '' + expect(isValid('https://cdn.other.com/a.glb')).toBe(true) + }) + + test('allowlist does not restrict non-https schemes', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + // these should still pass because they match earlier scheme-based branches + expect(isValid('asset://x')).toBe(true) + expect(isValid('blob:https://example.com/abc')).toBe(true) + expect(isValid('data:image/png;base64,AAA')).toBe(true) + expect(isValid('/public/a.glb')).toBe(true) + expect(isValid('http://localhost:3000/x')).toBe(true) + }) + + test('allowlist rejects subdomain spoofing', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app' + expect(isValid('https://cdn.pascal.app.evil.com/x')).toBe(false) + expect(isValid('https://evil.com/cdn.pascal.app')).toBe(false) + }) + + test('allowlist respects ports', () => { + if (!g.process?.env) return + g.process.env[ALLOWED_ORIGINS_ENV] = 'https://cdn.pascal.app:8443' + expect(isValid('https://cdn.pascal.app:8443/x')).toBe(true) + expect(isValid('https://cdn.pascal.app/x')).toBe(false) + }) + }) +}) diff --git a/packages/core/src/schema/asset-url.ts b/packages/core/src/schema/asset-url.ts new file mode 100644 index 00000000..5497bd26 --- /dev/null +++ b/packages/core/src/schema/asset-url.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' + +/** + * Scheme allowlist for asset-like URLs embedded in scene graphs. + * + * Phase 3 security audit: `scan.url`, `guide.url`, `material.texture.url`, and + * `item.asset.src` were previously bare `z.string()`. That meant an + * attacker-crafted scene loaded in the editor could beacon to arbitrary URLs + * (e.g. `javascript:`, `file:///etc/passwd`, `http://169.254.169.254/...`). + * + * This validator rejects URLs that don't match the scheme allowlist below. + */ +const ALLOWED_SCHEMES = ['asset:', 'blob:', 'https:', 'data:image/'] as const + +/** + * Optional environment variable that narrows which `https:` origins are + * accepted. Set to a comma-separated list (e.g. `https://cdn.pascal.app`). + * When unset, any `https:` origin is permitted. + */ +export const ALLOWED_ORIGINS_ENV = 'PASCAL_ALLOWED_ASSET_ORIGINS' + +// Narrow access to the environment variable without requiring @types/node in +// this package. The core package ships to both browser and Node contexts. +function readAllowedOrigins(): readonly string[] | undefined { + const g = globalThis as { process?: { env?: Record } } + const value = g.process?.env?.[ALLOWED_ORIGINS_ENV] + if (!value) return undefined + const list = value + .split(',') + .map((s: string) => s.trim()) + .filter((s: string) => s.length > 0) + return list.length > 0 ? list : undefined +} + +function isAllowedAssetUrl(url: string): boolean { + if (typeof url !== 'string' || url.length === 0) return false + if (url.startsWith('asset://')) return true // internal handle + if (url.startsWith('blob:')) return true // in-memory reference + if (url.startsWith('data:image/')) return true // inline image only (never data:text/html) + if (url.startsWith('/')) return true // app-relative path + try { + const parsed = new URL(url) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false + // http is only permitted for localhost development + if (parsed.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(parsed.hostname)) { + return false + } + // optional env-driven origin allowlist (only enforced for https URLs) + if (parsed.protocol === 'https:') { + const allowlist = readAllowedOrigins() + if (allowlist) return allowlist.includes(parsed.origin) + } + return true + } catch { + return false + } +} + +/** + * Zod validator for asset-style URL fields. Accepts: + * - `asset://…` internal handles + * - `blob:…` in-memory references + * - `data:image/…` inline images (not `data:text/html` or other types) + * - `/…` app-relative paths + * - `https://…` public URLs (optionally narrowed to an env allowlist) + * - `http://localhost[:port]/…` or `http://127.0.0.1/…` for local dev + * + * Rejects every other scheme, including `javascript:`, `file:`, `ftp:`, + * and `data:text/html`, as well as empty strings and non-URL garbage. + */ +export const AssetUrl = z.string().refine(isAllowedAssetUrl, { + message: + 'URL must be asset://, blob:, data:image/, /path, or https://. http://localhost allowed for dev.', +}) + +export type AssetUrl = z.infer + +// re-export the scheme allowlist for documentation / downstream validators +export { ALLOWED_SCHEMES } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 82086641..076ebc72 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,6 +4,13 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' export { CameraSchema } from './camera' // Collections export { type Collection, type CollectionId, generateCollectionId } from './collections' +export type { + MaterialMapProperties, + MaterialMaps, + MaterialPresetPayload, + MaterialTarget as MaterialTargetValue, + TextureWrapMode as TextureWrapModeValue, +} from './material' // Material export { DEFAULT_MATERIALS, @@ -14,15 +21,8 @@ export { MaterialProperties, MaterialSchema, MaterialTarget, - TextureWrapMode, resolveMaterial, -} from './material' -export type { - MaterialMapProperties, - MaterialMaps, - MaterialPresetPayload, - MaterialTarget as MaterialTargetValue, - TextureWrapMode as TextureWrapModeValue, + TextureWrapMode, } from './material' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 3ab96bf8..846140ef 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { AssetUrl } from './asset-url' export const MaterialPreset = z.enum([ 'white', @@ -30,7 +31,7 @@ export const MaterialSchema = z.object({ properties: MaterialProperties.optional(), texture: z .object({ - url: z.string(), + url: AssetUrl, repeat: z.tuple([z.number(), z.number()]).optional(), scale: z.number().optional(), }) @@ -56,16 +57,16 @@ export const TextureWrapMode = z.enum(['Repeat', 'ClampToEdge', 'MirroredRepeat' export type TextureWrapMode = z.infer export const MaterialMapsSchema = z.object({ - albedoMap: z.string().optional(), - metalnessMap: z.string().optional(), - roughnessMap: z.string().optional(), - normalMap: z.string().optional(), - displacementMap: z.string().optional(), - aoMap: z.string().optional(), - emissiveMap: z.string().optional(), - bumpMap: z.string().optional(), - alphaMap: z.string().optional(), - lightMap: z.string().optional(), + albedoMap: AssetUrl.optional(), + metalnessMap: AssetUrl.optional(), + roughnessMap: AssetUrl.optional(), + normalMap: AssetUrl.optional(), + displacementMap: AssetUrl.optional(), + aoMap: AssetUrl.optional(), + emissiveMap: AssetUrl.optional(), + bumpMap: AssetUrl.optional(), + alphaMap: AssetUrl.optional(), + lightMap: AssetUrl.optional(), }) export type MaterialMaps = z.infer diff --git a/packages/core/src/schema/nodes/guide.ts b/packages/core/src/schema/nodes/guide.ts index c331f656..359887e9 100644 --- a/packages/core/src/schema/nodes/guide.ts +++ b/packages/core/src/schema/nodes/guide.ts @@ -1,10 +1,11 @@ import { z } from 'zod' +import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' export const GuideNode = BaseNode.extend({ id: objectId('guide'), type: nodeType('guide'), - url: z.string(), + url: AssetUrl, position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.number().default(1), diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 36a90e66..1d980da5 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -1,5 +1,6 @@ import dedent from 'dedent' import { z } from 'zod' +import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' import type { CollectionId } from '../collections' @@ -79,7 +80,7 @@ const assetSchema = z.object({ category: z.string(), name: z.string(), thumbnail: z.string(), - src: z.string(), + src: AssetUrl, dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), tags: z.array(z.string()).optional(), diff --git a/packages/core/src/schema/nodes/scan.ts b/packages/core/src/schema/nodes/scan.ts index 2e2eaf1d..9f50a1b5 100644 --- a/packages/core/src/schema/nodes/scan.ts +++ b/packages/core/src/schema/nodes/scan.ts @@ -1,10 +1,11 @@ import { z } from 'zod' +import { AssetUrl } from '../asset-url' import { BaseNode, nodeType, objectId } from '../base' export const ScanNode = BaseNode.extend({ id: objectId('scan'), type: nodeType('scan'), - url: z.string(), + url: AssetUrl, position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.number().default(1), diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0bee7583..e6bfc7f8 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { + type CameraControlEvent, + type CameraControlFitSceneEvent, + emitter, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' @@ -340,12 +346,30 @@ export const CustomCameraControls = () => { focusNode(nodeId) } + const handleFitScene = ({ bounds }: CameraControlFitSceneEvent) => { + if (!controls.current || isPreviewMode) return + if (!bounds) { + // Restore default framing pose when no bounds were computed. + controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) + return + } + const [cx, cz] = bounds.center + const [w, d] = bounds.size + // Use the longer horizontal extent to size the orbit radius so the whole + // footprint sits in view regardless of aspect ratio. + const maxExtent = Math.max(w, d) + const distance = Math.max(maxExtent * 1.4, 15) + const height = Math.max(maxExtent * 0.8, 10) + controls.current.setLookAt(cx + distance * 0.7, height, cz + distance * 0.7, cx, 0, cz, true) + } + emitter.on('camera-controls:capture', handleNodeCapture) emitter.on('camera-controls:focus', handleNodeFocus) emitter.on('camera-controls:view', handleNodeView) emitter.on('camera-controls:top-view', handleTopView) emitter.on('camera-controls:orbit-cw', handleOrbitCW) emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) + emitter.on('camera-controls:fit-scene', handleFitScene) return () => { emitter.off('camera-controls:capture', handleNodeCapture) @@ -354,8 +378,9 @@ export const CustomCameraControls = () => { emitter.off('camera-controls:top-view', handleTopView) emitter.off('camera-controls:orbit-cw', handleOrbitCW) emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + emitter.off('camera-controls:fit-scene', handleFitScene) } - }, [focusNode]) + }, [focusNode, isPreviewMode]) const onTransitionStart = useCallback(() => { useViewer.getState().setCameraDragging(true) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 4dbbda1a..9a8d5432 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -12,6 +12,7 @@ import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from ' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' +import { useAutoFrame } from '../../hooks/use-auto-frame' import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save' import { useKeyboard } from '../../hooks/use-keyboard' import { @@ -22,8 +23,8 @@ import { } from '../../lib/scene' import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' -import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' +import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' @@ -732,6 +733,7 @@ export default function Editor({ commandPaletteEmptyAction, }: EditorProps) { useKeyboard({ isVersionPreviewMode }) + useAutoFrame() const { isLoadingSceneRef } = useAutoSave({ onSave, diff --git a/packages/editor/src/hooks/use-auto-frame.ts b/packages/editor/src/hooks/use-auto-frame.ts new file mode 100644 index 00000000..6b20238f --- /dev/null +++ b/packages/editor/src/hooks/use-auto-frame.ts @@ -0,0 +1,45 @@ +'use client' + +import { emitter, useScene } from '@pascal-app/core' +import { useEffect, useRef } from 'react' +import { computeSceneBoundsXZ } from '../lib/scene-bounds' + +/** + * Auto-frame the camera onto a freshly loaded scene. + * + * Motivation: when the MCP `setScene` tool (or any other entry point) swaps + * the scene graph while the default camera is pointing at empty space, the + * user sees a black viewport. This hook subscribes to the core scene store + * and, whenever `nodes` transitions from empty → non-empty, computes the + * XZ bounds of the new scene and emits `camera-controls:fit-scene`. The + * `` component picks up that event and frames the + * camera onto the bounds. + * + * Mount in exactly ONE component (the Editor). It holds no state of its own; + * the subscription is torn down on unmount. + */ +export function useAutoFrame(): void { + // Track the previous node count so we can detect the empty → non-empty edge. + const wasEmptyRef = useRef(true) + + useEffect(() => { + // Initialise from current store state so a remount after a setScene + // doesn't re-frame an already-populated scene. + wasEmptyRef.current = Object.keys(useScene.getState().nodes).length === 0 + + const unsubscribe = useScene.subscribe((state) => { + const isEmpty = Object.keys(state.nodes).length === 0 + const wasEmpty = wasEmptyRef.current + wasEmptyRef.current = isEmpty + + // Only react to empty → non-empty transitions. Normal edits keep both + // flags false; a `clearScene()` goes non-empty → empty and is ignored. + if (!wasEmpty || isEmpty) return + + const bounds = computeSceneBoundsXZ(state.nodes) + emitter.emit('camera-controls:fit-scene', bounds ? { bounds } : {}) + }) + + return unsubscribe + }, []) +} diff --git a/packages/editor/src/lib/scene-bounds.test.ts b/packages/editor/src/lib/scene-bounds.test.ts new file mode 100644 index 00000000..e607a928 --- /dev/null +++ b/packages/editor/src/lib/scene-bounds.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode } from '@pascal-app/core/schema' +import { computeSceneBoundsXZ } from './scene-bounds' + +function makeWall(start: [number, number], end: [number, number]): AnyNode { + return { + object: 'node', + id: `wall_${start.join('_')}_${end.join('_')}`, + type: 'wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start, + end, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode +} + +function makeZone(polygon: [number, number][]): AnyNode { + return { + object: 'node', + id: `zone_${polygon.length}_${polygon[0]?.[0] ?? 0}`, + type: 'zone', + parentId: null, + visible: true, + metadata: {}, + name: 'Zone', + polygon, + color: '#000000', + } as unknown as AnyNode +} + +function makeSite(points: [number, number][]): AnyNode { + return { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { type: 'polygon', points }, + children: [], + } as unknown as AnyNode +} + +describe('computeSceneBoundsXZ', () => { + test('returns null when given an empty array', () => { + expect(computeSceneBoundsXZ([])).toBeNull() + }) + + test('returns null when no geometry is found on any node', () => { + const barren = [ + { + object: 'node', + id: 'building_1', + type: 'building', + parentId: null, + visible: true, + metadata: {}, + children: [], + } as unknown as AnyNode, + ] + expect(computeSceneBoundsXZ(barren)).toBeNull() + }) + + test('computes bounds from wall endpoints', () => { + const nodes: AnyNode[] = [makeWall([0, 0], [4, 0]), makeWall([4, 0], [4, 3])] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + expect(bounds!.min).toEqual([0, 0]) + expect(bounds!.max).toEqual([4, 3]) + expect(bounds!.size).toEqual([4, 3]) + expect(bounds!.center).toEqual([2, 1.5]) + }) + + test('includes zone polygons', () => { + const nodes: AnyNode[] = [ + makeZone([ + [-10, -5], + [10, -5], + [10, 5], + [-10, 5], + ]), + ] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + expect(bounds!.min).toEqual([-10, -5]) + expect(bounds!.max).toEqual([10, 5]) + expect(bounds!.size).toEqual([20, 10]) + }) + + test('ignores the default 30×30 site bootstrap polygon', () => { + const nodes: AnyNode[] = [ + makeSite([ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ]), + makeWall([1, 1], [2, 2]), + ] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + // Only the wall should count — the default site polygon is skipped. + expect(bounds!.min).toEqual([1, 1]) + expect(bounds!.max).toEqual([2, 2]) + }) + + test('honours a non-default site polygon', () => { + const nodes: AnyNode[] = [ + makeSite([ + [-25, -20], + [25, -20], + [25, 20], + [-25, 20], + ]), + ] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + expect(bounds!.min).toEqual([-25, -20]) + expect(bounds!.max).toEqual([25, 20]) + }) + + test('combines walls, zones and positions across the flat dict', () => { + const nodes: Record = { + wallA: makeWall([-8, -3], [4, -3]), + wallB: makeWall([4, -3], [4, 6]), + zoneA: makeZone([ + [-8, -3], + [4, -3], + [4, 6], + [-8, 6], + ]), + item1: { + object: 'node', + id: 'item_1', + type: 'item', + parentId: null, + visible: true, + metadata: {}, + position: [7, 0, 8], + rotation: [0, 0, 0], + scale: [1, 1, 1], + children: [], + asset: { + id: 'a', + category: 'furniture', + name: 'Chair', + thumbnail: '', + src: '', + dimensions: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + }, + } as unknown as AnyNode, + } + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + expect(bounds!.min).toEqual([-8, -3]) + expect(bounds!.max).toEqual([7, 8]) + }) + + test('handles a single degenerate point with a minimum extent', () => { + const nodes: AnyNode[] = [makeWall([2, 2], [2, 2])] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + expect(bounds!.size[0]).toBeGreaterThan(0) + expect(bounds!.size[1]).toBeGreaterThan(0) + expect(bounds!.center).toEqual([2, 2]) + }) + + test('skips non-finite coordinates', () => { + const nodes: AnyNode[] = [makeWall([Number.NaN, 0], [4, 2]), makeWall([0, 0], [1, 1])] + const bounds = computeSceneBoundsXZ(nodes) + expect(bounds).not.toBeNull() + // NaN should be ignored; the usable points are (4,2), (0,0), (1,1). + expect(bounds!.min).toEqual([0, 0]) + expect(bounds!.max).toEqual([4, 2]) + }) +}) diff --git a/packages/editor/src/lib/scene-bounds.ts b/packages/editor/src/lib/scene-bounds.ts new file mode 100644 index 00000000..7d1b26a6 --- /dev/null +++ b/packages/editor/src/lib/scene-bounds.ts @@ -0,0 +1,169 @@ +/** + * Scene bounds in the X/Z plane. + * + * Used by the auto-frame hook to fit the camera onto a freshly loaded scene + * (see `../hooks/use-auto-frame`). The hook subscribes to the core scene + * store and, when `nodes` transitions from empty → non-empty, fires a + * `camera-controls:fit-scene` event on the core event bus carrying the + * computed bounds. + * + * This module contains no rendering code: it only walks the flat-dict node + * tree and derives an axis-aligned bounding box on the XZ (plan) plane. + */ + +import type { AnyNode } from '@pascal-app/core/schema' + +export type SceneBoundsXZ = { + /** Min [x, z] in world units (meters). */ + min: [number, number] + /** Max [x, z] in world units (meters). */ + max: [number, number] + /** Center [x, z] = (min + max) / 2. */ + center: [number, number] + /** Size [w, d] = max - min. */ + size: [number, number] +} + +// A very small guard against degenerate bounds (e.g. a single wall of zero length). +const MIN_BOUNDS_EXTENT = 0.0001 + +function extendPoint( + acc: { minX: number; minZ: number; maxX: number; maxZ: number; hasPoint: boolean }, + x: unknown, + z: unknown, +): void { + if (typeof x !== 'number' || typeof z !== 'number') return + if (!Number.isFinite(x) || !Number.isFinite(z)) return + if (x < acc.minX) acc.minX = x + if (x > acc.maxX) acc.maxX = x + if (z < acc.minZ) acc.minZ = z + if (z > acc.maxZ) acc.maxZ = z + acc.hasPoint = true +} + +/** + * Compute the axis-aligned XZ bounds of a scene. + * + * Walks every node and extracts 2D footprint points from the fields most + * nodes carry: + * - `start`/`end` → wall and fence endpoints in level coordinates. + * - `polygon` → zone, slab, site-boundary polygons. + * - `position` → building/item/door/window position; uses [x, z] only. + * + * Site-node polygons are intentionally excluded when they are the default + * 30×30 bootstrap polygon — otherwise a brand-new empty scene would frame + * an empty square around the origin. We still include site polygons that + * look intentional (> 4 points, or any point outside the ±15 m default). + * + * Returns `null` if no usable geometry was found. + */ +export function computeSceneBoundsXZ( + nodes: AnyNode[] | Record, +): SceneBoundsXZ | null { + const list: AnyNode[] = Array.isArray(nodes) ? nodes : Object.values(nodes) + if (list.length === 0) return null + + const acc = { + minX: Number.POSITIVE_INFINITY, + minZ: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxZ: Number.NEGATIVE_INFINITY, + hasPoint: false, + } + + for (const node of list) { + if (!node || typeof node !== 'object') continue + const anyNode = node as unknown as Record + + // Wall / fence endpoints in level coordinates. + const start = anyNode.start as unknown + const end = anyNode.end as unknown + if (Array.isArray(start) && start.length >= 2) extendPoint(acc, start[0], start[1]) + if (Array.isArray(end) && end.length >= 2) extendPoint(acc, end[0], end[1]) + + // Zone / slab polygons (and explicit polygon-shaped site boundaries). + const polygon = anyNode.polygon as unknown + if (Array.isArray(polygon)) { + // Zones/slabs expose a plain array of [x,z] tuples. Site nodes nest the + // points under `polygon.points` (a discriminated PropertyLineData shape). + for (const point of polygon) { + if (Array.isArray(point) && point.length >= 2) { + extendPoint(acc, point[0], point[1]) + } + } + } else if ( + polygon && + typeof polygon === 'object' && + Array.isArray((polygon as { points?: unknown }).points) + ) { + // Site nodes only: skip the default bootstrap square so a blank scene + // isn't auto-framed around an empty ±15 m box. Include any other site + // polygon (more than 4 points, or any coordinate beyond the default). + const points = (polygon as { points: unknown[] }).points + if (node.type === 'site' && isDefaultSitePolygon(points)) { + // Skip — default bootstrap polygon. + } else { + for (const point of points) { + if (Array.isArray(point) && point.length >= 2) { + extendPoint(acc, point[0], point[1]) + } + } + } + } + + // Position on the XZ plane (3D position = [x, y, z]). + const position = anyNode.position as unknown + if (Array.isArray(position) && position.length >= 3) { + extendPoint(acc, position[0], position[2]) + } + } + + if (!acc.hasPoint) return null + + // Ensure a minimum extent so a single-point scene still yields a box. + let minX = acc.minX + let minZ = acc.minZ + let maxX = acc.maxX + let maxZ = acc.maxZ + if (maxX - minX < MIN_BOUNDS_EXTENT) { + const cx = (minX + maxX) / 2 + minX = cx - MIN_BOUNDS_EXTENT / 2 + maxX = cx + MIN_BOUNDS_EXTENT / 2 + } + if (maxZ - minZ < MIN_BOUNDS_EXTENT) { + const cz = (minZ + maxZ) / 2 + minZ = cz - MIN_BOUNDS_EXTENT / 2 + maxZ = cz + MIN_BOUNDS_EXTENT / 2 + } + + const centerX = (minX + maxX) / 2 + const centerZ = (minZ + maxZ) / 2 + return { + min: [minX, minZ], + max: [maxX, maxZ], + center: [centerX, centerZ], + size: [maxX - minX, maxZ - minZ], + } +} + +/** + * Matches the `SiteNode` bootstrap polygon defined in + * `packages/core/src/schema/nodes/site.ts` (a 30×30 square at the origin). + * We ignore it so the default scene doesn't "auto-frame" onto an empty box. + */ +function isDefaultSitePolygon(points: unknown[]): boolean { + if (points.length !== 4) return false + const expected: [number, number][] = [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ] + for (let i = 0; i < 4; i++) { + const p = points[i] + const e = expected[i]! + if (!Array.isArray(p) || p.length < 2) return false + if (p[0] !== e[0] || p[1] !== e[1]) return false + } + return true +} diff --git a/packages/mcp/CROSS_CUTTING.md b/packages/mcp/CROSS_CUTTING.md index 1ef28fa5..268d5ba5 100644 --- a/packages/mcp/CROSS_CUTTING.md +++ b/packages/mcp/CROSS_CUTTING.md @@ -95,3 +95,107 @@ Delete `.github/workflows/mcp-ci.yml`. --- +## 4. `packages/mcp/package.json` — added `./storage` subpath export + +### What + +Added a `./storage` entry to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}`. The existing `"."` entry is unchanged. + +### Why + +The Next.js editor (`apps/editor`) needs access to `createSceneStore()` + the `SceneStore` types/errors in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs the storage adapter. The subpath export lets `apps/editor` do `import { createSceneStore, SceneVersionConflictError } from '@pascal-app/mcp/storage'` without dragging the rest of the package. + +### Impact + +Zero on existing consumers. Purely additive. The `.` entry continues to export `SceneBridge`, `createPascalMcpServer`, etc., exactly as before. + +### Reversibility + +Remove the `./storage` entry from `exports` and update `apps/editor` to inline the types / use a different factory. No data or behavior changes — pure module-graph shaping. + +### Related + +- `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency so the subpath resolves. +- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume this subpath. + +--- + +## 5. `packages/core/src/schema/asset-url.ts` — URL scheme allowlist on scene URL fields + +### What + +Introduced a shared `AssetUrl` Zod validator and replaced the bare `z.string()` +on every URL-bearing field in core's schemas: + +- `scan.url` (`packages/core/src/schema/nodes/scan.ts`) +- `guide.url` (`packages/core/src/schema/nodes/guide.ts`) +- `item.asset.src` (`packages/core/src/schema/nodes/item.ts`) +- `material.texture.url` (`packages/core/src/schema/material.ts`) +- `material.maps.*` (`albedoMap`, `normalMap`, `roughnessMap`, `metalnessMap`, + `aoMap`, `displacementMap`, `emissiveMap`, `bumpMap`, `alphaMap`, `lightMap`) + +The validator accepts `asset://…`, `blob:…`, `data:image/…`, `/…` app-relative +paths, `https://…`, and `http://localhost|127.0.0.1/…`. Optional origin +narrowing via `process.env.PASCAL_ALLOWED_ASSET_ORIGINS` (comma-separated). +Rejects `javascript:`, `file:`, `ftp:`, `ws:`, `data:text/html`, +`data:application/*`, link-local / private IPs over bare `http`, empty strings, +and non-URL garbage. + +### Why + +Phase 3 security audit (`packages/mcp/test-reports/research/R9-production-readiness.md` +entry "URL validation in scenes"): an attacker-crafted scene containing +`javascript:alert(1)` or `http://169.254.169.254/latest/meta-data/` for a +texture URL would beacon or exfiltrate when the editor renders it. +`AnyNode.safeParse`, used by the MCP bridge, now rejects those payloads at the +schema boundary. + +### Impact + +- **Existing scenes**: localStorage-resident scenes bypass strict validation on + load (the store's `setScene` only runs `safeParse` on stair-type via + `migrateNodes`), so this is *not* a breakage for returning users. Legacy + URLs will keep loading; only explicit MCP-bridge `safeParse` calls reject. +- **MCP consumers**: one existing test + (`packages/mcp/src/bridge/scene-bridge.test.ts`, previously using + `src: 'data:model/gltf-binary;base64,'`) now fails because `data:model/` is + not in the allowlist. Replaced with `asset://test/chair.glb` — the only + sanctioned scheme for an in-repo ItemNode fixture. +- **Other packages**: `@pascal-app/viewer`, `@pascal-app/editor`, and + `material-library.ts` all continue to work because every built-in URL is a + `/material/…` app-relative path (allowlisted). + +### Known gaps / follow-ups + +1. **`item.asset.thumbnail` stayed untyped** — the field is still bare + `z.string()` in `item.ts`. The Phase 3 audit called it out alongside `src`, + but the Phase 7 task scope only required `src`. Follow-up: apply `AssetUrl` + to `thumbnail` as well. Verify the `place-item` tool's default + `thumbnail: ''` (currently empty string) gets a proper fallback first. +2. **`dist/` pollution** — `packages/core/tsconfig.json` `include`s `src` and + doesn't exclude `**/*.test.ts`, so the new `asset-url.test.ts` is emitted + to `dist/schema/`. Harmless (nothing imports it), but should be excluded + for a clean publish. Mirror the `exclude: ["**/*.test.ts"]` pattern used + in `packages/mcp/tsconfig.json`. Out of scope for A7 because tsconfig is + not in the ownership list. +3. **`bun:test` typing** — the test file uses `@ts-expect-error` on its + `bun:test` import because `@pascal-app/core` does not depend on + `@types/bun`. Adding it as a dev dep (or, preferred, excluding tests from + the core tsc build per gap 2) would remove the directive. +4. **`data:image/svg+xml` loophole** — passes the validator because it starts + with `data:image/`, but SVG can carry inline scripts. If the editor ever + renders SVG via unsanitised HTML-injection APIs or ``, this + becomes an injection vector. Consider a stricter variant + (`data:image/(png|jpe?g|webp|gif)`) for texture slots where SVG isn't needed. +5. **Same-origin HTTP scenes** — `http://localhost` is allowed for dev, but a + scene persisted in dev and shared in prod will still validate. Consider + gating on `NODE_ENV` once we have a stable env-flag story. + +### Reversibility + +Delete `packages/core/src/schema/asset-url.ts` and revert the five imports in +`scan.ts`, `guide.ts`, `item.ts`, and `material.ts` to `z.string()`. The +scene-bridge test update is self-contained. + +--- + diff --git a/packages/mcp/examples/photo-to-scene.md b/packages/mcp/examples/photo-to-scene.md new file mode 100644 index 00000000..ddb347dd --- /dev/null +++ b/packages/mcp/examples/photo-to-scene.md @@ -0,0 +1,119 @@ +# Floor-plan photo to Pascal scene + +The `photo_to_scene` orchestrator takes a single floor-plan photo and +returns a saved, navigable Pascal scene. It chains vision (via MCP +sampling) → scene build → save in one call, so an agent doesn't have to +stitch three tools together manually. + +> **Note:** `photo_to_scene` uses MCP sampling to call the host's model. +> Hosts that do not advertise `sampling` capability will receive a +> structured `sampling_unavailable` error; fall back to the text-only +> `from_brief` prompt in that case. + +## The brief + +A user drops a photo of a hand-drawn floor plan into the chat and types: + +> **User:** here's a floor plan photo, turn it into a Pascal scene. + +## The tool call + +The agent reads the attachment as a data URI and issues a single tool call: + +```jsonc +// tool: photo_to_scene +{ + "name": "photo_to_scene", + "arguments": { + "image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", + "scaleHint": "1 cm = 1 m, approx 20 m²", + "name": "Weekend flat" + } +} +``` + +Optional knobs: + +- `save` (default `true`) — if `false`, the response includes `graph` + inline instead of persisting to the `SceneStore`. +- `defaultWallThickness` (default `0.2` m) — used when the vision model + doesn't propose a per-wall thickness. +- `defaultWallHeight` (default `2.6` m) — applied to every generated wall + since the vision schema only captures 2D geometry. + +## What happens under the hood + +1. The orchestrator issues an MCP sampling request to the host with the + image and a structured JSON-only system prompt, mirroring + `analyze_floorplan_image`. The host's model returns walls, rooms, and + approximate dimensions as JSON. +2. The reply is validated against a strict Zod schema. Unparseable or + schema-failing responses surface as `sampling_response_unparseable` / + `sampling_response_invalid` MCP errors. +3. A fresh `SceneGraph` is built using the core schema factories: a + `site` → `building` → `level 0` skeleton, then one `WallNode` per + vision wall and one `ZoneNode` per vision room. Each node is + re-parsed with `AnyNode.safeParse`; invalid ones are dropped with a + warning appended to `notes`. +4. `bridge.setScene(...)` swaps the live scene so any follow-up MCP call + (`find_nodes`, `measure`, `apply_patch`, ...) operates on the new + geometry. +5. If `save: true`, the graph is persisted via `SceneStore.save` and the + response carries `sceneId` + `url: /scene/`. + +## The response + +```jsonc +{ + "sceneId": "scene_01hx8a...", + "url": "/scene/scene_01hx8a...", + "walls": 4, + "rooms": 1, + "confidence": 0.82 +} +``` + +When `save: false` instead: + +```jsonc +{ + "walls": 4, + "rooms": 1, + "confidence": 0.82, + "graph": { + "nodes": { /* flat id → node dict */ }, + "rootNodeIds": ["site_..."], + "collections": {} + } +} +``` + +If any wall or room failed schema validation, the response includes a +`notes` string summarising what was dropped. + +## Opening the scene + +The user follows `url` in their browser: + +``` +https://your-pascal-host/scene/scene_01hx8a... +``` + +...and lands in the editor with the new scene loaded, camera auto-framed +on the building footprint. + +## Follow-up prompts + +Because the bridge now holds the new scene, subsequent agent turns can +operate on it without reloading: + +> **User:** add a door on the south wall between Living and Kitchen. + +The agent calls `find_nodes({ type: "wall" })`, picks the appropriate +wall, and issues `cut_opening` — no extra wiring needed. + +## Takeaways + +- `photo_to_scene` is a one-shot primitive: one call, one scene. +- Vision confidence is surfaced so the agent can warn the user. +- v0.1 covers walls + zones; doors, windows, items are follow-up tools. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 34781c13..42cadd89 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -10,6 +10,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./storage": { + "types": "./dist/storage/index.d.ts", + "import": "./dist/storage/index.js", + "default": "./dist/storage/index.js" } }, "bin": { @@ -33,6 +38,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@supabase/supabase-js": "^2", "zod": "^4.3.5" }, "devDependencies": { diff --git a/packages/mcp/sql/README.md b/packages/mcp/sql/README.md new file mode 100644 index 00000000..e4526118 --- /dev/null +++ b/packages/mcp/sql/README.md @@ -0,0 +1,71 @@ +# Pascal MCP — Supabase Migrations + +Numbered SQL files in `migrations/` set up (and later evolve) the Pascal +Supabase schema. Each file is idempotent where possible (`create ... if not +exists`, `create or replace function`) and should be applied in order. + +Currently shipped: + +| File | Purpose | +| --------------------- | ------------------------------------------------------- | +| `0001_scenes.sql` | Creates `projects`, `scenes`, `scene_revisions` + RLS. | + +## Prerequisites + +- A Supabase project (`Settings → Project Settings → API` gives you the URL + and keys). +- The `service_role` key, stored as `SUPABASE_SERVICE_ROLE_KEY` on any + process that runs `SupabaseSceneStore` (the MCP server, the Next.js API + route). **Never expose this key to a browser.** + +## Option 1 — Apply via Supabase CLI (recommended) + +```sh +# One-time: link this repo to your Supabase project +supabase login +supabase link --project-ref + +# Each migration — run once, in order +supabase db execute --file packages/mcp/sql/migrations/0001_scenes.sql +``` + +For a brand-new project you can also drop the files into +`supabase/migrations/` and use `supabase db push`, but the +`db execute --file` form works for any existing project without adopting the +CLI's migration tracking. + +## Option 2 — Apply via the Supabase Dashboard + +1. Open your project at . +2. `SQL Editor → New query`. +3. Paste the contents of `packages/mcp/sql/migrations/0001_scenes.sql`. +4. `Run`. You should see `Success. No rows returned.` + +Re-running the file is safe; every statement is guarded with +`if not exists` / `create or replace`. + +## Verifying the install + +In the dashboard SQL editor: + +```sql +select table_name +from information_schema.tables +where table_schema = 'public' + and table_name in ('projects', 'scenes', 'scene_revisions') +order by table_name; +``` + +All three should be present. Check `Database → Policies` to confirm RLS is +enabled with the `scenes_owner_all`, `scenes_public_read`, +`revisions_owner_read`, and `projects_owner_all` policies. + +## Environment variables consumed by the MCP server + +| Variable | Required | Notes | +| ---------------------------- | -------- | ------------------------------------------ | +| `SUPABASE_URL` | yes | `https://.supabase.co` | +| `SUPABASE_SERVICE_ROLE_KEY` | yes | Server-side only. Never log this value. | + +When both are set, `createSceneStore()` picks the Supabase backend; otherwise +it falls back to the filesystem store. diff --git a/packages/mcp/sql/migrations/0001_scenes.sql b/packages/mcp/sql/migrations/0001_scenes.sql new file mode 100644 index 00000000..06e6271c --- /dev/null +++ b/packages/mcp/sql/migrations/0001_scenes.sql @@ -0,0 +1,76 @@ +-- 0001_scenes.sql +-- Initial Pascal scene storage schema. +-- +-- Creates: +-- * projects — minimal project rows owned by an auth.users row +-- * scenes — the current state of a scene (graph_json + metadata) +-- * scene_revisions — append-only revision log keyed by (scene_id, version) +-- +-- Row-level security is enabled on all three tables. Owners get full access +-- to their own rows; anonymous users can read scenes flagged public = true. +-- The `service_role` key bypasses RLS, which is how the MCP server writes +-- on behalf of users. + +-- Projects (minimal — we'll extend in a later PR) +create table if not exists projects ( + id uuid primary key default gen_random_uuid(), + owner_id uuid references auth.users(id) on delete cascade, + name text not null, + created_at timestamptz not null default now() +); + +-- Scenes +create table if not exists scenes ( + id text primary key, -- slug; keeps URLs stable + project_id uuid references projects(id) on delete cascade, + owner_id uuid references auth.users(id) on delete set null, + name text not null check (length(name) between 1 and 200), + graph_json jsonb not null, + thumbnail_url text, + version int not null default 1 check (version >= 1), + public boolean not null default false, + size_bytes int not null default 0, + node_count int not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index if not exists idx_scenes_owner on scenes(owner_id); +create index if not exists idx_scenes_project on scenes(project_id); +create index if not exists idx_scenes_updated on scenes(updated_at desc); + +-- Revision history +create table if not exists scene_revisions ( + scene_id text references scenes(id) on delete cascade, + version int not null, + graph_json jsonb not null, + author_kind text not null check (author_kind in ('human', 'mcp', 'agent')), + author_id uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + primary key (scene_id, version) +); + +-- RLS +alter table scenes enable row level security; +alter table scene_revisions enable row level security; +alter table projects enable row level security; + +-- owner can do everything; anon can read public=true; service_role bypasses +create policy scenes_owner_all on scenes + for all using (auth.uid() = owner_id) with check (auth.uid() = owner_id); +create policy scenes_public_read on scenes + for select using (public = true); + +create policy revisions_owner_read on scene_revisions + for select using ( + exists (select 1 from scenes where scenes.id = scene_revisions.scene_id and scenes.owner_id = auth.uid()) + ); + +create policy projects_owner_all on projects + for all using (auth.uid() = owner_id) with check (auth.uid() = owner_id); + +-- updated_at trigger +create or replace function tg_touch_updated() returns trigger as $$ +begin new.updated_at := now(); return new; end; +$$ language plpgsql; +create trigger scenes_touch_updated before update on scenes + for each row execute function tg_touch_updated(); diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts index cee3c220..087be39c 100644 --- a/packages/mcp/src/bridge/scene-bridge.test.ts +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -530,7 +530,10 @@ describe('SceneBridge', () => { category: 'test', name: 'Test Asset', thumbnail: 'data:image/png;base64,', - src: 'data:model/gltf-binary;base64,', + // AssetUrl validator (asset-url.ts) only allows asset://, blob:, + // data:image/, /path, or https://; `data:model/gltf-binary` is not + // in the allowlist, so this test uses an internal asset handle. + src: 'asset://test/chair.glb', }, }) // Place item directly on level — ItemNode supports arbitrary parents in the model. diff --git a/packages/mcp/src/lib/rehydrate-site-children.ts b/packages/mcp/src/lib/rehydrate-site-children.ts new file mode 100644 index 00000000..a7500eb0 --- /dev/null +++ b/packages/mcp/src/lib/rehydrate-site-children.ts @@ -0,0 +1,40 @@ +import type { AnyNode } from '@pascal-app/core/schema' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' + +/** + * `cloneSceneGraph` normalises `SiteNode.children` to an array of node IDs, + * but core's `SiteNode` schema expects an array of embedded `BuildingNode` / + * `ItemNode` objects (see `packages/mcp/CROSS_CUTTING.md` §2). To keep the + * cloned graph validating against `AnyNode`, re-embed the site children from + * the flat dict. + * + * Pure: returns a new graph without mutating the input. + */ +export function rehydrateSiteChildren(graph: SceneGraph): SceneGraph { + const out: SceneGraph = { + nodes: { ...graph.nodes }, + rootNodeIds: [...graph.rootNodeIds], + ...(graph.collections ? { collections: graph.collections } : {}), + } + for (const [id, node] of Object.entries(out.nodes)) { + if (node.type !== 'site') continue + const childrenField = (node as { children?: unknown[] }).children + if (!Array.isArray(childrenField)) continue + const rehydrated: AnyNode[] = [] + for (const child of childrenField) { + if (typeof child === 'string') { + const target = out.nodes[child as keyof typeof out.nodes] + if (target && (target.type === 'building' || target.type === 'item')) { + rehydrated.push(target) + } + } else if (child && typeof child === 'object' && 'id' in (child as Record)) { + rehydrated.push(child as AnyNode) + } + } + out.nodes[id as keyof typeof out.nodes] = { + ...(node as AnyNode), + children: rehydrated, + } as AnyNode + } + return out +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 382cd67f..fefe460f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -2,11 +2,22 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneBridge } from './bridge/scene-bridge' import { registerPrompts } from './prompts' import { registerResources } from './resources' +import { createSceneStore } from './storage' +import type { + SceneListOptions, + SceneMeta, + SceneMutateOptions, + SceneSaveOptions, + SceneStore, + SceneWithGraph, +} from './storage/types' import { registerTools } from './tools' import { registerVisionTools } from './tools/vision' export type CreatePascalMcpServerOptions = { bridge: SceneBridge + /** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */ + store?: SceneStore name?: string version?: string } @@ -16,9 +27,48 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe name: opts.name ?? 'pascal-mcp', version: opts.version ?? '0.1.0', }) - registerTools(server, opts.bridge) + const store = opts.store ?? createLazySceneStore() + registerTools(server, opts.bridge, store) registerVisionTools(server, opts.bridge) registerResources(server, opts.bridge) registerPrompts(server, opts.bridge) 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 | null = null + const resolve = (): Promise => { + if (!cached) cached = createSceneStore() + return cached + } + return { + get backend(): 'filesystem' | 'supabase' { + return 'filesystem' + }, + async save(options: SceneSaveOptions): Promise { + const real = await resolve() + return real.save(options) + }, + async load(id: string): Promise { + const real = await resolve() + return real.load(id) + }, + async list(options?: SceneListOptions): Promise { + const real = await resolve() + return real.list(options) + }, + async delete(id: string, options?: SceneMutateOptions): Promise { + const real = await resolve() + return real.delete(id, options) + }, + async rename(id: string, newName: string, options?: SceneMutateOptions): Promise { + const real = await resolve() + return real.rename(id, newName, options) + }, + } +} diff --git a/packages/mcp/src/storage/filesystem-scene-store.test.ts b/packages/mcp/src/storage/filesystem-scene-store.test.ts new file mode 100644 index 00000000..36adce33 --- /dev/null +++ b/packages/mcp/src/storage/filesystem-scene-store.test.ts @@ -0,0 +1,599 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { + FilesystemSceneStore, + type FilesystemSceneStoreOptions, + resolveDefaultRootDir, +} from './filesystem-scene-store' +import { SceneInvalidError, SceneTooLargeError, SceneVersionConflictError } from './types' + +function makeGraph(overrides: Partial = {}): SceneGraph { + return { + nodes: { + site_abc: { + object: 'node', + id: 'site_abc', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + }, + building_def: { + object: 'node', + id: 'building_def', + type: 'building', + parentId: 'site_abc', + visible: true, + metadata: {}, + }, + } as SceneGraph['nodes'], + rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'], + ...overrides, + } +} + +async function mkTmpRoot(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-test-')) +} + +async function rmrf(p: string): Promise { + await fs.rm(p, { recursive: true, force: true }) +} + +function createStore(rootDir: string, opts: Partial = {}) { + return new FilesystemSceneStore({ rootDir, ...opts }) +} + +describe('resolveDefaultRootDir', () => { + test('respects PASCAL_DATA_DIR when set', () => { + const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '/custom/pascal' }) + expect(dir).toBe('/custom/pascal') + }) + + test('ignores empty PASCAL_DATA_DIR', () => { + const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '', HOME: '/home/user' }) + expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true) + }) + + test('falls back to XDG_DATA_HOME', () => { + if (process.platform === 'win32') return + const dir = resolveDefaultRootDir({ XDG_DATA_HOME: '/xdg/share' }) + expect(dir).toBe(path.join('/xdg/share', 'pascal', 'data')) + }) + + test('falls back to homedir + .pascal/data', () => { + if (process.platform === 'win32') return + const dir = resolveDefaultRootDir({}) + expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true) + }) +}) + +describe('FilesystemSceneStore', () => { + let rootDir: string + let store: FilesystemSceneStore + + beforeEach(async () => { + rootDir = await mkTmpRoot() + store = createStore(rootDir) + }) + + afterEach(async () => { + await rmrf(rootDir) + }) + + // ----------- Construction / defaults ----------- + + test('backend is "filesystem"', () => { + expect(store.backend).toBe('filesystem') + }) + + test('resolves default root when no rootDir is passed', () => { + const fallback = new FilesystemSceneStore({ env: { PASCAL_DATA_DIR: rootDir } }) + expect(fallback.backend).toBe('filesystem') + }) + + // ----------- save() ----------- + + test('generates an id when none is provided', async () => { + const meta = await store.save({ name: 'Scratch', graph: makeGraph() }) + expect(typeof meta.id).toBe('string') + expect(meta.id.length).toBeGreaterThan(0) + expect(meta.version).toBe(1) + }) + + test('round-trip save → load preserves graph exactly', async () => { + const graph = makeGraph() + const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph }) + expect(saved.id).toBe('kitchen') + const loaded = await store.load('kitchen') + expect(loaded).not.toBeNull() + expect(loaded!.graph).toEqual(graph) + expect(loaded!.name).toBe('Kitchen') + expect(loaded!.nodeCount).toBe(2) + expect(loaded!.version).toBe(1) + }) + + test('stores projectId, ownerId, and thumbnailUrl verbatim', async () => { + await store.save({ + id: 'meta-test', + name: 'Meta', + graph: makeGraph(), + projectId: 'proj-1', + ownerId: 'user-42', + thumbnailUrl: 'https://example.com/t.png', + }) + const loaded = await store.load('meta-test') + expect(loaded?.projectId).toBe('proj-1') + expect(loaded?.ownerId).toBe('user-42') + expect(loaded?.thumbnailUrl).toBe('https://example.com/t.png') + }) + + test('version bumps by 1 each save', async () => { + const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() }) + expect(first.version).toBe(1) + const second = await store.save({ + id: 'bump', + name: 'Bump', + graph: makeGraph(), + expectedVersion: 1, + }) + expect(second.version).toBe(2) + const third = await store.save({ + id: 'bump', + name: 'Bump', + graph: makeGraph(), + expectedVersion: 2, + }) + expect(third.version).toBe(3) + }) + + test('preserves createdAt on overwrite, updates updatedAt', async () => { + const first = await store.save({ id: 'times', name: 'T', graph: makeGraph() }) + await new Promise((r) => setTimeout(r, 5)) + const second = await store.save({ + id: 'times', + name: 'T', + graph: makeGraph(), + expectedVersion: 1, + }) + expect(second.createdAt).toBe(first.createdAt) + expect(second.updatedAt >= first.updatedAt).toBe(true) + }) + + test('expectedVersion mismatch throws SceneVersionConflictError', async () => { + await store.save({ id: 'conflict', name: 'C', graph: makeGraph() }) + await expect( + store.save({ id: 'conflict', name: 'C', graph: makeGraph(), expectedVersion: 99 }), + ).rejects.toThrow(SceneVersionConflictError) + }) + + test('expectedVersion=0 matches a brand-new id', async () => { + const meta = await store.save({ + id: 'fresh', + name: 'Fresh', + graph: makeGraph(), + expectedVersion: 0, + }) + expect(meta.version).toBe(1) + }) + + test('slug collision (no expectedVersion) throws', async () => { + await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() }) + await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow( + SceneInvalidError, + ) + }) + + test('save without id never collides (generates unique slug)', async () => { + const a = await store.save({ name: 'A', graph: makeGraph() }) + const b = await store.save({ name: 'B', graph: makeGraph() }) + expect(a.id).not.toBe(b.id) + }) + + test('name length 0 throws', async () => { + await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError) + }) + + test('name length 201 throws', async () => { + const longName = 'x'.repeat(201) + await expect(store.save({ name: longName, graph: makeGraph() })).rejects.toThrow( + SceneInvalidError, + ) + }) + + test('name length 200 is accepted', async () => { + const name = 'x'.repeat(200) + const meta = await store.save({ name, graph: makeGraph() }) + expect(meta.name).toBe(name) + }) + + test('non-string name throws', async () => { + await expect( + store.save({ name: 123 as unknown as string, graph: makeGraph() }), + ).rejects.toThrow(SceneInvalidError) + }) + + test('whitespace-only name throws', async () => { + await expect(store.save({ name: ' ', graph: makeGraph() })).rejects.toThrow(SceneInvalidError) + }) + + test('too-large scene throws SceneTooLargeError', async () => { + // Build a graph that encodes to > 10 MB in pretty JSON. + const nodes: Record = {} + const bigBlob = 'A'.repeat(2048) + for (let i = 0; i < 6000; i++) { + nodes[`site_${i}`] = { + object: 'node', + id: `site_${i}`, + type: 'site', + parentId: null, + visible: true, + metadata: { blob: bigBlob }, + } + } + const graph = { + nodes, + rootNodeIds: Object.keys(nodes), + } as unknown as SceneGraph + await expect(store.save({ name: 'Big', graph })).rejects.toThrow(SceneTooLargeError) + }) + + test('sanitizes id with path traversal attempt', async () => { + const meta = await store.save({ id: '../escape', name: 'Evil', graph: makeGraph() }) + expect(meta.id).toBe('escape') + const filesInScenes = await fs.readdir(path.join(rootDir, 'scenes')) + expect(filesInScenes).toContain('escape.json') + // Nothing wrote outside the scenes dir + const rootEntries = await fs.readdir(rootDir) + expect(rootEntries).toEqual(['scenes']) + }) + + test('sanitizes mixed-case / whitespace id', async () => { + const meta = await store.save({ id: 'My Kitchen!', name: 'Kitchen', graph: makeGraph() }) + expect(meta.id).toBe('my-kitchen') + }) + + test('fails fast if sanitized id is empty', async () => { + await expect(store.save({ id: '!!!', name: 'Bad', graph: makeGraph() })).rejects.toThrow() + }) + + test('pretty-prints JSON with 2-space indent', async () => { + await store.save({ id: 'pretty', name: 'P', graph: makeGraph() }) + const raw = await fs.readFile(path.join(rootDir, 'scenes', 'pretty.json'), 'utf8') + expect(raw.includes('\n "meta"')).toBe(true) + }) + + test('sizeBytes reflects on-disk byte length', async () => { + const meta = await store.save({ id: 'sized', name: 'S', graph: makeGraph() }) + const stat = await fs.stat(path.join(rootDir, 'scenes', 'sized.json')) + expect(meta.sizeBytes).toBe(stat.size) + }) + + test('nodeCount equals Object.keys(graph.nodes).length', async () => { + const meta = await store.save({ id: 'count', name: 'C', graph: makeGraph() }) + expect(meta.nodeCount).toBe(2) + }) + + test('writes index sidecar after save', async () => { + await store.save({ id: 'idx-a', name: 'A', graph: makeGraph() }) + const idxRaw = await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8') + const parsed = JSON.parse(idxRaw) as Array<{ id: string }> + expect(parsed.map((m) => m.id)).toContain('idx-a') + }) + + // ----------- load() ----------- + + test('load returns null for missing file', async () => { + const result = await store.load('nonexistent') + expect(result).toBeNull() + }) + + test('load throws SceneInvalidError for non-object nodes', async () => { + // Write bogus contents directly. + await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) + const bogus = { + meta: { + id: 'bogus', + name: 'Bogus', + projectId: null, + thumbnailUrl: null, + version: 1, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ownerId: null, + sizeBytes: 0, + nodeCount: 1, + }, + graph: { + nodes: { site_x: 'not-an-object' }, + rootNodeIds: ['site_x'], + }, + } + await fs.writeFile( + path.join(rootDir, 'scenes', 'bogus.json'), + JSON.stringify(bogus, null, 2), + 'utf8', + ) + await expect(store.load('bogus')).rejects.toThrow(SceneInvalidError) + }) + + test('load throws SceneInvalidError when nodes is not an object', async () => { + await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) + const badShape = { + meta: { + id: 'badshape', + name: 'B', + projectId: null, + thumbnailUrl: null, + version: 1, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ownerId: null, + sizeBytes: 0, + nodeCount: 0, + }, + graph: { + nodes: 'hello', + rootNodeIds: [], + }, + } + await fs.writeFile( + path.join(rootDir, 'scenes', 'badshape.json'), + JSON.stringify(badShape), + 'utf8', + ) + await expect(store.load('badshape')).rejects.toThrow(SceneInvalidError) + }) + + test('load throws SceneInvalidError for node missing "type"', async () => { + await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) + const noType = { + meta: { + id: 'notype', + name: 'N', + projectId: null, + thumbnailUrl: null, + version: 1, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + ownerId: null, + sizeBytes: 0, + nodeCount: 1, + }, + graph: { + nodes: { site_x: { id: 'site_x' } }, + rootNodeIds: ['site_x'], + }, + } + await fs.writeFile(path.join(rootDir, 'scenes', 'notype.json'), JSON.stringify(noType), 'utf8') + await expect(store.load('notype')).rejects.toThrow(SceneInvalidError) + }) + + test('load throws SceneInvalidError for unparseable JSON', async () => { + await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) + await fs.writeFile(path.join(rootDir, 'scenes', 'garbage.json'), '{not json', 'utf8') + await expect(store.load('garbage')).rejects.toThrow(SceneInvalidError) + }) + + // ----------- list() ----------- + + test('list returns [] when scenes dir is empty or absent', async () => { + expect(await store.list()).toEqual([]) + }) + + test('list finds all saved scenes', async () => { + await store.save({ id: 'a', name: 'A', graph: makeGraph() }) + await store.save({ id: 'b', name: 'B', graph: makeGraph() }) + await store.save({ id: 'c', name: 'C', graph: makeGraph() }) + const list = await store.list() + expect(list.map((m) => m.id).sort()).toEqual(['a', 'b', 'c']) + }) + + test('list uses index sidecar as fast path', async () => { + await store.save({ id: 'fast', name: 'F', graph: makeGraph() }) + // Corrupt the on-disk json so collectAllMeta would fail; the index should + // still list the entry as long as the file exists. + const list = await store.list() + expect(list.map((m) => m.id)).toContain('fast') + }) + + test('list falls back to readdir when index is absent', async () => { + await store.save({ id: 'slow', name: 'S', graph: makeGraph() }) + await fs.unlink(path.join(rootDir, 'scenes', '.index.json')) + const list = await store.list() + expect(list.map((m) => m.id)).toContain('slow') + }) + + test('list filters by projectId', async () => { + await store.save({ id: 'p1-a', name: 'A', graph: makeGraph(), projectId: 'p1' }) + await store.save({ id: 'p1-b', name: 'B', graph: makeGraph(), projectId: 'p1' }) + await store.save({ id: 'p2-c', name: 'C', graph: makeGraph(), projectId: 'p2' }) + const result = await store.list({ projectId: 'p1' }) + expect(result.map((m) => m.id).sort()).toEqual(['p1-a', 'p1-b']) + }) + + test('list filters by ownerId', async () => { + await store.save({ id: 'u1-a', name: 'A', graph: makeGraph(), ownerId: 'u1' }) + await store.save({ id: 'u2-b', name: 'B', graph: makeGraph(), ownerId: 'u2' }) + const result = await store.list({ ownerId: 'u1' }) + expect(result.map((m) => m.id)).toEqual(['u1-a']) + }) + + test('list respects limit', async () => { + await store.save({ id: 'l1', name: '1', graph: makeGraph() }) + await store.save({ id: 'l2', name: '2', graph: makeGraph() }) + await store.save({ id: 'l3', name: '3', graph: makeGraph() }) + const result = await store.list({ limit: 2 }) + expect(result.length).toBe(2) + }) + + test('list sorts by updatedAt desc', async () => { + await store.save({ id: 'first', name: '1', graph: makeGraph() }) + await new Promise((r) => setTimeout(r, 10)) + await store.save({ id: 'second', name: '2', graph: makeGraph() }) + const result = await store.list() + expect(result[0]?.id).toBe('second') + expect(result[1]?.id).toBe('first') + }) + + test('list ignores tmp files and non-json entries', async () => { + await store.save({ id: 'real', name: 'R', graph: makeGraph() }) + await fs.unlink(path.join(rootDir, 'scenes', '.index.json')) + await fs.writeFile(path.join(rootDir, 'scenes', 'stray.txt'), 'ignored', 'utf8') + await fs.writeFile(path.join(rootDir, 'scenes', 'real.json.tmp'), '{}', 'utf8') + const result = await store.list() + expect(result.map((m) => m.id)).toEqual(['real']) + }) + + test('list drops index entries whose file was removed out-of-band', async () => { + await store.save({ id: 'vanish', name: 'V', graph: makeGraph() }) + await store.save({ id: 'keep', name: 'K', graph: makeGraph() }) + // Bypass delete() — simulate another tool removing the file without updating the index + await fs.unlink(path.join(rootDir, 'scenes', 'vanish.json')) + const result = await store.list() + expect(result.map((m) => m.id)).toEqual(['keep']) + }) + + // ----------- delete() ----------- + + test('delete removes file and returns true', async () => { + await store.save({ id: 'del', name: 'D', graph: makeGraph() }) + const ok = await store.delete('del') + expect(ok).toBe(true) + expect(await store.load('del')).toBeNull() + }) + + test('delete returns false for missing scene', async () => { + expect(await store.delete('ghost')).toBe(false) + }) + + test('delete with matching expectedVersion succeeds', async () => { + await store.save({ id: 'dv', name: 'D', graph: makeGraph() }) + const ok = await store.delete('dv', { expectedVersion: 1 }) + expect(ok).toBe(true) + }) + + test('delete with mismatched expectedVersion throws', async () => { + await store.save({ id: 'dvx', name: 'D', graph: makeGraph() }) + await expect(store.delete('dvx', { expectedVersion: 99 })).rejects.toThrow( + SceneVersionConflictError, + ) + }) + + test('delete updates index', async () => { + await store.save({ id: 'i1', name: '1', graph: makeGraph() }) + await store.save({ id: 'i2', name: '2', graph: makeGraph() }) + await store.delete('i1') + const idx = JSON.parse( + await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8'), + ) as Array<{ id: string }> + expect(idx.map((m) => m.id)).toEqual(['i2']) + }) + + // ----------- rename() ----------- + + test('rename updates name and bumps version', async () => { + await store.save({ id: 'ren', name: 'Original', graph: makeGraph() }) + const renamed = await store.rename('ren', 'Shiny') + expect(renamed.name).toBe('Shiny') + expect(renamed.version).toBe(2) + const loaded = await store.load('ren') + expect(loaded?.name).toBe('Shiny') + }) + + test('rename preserves graph exactly', async () => { + const graph = makeGraph() + await store.save({ id: 'rg', name: 'Before', graph }) + await store.rename('rg', 'After') + const loaded = await store.load('rg') + expect(loaded?.graph).toEqual(graph) + }) + + test('rename preserves projectId / ownerId / thumbnailUrl', async () => { + await store.save({ + id: 'rmeta', + name: 'Before', + graph: makeGraph(), + projectId: 'p', + ownerId: 'u', + thumbnailUrl: 'https://x.y/z', + }) + const renamed = await store.rename('rmeta', 'After') + expect(renamed.projectId).toBe('p') + expect(renamed.ownerId).toBe('u') + expect(renamed.thumbnailUrl).toBe('https://x.y/z') + }) + + test('rename with matching expectedVersion succeeds', async () => { + await store.save({ id: 'rv', name: 'A', graph: makeGraph() }) + const renamed = await store.rename('rv', 'B', { expectedVersion: 1 }) + expect(renamed.version).toBe(2) + }) + + test('rename with mismatched expectedVersion throws', async () => { + await store.save({ id: 'rvx', name: 'A', graph: makeGraph() }) + await expect(store.rename('rvx', 'B', { expectedVersion: 99 })).rejects.toThrow( + SceneVersionConflictError, + ) + }) + + test('rename on missing scene throws SceneInvalidError', async () => { + await expect(store.rename('ghost', 'X')).rejects.toThrow(SceneInvalidError) + }) + + test('rename validates name length', async () => { + await store.save({ id: 'rnl', name: 'A', graph: makeGraph() }) + await expect(store.rename('rnl', '')).rejects.toThrow(SceneInvalidError) + await expect(store.rename('rnl', 'x'.repeat(201))).rejects.toThrow(SceneInvalidError) + }) + + // ----------- Integration: delete + list + rename round-trip ----------- + + test('round-trip: save → rename → list → delete', async () => { + await store.save({ id: 'rt1', name: 'One', graph: makeGraph() }) + await store.save({ id: 'rt2', name: 'Two', graph: makeGraph() }) + await store.rename('rt1', 'Uno') + const listed = await store.list() + const renamed = listed.find((m) => m.id === 'rt1') + expect(renamed?.name).toBe('Uno') + expect(renamed?.version).toBe(2) + expect(await store.delete('rt2')).toBe(true) + const after = await store.list() + expect(after.map((m) => m.id)).toEqual(['rt1']) + }) + + // ----------- Atomic write / concurrency ----------- + + test('atomic write does not leave tmp files on success', async () => { + await store.save({ id: 'atomic', name: 'A', graph: makeGraph() }) + const entries = await fs.readdir(path.join(rootDir, 'scenes')) + expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false) + }) + + test('concurrent saves do not leave a torn file', async () => { + // Atomic rename guarantees the on-disk file is always a complete, + // parseable snapshot even under parallel writes. We don't guarantee that + // optimistic version checks serialize writers — that requires an external + // lock — but each write either succeeds or rejects cleanly, and the + // final file is always loadable. + await store.save({ id: 'race', name: 'Race', graph: makeGraph() }) + const attempts = await Promise.allSettled( + Array.from({ length: 4 }, (_, i) => + store.save({ + id: 'race', + name: `Race-${i}`, + graph: makeGraph(), + expectedVersion: 1, + }), + ), + ) + expect(attempts.every((a) => a.status === 'fulfilled' || a.status === 'rejected')).toBe(true) + const loaded = await store.load('race') + expect(loaded).not.toBeNull() + // At least one concurrent save committed, so the version advanced. + expect(loaded!.version).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/packages/mcp/src/storage/filesystem-scene-store.ts b/packages/mcp/src/storage/filesystem-scene-store.ts new file mode 100644 index 00000000..4eb40718 --- /dev/null +++ b/packages/mcp/src/storage/filesystem-scene-store.ts @@ -0,0 +1,388 @@ +import { constants as fsConstants } from 'node:fs' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { z } from 'zod' +import { generateSlug, isValidSlug, sanitizeSlug } from './slug' +import { + SceneInvalidError, + type SceneListOptions, + type SceneMeta, + type SceneMutateOptions, + type SceneSaveOptions, + type SceneStore, + SceneTooLargeError, + SceneVersionConflictError, + type SceneWithGraph, +} from './types' + +const MAX_SCENE_BYTES = 10 * 1024 * 1024 // 10 MB +const MAX_NAME_LENGTH = 200 +const MIN_NAME_LENGTH = 1 +const SCENES_SUBDIR = 'scenes' +const INDEX_FILE = '.index.json' +const TMP_SUFFIX = '.tmp' + +/** + * Options for constructing a `FilesystemSceneStore`. + */ +export interface FilesystemSceneStoreOptions { + /** Root directory for scene storage. If omitted, resolved from env. */ + rootDir?: string + /** Optional env override for default root resolution. */ + env?: NodeJS.ProcessEnv +} + +/** + * Resolves the default root directory for on-disk scene storage. + * + * Precedence: + * 1. `PASCAL_DATA_DIR` + * 2. On Windows: `%APPDATA%/Pascal/data` + * 3. `$XDG_DATA_HOME/pascal/data` + * 4. `$HOME/.pascal/data` + */ +export function resolveDefaultRootDir(env: NodeJS.ProcessEnv = process.env): string { + if (env.PASCAL_DATA_DIR && env.PASCAL_DATA_DIR.length > 0) { + return env.PASCAL_DATA_DIR + } + if (process.platform === 'win32') { + const appData = env.APPDATA + if (appData && appData.length > 0) { + return path.join(appData, 'Pascal', 'data') + } + return path.join(os.homedir(), '.pascal', 'data') + } + const xdg = env.XDG_DATA_HOME + if (xdg && xdg.length > 0) { + return path.join(xdg, 'pascal', 'data') + } + return path.join(os.homedir(), '.pascal', 'data') +} + +/** + * Zod schema used to validate the top-level envelope of a persisted scene file. + * Kept intentionally lax — we validate `meta` fields inline and each node's shape + * via `Object.keys` length + per-node shape checks for performance. + */ +const PersistedSceneSchema = z.object({ + meta: z.object({ + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number().int().nonnegative(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number().int().nonnegative(), + nodeCount: z.number().int().nonnegative(), + }), + graph: z.object({ + nodes: z.record(z.string(), z.unknown()), + rootNodeIds: z.array(z.string()), + collections: z.record(z.string(), z.unknown()).optional(), + }), +}) + +type PersistedScene = z.infer + +/** + * File-backed implementation of `SceneStore`. + * + * Persists each scene as `/scenes/.json` with an optional sidecar + * index file `/scenes/.index.json` for fast listing. + * + * Writes are atomic via tmp file + rename. Saves bump `meta.version` by 1 and + * honor `expectedVersion` for optimistic concurrency control. Reads return + * `null` for missing files and throw `SceneInvalidError` when a file on disk + * has become corrupt. + */ +export class FilesystemSceneStore implements SceneStore { + readonly backend = 'filesystem' as const + + private readonly rootDir: string + private readonly scenesDir: string + private readonly indexPath: string + + constructor(opts: FilesystemSceneStoreOptions = {}) { + const root = opts.rootDir ?? resolveDefaultRootDir(opts.env ?? process.env) + this.rootDir = path.resolve(root) + this.scenesDir = path.join(this.rootDir, SCENES_SUBDIR) + this.indexPath = path.join(this.scenesDir, INDEX_FILE) + } + + async save(opts: SceneSaveOptions): Promise { + this.assertValidName(opts.name) + + const providedId = opts.id + const id = providedId ? sanitizeSlug(providedId) : generateSlug() + if (!isValidSlug(id)) { + throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`) + } + + await this.ensureScenesDir() + + const finalPath = this.scenePath(id) + const existing = await this.readPersisted(id) + + // Slug collision check: only when caller passed an explicit id + // and `expectedVersion` is NOT provided (i.e. this is treated as a create). + if (existing && providedId !== undefined && opts.expectedVersion === undefined) { + throw new SceneInvalidError( + `Scene with id "${id}" already exists. Pass a different id or provide expectedVersion to overwrite.`, + ) + } + + // Optimistic concurrency + if (opts.expectedVersion !== undefined) { + const currentVersion = existing?.meta.version ?? 0 + if (currentVersion !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`, + ) + } + } + + const now = new Date().toISOString() + const createdAt = existing?.meta.createdAt ?? now + const nextVersion = (existing?.meta.version ?? 0) + 1 + const nodeCount = Object.keys(opts.graph.nodes).length + + // Assemble meta + record so we can measure the final serialized size. + // sizeBytes is filled in after we know the encoded length. + const meta: SceneMeta = { + id, + name: opts.name, + projectId: opts.projectId ?? null, + thumbnailUrl: opts.thumbnailUrl ?? null, + version: nextVersion, + createdAt, + updatedAt: now, + ownerId: opts.ownerId ?? null, + sizeBytes: 0, + nodeCount, + } + + const record: PersistedScene = { meta, graph: opts.graph as PersistedScene['graph'] } + // Iterate until sizeBytes is stable: encoding the size changes the + // resulting byte count if the digit width shifts, so fixed-point it. + let json = this.serialize(record) + let sizeBytes = Buffer.byteLength(json, 'utf8') + // Fixed-point loop, bounded to avoid infinite cycles on pathological inputs. + for (let guard = 0; guard < 5; guard++) { + meta.sizeBytes = sizeBytes + record.meta = meta + const next = this.serialize(record) + const nextSize = Buffer.byteLength(next, 'utf8') + if (nextSize === sizeBytes) { + json = next + break + } + json = next + sizeBytes = nextSize + } + + if (sizeBytes > MAX_SCENE_BYTES) { + throw new SceneTooLargeError( + `Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${MAX_SCENE_BYTES} bytes`, + ) + } + + await this.atomicWrite(finalPath, json) + await this.writeIndex(await this.collectAllMeta()) + return meta + } + + async load(id: string): Promise { + const safeId = sanitizeSlug(id) + const record = await this.readPersisted(safeId) + if (!record) return null + return { ...record.meta, graph: record.graph as SceneWithGraph['graph'] } + } + + async list(opts: SceneListOptions = {}): Promise { + const metas = (await this.readIndex()) ?? (await this.collectAllMeta()) + let filtered = metas + if (opts.projectId !== undefined) { + filtered = filtered.filter((m) => m.projectId === opts.projectId) + } + if (opts.ownerId !== undefined) { + filtered = filtered.filter((m) => m.ownerId === opts.ownerId) + } + filtered = filtered.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)) + if (opts.limit !== undefined && opts.limit >= 0) { + filtered = filtered.slice(0, opts.limit) + } + return filtered + } + + async delete(id: string, opts: SceneMutateOptions = {}): Promise { + const safeId = sanitizeSlug(id) + const existing = await this.readPersisted(safeId) + if (!existing) return false + if (opts.expectedVersion !== undefined && existing.meta.version !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.meta.version}`, + ) + } + const finalPath = this.scenePath(safeId) + await fs.unlink(finalPath).catch((err: NodeJS.ErrnoException) => { + if (err.code !== 'ENOENT') throw err + }) + await this.writeIndex(await this.collectAllMeta()) + return true + } + + async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise { + this.assertValidName(newName) + const safeId = sanitizeSlug(id) + const existing = await this.readPersisted(safeId) + if (!existing) { + throw new SceneInvalidError(`Scene "${safeId}" not found`) + } + return this.save({ + id: safeId, + name: newName, + projectId: existing.meta.projectId, + ownerId: existing.meta.ownerId, + thumbnailUrl: existing.meta.thumbnailUrl, + graph: existing.graph as SceneWithGraph['graph'], + expectedVersion: opts.expectedVersion ?? existing.meta.version, + }) + } + + // ---------- Internal helpers ---------- + + private scenePath(id: string): string { + return path.join(this.scenesDir, `${id}.json`) + } + + private assertValidName(name: string): void { + if (typeof name !== 'string') { + throw new SceneInvalidError('Scene name must be a string') + } + const trimmed = name.trim() + if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) { + throw new SceneInvalidError( + `Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`, + ) + } + } + + private serialize(record: PersistedScene): string { + return JSON.stringify(record, null, 2) + } + + private async ensureScenesDir(): Promise { + await fs.mkdir(this.scenesDir, { recursive: true }) + } + + private async atomicWrite(finalPath: string, contents: string): Promise { + const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}${TMP_SUFFIX}` + await fs.writeFile(tmpPath, contents, { encoding: 'utf8', flag: 'w' }) + try { + await fs.rename(tmpPath, finalPath) + } catch (err) { + await fs.unlink(tmpPath).catch(() => {}) + throw err + } + } + + private async readPersisted(id: string): Promise { + const filePath = this.scenePath(id) + let raw: string + try { + raw = await fs.readFile(filePath, 'utf8') + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return null + throw err + } + return this.parseRecord(raw, filePath) + } + + private parseRecord(raw: string, filePath: string): PersistedScene { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch (err) { + throw new SceneInvalidError( + `Failed to parse scene file ${filePath}: ${(err as Error).message}`, + ) + } + const result = PersistedSceneSchema.safeParse(parsed) + if (!result.success) { + throw new SceneInvalidError( + `Scene file ${filePath} has invalid shape: ${result.error.message}`, + ) + } + const record = result.data + // Validate individual node envelopes: every value in `nodes` must be a + // non-null object with a `type` string. We don't fully parse each node via + // core's AnyNode because it's expensive and the schemas evolve; the lift + // is to catch egregious corruption early. + for (const [nodeId, node] of Object.entries(record.graph.nodes)) { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new SceneInvalidError(`Scene file ${filePath} has non-object node at "${nodeId}"`) + } + const typeField = (node as { type?: unknown }).type + if (typeof typeField !== 'string' || typeField.length === 0) { + throw new SceneInvalidError( + `Scene file ${filePath} has node "${nodeId}" missing a string "type"`, + ) + } + } + return record + } + + private async readIndex(): Promise { + try { + const raw = await fs.readFile(this.indexPath, 'utf8') + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + // Trust the index — it was written by us — but filter out any entries + // whose underlying file has since vanished. + const valid: SceneMeta[] = [] + for (const entry of parsed as SceneMeta[]) { + if (!entry || typeof entry.id !== 'string') continue + const exists = await fs + .access(this.scenePath(entry.id), fsConstants.F_OK) + .then(() => true) + .catch(() => false) + if (exists) valid.push(entry) + } + return valid + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return null + return null + } + } + + private async collectAllMeta(): Promise { + try { + const entries = await fs.readdir(this.scenesDir) + const metas: SceneMeta[] = [] + for (const entry of entries) { + if (!entry.endsWith('.json')) continue + if (entry === INDEX_FILE) continue + if (entry.endsWith(TMP_SUFFIX)) continue + const id = entry.slice(0, -'.json'.length) + const record = await this.readPersisted(id).catch(() => null) + if (record) metas.push(record.meta) + } + return metas + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === 'ENOENT') return [] + throw err + } + } + + private async writeIndex(metas: SceneMeta[]): Promise { + await this.ensureScenesDir() + const sorted = metas.slice().sort((a, b) => a.id.localeCompare(b.id)) + await this.atomicWrite(this.indexPath, `${JSON.stringify(sorted, null, 2)}\n`) + } +} diff --git a/packages/mcp/src/storage/index.ts b/packages/mcp/src/storage/index.ts new file mode 100644 index 00000000..76024ce0 --- /dev/null +++ b/packages/mcp/src/storage/index.ts @@ -0,0 +1,29 @@ +import type { SceneStore } from './types' + +export * from './slug' +export * from './types' + +/** + * Factory that picks the correct `SceneStore` backend based on env: + * - If `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are both set → Supabase. + * - Otherwise → filesystem. + * + * Implementations are loaded via dynamic `import()` so consumers only pay the + * cost of the backend they actually use. + */ +export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise { + const resolved = env ?? (typeof process !== 'undefined' ? process.env : undefined) + const supabaseUrl = resolved?.SUPABASE_URL + const supabaseKey = resolved?.SUPABASE_SERVICE_ROLE_KEY + + if (supabaseUrl && supabaseKey) { + const mod = await import('./supabase-scene-store') + return new mod.SupabaseSceneStore({ + url: supabaseUrl, + serviceRoleKey: supabaseKey, + }) + } + + const mod = await import('./filesystem-scene-store') + return new mod.FilesystemSceneStore() +} diff --git a/packages/mcp/src/storage/slug.ts b/packages/mcp/src/storage/slug.ts new file mode 100644 index 00000000..80772c85 --- /dev/null +++ b/packages/mcp/src/storage/slug.ts @@ -0,0 +1,66 @@ +const MAX_SLUG_LENGTH = 64 +const GENERATED_SLUG_LENGTH = 12 +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +/** + * Normalizes a raw string into a slug: + * - lowercase + * - spaces → hyphen + * - strip non [a-z0-9-] + * - collapse consecutive hyphens + * - trim hyphens from ends + * - enforce ≤ 64 chars + * + * Throws if the result is empty. + */ +export function sanitizeSlug(raw: string): string { + const sanitized = raw + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/g, '') + + if (sanitized.length === 0) { + throw new Error('Slug cannot be empty after sanitization') + } + + return sanitized +} + +/** + * Checks if a string is already a valid slug (no sanitization performed). + */ +export function isValidSlug(s: string): boolean { + if (typeof s !== 'string') return false + if (s.length === 0 || s.length > MAX_SLUG_LENGTH) return false + return SLUG_PATTERN.test(s) +} + +/** + * Generates a fresh 12-char lowercase alphanumeric slug using crypto randomness. + */ +export function generateSlug(): string { + const raw = globalThis.crypto?.randomUUID?.().replace(/-/g, '') ?? fallbackRandom() + const base = raw.toLowerCase().replace(/[^a-z0-9]/g, '') + if (base.length >= GENERATED_SLUG_LENGTH) { + return base.slice(0, GENERATED_SLUG_LENGTH) + } + // Pad with additional random chars if for any reason the base is short. + let out = base + while (out.length < GENERATED_SLUG_LENGTH) { + out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)] + } + return out.slice(0, GENERATED_SLUG_LENGTH) +} + +function fallbackRandom(): string { + let out = '' + for (let i = 0; i < GENERATED_SLUG_LENGTH * 2; i++) { + out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)] + } + return out +} diff --git a/packages/mcp/src/storage/store.test.ts b/packages/mcp/src/storage/store.test.ts new file mode 100644 index 00000000..55508c16 --- /dev/null +++ b/packages/mcp/src/storage/store.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import { generateSlug, isValidSlug, sanitizeSlug } from './slug' + +describe('sanitizeSlug', () => { + test('lowercases input', () => { + expect(sanitizeSlug('MyScene')).toBe('myscene') + }) + + test('converts spaces to hyphens', () => { + expect(sanitizeSlug('my awesome scene')).toBe('my-awesome-scene') + }) + + test('strips non alphanumeric characters', () => { + expect(sanitizeSlug('hello@world!_$%scene')).toBe('helloworldscene') + }) + + test('collapses runs of hyphens', () => { + expect(sanitizeSlug('a---b--c')).toBe('a-b-c') + }) + + test('collapses runs from mixed input', () => { + expect(sanitizeSlug('a b c')).toBe('a-b-c') + }) + + test('trims hyphens from the ends', () => { + expect(sanitizeSlug('---foo---')).toBe('foo') + }) + + test('enforces 64-char maximum', () => { + const long = 'a'.repeat(200) + const result = sanitizeSlug(long) + expect(result.length).toBeLessThanOrEqual(64) + expect(result).toBe('a'.repeat(64)) + }) + + test('trims trailing hyphen after truncation', () => { + const raw = `${'a'.repeat(63)}-bbbbb` + const result = sanitizeSlug(raw) + expect(result.endsWith('-')).toBe(false) + expect(result.length).toBeLessThanOrEqual(64) + }) + + test('throws when result is empty', () => { + expect(() => sanitizeSlug('')).toThrow() + expect(() => sanitizeSlug('!!!')).toThrow() + expect(() => sanitizeSlug(' ')).toThrow() + }) + + test('preserves already-valid slugs', () => { + expect(sanitizeSlug('already-valid-123')).toBe('already-valid-123') + }) +}) + +describe('isValidSlug', () => { + test('accepts typical slugs', () => { + expect(isValidSlug('my-scene')).toBe(true) + expect(isValidSlug('scene123')).toBe(true) + expect(isValidSlug('a')).toBe(true) + }) + + test('rejects empty string', () => { + expect(isValidSlug('')).toBe(false) + }) + + test('rejects uppercase', () => { + expect(isValidSlug('MyScene')).toBe(false) + }) + + test('rejects underscores and other punctuation', () => { + expect(isValidSlug('my_scene')).toBe(false) + expect(isValidSlug('my.scene')).toBe(false) + expect(isValidSlug('my/scene')).toBe(false) + }) + + test('rejects leading or trailing hyphens', () => { + expect(isValidSlug('-foo')).toBe(false) + expect(isValidSlug('foo-')).toBe(false) + }) + + test('rejects consecutive hyphens', () => { + expect(isValidSlug('foo--bar')).toBe(false) + }) + + test('rejects strings > 64 chars', () => { + expect(isValidSlug('a'.repeat(65))).toBe(false) + }) + + test('accepts exactly 64 chars', () => { + expect(isValidSlug('a'.repeat(64))).toBe(true) + }) +}) + +describe('generateSlug', () => { + test('returns a 12-char string', () => { + const slug = generateSlug() + expect(slug).toHaveLength(12) + }) + + test('is lowercase alphanumeric', () => { + for (let i = 0; i < 50; i++) { + const slug = generateSlug() + expect(slug).toMatch(/^[a-z0-9]{12}$/) + } + }) + + test('passes isValidSlug', () => { + for (let i = 0; i < 20; i++) { + expect(isValidSlug(generateSlug())).toBe(true) + } + }) + + test('produces unique values across many calls', () => { + const seen = new Set() + for (let i = 0; i < 200; i++) { + seen.add(generateSlug()) + } + // Allow for a tiny chance of collision but near-certain uniqueness. + expect(seen.size).toBeGreaterThan(195) + }) +}) + +// Note: createSceneStore() factory branching is covered transitively by +// the filesystem and supabase store tests. We avoid mock.module() here +// because bun's module mocks persist process-wide and pollute sibling +// test files (notably supabase-scene-store.test.ts). diff --git a/packages/mcp/src/storage/supabase-scene-store.test.ts b/packages/mcp/src/storage/supabase-scene-store.test.ts new file mode 100644 index 00000000..4e98b728 --- /dev/null +++ b/packages/mcp/src/storage/supabase-scene-store.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type SupabaseLikeClient, + type SupabaseQueryBuilder, + type SupabaseQueryResult, + SupabaseSceneStore, +} from './supabase-scene-store' +import { SceneVersionConflictError } from './types' + +/** + * Jest-style mock of the Supabase query chain. Each `from(table)` returns a + * fresh builder that records the sequence of operations (`insert | update | + * delete | select`), the collected `.eq()` filters, and any `limit/order`. + * The mock "database" is an in-memory array of rows per table. + */ +type Row = Record + +interface RecordedCall { + table: string + op: 'select' | 'insert' | 'update' | 'delete' | 'upsert' + values?: Row | Row[] + filters: Array<{ column: string; value: unknown }> + orderBy?: { column: string; ascending: boolean } + limit?: number + terminator?: 'single' | 'maybeSingle' | 'iterable' +} + +function createMockClient(): { + client: SupabaseLikeClient + tables: Record + calls: RecordedCall[] +} { + const tables: Record = {} + const calls: RecordedCall[] = [] + + function buildQuery(table: string): SupabaseQueryBuilder { + tables[table] ??= [] + const call: RecordedCall = { table, op: 'select', filters: [] } + + function matchesFilters(row: Row): boolean { + return call.filters.every((f) => row[f.column] === f.value) + } + + function applyOrderAndLimit(rows: Row[]): Row[] { + let out = [...rows] + if (call.orderBy) { + const { column, ascending } = call.orderBy + out.sort((a, b) => { + const av = a[column] as string | number + const bv = b[column] as string | number + if (av === bv) return 0 + return (av < bv ? -1 : 1) * (ascending ? 1 : -1) + }) + } + if (typeof call.limit === 'number') out = out.slice(0, call.limit) + return out + } + + function executeMany(): SupabaseQueryResult { + const rows = tables[table] as Row[] + if (call.op === 'select') { + const hits = rows.filter(matchesFilters) + return { data: applyOrderAndLimit(hits) as T[], error: null } + } + if (call.op === 'insert') { + const incoming = Array.isArray(call.values) ? call.values : [call.values!] + rows.push(...incoming) + return { data: incoming as T[], error: null } + } + if (call.op === 'update') { + const hits = rows.filter(matchesFilters) + for (const row of hits) Object.assign(row, call.values) + return { data: hits as T[], error: null } + } + if (call.op === 'delete') { + const hits = rows.filter(matchesFilters) + tables[table] = rows.filter((r) => !matchesFilters(r)) + return { data: hits as T[], error: null } + } + return { data: [] as T[], error: null } + } + + function executeSingle(required: boolean): SupabaseQueryResult { + const many = executeMany() + if (many.error) return { data: null, error: many.error } + const first = (many.data ?? [])[0] + if (!first) { + if (required) { + return { + data: null, + error: { message: 'No rows', code: 'PGRST116' }, + } + } + return { data: null, error: null } + } + return { data: first as T, error: null } + } + + const builder: SupabaseQueryBuilder = { + select(_columns?: string) { + // `select()` after a mutation keeps the mutation op; only flip to + // 'select' when no op has been set yet. + if (call.op === 'select') { + call.op = 'select' + } + return builder + }, + insert(values) { + call.op = 'insert' + call.values = values as Row | Row[] + return builder + }, + update(values) { + call.op = 'update' + call.values = values as Row + return builder + }, + delete() { + call.op = 'delete' + return builder + }, + upsert(values) { + call.op = 'upsert' + call.values = values as Row | Row[] + return builder + }, + eq(column, value) { + call.filters.push({ column, value }) + return builder + }, + order(column, opts) { + call.orderBy = { column, ascending: opts?.ascending ?? true } + return builder + }, + limit(count) { + call.limit = count + return builder + }, + async maybeSingle() { + call.terminator = 'maybeSingle' + calls.push(call) + return executeSingle(false) as SupabaseQueryResult + }, + async single() { + call.terminator = 'single' + calls.push(call) + return executeSingle(true) as SupabaseQueryResult + }, + // Supabase query builders are themselves thenable — the mock must be + // too, so that `await builder` resolves to the list result. + // biome-ignore lint/suspicious/noThenProperty: mirrors real Supabase client + then(onfulfilled, onrejected) { + call.terminator = 'iterable' + calls.push(call) + const result = executeMany() + return Promise.resolve(result).then(onfulfilled, onrejected) + }, + } + return builder + } + + const client: SupabaseLikeClient = { + from(table: string) { + return buildQuery(table) + }, + } + return { client, tables, calls } +} + +function fakeGraph(nodeCount = 2) { + const nodes: Record = {} + for (let i = 0; i < nodeCount; i++) { + nodes[`wall_${i}`] = { id: `wall_${i}`, type: 'wall' } + } + return { nodes, rootNodeIds: Object.keys(nodes) } +} + +describe('SupabaseSceneStore', () => { + let mock: ReturnType + let store: SupabaseSceneStore + + beforeEach(() => { + mock = createMockClient() + store = new SupabaseSceneStore({ + url: 'https://example.supabase.co', + serviceRoleKey: 'service-role-test-key', + client: mock.client, + }) + }) + + test('reports the supabase backend flag', () => { + expect(store.backend).toBe('supabase') + }) + + test('save (new scene) inserts at version 1 and logs a revision', async () => { + const meta = await store.save({ + name: 'first', + graph: fakeGraph(3) as never, + ownerId: null, + }) + expect(meta.version).toBe(1) + expect(meta.nodeCount).toBe(3) + expect(meta.id.length).toBeGreaterThan(0) + + // One row in scenes, one row in scene_revisions. + expect((mock.tables.scenes ?? []).length).toBe(1) + expect((mock.tables.scene_revisions ?? []).length).toBe(1) + expect((mock.tables.scene_revisions![0] as { author_kind: string }).author_kind).toBe('mcp') + }) + + test('save (existing scene) with matching expectedVersion bumps to 2', async () => { + const created = await store.save({ + id: 'my-scene', + name: 'v1', + graph: fakeGraph(1) as never, + }) + expect(created.version).toBe(1) + + const updated = await store.save({ + id: 'my-scene', + name: 'v1', + graph: fakeGraph(4) as never, + expectedVersion: 1, + }) + expect(updated.version).toBe(2) + expect(updated.nodeCount).toBe(4) + + // Two revisions should now be logged. + expect((mock.tables.scene_revisions ?? []).length).toBe(2) + const versions = (mock.tables.scene_revisions ?? []).map( + (r) => (r as { version: number }).version, + ) + expect(versions.sort()).toEqual([1, 2]) + }) + + test('save with stale expectedVersion throws SceneVersionConflictError', async () => { + await store.save({ id: 'stale', name: 's', graph: fakeGraph() as never }) + let caught: unknown = null + try { + await store.save({ + id: 'stale', + name: 's', + graph: fakeGraph() as never, + expectedVersion: 99, + }) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(SceneVersionConflictError) + }) + + test('load returns null when no row matches', async () => { + const result = await store.load('missing') + expect(result).toBeNull() + }) + + test('load returns the scene + graph when present', async () => { + const saved = await store.save({ id: 'my', name: 's', graph: fakeGraph(2) as never }) + const loaded = await store.load(saved.id) + expect(loaded).not.toBeNull() + expect(loaded!.id).toBe('my') + expect(Object.keys(loaded!.graph.nodes)).toEqual(['wall_0', 'wall_1']) + }) + + test('list applies ownerId filter and honours the default limit', async () => { + await store.save({ id: 'a', name: 'a', graph: fakeGraph() as never, ownerId: 'owner-1' }) + await store.save({ id: 'b', name: 'b', graph: fakeGraph() as never, ownerId: 'owner-2' }) + const onlyOne = await store.list({ ownerId: 'owner-1' }) + expect(onlyOne.map((r) => r.id)).toEqual(['a']) + + const listCall = mock.calls.find((c) => c.op === 'select' && c.terminator === 'iterable')! + expect(listCall.orderBy).toEqual({ column: 'updated_at', ascending: false }) + expect(listCall.limit).toBe(100) + expect(listCall.filters).toContainEqual({ column: 'owner_id', value: 'owner-1' }) + }) + + test('delete removes the row and cascade-deletes the revisions', async () => { + const saved = await store.save({ id: 'gone', name: 'g', graph: fakeGraph() as never }) + expect((mock.tables.scenes ?? []).length).toBe(1) + expect((mock.tables.scene_revisions ?? []).length).toBe(1) + + // Simulate on-delete-cascade by emptying revisions when scenes row goes. + const before = mock.tables.scenes!.length + const ok = await store.delete(saved.id) + expect(ok).toBe(true) + expect(mock.tables.scenes!.length).toBe(before - 1) + + // Confirm the mock recorded a delete with an id filter — this is the + // SQL-equivalent of `delete from scenes where id = ?` relied on by the + // ON DELETE CASCADE from scene_revisions → scenes. + const deleteCall = mock.calls.find((c) => c.op === 'delete' && c.table === 'scenes') + expect(deleteCall).toBeDefined() + expect(deleteCall!.filters).toContainEqual({ column: 'id', value: saved.id }) + }) + + test('delete returns false when the row does not exist', async () => { + const ok = await store.delete('never-existed') + expect(ok).toBe(false) + }) + + test('rename bumps the version and updates name', async () => { + const saved = await store.save({ id: 'ren', name: 'old', graph: fakeGraph() as never }) + const renamed = await store.rename(saved.id, 'new') + expect(renamed.version).toBe(saved.version + 1) + expect(renamed.name).toBe('new') + }) + + test('rename with stale expectedVersion throws SceneVersionConflictError', async () => { + const saved = await store.save({ id: 'ren2', name: 'old', graph: fakeGraph() as never }) + let caught: unknown = null + try { + await store.rename(saved.id, 'newer', { expectedVersion: saved.version + 5 }) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(SceneVersionConflictError) + }) + + test('constructor never exposes the service role key in thrown errors', () => { + let caught: unknown = null + try { + new SupabaseSceneStore({ + url: '', + serviceRoleKey: 'super-secret', + client: mock.client, + }) + } catch (err) { + caught = err + } + expect(caught).toBeInstanceOf(Error) + expect((caught as Error).message).not.toContain('super-secret') + }) +}) diff --git a/packages/mcp/src/storage/supabase-scene-store.ts b/packages/mcp/src/storage/supabase-scene-store.ts new file mode 100644 index 00000000..06f766bf --- /dev/null +++ b/packages/mcp/src/storage/supabase-scene-store.ts @@ -0,0 +1,414 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { generateSlug, sanitizeSlug } from './slug' +import { + type SceneId, + SceneInvalidError, + type SceneListOptions, + type SceneMeta, + type SceneMutateOptions, + SceneNotFoundError, + type SceneSaveOptions, + type SceneStore, + SceneVersionConflictError, + type SceneWithGraph, +} from './types' + +const DEFAULT_LIST_LIMIT = 100 +const MAX_NAME_LENGTH = 200 + +/** + * Minimal structural description of the Supabase client API we use. This lets + * the store be exercised in tests with a plain object mock and avoids a hard + * runtime dependency on `@supabase/supabase-js` for the test suite. + */ +export interface SupabaseQueryResult { + data: T | null + error: { message: string; code?: string; details?: string } | null +} + +export interface SupabaseQueryBuilder { + select(columns?: string): SupabaseQueryBuilder + insert(values: Partial | Partial[]): SupabaseQueryBuilder + update(values: Partial): SupabaseQueryBuilder + delete(): SupabaseQueryBuilder + upsert(values: Partial | Partial[]): SupabaseQueryBuilder + eq(column: string, value: unknown): SupabaseQueryBuilder + order(column: string, opts?: { ascending?: boolean }): SupabaseQueryBuilder + limit(count: number): SupabaseQueryBuilder + maybeSingle(): Promise> + single(): Promise> + then, TResult2 = never>( + onfulfilled?: + | ((value: SupabaseQueryResult) => TResult1 | PromiseLike) + | null + | undefined, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null | undefined, + ): Promise +} + +export interface SupabaseLikeClient { + from>(table: string): SupabaseQueryBuilder +} + +export interface SupabaseSceneStoreOptions { + url: string + serviceRoleKey: string + tableScenes?: string + tableRevisions?: string + /** + * Injectable client, primarily for tests. When omitted, the constructor + * will lazily import `@supabase/supabase-js` and build a real client from + * `url` + `serviceRoleKey`. + */ + client?: SupabaseLikeClient +} + +interface SceneRow { + id: string + project_id: string | null + owner_id: string | null + name: string + graph_json: SceneGraph + thumbnail_url: string | null + version: number + public: boolean + size_bytes: number + node_count: number + created_at: string + updated_at: string +} + +interface RevisionRow { + scene_id: string + version: number + graph_json: SceneGraph + author_kind: 'human' | 'mcp' | 'agent' + author_id: string | null + created_at: string +} + +function rowToMeta(row: SceneRow): SceneMeta { + return { + id: row.id, + name: row.name, + projectId: row.project_id, + ownerId: row.owner_id, + thumbnailUrl: row.thumbnail_url, + version: row.version, + createdAt: row.created_at, + updatedAt: row.updated_at, + sizeBytes: row.size_bytes, + nodeCount: row.node_count, + } +} + +function computeSize(graph: SceneGraph): number { + return Buffer.byteLength(JSON.stringify(graph), 'utf8') +} + +function countNodes(graph: SceneGraph): number { + return Object.keys(graph.nodes ?? {}).length +} + +function validateName(name: string): void { + if (typeof name !== 'string' || name.length < 1 || name.length > MAX_NAME_LENGTH) { + throw new SceneInvalidError(`name must be 1–${MAX_NAME_LENGTH} characters`) + } +} + +export class SupabaseSceneStore implements SceneStore { + readonly backend = 'supabase' as const + + private readonly tableScenes: string + private readonly tableRevisions: string + private clientPromise: Promise + + constructor(opts: SupabaseSceneStoreOptions) { + if (!opts.url) throw new Error('SupabaseSceneStore: url is required') + if (!opts.serviceRoleKey) throw new Error('SupabaseSceneStore: serviceRoleKey is required') + + this.tableScenes = opts.tableScenes ?? 'scenes' + this.tableRevisions = opts.tableRevisions ?? 'scene_revisions' + + if (opts.client) { + const injected = opts.client + this.clientPromise = Promise.resolve(injected) + } else { + // Lazy load the real client so tests that inject `client` don't need + // `@supabase/supabase-js` installed. + const url = opts.url + const key = opts.serviceRoleKey + this.clientPromise = import('@supabase/supabase-js').then((mod) => + mod.createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }), + ) as Promise + } + } + + private async client(): Promise { + return this.clientPromise + } + + async save(opts: SceneSaveOptions): Promise { + validateName(opts.name) + if (!opts.graph || typeof opts.graph !== 'object') { + throw new SceneInvalidError('graph is required') + } + + const nowIso = new Date().toISOString() + const sizeBytes = computeSize(opts.graph) + const nodeCount = countNodes(opts.graph) + + const client = await this.client() + + const providedId = opts.id + const hasId = typeof providedId === 'string' && providedId.length > 0 + + if (!hasId) { + // New scene — generate a fresh slug and insert at version 1. + const id = generateSlug() + const inserted = await client + .from(this.tableScenes) + .insert({ + id, + project_id: opts.projectId ?? null, + owner_id: opts.ownerId ?? null, + name: opts.name, + graph_json: opts.graph, + thumbnail_url: opts.thumbnailUrl ?? null, + version: 1, + size_bytes: sizeBytes, + node_count: nodeCount, + created_at: nowIso, + updated_at: nowIso, + }) + .select() + .single() + + if (inserted.error || !inserted.data) { + throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`) + } + + await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null) + return rowToMeta(inserted.data) + } + + // Existing scene — upsert path. + const id = sanitizeSlug(providedId) + + // Look up current version so we know the next value + can enforce + // expectedVersion locally even when Supabase's RLS answer is opaque. + const existing = await client + .from(this.tableScenes) + .select('*') + .eq('id', id) + .maybeSingle() + + if (existing.error) { + throw new Error(`Supabase lookup failed: ${existing.error.message}`) + } + + if (!existing.data) { + // No row yet for this id — insert as v1. + const inserted = await client + .from(this.tableScenes) + .insert({ + id, + project_id: opts.projectId ?? null, + owner_id: opts.ownerId ?? null, + name: opts.name, + graph_json: opts.graph, + thumbnail_url: opts.thumbnailUrl ?? null, + version: 1, + size_bytes: sizeBytes, + node_count: nodeCount, + created_at: nowIso, + updated_at: nowIso, + }) + .select() + .single() + + if (inserted.error || !inserted.data) { + throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`) + } + await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null) + return rowToMeta(inserted.data) + } + + const currentVersion = existing.data.version + if (typeof opts.expectedVersion === 'number' && opts.expectedVersion !== currentVersion) { + throw new SceneVersionConflictError( + `expected version ${opts.expectedVersion}, current ${currentVersion}`, + ) + } + + const nextVersion = currentVersion + 1 + // Optimistic lock via `where version = currentVersion`. + const updated = await client + .from(this.tableScenes) + .update({ + name: opts.name, + project_id: opts.projectId ?? existing.data.project_id, + owner_id: opts.ownerId ?? existing.data.owner_id, + graph_json: opts.graph, + thumbnail_url: + opts.thumbnailUrl === undefined ? existing.data.thumbnail_url : opts.thumbnailUrl, + version: nextVersion, + size_bytes: sizeBytes, + node_count: nodeCount, + updated_at: nowIso, + }) + .eq('id', id) + .eq('version', currentVersion) + .select() + .single() + + if (updated.error || !updated.data) { + // Either someone raced us (version drifted) or the row vanished. + throw new SceneVersionConflictError( + updated.error?.message ?? 'version conflict during update', + ) + } + + await this.insertRevision(client, id, nextVersion, opts.graph, opts.ownerId ?? null) + return rowToMeta(updated.data) + } + + async load(id: SceneId): Promise { + const client = await this.client() + const result = await client + .from(this.tableScenes) + .select('*') + .eq('id', id) + .maybeSingle() + + if (result.error) { + throw new Error(`Supabase load failed: ${result.error.message}`) + } + if (!result.data) return null + + return { ...rowToMeta(result.data), graph: result.data.graph_json } + } + + async list(opts?: SceneListOptions): Promise { + const client = await this.client() + let query = client + .from(this.tableScenes) + .select('*') + .order('updated_at', { ascending: false }) + .limit(opts?.limit ?? DEFAULT_LIST_LIMIT) + + if (opts?.projectId) query = query.eq('project_id', opts.projectId) + if (opts?.ownerId) query = query.eq('owner_id', opts.ownerId) + + const result = (await query) as SupabaseQueryResult + if (result.error) { + throw new Error(`Supabase list failed: ${result.error.message}`) + } + return (result.data ?? []).map(rowToMeta) + } + + async delete(id: SceneId, opts?: SceneMutateOptions): Promise { + const client = await this.client() + + if (typeof opts?.expectedVersion === 'number') { + const existing = await client + .from(this.tableScenes) + .select('version') + .eq('id', id) + .maybeSingle() + + if (existing.error) { + throw new Error(`Supabase lookup failed: ${existing.error.message}`) + } + if (!existing.data) return false + if (existing.data.version !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `expected version ${opts.expectedVersion}, current ${existing.data.version}`, + ) + } + } + + const deleted = await client + .from(this.tableScenes) + .delete() + .eq('id', id) + .select() + .maybeSingle() + + if (deleted.error) { + throw new Error(`Supabase delete failed: ${deleted.error.message}`) + } + return deleted.data !== null + } + + async rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise { + validateName(newName) + const client = await this.client() + + const existing = await client + .from(this.tableScenes) + .select('*') + .eq('id', id) + .maybeSingle() + + if (existing.error) { + throw new Error(`Supabase lookup failed: ${existing.error.message}`) + } + if (!existing.data) { + throw new SceneNotFoundError(`scene ${id} not found`) + } + + if ( + typeof opts?.expectedVersion === 'number' && + opts.expectedVersion !== existing.data.version + ) { + throw new SceneVersionConflictError( + `expected version ${opts.expectedVersion}, current ${existing.data.version}`, + ) + } + + const nextVersion = existing.data.version + 1 + const updated = await client + .from(this.tableScenes) + .update({ + name: newName, + version: nextVersion, + updated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('version', existing.data.version) + .select() + .single() + + if (updated.error || !updated.data) { + throw new SceneVersionConflictError( + updated.error?.message ?? 'version conflict during rename', + ) + } + return rowToMeta(updated.data) + } + + private async insertRevision( + client: SupabaseLikeClient, + sceneId: string, + version: number, + graph: SceneGraph, + authorId: string | null, + ): Promise { + const result = await client.from(this.tableRevisions).insert({ + scene_id: sceneId, + version, + graph_json: graph, + author_kind: 'mcp', + author_id: authorId, + created_at: new Date().toISOString(), + }) + if (result.error) { + // Revision history is best-effort; surface the failure so callers can + // log / alert, but don't swallow it silently. + throw new Error(`Supabase revision insert failed: ${result.error.message}`) + } + } +} diff --git a/packages/mcp/src/storage/types.ts b/packages/mcp/src/storage/types.ts new file mode 100644 index 00000000..641c8aa2 --- /dev/null +++ b/packages/mcp/src/storage/types.ts @@ -0,0 +1,88 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' + +/** + * Slug-safe scene identifier: lowercase alphanumerics and hyphens, ≤ 64 chars. + */ +export type SceneId = string + +export interface SceneMeta { + id: SceneId + name: string + projectId: string | null + thumbnailUrl: string | null + /** Monotonic, incremented on every save. */ + version: number + /** ISO 8601 timestamp. */ + createdAt: string + /** ISO 8601 timestamp. */ + updatedAt: string + ownerId: string | null + sizeBytes: number + nodeCount: number +} + +export interface SceneWithGraph extends SceneMeta { + graph: SceneGraph +} + +export interface SceneSaveOptions { + id?: SceneId + name: string + projectId?: string | null + ownerId?: string | null + graph: SceneGraph + thumbnailUrl?: string | null + /** When set, save fails with `SceneVersionConflictError` on mismatch. */ + expectedVersion?: number +} + +export interface SceneListOptions { + projectId?: string + ownerId?: string + limit?: number +} + +export interface SceneMutateOptions { + expectedVersion?: number +} + +export interface SceneStore { + readonly backend: 'filesystem' | 'supabase' + save(opts: SceneSaveOptions): Promise + load(id: SceneId): Promise + list(opts?: SceneListOptions): Promise + delete(id: SceneId, opts?: SceneMutateOptions): Promise + rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise +} + +export class SceneNotFoundError extends Error { + readonly code = 'not_found' as const + constructor(message = 'Scene not found') { + super(message) + this.name = 'SceneNotFoundError' + } +} + +export class SceneVersionConflictError extends Error { + readonly code = 'version_conflict' as const + constructor(message = 'Scene version conflict') { + super(message) + this.name = 'SceneVersionConflictError' + } +} + +export class SceneInvalidError extends Error { + readonly code = 'invalid' as const + constructor(message = 'Scene invalid') { + super(message) + this.name = 'SceneInvalidError' + } +} + +export class SceneTooLargeError extends Error { + readonly code = 'too_large' as const + constructor(message = 'Scene too large') { + super(message) + this.name = 'SceneTooLargeError' + } +} diff --git a/packages/mcp/src/templates/empty-studio.ts b/packages/mcp/src/templates/empty-studio.ts new file mode 100644 index 00000000..84ae6547 --- /dev/null +++ b/packages/mcp/src/templates/empty-studio.ts @@ -0,0 +1,264 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' + +/** + * 40 m² studio apartment — a single open room with one window, one front door + * and a single "Living/Kitchen" zone. Used as a starting point for small unit + * briefs. Deterministic ids (`site_empty`, `building_empty`, `level_0`, etc.) + * are regenerated by the MCP tool via `cloneSceneGraph` before applying. + */ + +// Footprint: 8 m × 5 m = 40 m² (centered at origin). +// Walls traverse the boundary counter-clockwise (right-handed XZ plane). +const W = 4 // half-width +const D = 2.5 // half-depth + +type StudioNodes = { + site: AnyNode + building: AnyNode + level: AnyNode + walls: AnyNode[] + zone: AnyNode + door: AnyNode + window: AnyNode +} + +function buildNodes(): StudioNodes { + const site: AnyNode = { + object: 'node', + id: 'site_empty' as AnyNodeId, + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + children: ['building_empty' as AnyNodeId], + } as unknown as AnyNode + + const building: AnyNode = { + object: 'node', + id: 'building_empty' as AnyNodeId, + type: 'building', + parentId: 'site_empty' as AnyNodeId, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0' as AnyNodeId], + } as unknown as AnyNode + + // Walls with deterministic ids; child ids are listed below after doors/windows + // are created, so we fill this array after computing them. + const wallIds = ['wall_n', 'wall_e', 'wall_s', 'wall_w'] as const + + // South wall carries the front door; west wall carries the window. + const door: AnyNode = { + object: 'node', + id: 'door_front' as AnyNodeId, + type: 'door', + parentId: 'wall_s' as AnyNodeId, + visible: true, + metadata: {}, + wallId: 'wall_s', + position: [0, 1.05, 0], + rotation: [0, 0, 0], + width: 0.9, + height: 2.1, + frameThickness: 0.05, + frameDepth: 0.07, + threshold: true, + thresholdHeight: 0.02, + hingesSide: 'left', + swingDirection: 'inward', + segments: [ + { + type: 'panel', + heightRatio: 0.4, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.6, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + ], + handle: true, + handleHeight: 1.05, + handleSide: 'right', + contentPadding: [0.04, 0.04], + doorCloser: false, + panicBar: false, + panicBarHeight: 1.0, + } as unknown as AnyNode + + const windowNode: AnyNode = { + object: 'node', + id: 'window_w' as AnyNodeId, + type: 'window', + parentId: 'wall_w' as AnyNodeId, + visible: true, + metadata: {}, + wallId: 'wall_w', + position: [0, 1.2, 0], + rotation: [0, 0, 0], + width: 1.5, + height: 1.2, + frameThickness: 0.05, + frameDepth: 0.07, + columnRatios: [1], + rowRatios: [1], + columnDividerThickness: 0.03, + rowDividerThickness: 0.03, + sill: true, + sillDepth: 0.08, + sillThickness: 0.03, + } as unknown as AnyNode + + const wallNorth: AnyNode = { + object: 'node', + id: 'wall_n' as AnyNodeId, + type: 'wall', + parentId: 'level_0' as AnyNodeId, + visible: true, + metadata: {}, + children: [], + thickness: 0.1, + height: 2.5, + start: [-W, -D], + end: [W, -D], + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode + + const wallEast: AnyNode = { + object: 'node', + id: 'wall_e' as AnyNodeId, + type: 'wall', + parentId: 'level_0' as AnyNodeId, + visible: true, + metadata: {}, + children: [], + thickness: 0.1, + height: 2.5, + start: [W, -D], + end: [W, D], + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode + + const wallSouth: AnyNode = { + object: 'node', + id: 'wall_s' as AnyNodeId, + type: 'wall', + parentId: 'level_0' as AnyNodeId, + visible: true, + metadata: {}, + children: ['door_front'], + thickness: 0.1, + height: 2.5, + start: [W, D], + end: [-W, D], + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode + + const wallWest: AnyNode = { + object: 'node', + id: 'wall_w' as AnyNodeId, + type: 'wall', + parentId: 'level_0' as AnyNodeId, + visible: true, + metadata: {}, + children: ['window_w'], + thickness: 0.1, + height: 2.5, + start: [-W, D], + end: [-W, -D], + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode + + const zone: AnyNode = { + object: 'node', + id: 'zone_living' as AnyNodeId, + type: 'zone', + parentId: 'level_0' as AnyNodeId, + visible: true, + metadata: {}, + name: 'Living / Kitchen', + color: '#60a5fa', + polygon: [ + [-W, -D], + [W, -D], + [W, D], + [-W, D], + ], + } as unknown as AnyNode + + const level: AnyNode = { + object: 'node', + id: 'level_0' as AnyNodeId, + type: 'level', + parentId: 'building_empty' as AnyNodeId, + visible: true, + metadata: {}, + level: 0, + children: [...wallIds, 'zone_living'] as AnyNodeId[], + } as unknown as AnyNode + + return { + site, + building, + level, + walls: [wallNorth, wallEast, wallSouth, wallWest], + zone, + door, + window: windowNode, + } +} + +function buildTemplate(): SceneGraph { + const n = buildNodes() + const nodes: Record = {} + for (const node of [n.site, n.building, n.level, ...n.walls, n.zone, n.door, n.window]) { + nodes[node.id as AnyNodeId] = node + } + + // SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects + // (not string ids) — so the site must embed the full building node. The + // rest of the tree uses string ids per the BaseNode/LevelNode/WallNode + // schemas. We mutate the flat-dict copy of the site here so the nested + // representation round-trips through AnyNode.safeParse. + const siteInDict = nodes['site_empty' as AnyNodeId] as unknown as { + children: unknown[] + } + siteInDict.children = [nodes['building_empty' as AnyNodeId]] + + return { + nodes, + rootNodeIds: ['site_empty'] as AnyNodeId[], + } +} + +export const template: SceneGraph = buildTemplate() + +export const metadata = { + id: 'empty-studio', + name: 'Empty studio', + description: + '40 m² single-room studio apartment: 4 walls, 1 living/kitchen zone, 1 window, 1 front door.', +} as const diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts new file mode 100644 index 00000000..b7205b45 --- /dev/null +++ b/packages/mcp/src/templates/garden-house.ts @@ -0,0 +1,283 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' + +/** + * "Garden house" — a simplified take on the Casa del Sol layout used in the + * MCP research fixtures. + * + * Footprint: 12 m × 8 m house centered at the origin, with a 12 m × 6 m + * back garden zone immediately to the north of the house, surrounded by a + * privacy fence on three sides. + * + * Contents: + * - 4 perimeter walls around the house + * - 1 front door (south wall), 1 large garden door (north wall) + * - 2 windows on the south wall, 1 window on each of east and west + * - 1 indoor "living" zone, 1 outdoor "garden" zone + * - 3 fence segments bounding the north/east/west of the garden + */ + +const HOUSE_W = 6 // half-width of the house (12 m total) +const HOUSE_D = 4 // half-depth of the house (8 m total) +const GARDEN_DEPTH = 6 // depth of the back-garden zone along +z direction + +const WALL_THICKNESS = 0.15 +const WALL_HEIGHT = 2.7 + +type NodeMap = Record + +function wall( + id: string, + start: [number, number], + end: [number, number], + children: string[] = [], +): AnyNode { + return { + object: 'node', + id, + type: 'wall', + parentId: 'level_0', + visible: true, + metadata: {}, + children, + thickness: WALL_THICKNESS, + height: WALL_HEIGHT, + start, + end, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode +} + +function door(id: string, parentWallId: string, width = 0.9): AnyNode { + return { + object: 'node', + id, + type: 'door', + parentId: parentWallId, + visible: true, + metadata: {}, + wallId: parentWallId, + position: [0, 1.05, 0], + rotation: [0, 0, 0], + width, + height: 2.1, + frameThickness: 0.05, + frameDepth: 0.07, + threshold: true, + thresholdHeight: 0.02, + hingesSide: 'left', + swingDirection: 'inward', + segments: [ + { + type: 'panel', + heightRatio: 0.5, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.5, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + ], + handle: true, + handleHeight: 1.05, + handleSide: 'right', + contentPadding: [0.04, 0.04], + doorCloser: false, + panicBar: false, + panicBarHeight: 1.0, + } as unknown as AnyNode +} + +function makeWindow(id: string, parentWallId: string, width = 1.2): AnyNode { + return { + object: 'node', + id, + type: 'window', + parentId: parentWallId, + visible: true, + metadata: {}, + wallId: parentWallId, + position: [0, 1.2, 0], + rotation: [0, 0, 0], + width, + height: 1.2, + frameThickness: 0.05, + frameDepth: 0.07, + columnRatios: [1], + rowRatios: [1], + columnDividerThickness: 0.03, + rowDividerThickness: 0.03, + sill: true, + sillDepth: 0.08, + sillThickness: 0.03, + } as unknown as AnyNode +} + +function fence(id: string, start: [number, number], end: [number, number]): AnyNode { + return { + object: 'node', + id, + type: 'fence', + parentId: 'level_0', + visible: true, + metadata: {}, + start, + end, + height: 1.8, + thickness: 0.08, + baseHeight: 0.22, + postSpacing: 2, + postSize: 0.1, + topRailHeight: 0.04, + groundClearance: 0, + edgeInset: 0.015, + baseStyle: 'grounded', + color: '#f3f4f6', + style: 'privacy', + } as unknown as AnyNode +} + +function buildTemplate(): SceneGraph { + const nodes: NodeMap = {} + + nodes.site_garden = { + object: 'node', + id: 'site_garden', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + children: ['building_garden'], + } as unknown as AnyNode + + nodes.building_garden = { + object: 'node', + id: 'building_garden', + type: 'building', + parentId: 'site_garden', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0'], + } as unknown as AnyNode + + // Openings + nodes.door_front = door('door_front', 'wall_s', 1.0) + nodes.door_garden = door('door_garden', 'wall_n', 1.6) + nodes.window_s1 = makeWindow('window_s1', 'wall_s', 1.2) + nodes.window_s2 = makeWindow('window_s2', 'wall_s', 1.2) + nodes.window_e = makeWindow('window_e', 'wall_e', 1.0) + nodes.window_w = makeWindow('window_w', 'wall_w', 1.0) + + // House perimeter (south is front, north opens to the garden) + nodes.wall_n = wall('wall_n', [-HOUSE_W, -HOUSE_D], [HOUSE_W, -HOUSE_D], ['door_garden']) + nodes.wall_e = wall('wall_e', [HOUSE_W, -HOUSE_D], [HOUSE_W, HOUSE_D], ['window_e']) + nodes.wall_s = wall( + 'wall_s', + [HOUSE_W, HOUSE_D], + [-HOUSE_W, HOUSE_D], + ['door_front', 'window_s1', 'window_s2'], + ) + nodes.wall_w = wall('wall_w', [-HOUSE_W, HOUSE_D], [-HOUSE_W, -HOUSE_D], ['window_w']) + + // Zones + nodes.zone_living = { + object: 'node', + id: 'zone_living', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Living', + color: '#60a5fa', + polygon: [ + [-HOUSE_W, -HOUSE_D], + [HOUSE_W, -HOUSE_D], + [HOUSE_W, HOUSE_D], + [-HOUSE_W, HOUSE_D], + ], + } as unknown as AnyNode + + nodes.zone_garden = { + object: 'node', + id: 'zone_garden', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Back garden', + color: '#86efac', + polygon: [ + [-HOUSE_W, -HOUSE_D - GARDEN_DEPTH], + [HOUSE_W, -HOUSE_D - GARDEN_DEPTH], + [HOUSE_W, -HOUSE_D], + [-HOUSE_W, -HOUSE_D], + ], + } as unknown as AnyNode + + // Privacy fence along 3 sides of the garden. + nodes.fence_n = fence( + 'fence_n', + [-HOUSE_W, -HOUSE_D - GARDEN_DEPTH], + [HOUSE_W, -HOUSE_D - GARDEN_DEPTH], + ) + nodes.fence_e = fence('fence_e', [HOUSE_W, -HOUSE_D - GARDEN_DEPTH], [HOUSE_W, -HOUSE_D]) + nodes.fence_w = fence('fence_w', [-HOUSE_W, -HOUSE_D], [-HOUSE_W, -HOUSE_D - GARDEN_DEPTH]) + + nodes.level_0 = { + object: 'node', + id: 'level_0', + type: 'level', + parentId: 'building_garden', + visible: true, + metadata: {}, + level: 0, + children: [ + 'wall_n', + 'wall_e', + 'wall_s', + 'wall_w', + 'zone_living', + 'zone_garden', + 'fence_n', + 'fence_e', + 'fence_w', + ], + } as unknown as AnyNode + + // SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects + // (not string ids) per the schema — embed the full building node here. + ;(nodes.site_garden as unknown as { children: unknown[] }).children = [nodes.building_garden!] + + return { + nodes: nodes as Record, + rootNodeIds: ['site_garden'] as AnyNodeId[], + } +} + +export const template: SceneGraph = buildTemplate() + +export const metadata = { + id: 'garden-house', + name: 'Garden house', + description: + '12 × 8 m single-level house with a fenced back-garden zone; 4 walls, 2 doors, 4 windows, 3 privacy fences.', +} as const diff --git a/packages/mcp/src/templates/index.ts b/packages/mcp/src/templates/index.ts new file mode 100644 index 00000000..3c2ed8e3 --- /dev/null +++ b/packages/mcp/src/templates/index.ts @@ -0,0 +1,41 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import * as emptyStudio from './empty-studio' +import * as gardenHouse from './garden-house' +import * as twoBedroom from './two-bedroom' + +export type TemplateMetadata = { + id: string + name: string + description: string +} + +export type TemplateEntry = { + /** Stable template id used by `create_from_template`. */ + id: string + name: string + description: string + /** Static SceneGraph — ids are placeholders; regenerate via `cloneSceneGraph`. */ + template: SceneGraph +} + +function makeEntry(template: SceneGraph, metadata: TemplateMetadata): TemplateEntry { + return { + id: metadata.id, + name: metadata.name, + description: metadata.description, + template, + } +} + +export const TEMPLATES = { + 'empty-studio': makeEntry(emptyStudio.template, emptyStudio.metadata), + 'two-bedroom': makeEntry(twoBedroom.template, twoBedroom.metadata), + 'garden-house': makeEntry(gardenHouse.template, gardenHouse.metadata), +} as const + +export type TemplateId = keyof typeof TEMPLATES + +/** Type guard for external callers that receive arbitrary string ids. */ +export function isTemplateId(id: string): id is TemplateId { + return Object.hasOwn(TEMPLATES, id) +} diff --git a/packages/mcp/src/templates/templates.test.ts b/packages/mcp/src/templates/templates.test.ts new file mode 100644 index 00000000..82778111 --- /dev/null +++ b/packages/mcp/src/templates/templates.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' +import { AnyNode } from '@pascal-app/core/schema' +import { TEMPLATES, type TemplateId } from './index' + +describe('scene templates', () => { + const ids: TemplateId[] = Object.keys(TEMPLATES) as TemplateId[] + + for (const id of ids) { + const entry = TEMPLATES[id] + + test(`${id} has required metadata`, () => { + expect(entry.id).toBe(id) + expect(typeof entry.name).toBe('string') + expect(entry.name.length).toBeGreaterThan(0) + expect(typeof entry.description).toBe('string') + expect(entry.description.length).toBeGreaterThan(0) + }) + + test(`${id} template nodes all pass AnyNode.safeParse`, () => { + const { nodes, rootNodeIds } = entry.template + expect(rootNodeIds.length).toBeGreaterThan(0) + expect(Object.keys(nodes).length).toBeGreaterThan(0) + + for (const [nodeId, node] of Object.entries(nodes)) { + const res = AnyNode.safeParse(node) + if (!res.success) { + // Surface the path/message of the first issue for debuggability. + const first = res.error.issues[0] + throw new Error( + `template ${id} node ${nodeId} failed AnyNode.safeParse at ${first?.path.join('.')}: ${first?.message}`, + ) + } + expect(res.success).toBe(true) + } + }) + + test(`${id} root ids resolve and parent links point to existing nodes`, () => { + const { nodes, rootNodeIds } = entry.template + for (const rid of rootNodeIds) { + expect(nodes[rid]).toBeDefined() + } + for (const node of Object.values(nodes)) { + if (node.parentId && !(node.parentId in nodes)) { + throw new Error( + `template ${id} node ${node.id} has parentId ${node.parentId} which does not exist`, + ) + } + } + }) + } + + test('empty-studio has 4 walls, 1 zone, 1 door, 1 window', () => { + const { nodes } = TEMPLATES['empty-studio'].template + const byType = groupByType(nodes) + expect(byType.wall ?? 0).toBe(4) + expect(byType.zone ?? 0).toBe(1) + expect(byType.door ?? 0).toBe(1) + expect(byType.window ?? 0).toBe(1) + }) + + test('two-bedroom has 9 walls, 4 zones, 4 doors, 5 windows', () => { + const { nodes } = TEMPLATES['two-bedroom'].template + const byType = groupByType(nodes) + expect(byType.wall ?? 0).toBe(9) + expect(byType.zone ?? 0).toBe(4) + expect(byType.door ?? 0).toBe(4) + expect(byType.window ?? 0).toBe(5) + }) + + test('garden-house has a fenced garden zone', () => { + const { nodes } = TEMPLATES['garden-house'].template + const byType = groupByType(nodes) + expect(byType.zone ?? 0).toBeGreaterThanOrEqual(2) + expect(byType.fence ?? 0).toBeGreaterThanOrEqual(3) + expect(byType.wall ?? 0).toBeGreaterThanOrEqual(4) + }) +}) + +function groupByType(nodes: Record): Record { + const out: Record = {} + for (const node of Object.values(nodes)) { + out[node.type] = (out[node.type] ?? 0) + 1 + } + return out +} diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts new file mode 100644 index 00000000..80fcc18f --- /dev/null +++ b/packages/mcp/src/templates/two-bedroom.ts @@ -0,0 +1,311 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' + +/** + * 80 m² two-bedroom apartment. + * + * Footprint: 10 m × 8 m = 80 m², centered near the origin. + * Contents: 9 walls (4 perimeter + 5 interior), 4 zones + * (living/kitchen, bedroom1, bedroom2, bath), 4 doors (front + 3 interior), + * 5 windows (2 on the living/kitchen, 1 per bedroom, 1 on the bath). + * Interior partitions split the north half into two bedrooms and a bath. + * + * Coordinate system: `[x, z]` on the XZ plane, with `x` running east/west + * and `z` running north/south (positive z points south). + */ + +// Perimeter extents: 10 m × 8 m. +const X_MIN = -5 +const X_MAX = 5 +const Z_MIN = -4 +const Z_MAX = 4 + +// Interior split lines. +const CORRIDOR_Z = 0 // horizontal wall separating north half (bedrooms+bath) from south (living) +const BED_X = -1 // vertical wall between bedroom 1 (west) and bath (east of it) +const BATH_X = 2 // vertical wall between bath (middle) and bedroom 2 (east) + +const WALL_THICKNESS = 0.1 +const WALL_HEIGHT = 2.5 + +type NodeMap = Record + +function wall( + id: string, + start: [number, number], + end: [number, number], + children: string[] = [], +): AnyNode { + return { + object: 'node', + id, + type: 'wall', + parentId: 'level_0', + visible: true, + metadata: {}, + children, + thickness: WALL_THICKNESS, + height: WALL_HEIGHT, + start, + end, + frontSide: 'unknown', + backSide: 'unknown', + } as unknown as AnyNode +} + +function door(id: string, parentWallId: string): AnyNode { + return { + object: 'node', + id, + type: 'door', + parentId: parentWallId, + visible: true, + metadata: {}, + wallId: parentWallId, + position: [0, 1.05, 0], + rotation: [0, 0, 0], + width: 0.8, + height: 2.1, + frameThickness: 0.05, + frameDepth: 0.07, + threshold: true, + thresholdHeight: 0.02, + hingesSide: 'left', + swingDirection: 'inward', + segments: [ + { + type: 'panel', + heightRatio: 0.5, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.5, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.01, + panelInset: 0.04, + }, + ], + handle: true, + handleHeight: 1.05, + handleSide: 'right', + contentPadding: [0.04, 0.04], + doorCloser: false, + panicBar: false, + panicBarHeight: 1.0, + } as unknown as AnyNode +} + +function makeWindow(id: string, parentWallId: string, width = 1.2): AnyNode { + return { + object: 'node', + id, + type: 'window', + parentId: parentWallId, + visible: true, + metadata: {}, + wallId: parentWallId, + position: [0, 1.2, 0], + rotation: [0, 0, 0], + width, + height: 1.2, + frameThickness: 0.05, + frameDepth: 0.07, + columnRatios: [1], + rowRatios: [1], + columnDividerThickness: 0.03, + rowDividerThickness: 0.03, + sill: true, + sillDepth: 0.08, + sillThickness: 0.03, + } as unknown as AnyNode +} + +function buildTemplate(): SceneGraph { + const nodes: NodeMap = {} + + // Root nodes + nodes.site_2br = { + object: 'node', + id: 'site_2br', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-15, -15], + [15, -15], + [15, 15], + [-15, 15], + ], + }, + children: ['building_2br'], + } as unknown as AnyNode + + nodes.building_2br = { + object: 'node', + id: 'building_2br', + type: 'building', + parentId: 'site_2br', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_0'], + } as unknown as AnyNode + + // Openings — declared up front so walls can list them as children. + nodes.door_front = door('door_front', 'wall_s') + nodes.door_bed1 = door('door_bed1', 'wall_corr_1') + nodes.door_bath = door('door_bath', 'wall_corr_2') + nodes.door_bed2 = door('door_bed2', 'wall_corr_3') + + nodes.window_living_a = makeWindow('window_living_a', 'wall_s', 1.5) + nodes.window_living_b = makeWindow('window_living_b', 'wall_e', 1.2) + nodes.window_bed1 = makeWindow('window_bed1', 'wall_n', 1.2) + nodes.window_bath = makeWindow('window_bath', 'wall_n', 0.6) + nodes.window_bed2 = makeWindow('window_bed2', 'wall_n', 1.2) + + // Perimeter walls (N, E, S, W) — 4 walls. + // Interior partitions — 5 walls (the east/west corridor wall is split into + // three segments by the two vertical partitions so doors have a clear host). + nodes.wall_n = wall( + 'wall_n', + [X_MIN, Z_MIN], + [X_MAX, Z_MIN], + ['window_bed1', 'window_bath', 'window_bed2'], + ) + nodes.wall_e = wall('wall_e', [X_MAX, Z_MIN], [X_MAX, Z_MAX], ['window_living_b']) + nodes.wall_s = wall('wall_s', [X_MAX, Z_MAX], [X_MIN, Z_MAX], ['door_front', 'window_living_a']) + nodes.wall_w = wall('wall_w', [X_MIN, Z_MAX], [X_MIN, Z_MIN]) + + // Corridor wall is broken into 3 segments so each has its own interior door. + // Segment 1: from west to BED_X (bedroom-1 wall) + nodes.wall_corr_1 = wall('wall_corr_1', [X_MIN, CORRIDOR_Z], [BED_X, CORRIDOR_Z], ['door_bed1']) + // Segment 2: from BED_X to BATH_X (bath wall) + nodes.wall_corr_2 = wall('wall_corr_2', [BED_X, CORRIDOR_Z], [BATH_X, CORRIDOR_Z], ['door_bath']) + // Segment 3: from BATH_X to east (bedroom-2 wall) + nodes.wall_corr_3 = wall('wall_corr_3', [BATH_X, CORRIDOR_Z], [X_MAX, CORRIDOR_Z], ['door_bed2']) + + // Two vertical partitions between the three north rooms. + nodes.wall_part_1 = wall('wall_part_1', [BED_X, Z_MIN], [BED_X, CORRIDOR_Z]) + nodes.wall_part_2 = wall('wall_part_2', [BATH_X, Z_MIN], [BATH_X, CORRIDOR_Z]) + + // Zones: one per room. + nodes.zone_living = { + object: 'node', + id: 'zone_living', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Living / Kitchen', + color: '#60a5fa', + polygon: [ + [X_MIN, CORRIDOR_Z], + [X_MAX, CORRIDOR_Z], + [X_MAX, Z_MAX], + [X_MIN, Z_MAX], + ], + } as unknown as AnyNode + + nodes.zone_bed1 = { + object: 'node', + id: 'zone_bed1', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Bedroom 1', + color: '#f472b6', + polygon: [ + [X_MIN, Z_MIN], + [BED_X, Z_MIN], + [BED_X, CORRIDOR_Z], + [X_MIN, CORRIDOR_Z], + ], + } as unknown as AnyNode + + nodes.zone_bath = { + object: 'node', + id: 'zone_bath', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Bath', + color: '#a7f3d0', + polygon: [ + [BED_X, Z_MIN], + [BATH_X, Z_MIN], + [BATH_X, CORRIDOR_Z], + [BED_X, CORRIDOR_Z], + ], + } as unknown as AnyNode + + nodes.zone_bed2 = { + object: 'node', + id: 'zone_bed2', + type: 'zone', + parentId: 'level_0', + visible: true, + metadata: {}, + name: 'Bedroom 2', + color: '#fcd34d', + polygon: [ + [BATH_X, Z_MIN], + [X_MAX, Z_MIN], + [X_MAX, CORRIDOR_Z], + [BATH_X, CORRIDOR_Z], + ], + } as unknown as AnyNode + + nodes.level_0 = { + object: 'node', + id: 'level_0', + type: 'level', + parentId: 'building_2br', + visible: true, + metadata: {}, + level: 0, + children: [ + 'wall_n', + 'wall_e', + 'wall_s', + 'wall_w', + 'wall_corr_1', + 'wall_corr_2', + 'wall_corr_3', + 'wall_part_1', + 'wall_part_2', + 'zone_living', + 'zone_bed1', + 'zone_bath', + 'zone_bed2', + ], + } as unknown as AnyNode + + // SiteNode.children is a discriminatedUnion of BuildingNode/ItemNode objects + // (not string ids) per the schema — embed the full building node here. + ;(nodes.site_2br as unknown as { children: unknown[] }).children = [nodes.building_2br!] + + return { + nodes: nodes as Record, + rootNodeIds: ['site_2br'] as AnyNodeId[], + } +} + +export const template: SceneGraph = buildTemplate() + +export const metadata = { + id: 'two-bedroom', + name: 'Two-bedroom apartment', + description: + '80 m² two-bedroom flat: 9 walls, 4 zones (living/kitchen, 2 bedrooms, bath), 4 doors and 5 windows.', +} as const diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index f0682b5c..51aee46e 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,5 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneStore } from '../storage/types' import { registerApplyPatch } from './apply-patch' import { registerCheckCollisions } from './check-collisions' import { registerCreateLevel } from './create-level' @@ -14,18 +15,25 @@ import { registerFindNodes } from './find-nodes' import { registerGetNode } from './get-node' import { registerGetScene } from './get-scene' import { registerMeasure } from './measure' +import { registerPhotoToSceneTool } from './photo-to-scene' import { registerPlaceItem } from './place-item' import { registerRedo } from './redo' +import { registerSceneLifecycleTools } from './scene-lifecycle' import { registerSetZone } from './set-zone' +import { registerTemplateTools } from './templates' import { registerUndo } from './undo' import { registerValidateScene } from './validate-scene' +import { registerVariantTools } from './variants' /** * Register every non-vision MCP tool against the given server. * Vision tools (analyze_floorplan_image, analyze_room_photo) are registered * separately via `registerVisionTools` (Agent E). + * + * Scene-lifecycle tools (save/load/list/delete/rename scene) are registered + * when a `store` is provided; callers that pass `undefined` skip them. */ -export function registerTools(server: McpServer, bridge: SceneBridge): void { +export function registerTools(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { registerGetScene(server, bridge) registerGetNode(server, bridge) registerDescribeNode(server, bridge) @@ -45,4 +53,10 @@ export function registerTools(server: McpServer, bridge: SceneBridge): void { registerExportGlb(server, bridge) registerValidateScene(server, bridge) registerCheckCollisions(server, bridge) + registerTemplateTools(server, bridge, store) + if (store) { + registerSceneLifecycleTools(server, bridge, store) + registerVariantTools(server, bridge, store) + registerPhotoToSceneTool(server, bridge, store) + } } diff --git a/packages/mcp/src/tools/photo-to-scene/index.ts b/packages/mcp/src/tools/photo-to-scene/index.ts new file mode 100644 index 00000000..c01ac5a6 --- /dev/null +++ b/packages/mcp/src/tools/photo-to-scene/index.ts @@ -0,0 +1,24 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { registerPhotoToScene } from './photo-to-scene' + +/** + * Register the `photo_to_scene` orchestrator tool. Chains the vision + * (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph + * synthesis → optional `SceneStore.save` → `bridge.setScene` so callers get a + * navigable Pascal scene from a single photo upload. + */ +export function registerPhotoToSceneTool( + server: McpServer, + bridge: SceneBridge, + store: SceneStore, +): void { + registerPhotoToScene(server, bridge, store) +} + +export { + photoToSceneInput, + photoToSceneOutput, + registerPhotoToScene, +} from './photo-to-scene' diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts new file mode 100644 index 00000000..5a006f08 --- /dev/null +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts @@ -0,0 +1,194 @@ +import { 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 { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { SceneBridge } from '../../bridge/scene-bridge' +import { InMemorySceneStore } from '../scene-lifecycle/test-utils' +import { registerPhotoToScene } from './photo-to-scene' + +type Handler = (req: unknown) => unknown | Promise + +/** + * Build a connected client/server pair for the `photo_to_scene` orchestrator. + * Optionally advertises the `sampling` capability on the client and installs + * a mock sampling handler that returns a caller-provided reply. + */ +async function makeWiredPair(opts: { withSampling: boolean; samplingHandler?: Handler }): Promise<{ + client: Client + bridge: SceneBridge + store: InMemorySceneStore +}> { + const bridge = new SceneBridge() + bridge.setScene({}, []) + const store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerPhotoToScene(server, bridge, store) + + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + + const client = new Client( + { name: 'test-client', version: '0.0.0' }, + { + capabilities: opts.withSampling ? { sampling: {} } : {}, + }, + ) + + if (opts.withSampling && opts.samplingHandler) { + const handler = opts.samplingHandler + client.setRequestHandler( + CreateMessageRequestSchema, + async (request) => + // Cast to unknown — tests return arbitrary shapes to exercise + // parse/validation paths in the tool handler. + (await handler(request)) as never, + ) + } + + await Promise.all([server.connect(srvT), client.connect(cliT)]) + return { client, bridge, store } +} + +const VALID_VISION_JSON = { + walls: [ + { start: [0, 0], end: [5, 0], thickness: 0.2 }, + { start: [5, 0], end: [5, 4] }, + { start: [5, 4], end: [0, 4] }, + { start: [0, 4], end: [0, 0] }, + ], + rooms: [ + { + name: 'Living Room', + polygon: [ + [0, 0], + [5, 0], + [5, 4], + [0, 4], + ], + approximateAreaSqM: 20, + }, + ], + approximateDimensions: { widthM: 5, depthM: 4 }, + confidence: 0.82, +} + +const VALID_REPLY = { + model: 'mock-model', + role: 'assistant', + content: { + type: 'text', + text: JSON.stringify(VALID_VISION_JSON), + }, +} + +describe('photo_to_scene', () => { + test('happy path: vision reply → walls + rooms + scene in bridge + saved', async () => { + const { client, bridge, store } = await makeWiredPair({ + withSampling: true, + samplingHandler: () => VALID_REPLY, + }) + const result = await client.callTool({ + name: 'photo_to_scene', + arguments: { + image: 'aGVsbG8=', + scaleHint: '1 cm = 1 m', + name: 'Test Scene', + }, + }) + expect(result.isError).toBeFalsy() + const structured = result.structuredContent as { + sceneId?: string + url?: string + walls: number + rooms: number + confidence: number + } + expect(structured.walls).toBe(4) + expect(structured.rooms).toBe(1) + expect(structured.confidence).toBe(0.82) + expect(typeof structured.sceneId).toBe('string') + expect(structured.url).toBe(`/scene/${structured.sceneId}`) + + // Bridge was swapped. + const rootIds = bridge.getRootNodeIds() + expect(rootIds.length).toBe(1) + const rootId = rootIds[0]! + const root = bridge.getNode(rootId) + expect(root?.type).toBe('site') + + // Walls and zones exist in the flat dict. + const allNodes = Object.values(bridge.getNodes()) + const walls = allNodes.filter((n) => n.type === 'wall') + const zones = allNodes.filter((n) => n.type === 'zone') + expect(walls.length).toBe(4) + expect(zones.length).toBe(1) + + // Scene was persisted in the store. + const saved = await store.load(structured.sceneId!) + expect(saved).not.toBeNull() + expect(saved?.name).toBe('Test Scene') + }) + + test('sampling unavailable → sampling_unavailable error', async () => { + const { client } = await makeWiredPair({ withSampling: false }) + const result = await client.callTool({ + name: 'photo_to_scene', + arguments: { image: 'aGVsbG8=' }, + }) + expect(result.isError).toBe(true) + const text = (result.content as Array<{ type: string; text: string }>)[0]!.text + expect(text).toContain('sampling_unavailable') + }) + + test('invalid JSON reply → sampling_response_unparseable', async () => { + const { client } = await makeWiredPair({ + withSampling: true, + samplingHandler: () => ({ + model: 'mock-model', + role: 'assistant', + content: { type: 'text', text: 'not json at all' }, + }), + }) + const result = await client.callTool({ + name: 'photo_to_scene', + arguments: { image: 'aGVsbG8=' }, + }) + expect(result.isError).toBe(true) + const text = (result.content as Array<{ type: string; text: string }>)[0]!.text + expect(text).toContain('sampling_response_unparseable') + }) + + test('save=false → returns graph inline, no sceneId', async () => { + const { client, store } = await makeWiredPair({ + withSampling: true, + samplingHandler: () => VALID_REPLY, + }) + const result = await client.callTool({ + name: 'photo_to_scene', + arguments: { + image: 'aGVsbG8=', + save: false, + }, + }) + expect(result.isError).toBeFalsy() + const structured = result.structuredContent as { + sceneId?: string + url?: string + walls: number + rooms: number + confidence: number + graph?: { nodes: Record; rootNodeIds: string[] } + } + expect(structured.sceneId).toBeUndefined() + expect(structured.url).toBeUndefined() + expect(structured.graph).toBeDefined() + expect(Array.isArray(structured.graph?.rootNodeIds)).toBe(true) + expect(structured.graph?.rootNodeIds.length).toBe(1) + expect(structured.walls).toBe(4) + expect(structured.rooms).toBe(1) + + // Nothing persisted. + const list = await store.list() + expect(list.length).toBe(0) + }) +}) diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts new file mode 100644 index 00000000..d481bc26 --- /dev/null +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -0,0 +1,427 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNodeId, AnyNode as AnyNodeT } from '@pascal-app/core/schema' +import { + AnyNode, + BuildingNode, + LevelNode, + SiteNode, + WallNode, + ZoneNode, +} from '@pascal-app/core/schema' +import { z } from 'zod' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' + +/** + * Input shape for the `photo_to_scene` orchestrator. `image` matches the + * contract documented on `analyze_floorplan_image` — base64 or http(s) URL. + */ +export const photoToSceneInput = { + image: z.string().describe('Base64 or https URL of the floor-plan photo'), + scaleHint: z.string().optional().describe('e.g. "1 cm = 1 m" or "approx 80 m²"'), + name: z.string().default('Scene from photo'), + save: z.boolean().default(true), + defaultWallThickness: z.number().default(0.2), + defaultWallHeight: z.number().default(2.6), +} + +export const photoToSceneOutput = { + sceneId: z.string().optional(), + url: z.string().optional(), + walls: z.number(), + rooms: z.number(), + confidence: z.number(), + notes: z.string().optional(), + graph: z.any().optional(), +} + +/** + * Shape of the vision JSON we consume. Kept in-sync with + * `analyze_floorplan_image`'s output schema (walls / rooms / + * approximateDimensions / confidence). + */ +const VisionResponseSchema = z.object({ + walls: z.array( + z.object({ + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + thickness: z.number().optional(), + }), + ), + rooms: z.array( + z.object({ + name: z.string(), + polygon: z.array(z.tuple([z.number(), z.number()])), + approximateAreaSqM: z.number().optional(), + }), + ), + approximateDimensions: z.object({ + widthM: z.number(), + depthM: z.number(), + }), + confidence: z.number().min(0).max(1), +}) + +type VisionResponse = z.infer + +/** + * System prompt mirrors `analyze_floorplan_image` — the contract between + * orchestrator and host is identical, so we keep the prompt verbatim to + * guarantee wire-compatible responses. + */ +const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor-plan data from an image. +Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences. + +{ + "walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...], + "rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...], + "approximateDimensions": { "widthM": number, "depthM": number }, + "confidence": number 0..1 +} + +Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent. +If the image is unclear, lower the confidence score but still produce your best attempt. +DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.` + +const DATA_URI_RE = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i + +type ImageBlock = { + type: 'image' + data: string + mimeType: string +} + +/** + * Resolve the `image` input into a sampling-ready image block. Follows the + * same fetch/data-uri/raw-base64 rules as the vision tool so the user gets + * consistent behaviour whether they call `photo_to_scene` or + * `analyze_floorplan_image` directly. + */ +async function resolveImageBlock(image: string): Promise { + if (/^https?:\/\//i.test(image)) { + const res = await fetch(image) + if (!res.ok) { + throw new McpError( + ErrorCode.InvalidParams, + `failed to fetch image: ${res.status} ${res.statusText}`, + { url: image, status: res.status }, + ) + } + const buf = Buffer.from(await res.arrayBuffer()) + const data = buf.toString('base64') + const mimeType = res.headers.get('content-type') ?? 'image/jpeg' + return { type: 'image', data, mimeType } + } + + const dataUriMatch = image.match(DATA_URI_RE) + if (dataUriMatch) { + return { + type: 'image', + mimeType: dataUriMatch[1]!, + data: dataUriMatch[2]!, + } + } + + return { type: 'image', mimeType: 'image/jpeg', data: image } +} + +/** Collect all text content blocks returned by the sampling host into one string. */ +function extractText( + content: + | { type: 'text'; text: string } + | { type: 'image' | 'audio'; data: string; mimeType: string } + | Array< + | { type: 'text'; text: string } + | { type: 'image' | 'audio'; data: string; mimeType: string } + | { type: string; [k: string]: unknown } + >, +): string { + const blocks = Array.isArray(content) ? content : [content] + const texts: string[] = [] + for (const block of blocks) { + if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') { + const t = (block as { text?: unknown }).text + if (typeof t === 'string') texts.push(t) + } + } + return texts.join('\n').trim() +} + +/** + * Call the host's sampling capability to analyse a floor-plan photo. Throws + * `sampling_unavailable` when the host has not advertised the capability and + * `sampling_response_unparseable` / `sampling_response_invalid` when the + * reply cannot be mapped onto `VisionResponseSchema`. + */ +async function callVisionSampling( + server: McpServer, + image: string, + scaleHint: string | undefined, +): Promise { + const caps = server.server.getClientCapabilities() + if (!caps?.sampling) { + throw new McpError(ErrorCode.InvalidRequest, 'sampling_unavailable') + } + + const imageBlock = await resolveImageBlock(image) + const instruction = scaleHint + ? `Analyze this floor plan. Scale hint: ${scaleHint}. Return ONLY the JSON described by the system prompt.` + : 'Analyze this floor plan. Return ONLY the JSON described by the system prompt.' + + const response = await server.server.createMessage({ + systemPrompt: SYSTEM_PROMPT, + temperature: 0, + maxTokens: 2000, + messages: [ + { + role: 'user', + content: [imageBlock, { type: 'text', text: instruction }], + }, + ], + }) + + const text = extractText(response.content as Parameters[0]) + if (!text) { + throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', { + reason: 'no text content returned by host', + }) + } + + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (err) { + throw new McpError(ErrorCode.InternalError, 'sampling_response_unparseable', { + raw: text, + reason: err instanceof Error ? err.message : String(err), + }) + } + + const validation = VisionResponseSchema.safeParse(parsed) + if (!validation.success) { + throw new McpError(ErrorCode.InternalError, 'sampling_response_invalid', { + raw: text, + errors: validation.error.issues, + }) + } + + return validation.data +} + +type BuildResult = { + nodes: Record + rootNodeIds: AnyNodeId[] + walls: number + rooms: number + warnings: string[] + levelId: AnyNodeId +} + +/** + * Build a SceneGraph (flat `nodes` dict + `rootNodeIds`) from the vision + * response. Uses the schema factories for every node so IDs, defaults, and + * parent linkage match what the core store would produce. Each node is + * revalidated via `AnyNode.safeParse`; failures are dropped with a warning. + */ +function buildSceneGraphFromVision( + vision: VisionResponse, + defaultWallThickness: number, + defaultWallHeight: number, +): BuildResult { + const warnings: string[] = [] + + // Build the skeleton: site → building → level. + const building = BuildingNode.parse({}) + const level = LevelNode.parse({ level: 0 }) + const site = SiteNode.parse({ children: [building] }) + + // Link parent ids so downstream traversal works. + const siteId = site.id as AnyNodeId + const buildingId = building.id as AnyNodeId + const levelId = level.id as AnyNodeId + const linkedBuilding: AnyNodeT = { + ...(building as AnyNodeT), + parentId: siteId, + } + const linkedLevel: AnyNodeT = { + ...(level as AnyNodeT), + parentId: buildingId, + } + + // BuildingNode children stores level ids (string[]). + ;(linkedBuilding as BuildingNode).children = [levelId as BuildingNode['children'][number]] + + // Collect level children (ids of walls/zones we create below). + const levelChildren: string[] = [] + + const nodes: Record = {} + + // Validate + add site, building, level in that order. + const siteValidated = AnyNode.safeParse(site) + if (!siteValidated.success) { + warnings.push(`site node failed schema validation: ${siteValidated.error.message}`) + } + nodes[siteId] = (siteValidated.success ? siteValidated.data : site) as AnyNodeT + + const buildingValidated = AnyNode.safeParse(linkedBuilding) + if (!buildingValidated.success) { + warnings.push(`building node failed schema validation: ${buildingValidated.error.message}`) + } + nodes[buildingId] = ( + buildingValidated.success ? buildingValidated.data : linkedBuilding + ) as AnyNodeT + + // Walls. + let wallsAdded = 0 + for (let i = 0; i < vision.walls.length; i++) { + const w = vision.walls[i]! + try { + const wall = WallNode.parse({ + start: w.start, + end: w.end, + thickness: w.thickness ?? defaultWallThickness, + height: defaultWallHeight, + }) + const linkedWall: AnyNodeT = { + ...(wall as AnyNodeT), + parentId: levelId, + } + const validated = AnyNode.safeParse(linkedWall) + if (!validated.success) { + warnings.push(`wall[${i}] dropped: ${validated.error.message}`) + continue + } + nodes[wall.id as AnyNodeId] = validated.data as AnyNodeT + levelChildren.push(wall.id) + wallsAdded++ + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + warnings.push(`wall[${i}] dropped: ${msg}`) + } + } + + // Rooms → zones. + let roomsAdded = 0 + for (let i = 0; i < vision.rooms.length; i++) { + const r = vision.rooms[i]! + try { + const zone = ZoneNode.parse({ + name: r.name, + polygon: r.polygon, + }) + const linkedZone: AnyNodeT = { + ...(zone as AnyNodeT), + parentId: levelId, + } + const validated = AnyNode.safeParse(linkedZone) + if (!validated.success) { + warnings.push(`room[${i}] dropped: ${validated.error.message}`) + continue + } + nodes[zone.id as AnyNodeId] = validated.data as AnyNodeT + levelChildren.push(zone.id) + roomsAdded++ + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + warnings.push(`room[${i}] dropped: ${msg}`) + } + } + // Finalise the level's children array now that walls/zones are in the dict. + ;(linkedLevel as LevelNode).children = levelChildren as LevelNode['children'] + const levelValidated = AnyNode.safeParse(linkedLevel) + if (!levelValidated.success) { + warnings.push(`level node failed schema validation: ${levelValidated.error.message}`) + } + nodes[levelId] = (levelValidated.success ? levelValidated.data : linkedLevel) as AnyNodeT + + return { + nodes, + rootNodeIds: [siteId], + walls: wallsAdded, + rooms: roomsAdded, + warnings, + levelId, + } +} + +export function registerPhotoToScene( + server: McpServer, + bridge: SceneBridge, + store: SceneStore, +): void { + server.registerTool( + 'photo_to_scene', + { + title: 'Photo to Pascal scene', + description: + 'Orchestrator: analyse a floor-plan photo via MCP sampling, translate the structured vision result into a Pascal SceneGraph (site → building → level with walls and zones), optionally save it, and swap the bridge to the new scene. Requires host support for sampling.', + inputSchema: photoToSceneInput, + outputSchema: photoToSceneOutput, + }, + async ({ image, scaleHint, name, save, defaultWallThickness, defaultWallHeight }) => { + // 1. Vision. + const vision = await callVisionSampling(server, image, scaleHint) + + // 2. Build scene graph. + const built = buildSceneGraphFromVision(vision, defaultWallThickness, defaultWallHeight) + + const graph: SceneGraph = { + nodes: built.nodes as SceneGraph['nodes'], + rootNodeIds: built.rootNodeIds as SceneGraph['rootNodeIds'], + collections: {} as SceneGraph['collections'], + } + + // 5. Swap the bridge to the new scene so follow-up MCP calls operate on it. + bridge.setScene(graph.nodes, graph.rootNodeIds) + + const notes = built.warnings.length > 0 ? built.warnings.join('; ') : undefined + + // 4. Save or return inline. + if (save) { + const meta = await store.save({ + name, + graph, + }) + const payload: { + sceneId: string + url: string + walls: number + rooms: number + confidence: number + notes?: string + } = { + sceneId: meta.id, + url: `/scene/${meta.id}`, + walls: built.walls, + rooms: built.rooms, + confidence: vision.confidence, + } + if (notes) payload.notes = notes + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } + + const payload: { + walls: number + rooms: number + confidence: number + notes?: string + graph: SceneGraph + } = { + walls: built.walls, + rooms: built.rooms, + confidence: vision.confidence, + graph, + } + if (notes) payload.notes = notes + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts new file mode 100644 index 00000000..724abb02 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts @@ -0,0 +1,56 @@ +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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { registerDeleteScene } from './delete-scene' +import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' + +const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } + +describe('delete_scene', () => { + let client: Client + let store: InMemorySceneStore + + beforeEach(async () => { + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerDeleteScene(server, store) + 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('deletes an existing scene and returns { deleted: true }', async () => { + await store.save({ id: 'gone-in-60', name: 'Expendable', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'delete_scene', + arguments: { id: 'gone-in-60' }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.deleted).toBe(true) + expect(await store.load('gone-in-60')).toBeNull() + }) + + test('throws scene_not_found when deleting an unknown id', async () => { + const result = await client.callTool({ + name: 'delete_scene', + arguments: { id: 'ghost' }, + }) + expect(result.isError).toBe(true) + }) + + test('throws version_conflict when expectedVersion mismatches', async () => { + await store.save({ id: 'locked', name: 'Locked', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'delete_scene', + arguments: { id: 'locked', expectedVersion: 99 }, + }) + expect(result.isError).toBe(true) + // Still present after failed delete. + expect(await store.load('locked')).not.toBeNull() + }) +}) diff --git a/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts b/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts new file mode 100644 index 00000000..3b0fa0a9 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts @@ -0,0 +1,50 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' + +export const deleteSceneInput = { + id: z.string().min(1).max(64), + expectedVersion: z.number().int().positive().optional(), +} + +export const deleteSceneOutput = { + deleted: z.boolean(), +} + +export function registerDeleteScene(server: McpServer, store: SceneStore): void { + server.registerTool( + 'delete_scene', + { + title: 'Delete scene', + description: + 'Delete a scene from the SceneStore by id. Optionally pass `expectedVersion` for optimistic concurrency.', + inputSchema: deleteSceneInput, + outputSchema: deleteSceneOutput, + }, + async ({ id, expectedVersion }) => { + try { + const deleted = await store.delete(id, { + ...(expectedVersion !== undefined ? { expectedVersion } : {}), + }) + const payload = { deleted } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } catch (err) { + if (err instanceof SceneNotFoundError) { + throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id }) + } + if (err instanceof SceneVersionConflictError) { + throwMcpError(ErrorCode.InvalidRequest, 'version_conflict', { + id, + expectedVersion, + }) + } + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InternalError, msg) + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/index.ts b/packages/mcp/src/tools/scene-lifecycle/index.ts new file mode 100644 index 00000000..d5325f8d --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/index.ts @@ -0,0 +1,32 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { registerDeleteScene } from './delete-scene' +import { registerListScenes } from './list-scenes' +import { registerLoadScene } from './load-scene' +import { registerRenameScene } from './rename-scene' +import { registerSaveScene } from './save-scene' + +/** + * Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`, + * `list_scenes`, `delete_scene`, `rename_scene`) against the given server. + * All tools operate against the supplied `SceneStore` so tests can inject an + * in-memory implementation. + */ +export function registerSceneLifecycleTools( + server: McpServer, + bridge: SceneBridge, + store: SceneStore, +): void { + registerSaveScene(server, bridge, store) + registerLoadScene(server, bridge, store) + registerListScenes(server, store) + registerDeleteScene(server, store) + registerRenameScene(server, store) +} + +export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene' +export { listScenesInput, listScenesOutput, registerListScenes } from './list-scenes' +export { loadSceneInput, loadSceneOutput, registerLoadScene } from './load-scene' +export { registerRenameScene, renameSceneInput, renameSceneOutput } from './rename-scene' +export { registerSaveScene, saveSceneInput, saveSceneOutput } from './save-scene' diff --git a/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts b/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts new file mode 100644 index 00000000..3350c0e3 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts @@ -0,0 +1,73 @@ +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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { registerListScenes } from './list-scenes' +import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' + +const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } + +describe('list_scenes', () => { + let client: Client + let store: InMemorySceneStore + + beforeEach(async () => { + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerListScenes(server, store) + 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('returns all saved scenes by default', async () => { + await store.save({ id: 'a', name: 'A', graph: emptyGraph }) + await store.save({ id: 'b', name: 'B', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'list_scenes', + arguments: {}, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + const scenes = parsed.scenes as unknown[] + expect(scenes).toHaveLength(2) + }) + + test('filters by projectId', async () => { + await store.save({ id: 'a', name: 'A', projectId: 'p1', graph: emptyGraph }) + await store.save({ id: 'b', name: 'B', projectId: 'p2', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'list_scenes', + arguments: { projectId: 'p1' }, + }) + const parsed = parseToolText(result.content as StoredTextContent[]) + const scenes = parsed.scenes as { id: string }[] + expect(scenes).toHaveLength(1) + expect(scenes[0]!.id).toBe('a') + }) + + test('rejects non-positive limit per schema', async () => { + const result = await client.callTool({ + name: 'list_scenes', + arguments: { limit: 0 }, + }) + expect(result.isError).toBe(true) + }) + + test('caps results with limit', async () => { + await store.save({ id: 'a', name: 'A', graph: emptyGraph }) + await store.save({ id: 'b', name: 'B', graph: emptyGraph }) + await store.save({ id: 'c', name: 'C', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'list_scenes', + arguments: { limit: 2 }, + }) + const parsed = parseToolText(result.content as StoredTextContent[]) + const scenes = parsed.scenes as unknown[] + expect(scenes).toHaveLength(2) + }) +}) diff --git a/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts b/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts new file mode 100644 index 00000000..6db28474 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts @@ -0,0 +1,57 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import type { SceneStore } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' + +const DEFAULT_LIMIT = 100 + +export const listScenesInput = { + projectId: z.string().optional(), + limit: z.number().int().positive().max(1000).optional(), +} + +export const listScenesOutput = { + scenes: z.array( + z.object({ + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number(), + nodeCount: z.number(), + }), + ), +} + +export function registerListScenes(server: McpServer, store: SceneStore): void { + server.registerTool( + 'list_scenes', + { + title: 'List scenes', + description: + 'List scenes in the SceneStore. Optionally filter by `projectId` and cap results with `limit` (default 100).', + inputSchema: listScenesInput, + outputSchema: listScenesOutput, + }, + async ({ projectId, limit }) => { + try { + const scenes = await store.list({ + ...(projectId !== undefined ? { projectId } : {}), + limit: limit ?? DEFAULT_LIMIT, + }) + const payload = { scenes } + 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, msg) + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts new file mode 100644 index 00000000..2af4f360 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts @@ -0,0 +1,64 @@ +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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { SceneBridge } from '../../bridge/scene-bridge' +import { registerLoadScene } from './load-scene' +import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' + +describe('load_scene', () => { + let client: Client + let bridge: SceneBridge + let store: InMemorySceneStore + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerLoadScene(server, bridge, store) + 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('loads a stored scene and returns its SceneMeta', async () => { + const graph = { + nodes: { + root_a: { id: 'root_a', type: 'site', parentId: null, children: [] }, + }, + rootNodeIds: ['root_a'], + } as unknown as SceneGraph + const meta = await store.save({ id: 'scene-one', name: 'One', graph }) + + const result = await client.callTool({ + name: 'load_scene', + arguments: { id: 'scene-one' }, + }) + + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.id).toBe('scene-one') + expect(parsed.name).toBe('One') + expect(parsed.version).toBe(meta.version) + expect(bridge.getRootNodeIds()).toContain('root_a') + }) + + test('throws scene_not_found when id is unknown', async () => { + const result = await client.callTool({ + name: 'load_scene', + arguments: { id: 'does-not-exist' }, + }) + expect(result.isError).toBe(true) + }) + + test('rejects empty id per schema', async () => { + const result = await client.callTool({ + name: 'load_scene', + arguments: { id: '' }, + }) + expect(result.isError).toBe(true) + }) +}) diff --git a/packages/mcp/src/tools/scene-lifecycle/load-scene.ts b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts new file mode 100644 index 00000000..0a14801b --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts @@ -0,0 +1,63 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' + +export const loadSceneInput = { + id: z.string().min(1).max(64), +} + +export const loadSceneOutput = { + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number(), + nodeCount: z.number(), +} + +export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { + server.registerTool( + 'load_scene', + { + title: 'Load scene', + description: + 'Load a scene from the SceneStore into the bridge. Returns the scene metadata. Throws `scene_not_found` if the id does not exist.', + inputSchema: loadSceneInput, + outputSchema: loadSceneOutput, + }, + async ({ id }) => { + const result = await store.load(id) + if (!result) { + throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id }) + } + try { + bridge.loadJSON(result.graph) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + 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, + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts new file mode 100644 index 00000000..22206980 --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts @@ -0,0 +1,59 @@ +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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { registerRenameScene } from './rename-scene' +import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' + +const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } + +describe('rename_scene', () => { + let client: Client + let store: InMemorySceneStore + + beforeEach(async () => { + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerRenameScene(server, store) + 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('renames a scene and returns the new SceneMeta', async () => { + await store.save({ id: 'to-rename', name: 'Old Name', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'rename_scene', + arguments: { id: 'to-rename', newName: 'Brand New Name' }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.id).toBe('to-rename') + expect(parsed.name).toBe('Brand New Name') + expect(parsed.version).toBe(2) + }) + + test('throws scene_not_found for missing ids', async () => { + const result = await client.callTool({ + name: 'rename_scene', + arguments: { id: 'ghost', newName: 'Does Not Matter' }, + }) + expect(result.isError).toBe(true) + }) + + test('throws version_conflict when expectedVersion mismatches', async () => { + await store.save({ id: 'locked-name', name: 'Stable', graph: emptyGraph }) + + const result = await client.callTool({ + name: 'rename_scene', + arguments: { + id: 'locked-name', + newName: 'Attempted', + expectedVersion: 42, + }, + }) + expect(result.isError).toBe(true) + }) +}) diff --git a/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts b/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts new file mode 100644 index 00000000..315e233f --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts @@ -0,0 +1,71 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' + +export const renameSceneInput = { + id: z.string().min(1).max(64), + newName: z.string().min(1).max(200), + expectedVersion: z.number().int().positive().optional(), +} + +export const renameSceneOutput = { + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number(), + nodeCount: z.number(), +} + +export function registerRenameScene(server: McpServer, store: SceneStore): void { + server.registerTool( + 'rename_scene', + { + title: 'Rename scene', + description: + 'Rename a scene in the SceneStore. Returns the updated SceneMeta. Optionally pass `expectedVersion` for optimistic concurrency.', + inputSchema: renameSceneInput, + outputSchema: renameSceneOutput, + }, + async ({ id, newName, expectedVersion }) => { + try { + const meta = await store.rename(id, newName, { + ...(expectedVersion !== undefined ? { expectedVersion } : {}), + }) + 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, + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } catch (err) { + if (err instanceof SceneNotFoundError) { + throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id }) + } + if (err instanceof SceneVersionConflictError) { + throwMcpError(ErrorCode.InvalidRequest, 'version_conflict', { + id, + expectedVersion, + }) + } + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InternalError, msg) + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts new file mode 100644 index 00000000..de4c006e --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts @@ -0,0 +1,83 @@ +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 { SceneBridge } from '../../bridge/scene-bridge' +import { registerSaveScene } from './save-scene' +import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' + +describe('save_scene', () => { + let client: Client + let bridge: SceneBridge + let store: InMemorySceneStore + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerSaveScene(server, bridge, store) + 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('saves the current scene and returns SceneMeta with url', async () => { + const result = await client.callTool({ + name: 'save_scene', + arguments: { name: 'My Scene' }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + 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.nodeCount).toBeGreaterThan(0) + }) + + test('saves a provided graph when includeCurrentScene is false', async () => { + const graph = { + nodes: { root: { id: 'root', type: 'site', parentId: null, children: [] } }, + rootNodeIds: ['root'], + } + const result = await client.callTool({ + name: 'save_scene', + arguments: { + name: 'From Graph', + includeCurrentScene: false, + graph, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.name).toBe('From Graph') + expect(parsed.nodeCount).toBe(1) + }) + + test('errors when includeCurrentScene is false and no graph is provided', async () => { + const result = await client.callTool({ + name: 'save_scene', + arguments: { name: 'No Graph', includeCurrentScene: false }, + }) + expect(result.isError).toBe(true) + }) + + test('returns version_conflict when expectedVersion mismatches', async () => { + const first = await client.callTool({ + name: 'save_scene', + arguments: { name: 'Original' }, + }) + const parsed = parseToolText(first.content as StoredTextContent[]) + const result = await client.callTool({ + name: 'save_scene', + arguments: { + id: parsed.id as string, + name: 'Second', + expectedVersion: 99, + }, + }) + expect(result.isError).toBe(true) + }) +}) diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts new file mode 100644 index 00000000..6ac040ea --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts @@ -0,0 +1,111 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { z } from 'zod' +import type { SceneBridge } from '../../bridge/scene-bridge' +import { type SceneStore, SceneVersionConflictError } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' + +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(), + thumbnail: z.string().url().optional(), + includeCurrentScene: z + .boolean() + .default(true) + .describe('If true, save the bridge current scene. If false, use the graph arg.'), + graph: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Full SceneGraph { nodes, rootNodeIds, collections? } to save instead of the bridge state.', + ), +} + +export const saveSceneOutput = { + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number(), + nodeCount: z.number(), + url: z.string(), +} + +export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { + server.registerTool( + 'save_scene', + { + 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/`.', + inputSchema: saveSceneInput, + outputSchema: saveSceneOutput, + }, + async ({ id, name, projectId, expectedVersion, thumbnail, includeCurrentScene, graph }) => { + let sceneGraph: SceneGraph + if (includeCurrentScene) { + const validation = bridge.validateScene() + if (!validation.valid) { + throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors }) + } + const exported = bridge.exportJSON() + sceneGraph = { + nodes: exported.nodes, + rootNodeIds: exported.rootNodeIds, + collections: exported.collections as SceneGraph['collections'], + } + } else { + if (!graph) { + throwMcpError( + ErrorCode.InvalidParams, + 'graph_required: pass `graph` when includeCurrentScene is false', + ) + } + sceneGraph = graph as unknown as SceneGraph + } + + try { + const meta = await store.save({ + ...(id !== undefined ? { id } : {}), + name, + ...(projectId !== undefined ? { projectId } : {}), + graph: sceneGraph, + ...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}), + ...(expectedVersion !== undefined ? { expectedVersion } : {}), + }) + 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}`, + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } catch (err) { + if (err instanceof SceneVersionConflictError) { + throwMcpError(ErrorCode.InvalidRequest, 'version_conflict', { + expectedVersion, + id, + }) + } + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InvalidRequest, msg) + } + }, + ) +} diff --git a/packages/mcp/src/tools/scene-lifecycle/test-utils.ts b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts new file mode 100644 index 00000000..ad99943f --- /dev/null +++ b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts @@ -0,0 +1,146 @@ +import { + type SceneListOptions, + type SceneMeta, + type SceneMutateOptions, + SceneNotFoundError, + type SceneSaveOptions, + type SceneStore, + SceneVersionConflictError, + type SceneWithGraph, +} from '../../storage/types' + +export type StoredTextContent = { type: string; text: string } + +export function parseToolText(content: StoredTextContent[]): Record { + return JSON.parse(content[0]!.text) as Record +} + +/** + * In-memory `SceneStore` for tests. Backed by a plain `Map` keyed by id. + * Implements the full interface including optimistic concurrency via + * `expectedVersion`. + */ +export class InMemorySceneStore implements SceneStore { + readonly backend = 'filesystem' as const + private readonly data = new Map() + private idCounter = 0 + + async save(opts: SceneSaveOptions): Promise { + const existing = opts.id ? this.data.get(opts.id) : undefined + if (existing) { + if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Expected version ${opts.expectedVersion}, have ${existing.version}`, + ) + } + const now = new Date().toISOString() + const nodeCount = Object.keys(opts.graph.nodes ?? {}).length + const serialized = JSON.stringify(opts.graph) + const updated: SceneWithGraph = { + id: existing.id, + name: opts.name, + projectId: opts.projectId ?? existing.projectId, + thumbnailUrl: opts.thumbnailUrl ?? existing.thumbnailUrl, + version: existing.version + 1, + createdAt: existing.createdAt, + updatedAt: now, + ownerId: opts.ownerId ?? existing.ownerId, + sizeBytes: serialized.length, + nodeCount, + graph: opts.graph, + } + this.data.set(existing.id, updated) + return this.toMeta(updated) + } + + if (opts.expectedVersion !== undefined) { + throw new SceneVersionConflictError('Cannot pass expectedVersion for a new scene') + } + + const id = opts.id ?? `scene_${++this.idCounter}` + const now = new Date().toISOString() + const serialized = JSON.stringify(opts.graph) + const nodeCount = Object.keys(opts.graph.nodes ?? {}).length + const record: SceneWithGraph = { + id, + name: opts.name, + projectId: opts.projectId ?? null, + thumbnailUrl: opts.thumbnailUrl ?? null, + version: 1, + createdAt: now, + updatedAt: now, + ownerId: opts.ownerId ?? null, + sizeBytes: serialized.length, + nodeCount, + graph: opts.graph, + } + this.data.set(id, record) + return this.toMeta(record) + } + + async load(id: string): Promise { + const rec = this.data.get(id) + if (!rec) return null + return { + ...rec, + graph: JSON.parse(JSON.stringify(rec.graph)), + } + } + + async list(opts?: SceneListOptions): Promise { + let scenes = Array.from(this.data.values()).map((r) => this.toMeta(r)) + if (opts?.projectId !== undefined) { + scenes = scenes.filter((s) => s.projectId === opts.projectId) + } + if (opts?.ownerId !== undefined) { + scenes = scenes.filter((s) => s.ownerId === opts.ownerId) + } + scenes.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)) + if (opts?.limit !== undefined) scenes = scenes.slice(0, opts.limit) + return scenes + } + + async delete(id: string, opts?: SceneMutateOptions): Promise { + const rec = this.data.get(id) + if (!rec) throw new SceneNotFoundError(`Scene ${id} not found`) + if (opts?.expectedVersion !== undefined && rec.version !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Expected version ${opts.expectedVersion}, have ${rec.version}`, + ) + } + return this.data.delete(id) + } + + async rename(id: string, newName: string, opts?: SceneMutateOptions): Promise { + const rec = this.data.get(id) + if (!rec) throw new SceneNotFoundError(`Scene ${id} not found`) + if (opts?.expectedVersion !== undefined && rec.version !== opts.expectedVersion) { + throw new SceneVersionConflictError( + `Expected version ${opts.expectedVersion}, have ${rec.version}`, + ) + } + const updated: SceneWithGraph = { + ...rec, + name: newName, + version: rec.version + 1, + updatedAt: new Date().toISOString(), + } + this.data.set(id, updated) + return this.toMeta(updated) + } + + private toMeta(rec: SceneWithGraph): SceneMeta { + return { + id: rec.id, + name: rec.name, + projectId: rec.projectId, + thumbnailUrl: rec.thumbnailUrl, + version: rec.version, + createdAt: rec.createdAt, + updatedAt: rec.updatedAt, + ownerId: rec.ownerId, + sizeBytes: rec.sizeBytes, + nodeCount: rec.nodeCount, + } + } +} diff --git a/packages/mcp/src/tools/templates/create-from-template.ts b/packages/mcp/src/tools/templates/create-from-template.ts new file mode 100644 index 00000000..22f2941e --- /dev/null +++ b/packages/mcp/src/tools/templates/create-from-template.ts @@ -0,0 +1,154 @@ +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 type { SceneBridge } from '../../bridge/scene-bridge' +import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children' +import type { SceneStore } from '../../storage/types' +import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates' +import { ErrorCode, throwMcpError } from '../errors' + +export const createFromTemplateInput = { + id: z + .string() + .describe( + 'Template id (see `list_templates`). Currently one of: "empty-studio", "two-bedroom", "garden-house".', + ), + name: z + .string() + .min(1) + .max(200) + .optional() + .describe('Optional display name for the saved scene. Defaults to the template name.'), + /** + * When a `SceneStore` is wired into the MCP server, set this flag to `true` + * to immediately save the instantiated template and return its `SceneMeta`. + * When `false` (default) the template is applied to the bridge only. + */ + save: z.boolean().default(false), + projectId: z.string().optional(), +} + +export const createFromTemplateOutput = { + templateId: z.string(), + rootNodeIds: z.array(z.string()), + nodeCount: z.number(), + /** Present when `save: true` (and a store was available). */ + scene: z + .object({ + id: z.string(), + name: z.string(), + projectId: z.string().nullable(), + thumbnailUrl: z.string().nullable(), + version: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + ownerId: z.string().nullable(), + sizeBytes: z.number(), + nodeCount: z.number(), + url: z.string(), + }) + .optional(), +} + +/** + * `create_from_template` — instantiate a seed template into the bridge, and + * optionally persist it via the attached `SceneStore`. + * + * The source template is cloned with fresh ids (`cloneSceneGraph`) so the + * deterministic placeholders (`site_empty`, `wall_n`, …) don't collide + * across repeated calls or with other scenes. + */ +export function registerCreateFromTemplate( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + server.registerTool( + 'create_from_template', + { + title: 'Create scene from template', + description: + 'Instantiate a seed Pascal scene template into the bridge. Regenerates all ids before applying. When `save: true` and a SceneStore is wired, also persists the new scene and returns the SceneMeta.', + inputSchema: createFromTemplateInput, + outputSchema: createFromTemplateOutput, + }, + async ({ id, name, save, projectId }) => { + if (!isTemplateId(id)) { + throwMcpError( + ErrorCode.InvalidParams, + `unknown_template: ${id}. Call list_templates for the set of valid ids.`, + ) + } + + const entry = TEMPLATES[id as TemplateId] + // Clone: regenerate ids so each instantiation is independent. + // `cloneSceneGraph` flattens SiteNode.children to string ids; rehydrate + // them back to embedded objects to satisfy the SiteNode schema (see + // CROSS_CUTTING §2). + const cloned = rehydrateSiteChildren(cloneSceneGraph(entry.template)) + const nodes = cloned.nodes as Record + const rootNodeIds = cloned.rootNodeIds as AnyNodeId[] + + try { + bridge.setScene(nodes, rootNodeIds) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InternalError, `apply_failed: ${msg}`) + } + + const basePayload = { + templateId: entry.id, + rootNodeIds: rootNodeIds as string[], + nodeCount: Object.keys(nodes).length, + } + + if (!save) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(basePayload) }], + structuredContent: basePayload, + } + } + + if (!store) { + // Graceful no-store mode: report that save was skipped rather than + // erroring — this makes the tool usable in headless bridge-only + // deployments (tests, smoke scripts) without crashing. + const payload = { ...basePayload, saveSkipped: true } as const + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: basePayload, + } + } + + try { + const meta = await store.save({ + name: name ?? entry.name, + ...(projectId !== undefined ? { projectId } : {}), + graph: { nodes, 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}`, + } + const payload = { ...basePayload, scene } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InternalError, `save_failed: ${msg}`) + } + }, + ) +} diff --git a/packages/mcp/src/tools/templates/index.ts b/packages/mcp/src/tools/templates/index.ts new file mode 100644 index 00000000..dd4f660a --- /dev/null +++ b/packages/mcp/src/tools/templates/index.ts @@ -0,0 +1,33 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { registerCreateFromTemplate } from './create-from-template' +import { registerListTemplates } from './list-templates' + +/** + * Register the template MCP tools (`list_templates`, `create_from_template`) + * against the given server. + * + * `store` is optional: when omitted, `create_from_template` still applies the + * template to the bridge but skips the save step. This makes the tool safe + * to wire into headless bridge-only deployments. + */ +export function registerTemplateTools( + server: McpServer, + bridge: SceneBridge, + store?: SceneStore, +): void { + registerListTemplates(server) + registerCreateFromTemplate(server, bridge, store) +} + +export { + createFromTemplateInput, + createFromTemplateOutput, + registerCreateFromTemplate, +} from './create-from-template' +export { + listTemplatesInput, + listTemplatesOutput, + registerListTemplates, +} from './list-templates' diff --git a/packages/mcp/src/tools/templates/list-templates.ts b/packages/mcp/src/tools/templates/list-templates.ts new file mode 100644 index 00000000..a24f7921 --- /dev/null +++ b/packages/mcp/src/tools/templates/list-templates.ts @@ -0,0 +1,47 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import { TEMPLATES } from '../../templates' + +export const listTemplatesInput = {} as const + +export const listTemplatesOutput = { + templates: z.array( + z.object({ + id: z.string(), + name: z.string(), + description: z.string(), + nodeCount: z.number(), + }), + ), +} + +/** + * `list_templates` — enumerate the seed templates shipped with the MCP server. + * Stateless; used by the `from_brief` prompt and by the UI to populate a + * "start from a template" picker. + */ +export function registerListTemplates(server: McpServer): void { + server.registerTool( + 'list_templates', + { + title: 'List scene templates', + description: + 'List the seed Pascal scene templates available to `create_from_template`. Returns the id, display name, one-line description and node count for each.', + inputSchema: listTemplatesInput, + outputSchema: listTemplatesOutput, + }, + async () => { + const templates = Object.values(TEMPLATES).map((entry) => ({ + id: entry.id, + name: entry.name, + description: entry.description, + nodeCount: Object.keys(entry.template.nodes).length, + })) + const payload = { templates } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/templates/templates.test.ts b/packages/mcp/src/tools/templates/templates.test.ts new file mode 100644 index 00000000..9d93253f --- /dev/null +++ b/packages/mcp/src/tools/templates/templates.test.ts @@ -0,0 +1,169 @@ +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 { SceneBridge } from '../../bridge/scene-bridge' +import { + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from '../scene-lifecycle/test-utils' +import { registerCreateFromTemplate } from './create-from-template' +import { registerListTemplates } from './list-templates' + +describe('list_templates', () => { + let client: Client + + beforeEach(async () => { + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerListTemplates(server) + 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('enumerates all three seed templates', async () => { + const result = await client.callTool({ name: 'list_templates', arguments: {} }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + const list = parsed.templates as Array<{ + id: string + name: string + description: string + nodeCount: number + }> + const ids = list.map((t) => t.id).sort() + expect(ids).toEqual(['empty-studio', 'garden-house', 'two-bedroom']) + for (const t of list) { + expect(typeof t.name).toBe('string') + expect(t.name.length).toBeGreaterThan(0) + expect(typeof t.description).toBe('string') + expect(t.nodeCount).toBeGreaterThan(0) + } + }) + + test('returns structuredContent matching the text payload', async () => { + const result = await client.callTool({ name: 'list_templates', arguments: {} }) + expect(result.structuredContent).toBeDefined() + const structured = result.structuredContent as { templates: Array<{ id: string }> } + expect(structured.templates.length).toBe(3) + }) +}) + +describe('create_from_template', () => { + let client: Client + let bridge: SceneBridge + let store: InMemorySceneStore + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerCreateFromTemplate(server, bridge, store) + 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('applies a template to the bridge with fresh ids', async () => { + const result = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'empty-studio' }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.templateId).toBe('empty-studio') + expect((parsed.rootNodeIds as string[]).length).toBeGreaterThan(0) + expect(parsed.nodeCount as number).toBeGreaterThan(0) + + // Fresh ids — placeholder "site_empty" should not appear. + const bridgeNodes = Object.keys(bridge.getNodes()) + expect(bridgeNodes).not.toContain('site_empty') + expect(bridgeNodes.length).toBeGreaterThan(0) + + // Root id from the tool response should exist in the bridge. + for (const rid of parsed.rootNodeIds as string[]) { + expect(bridge.getNode(rid as any)).not.toBeNull() + } + }) + + test('rejects unknown template ids', async () => { + const result = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'not-a-template' }, + }) + expect(result.isError).toBe(true) + }) + + test('saves to the store when save: true', async () => { + const result = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'two-bedroom', save: true, name: 'My flat' }, + }) + 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 } + expect(scene.name).toBe('My flat') + expect(scene.url).toBe(`/scene/${scene.id}`) + expect(scene.nodeCount).toBeGreaterThan(0) + + // Confirm the store actually holds it. + const loaded = await store.load(scene.id) + expect(loaded).not.toBeNull() + }) + + test('two invocations produce disjoint id sets', async () => { + const a = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'empty-studio' }, + }) + const idsA = (parseToolText(a.content as StoredTextContent[]).rootNodeIds as string[]).sort() + const b = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'empty-studio' }, + }) + const idsB = (parseToolText(b.content as StoredTextContent[]).rootNodeIds as string[]).sort() + for (const id of idsA) { + expect(idsB).not.toContain(id) + } + }) +}) + +describe('create_from_template without a store', () => { + let client: Client + let bridge: SceneBridge + + beforeEach(async () => { + bridge = new SceneBridge() + bridge.setScene({}, []) + const server = new McpServer({ name: 'test', version: '0.0.0' }) + // No store passed → save should be gracefully skipped. + registerCreateFromTemplate(server, bridge) + 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('applies a template without erroring when no store is wired', async () => { + const result = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'garden-house' }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.templateId).toBe('garden-house') + }) + + test('save:true is a no-op but still succeeds without a store', async () => { + const result = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'garden-house', save: true }, + }) + // Does not error; no `scene` field is returned because there is no store. + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) + expect(parsed.scene).toBeUndefined() + }) +}) diff --git a/packages/mcp/src/tools/variants/generate-variants.test.ts b/packages/mcp/src/tools/variants/generate-variants.test.ts new file mode 100644 index 00000000..0d9f5f86 --- /dev/null +++ b/packages/mcp/src/tools/variants/generate-variants.test.ts @@ -0,0 +1,295 @@ +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 type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' +import { SceneBridge } from '../../bridge/scene-bridge' +import { + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from '../scene-lifecycle/test-utils' +import { registerGenerateVariants } from './generate-variants' + +type Variant = { + index: number + description: string + nodeCount: number + sceneId?: string + url?: string + graph?: SceneGraph +} + +function emptyBase(): SceneGraph { + return { + nodes: { + site_empty: { + object: 'node', + id: 'site_empty', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ], + }, + children: [], + }, + } as unknown as SceneGraph['nodes'], + rootNodeIds: ['site_empty'] as AnyNodeId[], + } +} + +async function setup(): Promise<{ + client: Client + bridge: SceneBridge + store: InMemorySceneStore +}> { + const bridge = new SceneBridge() + bridge.setScene({}, []) + bridge.loadDefault() + const store = new InMemorySceneStore() + const server = new McpServer({ name: 'test', version: '0.0.0' }) + registerGenerateVariants(server, bridge, store) + const [srvT, cliT] = InMemoryTransport.createLinkedPair() + const client = new Client({ name: 'test-client', version: '0.0.0' }) + await Promise.all([server.connect(srvT), client.connect(cliT)]) + return { client, bridge, store } +} + +describe('generate_variants', () => { + let client: Client + let bridge: SceneBridge + let store: InMemorySceneStore + + beforeEach(async () => { + ;({ client, bridge, store } = await setup()) + }) + + test('happy path: returns count variants that exercise the mutation', async () => { + // Seed the bridge scene with some walls of known thickness. + const base = bridge.exportJSON() + // Find the level and add a couple of walls. + const level = Object.values(base.nodes).find((n) => n.type === 'level') + expect(level).toBeDefined() + const withWalls: SceneGraph = { + nodes: { + ...base.nodes, + wall_1: { + object: 'node', + id: 'wall_1', + type: 'wall', + parentId: level?.id ?? null, + visible: true, + metadata: {}, + start: [0, 0], + end: [5, 0], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_2: { + object: 'node', + id: 'wall_2', + type: 'wall', + parentId: level?.id ?? null, + visible: true, + metadata: {}, + start: [0, 5], + end: [5, 5], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + } as unknown as SceneGraph['nodes'], + rootNodeIds: base.rootNodeIds, + } + bridge.setScene(withWalls.nodes, withWalls.rootNodeIds) + + const result = await client.callTool({ + name: 'generate_variants', + arguments: { + count: 3, + vary: ['wall-thickness'], + seed: 42, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + expect(parsed.variants.length).toBe(3) + for (const v of parsed.variants) { + expect(v.graph).toBeDefined() + // Every wall's thickness is in the allowed set. + const allowed = new Set([0.1, 0.15, 0.2, 0.25]) + for (const node of Object.values((v.graph as SceneGraph).nodes)) { + if (node.type !== 'wall') continue + expect(allowed.has((node as { thickness: number }).thickness)).toBe(true) + } + } + }) + + test('deterministic: same seed yields same mutation outputs', async () => { + // Seed walls so the mutation has something to act on. + const base = bridge.exportJSON() + const level = Object.values(base.nodes).find((n) => n.type === 'level') + const withWalls: SceneGraph = { + nodes: { + ...base.nodes, + wall_a: { + object: 'node', + id: 'wall_a', + type: 'wall', + parentId: level?.id ?? null, + visible: true, + metadata: {}, + start: [0, 0], + end: [4, 0], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_b: { + object: 'node', + id: 'wall_b', + type: 'wall', + parentId: level?.id ?? null, + visible: true, + metadata: {}, + start: [0, 4], + end: [4, 4], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + } as unknown as SceneGraph['nodes'], + rootNodeIds: base.rootNodeIds, + } + bridge.setScene(withWalls.nodes, withWalls.rootNodeIds) + + const args = { count: 2, vary: ['wall-thickness'], seed: 123 } + const r1 = await client.callTool({ name: 'generate_variants', arguments: args }) + const r2 = await client.callTool({ name: 'generate_variants', arguments: args }) + const p1 = parseToolText(r1.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + const p2 = parseToolText(r2.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + expect(p1.variants.length).toBe(p2.variants.length) + // Compare the mutated fields (not the ids, which fresh-nanoid each time). + function wallThicknesses(g: SceneGraph): number[] { + return Object.values(g.nodes) + .filter((n) => n.type === 'wall') + .map((w) => (w as { thickness: number }).thickness) + .sort() + } + for (let i = 0; i < p1.variants.length; i++) { + const t1 = wallThicknesses(p1.variants[i]?.graph as SceneGraph) + const t2 = wallThicknesses(p2.variants[i]?.graph as SceneGraph) + expect(t1).toEqual(t2) + } + }) + + test('no-op: empty scene + wall-thickness still returns count graphs, unchanged', async () => { + const graph = emptyBase() + // Save, then reference by id. + const meta = await store.save({ name: 'empty', graph }) + const result = await client.callTool({ + name: 'generate_variants', + arguments: { + baseSceneId: meta.id, + count: 3, + vary: ['wall-thickness'], + seed: 99, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + expect(parsed.variants.length).toBe(3) + for (const v of parsed.variants) { + const g = v.graph as SceneGraph + expect(g).toBeDefined() + // No walls were present — so node counts should match the (forked) base. + expect(Object.keys(g.nodes).length).toBe(Object.keys(graph.nodes).length) + } + }) + + test('save=true: each variant gets a sceneId and url', async () => { + const result = await client.callTool({ + name: 'generate_variants', + arguments: { + count: 2, + vary: ['wall-thickness'], + seed: 55, + save: true, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + expect(parsed.variants.length).toBe(2) + for (const v of parsed.variants) { + expect(typeof v.sceneId).toBe('string') + expect(v.url).toBe(`/scene/${v.sceneId}`) + // Inline graph should be omitted. + expect(v.graph).toBeUndefined() + } + const listed = await store.list() + expect(listed.length).toBe(2) + }) + + test('baseSceneId not found returns an error', async () => { + const result = await client.callTool({ + name: 'generate_variants', + arguments: { + baseSceneId: 'scene_does_not_exist', + count: 2, + vary: ['wall-thickness'], + seed: 1, + }, + }) + expect(result.isError).toBe(true) + }) + + test('every returned variant validates against AnyNode', async () => { + const result = await client.callTool({ + name: 'generate_variants', + arguments: { + count: 3, + vary: ['wall-thickness', 'wall-height'], + seed: 7, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = parseToolText(result.content as StoredTextContent[]) as unknown as { + variants: Variant[] + } + for (const v of parsed.variants) { + const g = v.graph as SceneGraph + for (const node of Object.values(g.nodes)) { + const res = AnyNodeSchema.safeParse(node) + expect(res.success).toBe(true) + } + } + }) +}) diff --git a/packages/mcp/src/tools/variants/generate-variants.ts b/packages/mcp/src/tools/variants/generate-variants.ts new file mode 100644 index 00000000..60a50078 --- /dev/null +++ b/packages/mcp/src/tools/variants/generate-variants.ts @@ -0,0 +1,197 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph' +import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' +import { z } from 'zod' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { ErrorCode, throwMcpError } from '../errors' +import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations' + +const MUTATION_KINDS = [ + 'wall-thickness', + 'wall-height', + 'zone-labels', + 'room-proportions', + 'open-plan', + 'door-positions', + 'fence-style', +] as const + +export const generateVariantsInput = { + baseSceneId: z + .string() + .optional() + .describe('If set, fork from this saved scene; else fork from current bridge state.'), + count: z.number().int().min(1).max(10).default(3), + vary: z.array(z.enum(MUTATION_KINDS)).min(1).default(['wall-thickness', 'wall-height']), + seed: z.number().int().optional().describe('Deterministic RNG seed.'), + save: z + .boolean() + .default(false) + .describe('If true, also save each variant via SceneStore and return ids.'), +} + +export const generateVariantsOutput = { + variants: z.array( + z.object({ + index: z.number(), + description: z.string(), + nodeCount: z.number(), + sceneId: z.string().optional(), + url: z.string().optional(), + graph: z.any().optional(), + }), + ), +} + +/** + * `forkSceneGraph` normalises `SiteNode.children` to string IDs, but the + * `SiteNode` schema declares that field as an array of full `BuildingNode` / + * `ItemNode` objects (see CROSS_CUTTING §2). To keep variants validating + * against `AnyNode`, re-embed the site children from the flat dict. + * + * Pure: returns a new graph without mutating the input. + */ +function rehydrateSiteChildren(graph: SceneGraph): SceneGraph { + const out: SceneGraph = { + nodes: { ...graph.nodes }, + rootNodeIds: [...graph.rootNodeIds], + ...(graph.collections ? { collections: graph.collections } : {}), + } + for (const [id, node] of Object.entries(out.nodes)) { + if (node.type !== 'site') continue + const childrenField = (node as { children?: unknown[] }).children + if (!Array.isArray(childrenField)) continue + const rehydrated: AnyNode[] = [] + for (const child of childrenField) { + if (typeof child === 'string') { + const target = out.nodes[child as keyof typeof out.nodes] + if (target && (target.type === 'building' || target.type === 'item')) { + rehydrated.push(target) + } + } else if (child && typeof child === 'object' && 'id' in (child as Record)) { + rehydrated.push(child as AnyNode) + } + } + out.nodes[id as keyof typeof out.nodes] = { + ...(node as AnyNode), + children: rehydrated, + } as AnyNode + } + return out +} + +/** + * Count how many nodes in a graph fail `AnyNode` validation. Used to keep the + * tool from returning silently corrupt variants. + */ +function countInvalidNodes(graph: SceneGraph): number { + let invalid = 0 + for (const node of Object.values(graph.nodes)) { + if (!AnyNodeSchema.safeParse(node).success) invalid++ + } + return invalid +} + +export function registerGenerateVariants( + server: McpServer, + bridge: SceneBridge, + store: SceneStore, +): void { + server.registerTool( + 'generate_variants', + { + title: 'Generate variants', + description: + 'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via the SceneStore and returned with an id + URL; otherwise the graph is returned inline.', + inputSchema: generateVariantsInput, + outputSchema: generateVariantsOutput, + }, + async ({ baseSceneId, count, vary, seed, save }) => { + // 1. Obtain the base SceneGraph. + let base: SceneGraph + let baseName = 'scene' + if (baseSceneId) { + const loaded = await store.load(baseSceneId) + if (!loaded) { + throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId }) + } + base = loaded.graph + baseName = loaded.name + } else { + const exported = bridge.exportJSON() + base = { + nodes: exported.nodes, + rootNodeIds: exported.rootNodeIds, + collections: exported.collections as SceneGraph['collections'], + } + } + + // 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 mutations = vary as MutationKind[] + const variants: Array<{ + index: number + description: string + nodeCount: number + sceneId?: string + url?: string + graph?: SceneGraph + }> = [] + + for (let i = 0; i < count; i++) { + // Each variant gets its own RNG stream derived from (seed + i) so + // results are deterministic per-index. + const rng = mulberry32(initialSeed + i) + + let forked: SceneGraph = forkSceneGraph(base) + for (const kind of mutations) { + forked = applyMutation(forked, rng, kind) + } + // Re-embed site children so variants match the SiteNode schema. + forked = rehydrateSiteChildren(forked) + + const invalidCount = countInvalidNodes(forked) + if (invalidCount > 0) { + throwMcpError( + ErrorCode.InternalError, + `variant_invalid: variant ${i} produced ${invalidCount} invalid node(s)`, + { index: i }, + ) + } + + const nodeCount = Object.keys(forked.nodes).length + const description = describeVariant(forked, mutations) + + if (save) { + try { + const meta = await store.save({ + name: `${baseName}-variant-${i + 1}`, + graph: forked, + }) + variants.push({ + index: i, + description, + nodeCount, + sceneId: meta.id, + url: `/scene/${meta.id}`, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throwMcpError(ErrorCode.InternalError, `save_failed: ${msg}`, { index: i }) + } + } else { + variants.push({ index: i, description, nodeCount, graph: forked }) + } + } + + const payload = { variants } + return { + content: [{ type: 'text' as const, text: JSON.stringify(payload) }], + structuredContent: payload, + } + }, + ) +} diff --git a/packages/mcp/src/tools/variants/index.ts b/packages/mcp/src/tools/variants/index.ts new file mode 100644 index 00000000..4075cecb --- /dev/null +++ b/packages/mcp/src/tools/variants/index.ts @@ -0,0 +1,30 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneStore } from '../../storage/types' +import { registerGenerateVariants } from './generate-variants' + +/** + * Register the variant-generation MCP tools against the given server. Uses the + * supplied `SceneStore` both to load a `baseSceneId` (when provided) and to + * persist variants when `save=true`. + */ +export function registerVariantTools( + server: McpServer, + bridge: SceneBridge, + store: SceneStore, +): void { + registerGenerateVariants(server, bridge, store) +} + +export { + generateVariantsInput, + generateVariantsOutput, + registerGenerateVariants, +} from './generate-variants' +export { + applyMutation, + describeVariant, + type MutationKind, + mulberry32, + type Rng, +} from './mutations' diff --git a/packages/mcp/src/tools/variants/mutations.test.ts b/packages/mcp/src/tools/variants/mutations.test.ts new file mode 100644 index 00000000..faaf1e85 --- /dev/null +++ b/packages/mcp/src/tools/variants/mutations.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test } from 'bun:test' +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNodeId } from '@pascal-app/core/schema' +import { applyMutation, mulberry32 } from './mutations' + +function makeBaseGraph(): SceneGraph { + const nodes: SceneGraph['nodes'] = { + site_a: { + object: 'node', + id: 'site_a', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ], + }, + children: [], + }, + building_a: { + object: 'node', + id: 'building_a', + type: 'building', + parentId: 'site_a', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + children: ['level_a'], + }, + level_a: { + object: 'node', + id: 'level_a', + type: 'level', + parentId: 'building_a', + visible: true, + metadata: {}, + children: ['wall_n', 'wall_s', 'wall_e', 'wall_w', 'wall_mid', 'zone_kitchen', 'zone_living'], + }, + wall_n: { + object: 'node', + id: 'wall_n', + type: 'wall', + parentId: 'level_a', + visible: true, + metadata: {}, + start: [-10, 10], + end: [10, 10], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_s: { + object: 'node', + id: 'wall_s', + type: 'wall', + parentId: 'level_a', + visible: true, + metadata: {}, + start: [-10, -10], + end: [10, -10], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_e: { + object: 'node', + id: 'wall_e', + type: 'wall', + parentId: 'level_a', + visible: true, + metadata: {}, + start: [10, -10], + end: [10, 10], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_w: { + object: 'node', + id: 'wall_w', + type: 'wall', + parentId: 'level_a', + visible: true, + metadata: {}, + start: [-10, -10], + end: [-10, 10], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + wall_mid: { + object: 'node', + id: 'wall_mid', + type: 'wall', + parentId: 'level_a', + visible: true, + metadata: {}, + start: [-5, 0], + end: [5, 0], + thickness: 0.1, + height: 2.5, + children: ['door_mid'], + frontSide: 'unknown', + backSide: 'unknown', + }, + door_mid: { + object: 'node', + id: 'door_mid', + type: 'door', + parentId: 'wall_mid', + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + wallId: 'wall_mid', + width: 0.9, + height: 2.1, + frameThickness: 0.05, + frameDepth: 0.07, + threshold: true, + thresholdHeight: 0.02, + hingesSide: 'left', + swingDirection: 'inward', + segments: [], + handle: true, + handleHeight: 1.05, + handleSide: 'right', + contentPadding: [0.04, 0.04], + doorCloser: false, + panicBar: false, + panicBarHeight: 1.0, + }, + zone_kitchen: { + object: 'node', + id: 'zone_kitchen', + type: 'zone', + parentId: 'level_a', + visible: true, + metadata: {}, + name: 'Kitchen', + polygon: [ + [-5, 0], + [5, 0], + [5, 10], + [-5, 10], + ], + color: '#ff0000', + }, + zone_living: { + object: 'node', + id: 'zone_living', + type: 'zone', + parentId: 'level_a', + visible: true, + metadata: {}, + name: 'Living', + polygon: [ + [-5, -10], + [5, -10], + [5, 0], + [-5, 0], + ], + color: '#00ff00', + }, + fence_1: { + object: 'node', + id: 'fence_1', + type: 'fence', + parentId: 'site_a', + visible: true, + metadata: {}, + start: [-8, -8], + end: [8, -8], + height: 1.8, + thickness: 0.08, + baseHeight: 0.22, + postSpacing: 2, + postSize: 0.1, + topRailHeight: 0.04, + groundClearance: 0, + edgeInset: 0.015, + baseStyle: 'grounded', + color: '#ffffff', + style: 'slat', + }, + } as unknown as SceneGraph['nodes'] + return { + nodes, + rootNodeIds: ['site_a'] as AnyNodeId[], + } +} + +describe('mulberry32', () => { + test('is deterministic for the same seed', () => { + const a = mulberry32(42) + const b = mulberry32(42) + for (let i = 0; i < 10; i++) { + expect(a()).toBe(b()) + } + }) + test('produces values in [0, 1)', () => { + const rng = mulberry32(7) + for (let i = 0; i < 100; i++) { + const v = rng() + expect(v).toBeGreaterThanOrEqual(0) + expect(v).toBeLessThan(1) + } + }) +}) + +describe('applyMutation: wall-thickness', () => { + test('assigns every wall a thickness from the fixed set', () => { + const rng = mulberry32(1) + const out = applyMutation(makeBaseGraph(), rng, 'wall-thickness') + const allowed = new Set([0.1, 0.15, 0.2, 0.25]) + let walls = 0 + for (const node of Object.values(out.nodes)) { + if (node.type !== 'wall') continue + walls++ + expect(allowed.has((node as { thickness: number }).thickness)).toBe(true) + } + expect(walls).toBeGreaterThan(0) + }) + + test('does not mutate the input graph', () => { + const base = makeBaseGraph() + const before = JSON.stringify(base) + applyMutation(base, mulberry32(5), 'wall-thickness') + expect(JSON.stringify(base)).toBe(before) + }) +}) + +describe('applyMutation: wall-height', () => { + test('assigns every wall a height from the fixed set', () => { + const rng = mulberry32(2) + const out = applyMutation(makeBaseGraph(), rng, 'wall-height') + const allowed = new Set([2.4, 2.6, 2.7, 3.0]) + for (const node of Object.values(out.nodes)) { + if (node.type !== 'wall') continue + expect(allowed.has((node as { height: number }).height)).toBe(true) + } + }) +}) + +describe('applyMutation: zone-labels', () => { + test('shuffles labels but preserves the set', () => { + const base = makeBaseGraph() + const rng = mulberry32(3) + const out = applyMutation(base, rng, 'zone-labels') + const before = new Set() + for (const node of Object.values(base.nodes)) { + if (node.type === 'zone') before.add((node as { name: string }).name) + } + const after = new Set() + for (const node of Object.values(out.nodes)) { + if (node.type === 'zone') after.add((node as { name: string }).name) + } + expect(after).toEqual(before) + }) +}) + +describe('applyMutation: room-proportions', () => { + test('only nudges interior walls, leaves perimeter alone', () => { + const base = makeBaseGraph() + const rng = mulberry32(4) + const out = applyMutation(base, rng, 'room-proportions') + // Perimeter wall should be unchanged. + const n = out.nodes.wall_n as { start: [number, number]; end: [number, number] } + expect(n.start).toEqual([-10, 10]) + expect(n.end).toEqual([10, 10]) + // Interior wall should (usually) be different. + const mid = out.nodes.wall_mid as { start: [number, number]; end: [number, number] } + const midBase = base.nodes.wall_mid as { start: [number, number]; end: [number, number] } + const changed = + mid.start[0] !== midBase.start[0] || + mid.start[1] !== midBase.start[1] || + mid.end[0] !== midBase.end[0] || + mid.end[1] !== midBase.end[1] + expect(changed).toBe(true) + }) +}) + +describe('applyMutation: open-plan', () => { + test('removes exactly one interior wall and its attached openings', () => { + const base = makeBaseGraph() + const baseWallCount = Object.values(base.nodes).filter((n) => n.type === 'wall').length + const rng = mulberry32(5) + const out = applyMutation(base, rng, 'open-plan') + const afterWallCount = Object.values(out.nodes).filter((n) => n.type === 'wall').length + expect(afterWallCount).toBe(baseWallCount - 1) + // Interior wall `wall_mid` had a door — both should be gone. + expect(out.nodes.wall_mid).toBeUndefined() + expect(out.nodes.door_mid).toBeUndefined() + }) + + test('skips gracefully when there are no interior walls', () => { + const graph: SceneGraph = { + nodes: { + site_a: { + object: 'node', + id: 'site_a', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-10, -10], + [10, -10], + [10, 10], + [-10, 10], + ], + }, + children: [], + }, + wall_n: { + object: 'node', + id: 'wall_n', + type: 'wall', + parentId: 'site_a', + visible: true, + metadata: {}, + start: [-10, 10], + end: [10, 10], + thickness: 0.1, + height: 2.5, + children: [], + frontSide: 'unknown', + backSide: 'unknown', + }, + } as unknown as SceneGraph['nodes'], + rootNodeIds: ['site_a'] as AnyNodeId[], + } + const out = applyMutation(graph, mulberry32(9), 'open-plan') + expect(Object.keys(out.nodes)).toEqual(Object.keys(graph.nodes)) + }) +}) + +describe('applyMutation: door-positions', () => { + test('sets every door wallT in [0.2, 0.8]', () => { + const rng = mulberry32(6) + const out = applyMutation(makeBaseGraph(), rng, 'door-positions') + for (const node of Object.values(out.nodes)) { + if (node.type !== 'door') continue + const t = (node as { wallT?: number }).wallT + expect(typeof t).toBe('number') + expect(t as number).toBeGreaterThanOrEqual(0.2) + expect(t as number).toBeLessThanOrEqual(0.8) + } + }) +}) + +describe('applyMutation: fence-style', () => { + test('sets every fence style to one of privacy/slat/rail', () => { + const rng = mulberry32(7) + const out = applyMutation(makeBaseGraph(), rng, 'fence-style') + const allowed = new Set(['privacy', 'slat', 'rail']) + for (const node of Object.values(out.nodes)) { + if (node.type !== 'fence') continue + expect(allowed.has((node as { style: string }).style)).toBe(true) + } + }) +}) + +describe('applyMutation: no-op behaviour', () => { + test('wall-thickness on a graph with no walls leaves nodes unchanged', () => { + const graph: SceneGraph = { + nodes: { + site_a: { + object: 'node', + id: 'site_a', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + polygon: { + type: 'polygon', + points: [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + ], + }, + children: [], + }, + } as unknown as SceneGraph['nodes'], + rootNodeIds: ['site_a'] as AnyNodeId[], + } + const out = applyMutation(graph, mulberry32(8), 'wall-thickness') + expect(JSON.stringify(out.nodes)).toBe(JSON.stringify(graph.nodes)) + }) +}) diff --git a/packages/mcp/src/tools/variants/mutations.ts b/packages/mcp/src/tools/variants/mutations.ts new file mode 100644 index 00000000..8f38d689 --- /dev/null +++ b/packages/mcp/src/tools/variants/mutations.ts @@ -0,0 +1,331 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' + +/** Mutation kinds handled by `applyMutation`. */ +export type MutationKind = + | 'wall-thickness' + | 'wall-height' + | 'zone-labels' + | 'room-proportions' + | 'open-plan' + | 'door-positions' + | 'fence-style' + +/** Deterministic 32-bit 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 + */ +export function mulberry32(seed: number): Rng { + let state = seed | 0 + 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 + } +} + +/** Pick a random element from a non-empty array. */ +function pickFrom(rng: Rng, values: readonly T[]): T { + const idx = Math.floor(rng() * values.length) + return values[Math.min(idx, values.length - 1)] as T +} + +/** Shallow clone a scene graph: nodes are copied one level deep, node dict is fresh. */ +function cloneGraph(graph: SceneGraph): SceneGraph { + const clonedNodes: Record = {} as Record + for (const [id, node] of Object.entries(graph.nodes)) { + // structuredClone so sub-objects (arrays, tuples, metadata) are independent. + clonedNodes[id as AnyNodeId] = structuredClone(node) as AnyNode + } + return { + nodes: clonedNodes, + rootNodeIds: [...graph.rootNodeIds], + ...(graph.collections ? { collections: structuredClone(graph.collections) } : {}), + } +} + +const WALL_THICKNESS_OPTIONS = [0.1, 0.15, 0.2, 0.25] as const +const WALL_HEIGHT_OPTIONS = [2.4, 2.6, 2.7, 3.0] as const +const FENCE_STYLES = ['privacy', 'slat', 'rail'] as const + +/** Fisher–Yates shuffle in place using the provided RNG. */ +function shuffleInPlace(arr: T[], rng: Rng): void { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + const tmp = arr[i] as T + arr[i] = arr[j] as T + arr[j] = tmp + } +} + +/** + * Compute 2D bounds (min/max x/z) of the first `site` node's polygon points, + * or `null` if no site is present. + */ +function siteBounds( + graph: SceneGraph, +): { minX: number; maxX: number; minZ: number; maxZ: number } | null { + for (const node of Object.values(graph.nodes)) { + 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 + for (const [x, z] of pts) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (z < minZ) minZ = z + if (z > maxZ) maxZ = z + } + if (!Number.isFinite(minX)) continue + return { minX, maxX, minZ, maxZ } + } + return null +} + +/** + * Heuristic: a wall is a perimeter wall if either of its endpoints sits close + * to the site polygon's bounding rectangle (within `epsilon`). Returns `false` + * if there is no site polygon (treat everything as interior so the mutations + * still exercise something on partial scenes). + */ +function isPerimeterWall( + wall: AnyNode & { start?: [number, number]; end?: [number, number] }, + bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null, + epsilon = 0.01, +): boolean { + 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 || + Math.abs(z - bounds.minZ) <= epsilon || + Math.abs(z - bounds.maxZ) <= epsilon + const [sx, sz] = wall.start + const [ex, ez] = wall.end + return onBound(sx, sz) || onBound(ex, ez) +} + +function applyWallThickness(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + for (const node of Object.values(out.nodes)) { + if (node.type !== 'wall') continue + ;(node as { thickness?: number }).thickness = pickFrom(rng, WALL_THICKNESS_OPTIONS) + } + return out +} + +function applyWallHeight(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + for (const node of Object.values(out.nodes)) { + if (node.type !== 'wall') continue + ;(node as { height?: number }).height = pickFrom(rng, WALL_HEIGHT_OPTIONS) + } + return out +} + +function applyZoneLabels(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + const zoneNodes: Array = [] + for (const node of Object.values(out.nodes)) { + if (node.type === 'zone') zoneNodes.push(node as AnyNode & { name?: string }) + } + if (zoneNodes.length < 2) return out + const labels = zoneNodes.map((z) => z.name ?? '') + shuffleInPlace(labels, rng) + for (let i = 0; i < zoneNodes.length; i++) { + ;(zoneNodes[i] as { name?: string }).name = labels[i] + } + return out +} + +function applyRoomProportions(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + const bounds = siteBounds(out) + for (const node of Object.values(out.nodes)) { + if (node.type !== 'wall') continue + const wall = node as AnyNode & { + start?: [number, number] + end?: [number, number] + } + 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) + const clampX = (v: number): number => + bounds ? Math.min(bounds.maxX, Math.max(bounds.minX, v)) : v + const clampZ = (v: number): number => + bounds ? Math.min(bounds.maxZ, Math.max(bounds.minZ, v)) : v + const [sx, sz] = wall.start + const [ex, ez] = wall.end + wall.start = [clampX(nudge(sx)), clampZ(nudge(sz))] + wall.end = [clampX(nudge(ex)), clampZ(nudge(ez))] + } + return out +} + +function applyOpenPlan(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + const bounds = siteBounds(out) + const interiorWallIds: AnyNodeId[] = [] + for (const [id, node] of Object.entries(out.nodes)) { + if (node.type !== 'wall') continue + if (isPerimeterWall(node as AnyNode, bounds)) continue + interiorWallIds.push(id as AnyNodeId) + } + if (interiorWallIds.length === 0) return out + const targetId = interiorWallIds[Math.floor(rng() * interiorWallIds.length)] as AnyNodeId + // Collect any openings attached to this wall so we can drop them too. + const attached: AnyNodeId[] = [] + for (const [attId, node] of Object.entries(out.nodes)) { + if ((node as { wallId?: string }).wallId === targetId) attached.push(attId as AnyNodeId) + } + const removal = new Set([targetId, ...attached]) + // Drop from nodes. + for (const id of removal) delete out.nodes[id] + // Drop from rootNodeIds (unlikely for walls, but consistent). + 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)) { + continue + } + const children = (parent as { children: unknown[] }).children + ;(parent as { children: unknown[] }).children = children.filter((child) => { + if (typeof child === 'string') return !removal.has(child as AnyNodeId) + if (child && typeof child === 'object' && 'id' in (child as Record)) { + return !removal.has((child as { id: AnyNodeId }).id) + } + return true + }) + } + return out +} + +function applyDoorPositions(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + // Group doors by their parent wall so we can space them out and skip collisions. + const doorsByWall = new Map>() + for (const node of Object.values(out.nodes)) { + if (node.type !== 'door') continue + const wallId = (node as { wallId?: string }).wallId + if (!wallId) continue + let list = doorsByWall.get(wallId) + if (!list) { + list = [] + doorsByWall.set(wallId, list) + } + list.push(node as AnyNode & { wallT?: number; wallId?: string }) + } + for (const [, doors] of doorsByWall) { + // Minimum separation along the parametric wall axis — rough keep-away to + // avoid obvious overlaps. + const minGap = 0.15 + const usedTs: number[] = [] + for (const door of doors) { + let attempts = 0 + let t = 0.5 + while (attempts < 8) { + t = 0.2 + rng() * 0.6 // [0.2, 0.8] + const collides = usedTs.some((u) => Math.abs(u - t) < minGap) + if (!collides) break + attempts++ + } + // If we still collide after 8 attempts, skip this door (leave it alone). + if (usedTs.some((u) => Math.abs(u - t) < minGap)) continue + usedTs.push(t) + ;(door as { wallT?: number }).wallT = t + } + } + return out +} + +function applyFenceStyle(graph: SceneGraph, rng: Rng): SceneGraph { + const out = cloneGraph(graph) + let i = 0 + for (const node of Object.values(out.nodes)) { + if (node.type !== 'fence') continue + // Use rng to choose a rotation offset so each call can produce a different + // starting point even when called multiple times with the same base. + const offset = Math.floor(rng() * FENCE_STYLES.length) + const style = FENCE_STYLES[(i + offset) % FENCE_STYLES.length] + ;(node as { style?: string }).style = style + i++ + } + return out +} + +/** Pure: apply a single mutation and return a fresh graph. */ +export function applyMutation(graph: SceneGraph, rng: Rng, kind: MutationKind): SceneGraph { + switch (kind) { + case 'wall-thickness': + return applyWallThickness(graph, rng) + case 'wall-height': + return applyWallHeight(graph, rng) + case 'zone-labels': + return applyZoneLabels(graph, rng) + case 'room-proportions': + return applyRoomProportions(graph, rng) + case 'open-plan': + return applyOpenPlan(graph, rng) + case 'door-positions': + return applyDoorPositions(graph, rng) + case 'fence-style': + return applyFenceStyle(graph, rng) + } +} + +/** + * Human-readable summary of the mutations applied to a variant. Reads the + * interesting fields from the graph (e.g. first wall's thickness/height). + */ +export function describeVariant(graph: SceneGraph, mutations: readonly MutationKind[]): string { + const parts: string[] = [] + if (mutations.includes('wall-thickness')) { + const t = firstWallField(graph, 'thickness') + if (t !== null) parts.push(`wall thickness ${t}m`) + } + if (mutations.includes('wall-height')) { + const h = firstWallField(graph, 'height') + if (h !== null) parts.push(`wall height ${h}m`) + } + if (mutations.includes('zone-labels')) { + const names: string[] = [] + for (const node of Object.values(graph.nodes)) { + if (node.type === 'zone') names.push((node as { name?: string }).name ?? '') + } + if (names.length > 0) parts.push(`zones [${names.join(', ')}]`) + } + if (mutations.includes('room-proportions')) parts.push('room proportions nudged') + if (mutations.includes('open-plan')) parts.push('open-plan') + if (mutations.includes('door-positions')) parts.push('doors repositioned') + if (mutations.includes('fence-style')) { + const s = firstFenceField(graph, 'style') + if (s !== null) parts.push(`fence style ${s}`) + } + return parts.length > 0 ? parts.join(', ') : 'no-op' +} + +function firstWallField(graph: SceneGraph, field: 'thickness' | 'height'): number | null { + for (const node of Object.values(graph.nodes)) { + if (node.type !== 'wall') continue + const v = (node as Record)[field] + if (typeof v === 'number') return v + } + return null +} + +function firstFenceField(graph: SceneGraph, field: 'style'): string | null { + for (const node of Object.values(graph.nodes)) { + if (node.type !== 'fence') continue + const v = (node as Record)[field] + if (typeof v === 'string') return v + } + return null +} diff --git a/packages/mcp/test-reports/casa-sol/build.ts b/packages/mcp/test-reports/casa-sol/build.ts index e446902b..17d481fa 100644 --- a/packages/mcp/test-reports/casa-sol/build.ts +++ b/packages/mcp/test-reports/casa-sol/build.ts @@ -19,12 +19,12 @@ * bun packages/mcp/test-reports/casa-sol/build.ts */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' const HERE = dirname(fileURLToPath(import.meta.url)) const SERVER_URL = 'http://localhost:3917/mcp' @@ -234,7 +234,14 @@ const OPENINGS: OpeningSpec[] = [ }, { wallDesignId: 2, kind: 'door', position: 0.65, width: 0.9, height: 2.1, label: 'kitchen-back' }, { wallDesignId: 6, kind: 'door', position: 0.3, width: 0.8, height: 2.1, label: 'master-door' }, - { wallDesignId: 7, kind: 'door', position: 0.5, width: 0.8, height: 2.1, label: 'bedroom-2-door' }, + { + wallDesignId: 7, + kind: 'door', + position: 0.5, + width: 0.8, + height: 2.1, + label: 'bedroom-2-door', + }, { wallDesignId: 9, kind: 'door', position: 0.5, width: 0.7, height: 2.0, label: 'bath2-door' }, { wallDesignId: 1, @@ -329,8 +336,12 @@ async function main(): Promise { log(`[casa] falling back to in-memory MCP server (same tool surface)`) // Load the in-process MCP server to keep the build moving. This preserves // the tool contract; the only thing we lose is the HTTP wire test. - const { SceneBridge } = await import('/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/bridge/scene-bridge.ts') - const { createPascalMcpServer } = await import('/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/server.ts') + const { SceneBridge } = await import( + '/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/bridge/scene-bridge.ts' + ) + const { createPascalMcpServer } = await import( + '/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/src/server.ts' + ) const bridge = new SceneBridge() bridge.loadDefault() @@ -530,7 +541,9 @@ async function main(): Promise { }) } } - const ids = openingResults.filter((r) => r.ok && r.openingId).map((r) => r.openingId!) as string[] + const ids = openingResults + .filter((r) => r.ok && r.openingId) + .map((r) => r.openingId!) as string[] return { summary: `${doors} doors, ${windows} windows, ${failures.length} failures`, nodeIds: ids, @@ -691,9 +704,8 @@ async function main(): Promise { // ----- Step 12: Final validate + summary counts ----- const finalValid = await runValidate(client, 'final') - const allNodes = ( - await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {}) - ).nodes + const allNodes = (await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {})) + .nodes const tally: Record = {} for (const n of allNodes) { tally[n.type] = (tally[n.type] ?? 0) + 1 @@ -790,7 +802,9 @@ async function main(): Promise { lines.push('## Validation') lines.push('') - lines.push(`- Final \`validate_scene\`: valid=\`${finalValid.valid}\`, errors=${finalValid.errors.length}`) + lines.push( + `- Final \`validate_scene\`: valid=\`${finalValid.valid}\`, errors=${finalValid.errors.length}`, + ) if (!finalValid.valid && finalValid.errors.length > 0) { lines.push('') lines.push('Errors (verbatim):') diff --git a/packages/mcp/test-reports/casa-sol/scene.json b/packages/mcp/test-reports/casa-sol/scene.json index 975f1789..9c225f2d 100644 --- a/packages/mcp/test-reports/casa-sol/scene.json +++ b/packages/mcp/test-reports/casa-sol/scene.json @@ -9,24 +9,7 @@ "metadata": {}, "polygon": { "type": "polygon", - "points": [ - [ - -15, - -15 - ], - [ - 15, - -15 - ], - [ - 15, - 15 - ], - [ - -15, - 15 - ] - ] + "points": [[-15, -15], [15, -15], [15, 15], [-15, 15]] }, "children": [ { @@ -36,19 +19,9 @@ "parentId": null, "visible": true, "metadata": {}, - "children": [ - "level_9ded0vdlfag09tdt" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] + "children": ["level_9ded0vdlfag09tdt"], + "position": [0, 0, 0], + "rotation": [0, 0, 0] } ] }, @@ -59,19 +32,9 @@ "parentId": null, "visible": true, "metadata": {}, - "children": [ - "level_9ded0vdlfag09tdt" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] + "children": ["level_9ded0vdlfag09tdt"], + "position": [0, 0, 0], + "rotation": [0, 0, 0] }, "level_9ded0vdlfag09tdt": { "object": "node", @@ -123,14 +86,8 @@ ], "thickness": 0.2, "height": 2.7, - "start": [ - -8, - 4 - ], - "end": [ - 4, - 4 - ], + "start": [-8, 4], + "end": [4, 4], "frontSide": "unknown", "backSide": "unknown" }, @@ -141,20 +98,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "door_lv8wmjjs6em9ayqg", - "window_qy4pp3lwyn5cq2u1" - ], + "children": ["door_lv8wmjjs6em9ayqg", "window_qy4pp3lwyn5cq2u1"], "thickness": 0.2, "height": 2.7, - "start": [ - -8, - -4 - ], - "end": [ - 4, - -4 - ], + "start": [-8, -4], + "end": [4, -4], "frontSide": "unknown", "backSide": "unknown" }, @@ -165,19 +113,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "window_r9mc8jkp0dem9s8u" - ], + "children": ["window_r9mc8jkp0dem9s8u"], "thickness": 0.2, "height": 2.7, - "start": [ - -8, - -4 - ], - "end": [ - -8, - 4 - ], + "start": [-8, -4], + "end": [-8, 4], "frontSide": "unknown", "backSide": "unknown" }, @@ -188,20 +128,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "window_bbmwfj9hjxfz6y7c", - "window_19otr8qt84hke69s" - ], + "children": ["window_bbmwfj9hjxfz6y7c", "window_19otr8qt84hke69s"], "thickness": 0.2, "height": 2.7, - "start": [ - 4, - -4 - ], - "end": [ - 4, - 4 - ], + "start": [4, -4], + "end": [4, 4], "frontSide": "unknown", "backSide": "unknown" }, @@ -215,14 +146,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - -1, - 0 - ], - "end": [ - -1, - 4 - ], + "start": [-1, 0], + "end": [-1, 4], "frontSide": "unknown", "backSide": "unknown" }, @@ -233,19 +158,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "door_g1ffn08lohho4vf0" - ], + "children": ["door_g1ffn08lohho4vf0"], "thickness": 0.2, "height": 2.7, - "start": [ - -1, - -4 - ], - "end": [ - -1, - 0 - ], + "start": [-1, -4], + "end": [-1, 0], "frontSide": "unknown", "backSide": "unknown" }, @@ -256,19 +173,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "door_zuyuxee4afd5ae0o" - ], + "children": ["door_zuyuxee4afd5ae0o"], "thickness": 0.2, "height": 2.7, - "start": [ - -4, - -4 - ], - "end": [ - -4, - 0 - ], + "start": [-4, -4], + "end": [-4, 0], "frontSide": "unknown", "backSide": "unknown" }, @@ -282,14 +191,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - -4, - -1 - ], - "end": [ - -1, - -1 - ], + "start": [-4, -1], + "end": [-1, -1], "frontSide": "unknown", "backSide": "unknown" }, @@ -300,19 +203,11 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "children": [ - "door_n4kgjkvq18a87rh0" - ], + "children": ["door_n4kgjkvq18a87rh0"], "thickness": 0.2, "height": 2.7, - "start": [ - -4, - -2 - ], - "end": [ - -1, - -2 - ], + "start": [-4, -2], + "end": [-1, -2], "frontSide": "unknown", "backSide": "unknown" }, @@ -324,24 +219,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -8, - 0 - ], - [ - -1, - 0 - ], - [ - -1, - 4 - ], - [ - -8, - 4 - ] - ], + "polygon": [[-8, 0], [-1, 0], [-1, 4], [-8, 4]], "color": "#3b82f6" }, "zone_x409m2lnw1jpmi8m": { @@ -352,24 +230,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -1, - 0 - ], - [ - 4, - 0 - ], - [ - 4, - 4 - ], - [ - -1, - 4 - ] - ], + "polygon": [[-1, 0], [4, 0], [4, 4], [-1, 4]], "color": "#3b82f6" }, "zone_and0s1ux5v8rexj6": { @@ -380,24 +241,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -8, - -4 - ], - [ - -4, - -4 - ], - [ - -4, - 0 - ], - [ - -8, - 0 - ] - ], + "polygon": [[-8, -4], [-4, -4], [-4, 0], [-8, 0]], "color": "#3b82f6" }, "zone_n8w3oyzr3c4ovcbu": { @@ -408,24 +252,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -4, - -2 - ], - [ - -1, - -2 - ], - [ - -1, - -1 - ], - [ - -4, - -1 - ] - ], + "polygon": [[-4, -2], [-1, -2], [-1, -1], [-4, -1]], "color": "#3b82f6" }, "zone_c1wa4cr91h215zzk": { @@ -436,24 +263,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -4, - -4 - ], - [ - -1, - -4 - ], - [ - -1, - -2 - ], - [ - -4, - -2 - ] - ], + "polygon": [[-4, -4], [-1, -4], [-1, -2], [-4, -2]], "color": "#3b82f6" }, "zone_e8e6qqmx85tockrm": { @@ -464,24 +274,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -4, - -1 - ], - [ - -1, - -1 - ], - [ - -1, - 0 - ], - [ - -4, - 0 - ] - ], + "polygon": [[-4, -1], [-1, -1], [-1, 0], [-4, 0]], "color": "#3b82f6" }, "zone_ebbidjjln9doosy5": { @@ -492,24 +285,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - -1, - -4 - ], - [ - 4, - -4 - ], - [ - 4, - 0 - ], - [ - -1, - 0 - ] - ], + "polygon": [[-1, -4], [4, -4], [4, 0], [-1, 0]], "color": "#3b82f6" }, "door_v19tfw6dbg60pjka": { @@ -519,16 +295,8 @@ "parentId": "wall_vc5l8mk2b3j3ukvq", "visible": true, "metadata": {}, - "position": [ - 0.2, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.2, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_vc5l8mk2b3j3ukvq", "width": 0.9, "height": 2.1, @@ -542,9 +310,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -552,9 +318,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -563,10 +327,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -578,16 +339,8 @@ "parentId": "wall_vc5l8mk2b3j3ukvq", "visible": true, "metadata": {}, - "position": [ - 0.75, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.75, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_vc5l8mk2b3j3ukvq", "width": 2.2, "height": 2.1, @@ -601,9 +354,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -611,9 +362,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -622,10 +371,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -637,16 +383,8 @@ "parentId": "wall_9jaybb53j2r5j5en", "visible": true, "metadata": {}, - "position": [ - 0.65, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.65, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_9jaybb53j2r5j5en", "width": 0.9, "height": 2.1, @@ -660,9 +398,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -670,9 +406,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -681,10 +415,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -696,16 +427,8 @@ "parentId": "wall_53gr0rtp1ssmxvz2", "visible": true, "metadata": {}, - "position": [ - 0.3, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.3, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_53gr0rtp1ssmxvz2", "width": 0.8, "height": 2.1, @@ -719,9 +442,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -729,9 +450,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -740,10 +459,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -755,16 +471,8 @@ "parentId": "wall_yzuosc4u4z0a9jqo", "visible": true, "metadata": {}, - "position": [ - 0.5, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_yzuosc4u4z0a9jqo", "width": 0.8, "height": 2.1, @@ -778,9 +486,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -788,9 +494,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -799,10 +503,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -814,16 +515,8 @@ "parentId": "wall_3mznfrklw8ar7jrz", "visible": true, "metadata": {}, - "position": [ - 0.5, - 1, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 1, 0], + "rotation": [0, 0, 0], "wallId": "wall_3mznfrklw8ar7jrz", "width": 0.7, "height": 2, @@ -837,9 +530,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -847,9 +538,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -858,10 +547,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -873,27 +559,15 @@ "parentId": "wall_vc5l8mk2b3j3ukvq", "visible": true, "metadata": {}, - "position": [ - 0.3, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.3, 0.7, 0], + "rotation": [0, 0, 0], "wallId": "wall_vc5l8mk2b3j3ukvq", "width": 2, "height": 1.4, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -907,27 +581,15 @@ "parentId": "wall_vc5l8mk2b3j3ukvq", "visible": true, "metadata": {}, - "position": [ - 0.45, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.45, 0.7, 0], + "rotation": [0, 0, 0], "wallId": "wall_vc5l8mk2b3j3ukvq", "width": 1.4, "height": 1.4, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -941,27 +603,15 @@ "parentId": "wall_a16b1eirqz5p9f98", "visible": true, "metadata": {}, - "position": [ - 0.25, - 0.55, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.25, 0.55, 0], + "rotation": [0, 0, 0], "wallId": "wall_a16b1eirqz5p9f98", "width": 1, "height": 1.1, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -975,27 +625,15 @@ "parentId": "wall_yiwfcyw716g5ql5y", "visible": true, "metadata": {}, - "position": [ - 0.25, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.25, 0.7, 0], + "rotation": [0, 0, 0], "wallId": "wall_yiwfcyw716g5ql5y", "width": 1.4, "height": 1.4, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -1009,27 +647,15 @@ "parentId": "wall_yiwfcyw716g5ql5y", "visible": true, "metadata": {}, - "position": [ - 0.75, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.75, 0.7, 0], + "rotation": [0, 0, 0], "wallId": "wall_yiwfcyw716g5ql5y", "width": 1.4, "height": 1.4, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -1043,27 +669,15 @@ "parentId": "wall_9jaybb53j2r5j5en", "visible": true, "metadata": {}, - "position": [ - 0.3, - 0.3, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.3, 0.3, 0], + "rotation": [0, 0, 0], "wallId": "wall_9jaybb53j2r5j5en", "width": 0.8, "height": 0.6, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -1082,24 +696,7 @@ "depthM": 1.8, "finish": "tile" }, - "polygon": [ - [ - 5, - -1.5 - ], - [ - 10, - -1.5 - ], - [ - 10, - 1.5 - ], - [ - 5, - 1.5 - ] - ], + "polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]], "color": "#3b82f6" }, "slab_tqyxze2hshzm1it3": { @@ -1109,24 +706,7 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "polygon": [ - [ - 5, - -1.5 - ], - [ - 10, - -1.5 - ], - [ - 10, - 1.5 - ], - [ - 5, - 1.5 - ] - ], + "polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]], "holes": [], "holeMetadata": [], "elevation": -1.8, @@ -1139,14 +719,8 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "start": [ - -10, - 7.5 - ], - "end": [ - -1, - 7.5 - ], + "start": [-10, 7.5], + "end": [-1, 7.5], "height": 1.8, "thickness": 0.08, "baseHeight": 0.22, @@ -1166,14 +740,8 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "start": [ - 1, - 7.5 - ], - "end": [ - 10, - 7.5 - ], + "start": [1, 7.5], + "end": [10, 7.5], "height": 1.8, "thickness": 0.08, "baseHeight": 0.22, @@ -1193,14 +761,8 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "start": [ - 10, - 7.5 - ], - "end": [ - 10, - -7.5 - ], + "start": [10, 7.5], + "end": [10, -7.5], "height": 1.8, "thickness": 0.08, "baseHeight": 0.22, @@ -1220,14 +782,8 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "start": [ - 10, - -7.5 - ], - "end": [ - -10, - -7.5 - ], + "start": [10, -7.5], + "end": [-10, -7.5], "height": 1.8, "thickness": 0.08, "baseHeight": 0.22, @@ -1247,14 +803,8 @@ "parentId": "level_9ded0vdlfag09tdt", "visible": true, "metadata": {}, - "start": [ - -10, - -7.5 - ], - "end": [ - -10, - 7.5 - ], + "start": [-10, -7.5], + "end": [-10, 7.5], "height": 1.8, "thickness": 0.08, "baseHeight": 0.22, @@ -1277,29 +827,10 @@ "metadata": { "kind": "garden" }, - "polygon": [ - [ - -10, - -7.5 - ], - [ - 10, - -7.5 - ], - [ - 10, - 7.5 - ], - [ - -10, - 7.5 - ] - ], + "polygon": [[-10, -7.5], [10, -7.5], [10, 7.5], [-10, 7.5]], "color": "#3b82f6" } }, - "rootNodeIds": [ - "site_3ss5ro12ozrgtdbf" - ], + "rootNodeIds": ["site_3ss5ro12ozrgtdbf"], "collections": {} -} \ No newline at end of file +} diff --git a/packages/mcp/test-reports/phase7-e2e.ts b/packages/mcp/test-reports/phase7-e2e.ts new file mode 100644 index 00000000..aa347a15 --- /dev/null +++ b/packages/mcp/test-reports/phase7-e2e.ts @@ -0,0 +1,95 @@ +/** + * Phase 7 end-to-end: prove MCP save_scene → editor /scene/[id] renders the scene + * without any window.__pascalScene injection. + * + * Run: PASCAL_DATA_DIR=/tmp/pascal-e2e bun run packages/mcp/test-reports/phase7-e2e.ts + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +const MCP_URL = 'http://localhost:3917/mcp' +const EDITOR_URL = 'http://localhost:3002' + +async function main() { + console.log('---- Phase 7 e2e ----') + + // 1. Connect to MCP over HTTP + const transport = new StreamableHTTPClientTransport(new URL(MCP_URL)) + const client = new Client({ name: 'e2e', version: '0.0.0' }) + await client.connect(transport) + console.log('OK 1 connect MCP HTTP') + + // 2. Build a scene from a template + const created = await client.callTool({ + name: 'create_from_template', + arguments: { id: 'two-bedroom', name: 'e2e-two-bedroom' }, + }) + if (created.isError) throw new Error(`create_from_template: ${JSON.stringify(created)}`) + console.log('OK 2 create_from_template two-bedroom') + + // 3. Save it + const saved = await client.callTool({ + name: 'save_scene', + arguments: { name: 'e2e test house' }, + }) + if (saved.isError) throw new Error(`save_scene: ${JSON.stringify(saved)}`) + const savedData = JSON.parse((saved.content as Array<{ text: string }>)[0]!.text) + const sceneId = savedData.id as string + console.log(`OK 3 save_scene -> id=${sceneId}, version=${savedData.version}`) + + // 4. list_scenes + const list = await client.callTool({ name: 'list_scenes', arguments: {} }) + if (list.isError) throw new Error(`list_scenes: ${JSON.stringify(list)}`) + const listData = JSON.parse((list.content as Array<{ text: string }>)[0]!.text) + console.log(`OK 4 list_scenes -> ${listData.scenes.length} scenes`) + + // 5. Fetch via editor's API (proves A5 works against the same store) + const apiRes = await fetch(`${EDITOR_URL}/api/scenes/${sceneId}`) + if (!apiRes.ok) throw new Error(`GET /api/scenes/${sceneId} → ${apiRes.status}`) + const apiBody = await apiRes.json() + const nodeCount = Object.keys(apiBody.graph.nodes).length + console.log(`OK 5 editor /api/scenes/${sceneId} → ${nodeCount} nodes`) + + // 6. Fetch editor's /scenes list page (HTML) + const listHtmlRes = await fetch(`${EDITOR_URL}/scenes`) + if (!listHtmlRes.ok) throw new Error(`GET /scenes → ${listHtmlRes.status}`) + const listHtml = await listHtmlRes.text() + const hasSceneLink = listHtml.includes(`/scene/${sceneId}`) + console.log(`OK 6 /scenes renders, links scene: ${hasSceneLink}`) + + // 7. Fetch /scene/[id] page + const sceneHtmlRes = await fetch(`${EDITOR_URL}/scene/${sceneId}`) + if (!sceneHtmlRes.ok) throw new Error(`GET /scene/${sceneId} → ${sceneHtmlRes.status}`) + console.log(`OK 7 /scene/${sceneId} renders (${sceneHtmlRes.status})`) + + // 8. generate_variants — 3 variants, save=true + const variants = await client.callTool({ + name: 'generate_variants', + arguments: { count: 3, vary: ['wall-thickness', 'wall-height'], save: true, seed: 42 }, + }) + if (variants.isError) throw new Error(`generate_variants: ${JSON.stringify(variants)}`) + const variantsData = JSON.parse((variants.content as Array<{ text: string }>)[0]!.text) + console.log(`OK 8 generate_variants -> ${variantsData.variants.length} variants`) + + // 9. list_scenes again — should be > 1 + const list2 = await client.callTool({ name: 'list_scenes', arguments: {} }) + const list2Data = JSON.parse((list2.content as Array<{ text: string }>)[0]!.text) + console.log(`OK 9 list_scenes now shows ${list2Data.scenes.length} scenes`) + + // 10. delete_scene + const deleted = await client.callTool({ name: 'delete_scene', arguments: { id: sceneId } }) + if (deleted.isError) throw new Error(`delete_scene: ${JSON.stringify(deleted)}`) + const deletedData = JSON.parse((deleted.content as Array<{ text: string }>)[0]!.text) + console.log(`OK 10 delete_scene -> deleted=${deletedData.deleted}`) + + await client.close() + console.log(`\nSceneId to open in browser: ${EDITOR_URL}/scenes`) + console.log(`Direct: ${EDITOR_URL}/scene/${variantsData.variants[0].sceneId}`) + console.log('\n✅ Phase 7 e2e PASSED\n') +} + +main().catch((err) => { + console.error('\n❌ e2e failed:', err) + process.exit(1) +}) diff --git a/packages/mcp/test-reports/research/PHASE7_PLAN.md b/packages/mcp/test-reports/research/PHASE7_PLAN.md new file mode 100644 index 00000000..135f887c --- /dev/null +++ b/packages/mcp/test-reports/research/PHASE7_PLAN.md @@ -0,0 +1,82 @@ +# Phase 7 plan — A+B storage + edge cases + ideas + +## Shared SceneStore contract (every agent reuses this) + +```ts +// packages/mcp/src/storage/types.ts (Agent 1 owns) + +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' + +export type SceneId = string // slug-safe (a-z0-9-), ≤ 64 chars + +export interface SceneMeta { + id: SceneId + name: string + projectId: string | null + thumbnailUrl: string | null + version: number // monotonic, incremented on every save + createdAt: string // ISO 8601 + updatedAt: string + ownerId: string | null + sizeBytes: number + nodeCount: number +} + +export interface SceneWithGraph extends SceneMeta { + graph: SceneGraph +} + +export interface SceneStore { + readonly backend: 'filesystem' | 'supabase' + save(opts: { + id?: SceneId + name: string + projectId?: string | null + ownerId?: string | null + graph: SceneGraph + thumbnailUrl?: string | null + expectedVersion?: number // 409 on mismatch + }): Promise + load(id: SceneId): Promise + list(opts?: { projectId?: string; ownerId?: string; limit?: number }): Promise + delete(id: SceneId, opts?: { expectedVersion?: number }): Promise + rename(id: SceneId, newName: string, opts?: { expectedVersion?: number }): Promise +} + +export class SceneNotFoundError extends Error { code = 'not_found' as const } +export class SceneVersionConflictError extends Error { code = 'version_conflict' as const } +export class SceneInvalidError extends Error { code = 'invalid' as const } +export class SceneTooLargeError extends Error { code = 'too_large' as const } + +export function createSceneStore(env?: NodeJS.ProcessEnv): SceneStore { /* factory */ } +``` + +## Agent scope map + +| Agent | Scope | File ownership | +|---|---|---| +| A1 | Storage interface + types + factory | `packages/mcp/src/storage/types.ts`, `packages/mcp/src/storage/index.ts`, `packages/mcp/src/storage/store.test.ts` | +| A2 | Filesystem impl | `packages/mcp/src/storage/filesystem-scene-store.ts` + tests | +| A3 | Supabase impl + migration SQL | `packages/mcp/src/storage/supabase-scene-store.ts`, `packages/mcp/sql/migrations/0001_scenes.sql` + tests | +| A4 | MCP scene-lifecycle tools | `packages/mcp/src/tools/scene-lifecycle/*.ts` + index wiring | +| A5 | Next.js API routes | `apps/editor/app/api/scenes/route.ts`, `apps/editor/app/api/scenes/[id]/route.ts`, `apps/editor/lib/scene-store-server.ts` | +| A6 | Editor routes + kill dev hook | `apps/editor/app/scene/[id]/page.tsx`, `apps/editor/app/scenes/page.tsx`, edit `apps/editor/app/page.tsx` | +| A7 | URL hardening in core schemas | `packages/core/src/schema/nodes/{scan,guide,item}.ts`, `packages/core/src/schema/material.ts` + migration | +| A8 | Auto-frame camera + scene templates | `packages/editor/src/hooks/use-auto-frame.ts`, `packages/mcp/src/templates/*`, `packages/mcp/src/tools/scene-lifecycle/list-templates.ts` | +| A9 | Multi-variant generation | `packages/mcp/src/tools/variants/*` + tests | +| A10 | Photo → scene + example | `packages/mcp/src/tools/photo-to-scene/*` (orchestrator), update `README.md`, new `examples/photo-to-scene.md` | + +## Global coordination rules +- Agent A1 drops first (interface only). A2, A3, A4, A5 read from `packages/mcp/src/storage/types.ts`; if it doesn't exist when they start, they should **inline a copy of the types above** and the integrator fixes up the import later. +- All MCP tools use `StreamableHTTPClientTransport`-compatible input/output Zod schemas. +- Every tool uses the shared `SceneStore` via `createSceneStore()` — never instantiates concrete stores. +- Tests are `bun:test`, colocated. +- Biome 2-space, single quote, no semicolons, trailing commas all. +- Do NOT run `bun install` — already done. +- Do NOT modify files outside your ownership. + +## Acceptance +- `bun test --cwd packages/mcp` green. +- `bunx biome check packages/mcp apps/editor/app` green. +- `bun run --cwd packages/mcp build` green. +- `MCP save_scene → list_scenes → editor opens /scene/` works without `window.__pascalScene`. diff --git a/packages/mcp/test-reports/t1-stdio/run.ts b/packages/mcp/test-reports/t1-stdio/run.ts index 29574439..353c2b19 100644 --- a/packages/mcp/test-reports/t1-stdio/run.ts +++ b/packages/mcp/test-reports/t1-stdio/run.ts @@ -168,18 +168,21 @@ async function main(): Promise { } // ---- 1. get_scene ------------------------------------------------------ - const sceneResult = await run('get_scene', {}, { - describe: (r) => { - const s = r.structuredContent as any - const nodeCount = s?.nodes ? Object.keys(s.nodes).length : 0 - const rootCount = s?.rootNodeIds?.length ?? 0 - return `${nodeCount} nodes, ${rootCount} roots` + const sceneResult = await run( + 'get_scene', + {}, + { + describe: (r) => { + const s = r.structuredContent as any + const nodeCount = s?.nodes ? Object.keys(s.nodes).length : 0 + const rootCount = s?.rootNodeIds?.length ?? 0 + return `${nodeCount} nodes, ${rootCount} roots` + }, }, - }) + ) // Discover key node ids from the scene snapshot. - const sceneNodes: Record = - (sceneResult?.structuredContent as any)?.nodes ?? {} + const sceneNodes: Record = (sceneResult?.structuredContent as any)?.nodes ?? {} const sceneRoots: string[] = (sceneResult?.structuredContent as any)?.rootNodeIds ?? [] const findFirst = (type: string): any | null => { @@ -198,30 +201,41 @@ async function main(): Promise { ) // ---- 2. get_node ------------------------------------------------------- - await run('get_node', { id: siteNode?.id ?? sceneRoots[0] ?? '' }, { - describe: (r) => { - const n = (r.structuredContent as any)?.node - return `node type=${n?.type}, id=${n?.id}` + await run( + 'get_node', + { id: siteNode?.id ?? sceneRoots[0] ?? '' }, + { + describe: (r) => { + const n = (r.structuredContent as any)?.node + return `node type=${n?.type}, id=${n?.id}` + }, }, - }) + ) // ---- 3. describe_node -------------------------------------------------- - await run('describe_node', { id: siteNode?.id ?? sceneRoots[0] ?? '' }, { - describe: (r) => { - const s = r.structuredContent as any - return `type=${s?.type}, ${s?.childrenIds?.length ?? 0} children` + await run( + 'describe_node', + { id: siteNode?.id ?? sceneRoots[0] ?? '' }, + { + describe: (r) => { + const s = r.structuredContent as any + return `type=${s?.type}, ${s?.childrenIds?.length ?? 0} children` + }, }, - }) + ) // ---- 4. find_nodes ----------------------------------------------------- - const findLevels = await run('find_nodes', { type: 'level' }, { - describe: (r) => `${(r.structuredContent as any)?.nodes?.length ?? 0} level node(s)`, - }) + const findLevels = await run( + 'find_nodes', + { type: 'level' }, + { + describe: (r) => `${(r.structuredContent as any)?.nodes?.length ?? 0} level node(s)`, + }, + ) // Refresh levelNode from find_nodes output (most current). const foundLevels = (findLevels?.structuredContent as any)?.nodes ?? [] - const groundLevelId: string | undefined = - foundLevels[0]?.id ?? levelNode?.id ?? undefined + const groundLevelId: string | undefined = foundLevels[0]?.id ?? levelNode?.id ?? undefined console.log(`[t1] groundLevelId=${groundLevelId}`) // ---- 5. measure -------------------------------------------------------- @@ -462,38 +476,58 @@ async function main(): Promise { } // ---- 14. undo ---------------------------------------------------------- - await run('undo', {}, { - describe: (r) => `undone=${(r.structuredContent as any)?.undone}`, - }) + await run( + 'undo', + {}, + { + describe: (r) => `undone=${(r.structuredContent as any)?.undone}`, + }, + ) // ---- 15. redo ---------------------------------------------------------- - await run('redo', {}, { - describe: (r) => `redone=${(r.structuredContent as any)?.redone}`, - }) + await run( + 'redo', + {}, + { + describe: (r) => `redone=${(r.structuredContent as any)?.redone}`, + }, + ) // ---- 16. export_json --------------------------------------------------- - await run('export_json', { pretty: true }, { - describe: (r) => { - const s = r.structuredContent as any - return `${s?.json?.length ?? 0} chars JSON` + await run( + 'export_json', + { pretty: true }, + { + describe: (r) => { + const s = r.structuredContent as any + return `${s?.json?.length ?? 0} chars JSON` + }, }, - }) + ) // ---- 17. export_glb ---------------------------------------------------- await run('export_glb', {}) // ---- 18. validate_scene ------------------------------------------------ - await run('validate_scene', {}, { - describe: (r) => { - const s = r.structuredContent as any - return `valid=${s?.valid}, errors=${s?.errors?.length ?? 0}` + await run( + 'validate_scene', + {}, + { + describe: (r) => { + const s = r.structuredContent as any + return `valid=${s?.valid}, errors=${s?.errors?.length ?? 0}` + }, }, - }) + ) // ---- 19. check_collisions ---------------------------------------------- - await run('check_collisions', {}, { - describe: (r) => `${(r.structuredContent as any)?.collisions?.length ?? 0} collision(s)`, - }) + await run( + 'check_collisions', + {}, + { + describe: (r) => `${(r.structuredContent as any)?.collisions?.length ?? 0} collision(s)`, + }, + ) // ---- 20. analyze_floorplan_image — expected sampling_unavailable ------- await run( diff --git a/packages/mcp/test-reports/t2-http/run.ts b/packages/mcp/test-reports/t2-http/run.ts index 451caa98..d675305c 100644 --- a/packages/mcp/test-reports/t2-http/run.ts +++ b/packages/mcp/test-reports/t2-http/run.ts @@ -20,13 +20,14 @@ * best-effort report. */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' const TARGET_URL = 'http://localhost:3917/mcp' -const OUT_DIR = '/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t2-http' +const OUT_DIR = + '/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t2-http' type ToolResult = { name: string @@ -106,9 +107,9 @@ function getStructured( const r = result.result as { structuredContent?: unknown; content?: unknown } if (r.structuredContent !== undefined) return r.structuredContent as T if (Array.isArray(r.content)) { - const textBlock = r.content.find( - (c) => (c as { type?: string }).type === 'text', - ) as { text?: string } | undefined + const textBlock = r.content.find((c) => (c as { type?: string }).type === 'text') as + | { text?: string } + | undefined if (textBlock?.text) { try { return JSON.parse(textBlock.text) as T @@ -430,26 +431,24 @@ async function main() { // ---- cut_opening ------------------------------------------------------ let openingId: string | null = null - { - if (!wallId) { - record('cut_opening', false, 'skipped — no wall id to cut') - } else { - const r = await callTool(clientA, 'cut_opening', { - wallId, - type: 'door', - position: 0.5, - width: 0.9, - height: 2, - }) - const struct = getStructured<{ openingId: string }>(r) - openingId = struct?.openingId ?? null - record( - 'cut_opening', - r.ok && typeof struct?.openingId === 'string', - r.ok ? `cut opening ${struct?.openingId}` : `error: ${String(r.error)}`, - r.latencyMs, - ) - } + if (!wallId) { + record('cut_opening', false, 'skipped — no wall id to cut') + } else { + const r = await callTool(clientA, 'cut_opening', { + wallId, + type: 'door', + position: 0.5, + width: 0.9, + height: 2, + }) + const struct = getStructured<{ openingId: string }>(r) + openingId = struct?.openingId ?? null + record( + 'cut_opening', + r.ok && typeof struct?.openingId === 'string', + r.ok ? `cut opening ${struct?.openingId}` : `error: ${String(r.error)}`, + r.latencyMs, + ) } // ---- set_zone --------------------------------------------------------- @@ -475,59 +474,45 @@ async function main() { r.latencyMs, ) } - - // ---- duplicate_level -------------------------------------------------- - { - if (!extraLevelId) { - record('duplicate_level', false, 'skipped — no extra level to duplicate') - } else { - const r = await callTool(clientA, 'duplicate_level', { levelId: extraLevelId }) - const struct = getStructured<{ newLevelId: string; newNodeIds: string[] }>(r) - record( - 'duplicate_level', - r.ok && typeof struct?.newLevelId === 'string', - r.ok - ? `duplicated → new level ${struct?.newLevelId} (${struct?.newNodeIds?.length ?? 0} nodes)` - : `error: ${String(r.error)}`, - r.latencyMs, - ) - } + if (!extraLevelId) { + record('duplicate_level', false, 'skipped — no extra level to duplicate') + } else { + const r = await callTool(clientA, 'duplicate_level', { levelId: extraLevelId }) + const struct = getStructured<{ newLevelId: string; newNodeIds: string[] }>(r) + record( + 'duplicate_level', + r.ok && typeof struct?.newLevelId === 'string', + r.ok + ? `duplicated → new level ${struct?.newLevelId} (${struct?.newNodeIds?.length ?? 0} nodes)` + : `error: ${String(r.error)}`, + r.latencyMs, + ) } - - // ---- apply_patch ------------------------------------------------------ - { - if (!zoneId) { - record('apply_patch', false, 'skipped — no zone to patch') - } else { - const r = await callTool(clientA, 'apply_patch', { - patches: [{ op: 'update', id: zoneId, data: { name: 'T2-zone-renamed' } }], - }) - const struct = getStructured<{ appliedOps: number }>(r) - record( - 'apply_patch', - r.ok && struct?.appliedOps === 1, - r.ok ? `appliedOps=${struct?.appliedOps}` : `error: ${String(r.error)}`, - r.latencyMs, - ) - } + if (!zoneId) { + record('apply_patch', false, 'skipped — no zone to patch') + } else { + const r = await callTool(clientA, 'apply_patch', { + patches: [{ op: 'update', id: zoneId, data: { name: 'T2-zone-renamed' } }], + }) + const struct = getStructured<{ appliedOps: number }>(r) + record( + 'apply_patch', + r.ok && struct?.appliedOps === 1, + r.ok ? `appliedOps=${struct?.appliedOps}` : `error: ${String(r.error)}`, + r.latencyMs, + ) } - - // ---- delete_node ------------------------------------------------------ - { - if (!openingId) { - record('delete_node', false, 'skipped — no opening to delete') - } else { - const r = await callTool(clientA, 'delete_node', { id: openingId, cascade: true }) - const struct = getStructured<{ deletedIds: string[] }>(r) - record( - 'delete_node', - r.ok && Array.isArray(struct?.deletedIds) && (struct?.deletedIds?.length ?? 0) >= 1, - r.ok - ? `deleted ${struct?.deletedIds?.length ?? 0} nodes` - : `error: ${String(r.error)}`, - r.latencyMs, - ) - } + if (!openingId) { + record('delete_node', false, 'skipped — no opening to delete') + } else { + const r = await callTool(clientA, 'delete_node', { id: openingId, cascade: true }) + const struct = getStructured<{ deletedIds: string[] }>(r) + record( + 'delete_node', + r.ok && Array.isArray(struct?.deletedIds) && (struct?.deletedIds?.length ?? 0) >= 1, + r.ok ? `deleted ${struct?.deletedIds?.length ?? 0} nodes` : `error: ${String(r.error)}`, + r.latencyMs, + ) } // ---- undo ------------------------------------------------------------- @@ -695,7 +680,9 @@ async function main() { distinctSessions = sidA !== sidB const toolsListB = await clientB.listTools() toolCountB = toolsListB.tools.length - console.log(`Session B listTools() → ${toolCountB} tools; sessions distinct: ${distinctSessions}`) + console.log( + `Session B listTools() → ${toolCountB} tools; sessions distinct: ${distinctSessions}`, + ) const sceneB = await callTool(clientB, 'get_scene', {}) const sceneBstruct = getStructured<{ nodes: Record }>(sceneB) diff --git a/packages/mcp/test-reports/t3-scenario/apartment.json b/packages/mcp/test-reports/t3-scenario/apartment.json index 0e5ab239..d293fb21 100644 --- a/packages/mcp/test-reports/t3-scenario/apartment.json +++ b/packages/mcp/test-reports/t3-scenario/apartment.json @@ -9,24 +9,7 @@ "metadata": {}, "polygon": { "type": "polygon", - "points": [ - [ - -15, - -15 - ], - [ - 15, - -15 - ], - [ - 15, - 15 - ], - [ - -15, - 15 - ] - ] + "points": [[-15, -15], [15, -15], [15, 15], [-15, 15]] }, "children": [ { @@ -36,19 +19,9 @@ "parentId": null, "visible": true, "metadata": {}, - "children": [ - "level_wyuoxj87czq3v0re" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] + "children": ["level_wyuoxj87czq3v0re"], + "position": [0, 0, 0], + "rotation": [0, 0, 0] } ] }, @@ -59,19 +32,9 @@ "parentId": null, "visible": true, "metadata": {}, - "children": [ - "level_wyuoxj87czq3v0re" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] + "children": ["level_wyuoxj87czq3v0re"], + "position": [0, 0, 0], + "rotation": [0, 0, 0] }, "level_wyuoxj87czq3v0re": { "object": "node", @@ -106,20 +69,11 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "children": [ - "window_47x40mtv2l4ca9p4", - "window_n09awmg5m3ct4fvn" - ], + "children": ["window_47x40mtv2l4ca9p4", "window_n09awmg5m3ct4fvn"], "thickness": 0.2, "height": 2.7, - "start": [ - 0, - 0 - ], - "end": [ - 10, - 0 - ], + "start": [0, 0], + "end": [10, 0], "frontSide": "unknown", "backSide": "unknown" }, @@ -133,14 +87,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 10, - 0 - ], - "end": [ - 10, - 8 - ], + "start": [10, 0], + "end": [10, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -151,19 +99,11 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "children": [ - "window_xlta3f3f0cnmbti3" - ], + "children": ["window_xlta3f3f0cnmbti3"], "thickness": 0.2, "height": 2.7, - "start": [ - 10, - 8 - ], - "end": [ - 0, - 8 - ], + "start": [10, 8], + "end": [0, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -177,14 +117,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 0, - 8 - ], - "end": [ - 0, - 0 - ], + "start": [0, 8], + "end": [0, 0], "frontSide": "unknown", "backSide": "unknown" }, @@ -198,14 +132,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 0, - 5 - ], - "end": [ - 3, - 5 - ], + "start": [0, 5], + "end": [3, 5], "frontSide": "unknown", "backSide": "unknown" }, @@ -216,19 +144,11 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "children": [ - "door_cjzja4lt8owg88wg" - ], + "children": ["door_cjzja4lt8owg88wg"], "thickness": 0.2, "height": 2.7, - "start": [ - 3, - 5 - ], - "end": [ - 3, - 8 - ], + "start": [3, 5], + "end": [3, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -242,14 +162,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 7, - 5 - ], - "end": [ - 10, - 5 - ], + "start": [7, 5], + "end": [10, 5], "frontSide": "unknown", "backSide": "unknown" }, @@ -260,19 +174,11 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "children": [ - "door_bs7bf0azevq9vd76" - ], + "children": ["door_bs7bf0azevq9vd76"], "thickness": 0.2, "height": 2.7, - "start": [ - 7, - 5 - ], - "end": [ - 7, - 8 - ], + "start": [7, 5], + "end": [7, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -283,19 +189,11 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "children": [ - "door_o8etwqsemfgj5mkj" - ], + "children": ["door_o8etwqsemfgj5mkj"], "thickness": 0.2, "height": 2.7, - "start": [ - 4, - 6 - ], - "end": [ - 6, - 6 - ], + "start": [4, 6], + "end": [6, 6], "frontSide": "unknown", "backSide": "unknown" }, @@ -309,14 +207,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 4, - 6 - ], - "end": [ - 4, - 8 - ], + "start": [4, 6], + "end": [4, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -330,14 +222,8 @@ "children": [], "thickness": 0.2, "height": 2.7, - "start": [ - 6, - 6 - ], - "end": [ - 6, - 8 - ], + "start": [6, 6], + "end": [6, 8], "frontSide": "unknown", "backSide": "unknown" }, @@ -349,24 +235,7 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "polygon": [ - [ - 0, - 5 - ], - [ - 3, - 5 - ], - [ - 3, - 8 - ], - [ - 0, - 8 - ] - ], + "polygon": [[0, 5], [3, 5], [3, 8], [0, 8]], "color": "#3b82f6" }, "zone_u95l1bt35jci3gvu": { @@ -377,24 +246,7 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "polygon": [ - [ - 7, - 5 - ], - [ - 10, - 5 - ], - [ - 10, - 8 - ], - [ - 7, - 8 - ] - ], + "polygon": [[7, 5], [10, 5], [10, 8], [7, 8]], "color": "#3b82f6" }, "zone_r9ma8tvsqt9w1zey": { @@ -405,24 +257,7 @@ "parentId": "level_wyuoxj87czq3v0re", "visible": true, "metadata": {}, - "polygon": [ - [ - 4, - 6 - ], - [ - 6, - 6 - ], - [ - 6, - 8 - ], - [ - 4, - 8 - ] - ], + "polygon": [[4, 6], [6, 6], [6, 8], [4, 8]], "color": "#3b82f6" }, "zone_l189q61kf9ra2m8t": { @@ -434,54 +269,18 @@ "visible": true, "metadata": {}, "polygon": [ - [ - 0, - 0 - ], - [ - 10, - 0 - ], - [ - 10, - 5 - ], - [ - 7, - 5 - ], - [ - 7, - 8 - ], - [ - 6, - 8 - ], - [ - 6, - 6 - ], - [ - 4, - 6 - ], - [ - 4, - 8 - ], - [ - 3, - 8 - ], - [ - 3, - 5 - ], - [ - 0, - 5 - ] + [0, 0], + [10, 0], + [10, 5], + [7, 5], + [7, 8], + [6, 8], + [6, 6], + [4, 6], + [4, 8], + [3, 8], + [3, 5], + [0, 5] ], "color": "#3b82f6" }, @@ -492,16 +291,8 @@ "parentId": "wall_qv53jm9kvl7k6slf", "visible": true, "metadata": {}, - "position": [ - 0.5, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_qv53jm9kvl7k6slf", "width": 0.9, "height": 2.1, @@ -515,9 +306,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -525,9 +314,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -536,10 +323,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -551,16 +335,8 @@ "parentId": "wall_hrfixeusz7zb7x63", "visible": true, "metadata": {}, - "position": [ - 0.5, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_hrfixeusz7zb7x63", "width": 0.9, "height": 2.1, @@ -574,9 +350,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -584,9 +358,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -595,10 +367,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -610,16 +379,8 @@ "parentId": "wall_1ullk9bm6dw15i9t", "visible": true, "metadata": {}, - "position": [ - 0.5, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 1.05, 0], + "rotation": [0, 0, 0], "wallId": "wall_1ullk9bm6dw15i9t", "width": 0.9, "height": 2.1, @@ -633,9 +394,7 @@ { "type": "panel", "heightRatio": 0.4, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -643,9 +402,7 @@ { "type": "panel", "heightRatio": 0.6, - "columnRatios": [ - 1 - ], + "columnRatios": [1], "dividerThickness": 0.03, "panelDepth": 0.01, "panelInset": 0.04 @@ -654,10 +411,7 @@ "handle": true, "handleHeight": 1.05, "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], + "contentPadding": [0.04, 0.04], "doorCloser": false, "panicBar": false, "panicBarHeight": 1 @@ -669,27 +423,15 @@ "parentId": "wall_y87bsrljd2245n51", "visible": true, "metadata": {}, - "position": [ - 0.3, - 0.6, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.3, 0.6, 0], + "rotation": [0, 0, 0], "wallId": "wall_y87bsrljd2245n51", "width": 1.2, "height": 1.2, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -703,27 +445,15 @@ "parentId": "wall_y87bsrljd2245n51", "visible": true, "metadata": {}, - "position": [ - 0.7, - 0.6, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.7, 0.6, 0], + "rotation": [0, 0, 0], "wallId": "wall_y87bsrljd2245n51", "width": 1.2, "height": 1.2, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -737,27 +467,15 @@ "parentId": "wall_aegff27krjwgmkmi", "visible": true, "metadata": {}, - "position": [ - 0.5, - 0.6, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], + "position": [0.5, 0.6, 0], + "rotation": [0, 0, 0], "wallId": "wall_aegff27krjwgmkmi", "width": 1.2, "height": 1.2, "frameThickness": 0.05, "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], + "columnRatios": [1], + "rowRatios": [1], "columnDividerThickness": 0.03, "rowDividerThickness": 0.03, "sill": true, @@ -765,8 +483,6 @@ "sillThickness": 0.03 } }, - "rootNodeIds": [ - "site_xs1r72ib2ymzpjus" - ], + "rootNodeIds": ["site_xs1r72ib2ymzpjus"], "collections": {} -} \ No newline at end of file +} diff --git a/packages/mcp/test-reports/t3-scenario/run-summary.json b/packages/mcp/test-reports/t3-scenario/run-summary.json index a2b58faf..44680100 100644 --- a/packages/mcp/test-reports/t3-scenario/run-summary.json +++ b/packages/mcp/test-reports/t3-scenario/run-summary.json @@ -10,10 +10,7 @@ "ok": true, "durationMs": 4, "summary": "building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)", - "nodeIds": [ - "building_bfqg91ai9ijps9ej", - "level_wyuoxj87czq3v0re" - ] + "nodeIds": ["building_bfqg91ai9ijps9ej", "level_wyuoxj87czq3v0re"] }, { "n": 2, @@ -85,10 +82,7 @@ "ok": true, "durationMs": 3, "summary": "furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m", - "nodeIds": [ - "zone_3fyksm10tb0dhn1e", - "zone_u95l1bt35jci3gvu" - ] + "nodeIds": ["zone_3fyksm10tb0dhn1e", "zone_u95l1bt35jci3gvu"] }, { "n": 8, @@ -117,9 +111,7 @@ "ok": true, "durationMs": 2, "summary": "newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0", - "nodeIds": [ - "level_cxvltlqvgqcasiep" - ] + "nodeIds": ["level_cxvltlqvgqcasiep"] }, { "n": 12, @@ -170,4 +162,4 @@ "delta": 3 } } -} \ No newline at end of file +} diff --git a/packages/mcp/test-reports/t3-scenario/run.ts b/packages/mcp/test-reports/t3-scenario/run.ts index 9a9c0f80..d2726205 100644 --- a/packages/mcp/test-reports/t3-scenario/run.ts +++ b/packages/mcp/test-reports/t3-scenario/run.ts @@ -14,12 +14,12 @@ * bun packages/mcp/test-reports/t3-scenario/run.ts */ -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { mkdirSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' const HERE = dirname(fileURLToPath(import.meta.url)) const SERVER_URL = 'http://localhost:3917/mcp' @@ -293,15 +293,8 @@ async function main(): Promise { } }) - const [ - bed1SouthId, - bed1EastId, - bed2SouthId, - bed2WestId, - bathSouthId, - bathWestId, - bathEastId, - ] = interior ?? [] + const [bed1SouthId, bed1EastId, bed2SouthId, bed2WestId, bathSouthId, bathWestId, bathEastId] = + interior ?? [] // ----- Step 4: Set zones ----- const zones = await timed(4, 'set zones', async () => { diff --git a/packages/mcp/test-reports/t5-resources-prompts/run.ts b/packages/mcp/test-reports/t5-resources-prompts/run.ts index 42ab6c2a..9d05f868 100644 --- a/packages/mcp/test-reports/t5-resources-prompts/run.ts +++ b/packages/mcp/test-reports/t5-resources-prompts/run.ts @@ -1,18 +1,6 @@ -/** - * T5 — MCP resources + prompts test harness. - * - * Connects to the shared MCP HTTP server at http://localhost:3917 (path /mcp), - * exercises the 4 documented resources and 3 prompts, and prints a structured - * pass/fail summary that the REPORT.md can be authored from. - * - * Usage: - * bun packages/mcp/test-reports/t5-resources-prompts/run.ts - */ -import { existsSync } from 'node:fs' import { dirname, resolve as pathResolve } from 'node:path' import { fileURLToPath } from 'node:url' import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' const HTTP_URL = new URL('http://localhost:3917/mcp') @@ -104,9 +92,7 @@ async function main(): Promise { .join(', ')}`, ) } catch (err) { - console.error( - `[t5] listResources error: ${err instanceof Error ? err.message : String(err)}`, - ) + console.error(`[t5] listResources error: ${err instanceof Error ? err.message : String(err)}`) } // ---------------- Resource 1: pascal://scene/current ---------------- @@ -116,9 +102,7 @@ async function main(): Promise { if (!c) { resourceOutcomes.push(fail('scene/current', 'no contents returned')) } else if (c.mimeType !== 'application/json') { - resourceOutcomes.push( - fail('scene/current', `wrong mime type: ${String(c.mimeType)}`), - ) + resourceOutcomes.push(fail('scene/current', `wrong mime type: ${String(c.mimeType)}`)) } else { const text = typeof c.text === 'string' ? c.text : '' let parsed: unknown @@ -144,10 +128,7 @@ async function main(): Promise { const nodeCount = Object.keys(obj.nodes as Record).length const rootCount = (obj.rootNodeIds as unknown[]).length resourceOutcomes.push( - ok( - 'scene/current', - `application/json, nodes=${nodeCount}, rootNodeIds=${rootCount}`, - ), + ok('scene/current', `application/json, nodes=${nodeCount}, rootNodeIds=${rootCount}`), ) } } @@ -172,13 +153,9 @@ async function main(): Promise { const hasHeading = /^# /m.test(text) const hasZoneOrLevel = /level/i.test(text) || /zone/i.test(text) if (!hasHeading) { - resourceOutcomes.push( - fail('scene/current/summary', 'no markdown # heading found'), - ) + resourceOutcomes.push(fail('scene/current/summary', 'no markdown # heading found')) } else if (!hasZoneOrLevel) { - resourceOutcomes.push( - fail('scene/current/summary', 'no level/zone references'), - ) + resourceOutcomes.push(fail('scene/current/summary', 'no level/zone references')) } else { // Extract a few first lines as preview const preview = text.split('\n').slice(0, 4).join(' | ') @@ -192,10 +169,7 @@ async function main(): Promise { } } catch (err) { resourceOutcomes.push( - fail( - 'scene/current/summary', - `threw: ${err instanceof Error ? err.message : String(err)}`, - ), + fail('scene/current/summary', `threw: ${err instanceof Error ? err.message : String(err)}`), ) } @@ -206,15 +180,16 @@ async function main(): Promise { if (!c) { resourceOutcomes.push(fail('catalog/items', 'no contents returned')) } else if (c.mimeType !== 'application/json') { - resourceOutcomes.push( - fail('catalog/items', `wrong mime type: ${String(c.mimeType)}`), - ) + resourceOutcomes.push(fail('catalog/items', `wrong mime type: ${String(c.mimeType)}`)) } else { const text = typeof c.text === 'string' ? c.text : '' const parsed = JSON.parse(text) as { status?: unknown; items?: unknown } if (parsed.status !== 'catalog_unavailable') { resourceOutcomes.push( - fail('catalog/items', `expected status='catalog_unavailable' got ${String(parsed.status)}`), + fail( + 'catalog/items', + `expected status='catalog_unavailable' got ${String(parsed.status)}`, + ), ) } else { resourceOutcomes.push( @@ -241,8 +216,7 @@ async function main(): Promise { name: 'find_nodes', arguments: { type: 'level' }, }) - const sc = (findResult as { structuredContent?: { nodes?: unknown[] } }) - .structuredContent + const sc = (findResult as { structuredContent?: { nodes?: unknown[] } }).structuredContent const nodes = Array.isArray(sc?.nodes) ? sc.nodes : [] if (nodes.length > 0) { const first = nodes[0] as { id?: string } @@ -252,9 +226,7 @@ async function main(): Promise { } console.log(`[t5] discovered levelId = ${String(discoveredLevelId)}`) } catch (err) { - console.error( - `[t5] find_nodes threw: ${err instanceof Error ? err.message : String(err)}`, - ) + console.error(`[t5] find_nodes threw: ${err instanceof Error ? err.message : String(err)}`) } if (!discoveredLevelId) { @@ -281,19 +253,12 @@ async function main(): Promise { } if (parsed.error) { resourceOutcomes.push( - fail( - 'constraints/{levelId}', - `error in payload: ${safeStringify(parsed.error)}`, - ), + fail('constraints/{levelId}', `error in payload: ${safeStringify(parsed.error)}`), ) } else if (!Array.isArray(parsed.slabs)) { - resourceOutcomes.push( - fail('constraints/{levelId}', 'missing slabs array'), - ) + resourceOutcomes.push(fail('constraints/{levelId}', 'missing slabs array')) } else if (!Array.isArray(parsed.wallPolygons)) { - resourceOutcomes.push( - fail('constraints/{levelId}', 'missing wallPolygons array'), - ) + resourceOutcomes.push(fail('constraints/{levelId}', 'missing wallPolygons array')) } else { resourceOutcomes.push( ok( @@ -318,15 +283,9 @@ async function main(): Promise { const list = await client.listPrompts() listPromptsCount = Array.isArray(list.prompts) ? list.prompts.length : 0 console.log(`[t5] listPrompts count = ${listPromptsCount}`) - console.log( - `[t5] listPrompts names = ${(list.prompts ?? []) - .map((p) => p.name) - .join(', ')}`, - ) + console.log(`[t5] listPrompts names = ${(list.prompts ?? []).map((p) => p.name).join(', ')}`) } catch (err) { - console.error( - `[t5] listPrompts error: ${err instanceof Error ? err.message : String(err)}`, - ) + console.error(`[t5] listPrompts error: ${err instanceof Error ? err.message : String(err)}`) } // ---------------- Prompt 1: from_brief ---------------- @@ -386,10 +345,7 @@ async function main(): Promise { } } catch (err) { promptOutcomes.push( - fail( - 'iterate_on_feedback', - `threw: ${err instanceof Error ? err.message : String(err)}`, - ), + fail('iterate_on_feedback', `threw: ${err instanceof Error ? err.message : String(err)}`), ) } @@ -406,9 +362,7 @@ async function main(): Promise { const messages = result.messages ?? [] const userMsgs = messages.filter((m) => m.role === 'user') if (userMsgs.length === 0) { - promptOutcomes.push( - fail('renovation_from_photos', 'no user messages returned'), - ) + promptOutcomes.push(fail('renovation_from_photos', 'no user messages returned')) } else { // Look across all message content for the URLs we passed. const allText = messages