fix(mcp): use local sqlite scene storage

This commit is contained in:
Aymeric Rabot
2026-04-24 13:32:04 -07:00
parent 06d00566f0
commit b3d1f663f6
119 changed files with 1086 additions and 23694 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
graph: parsed.data.graph as never,
thumbnailUrl:
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
expectedVersion,
expectedVersion: expectedVersion ?? existing.version,
})
return NextResponse.json(meta, {
headers: { ETag: `"${meta.version}"` },
+1 -1
View File
@@ -8,7 +8,7 @@ describe('getSceneStore', () => {
createSceneStore: async (_env?: NodeJS.ProcessEnv) => {
callCount++
return {
backend: 'filesystem' as const,
backend: 'sqlite' as const,
__instanceNumber: callCount,
save: async () => ({}) as never,
load: async () => null,
+4 -62
View File
@@ -1,67 +1,9 @@
// 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.
// v0.1 is scoped to local-first use on a developer machine. A hosted editor
// should pass request-scoped user context through this factory before exposing
// these routes publicly.
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<SceneMeta>
load(id: SceneId): Promise<SceneWithGraph | null>
list(opts?: SceneListOptions): Promise<SceneMeta[]>
delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean>
rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise<SceneMeta>
}
import type { SceneStore } from '@pascal-app/mcp/storage'
/**
* Per-process singleton. The factory is async because backend modules are
+7 -1
View File
@@ -4,7 +4,13 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
transpilePackages: ['three', '@pascal-app/viewer', '@pascal-app/core', '@pascal-app/editor'],
transpilePackages: [
'three',
'@pascal-app/viewer',
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
],
turbopack: {
resolveAlias: {
react: './node_modules/react',
+1 -1
View File
@@ -17,7 +17,7 @@
"next.config.js",
".next/types/**/*.ts"
],
"exclude": ["node_modules"],
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/viewer" }
+1 -25
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "editor",
@@ -153,7 +154,6 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2",
"zod": "^4.3.5",
},
"devDependencies": {
@@ -587,20 +587,6 @@
"@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=="],
@@ -671,8 +657,6 @@
"@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=="],
@@ -1031,8 +1015,6 @@
"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=="],
@@ -1549,8 +1531,6 @@
"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=="],
@@ -1617,8 +1597,6 @@
"@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=="],
@@ -1673,8 +1651,6 @@
"@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=="],
+10
View File
@@ -27,3 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- stdio and Streamable HTTP transports.
- `pascal-mcp` CLI binary with `--stdio`, `--http --port`, and `--scene`
flags.
- Local `SqliteSceneStore` backed by built-in SQLite drivers (`bun:sqlite` in
the MCP CLI, `node:sqlite` in the Next.js editor server), with WAL mode,
transaction-scoped optimistic locking, revision rows, and shared
`PASCAL_DATA_DIR` / `PASCAL_DB_PATH` configuration for MCP and the editor.
### Removed
- Supabase storage adapter, SQL migrations, and the `@supabase/supabase-js`
runtime dependency.
- Committed MCP `test-reports/` development artifacts.
+6 -5
View File
@@ -103,7 +103,9 @@ Added a `./storage` entry to the `"exports"` map of `@pascal-app/mcp`, pointing
### 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.
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 type { SceneStore } from '@pascal-app/mcp/storage'` and dynamically import `createSceneStore` without re-declaring the storage contract.
The concrete backend is now `SqliteSceneStore`, backed by built-in SQLite drivers (`bun:sqlite` for the MCP CLI and `node:sqlite` for the Next.js editor server). It writes to `~/.pascal/data/pascal.db` by default and also supports `PASCAL_DATA_DIR`, `PASCAL_DB_PATH`, and `PASCAL_MAX_SCENE_BYTES`.
### Impact
@@ -111,12 +113,13 @@ Zero on existing consumers. Purely additive. The `.` entry continues to export `
### 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.
Remove the `./storage` entry from `exports` and update `apps/editor` to 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.
- `packages/mcp/src/storage/sqlite-scene-store.ts` is the only production storage backend.
---
@@ -143,8 +146,7 @@ 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
Security review found that 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
@@ -198,4 +200,3 @@ Delete `packages/core/src/schema/asset-url.ts` and revert the five imports in
scene-bridge test update is self-contained.
---
+1 -1
View File
@@ -326,7 +326,7 @@ CLI `pascal-mcp` flags:
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"start": "node dist/bin/pascal-mcp.js",
"start": "bun dist/bin/pascal-mcp.js",
"test": "bun test",
"smoke": "bun run scripts/smoke.ts",
"prepublishOnly": "bun run build && bun test"
+48 -24
View File
@@ -2,9 +2,9 @@
## Summary
Introduces a new workspace package `@pascal-app/mcp` (v0.1.0) that exposes the Pascal scene graph (`@pascal-app/core`) as MCP **tools**, **resources**, and **prompts** so any MCP-compatible AI host — Claude Desktop, Claude Code, Cursor, a custom agent — can read and mutate Pascal projects programmatically with full Zod validation, atomic patches, undo-safe mutations, and multimodal image inputs.
Introduces a new workspace package `@pascal-app/mcp` (v0.1.0) that exposes the Pascal scene graph (`@pascal-app/core`) as MCP **tools**, **resources**, and **prompts** so any MCP-compatible AI host — Claude Desktop, Claude Code, Codex CLI, Cursor, or a custom agent — can read, mutate, save, and reopen Pascal projects programmatically with full Zod validation, atomic patches, undo-safe mutations, multimodal image inputs, and local SQLite persistence.
**83 files changed, ~5,900 LOC across 68 source files + 27 test files; 142/142 tests pass.**
The branch is now local-first: scenes persist to `~/.pascal/data/pascal.db` through SQLite, using `bun:sqlite` in the MCP CLI and `node:sqlite` when the Next.js editor server imports the storage package. The earlier Supabase adapter, SQL migrations, and committed `test-reports/` artifacts have been removed.
## Motivation
@@ -36,6 +36,10 @@ Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/edito
| `check_collisions` | Item placement conflicts per level |
| `analyze_floorplan_image` | (Vision/sampling) Extract structured floor plan |
| `analyze_room_photo` | (Vision/sampling) Extract room dimensions + fixtures |
| `save_scene` / `load_scene` / `list_scenes` / `rename_scene` / `delete_scene` | Persist scenes in local SQLite |
| `list_templates` / `create_from_template` | Seed scenes from bundled templates |
| `generate_variants` | Fork and mutate scene variants |
| `photo_to_scene` | Vision sampling to scene graph, optionally saved |
### Resources
@@ -62,23 +66,23 @@ Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/edito
│ stdio │ HTTP │
│ ▼ │
│ ┌──────── packages/mcp/src/bin/pascal-mcp.ts ────────┐ │
│ │ (loads node-shims FIRST, then creates bridge) │ │
│ │ (Bun CLI, loads node-shims first) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──── createPascalMcpServer({ bridge }) ────┐ │
│ │ registerTools() → 19 tools │ │
│ │ registerVisionTools() → 2 tools │ │
│ │ registerResources() → 4 resources │ │
│ │ registerPrompts() → 3 prompts │ │
│ │ registerTools() │ │
│ │ registerVisionTools() │ │
│ │ registerResources() │ │
│ │ registerPrompts() │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────── SceneBridge ───────────────┐
│ ┌──────────── SceneBridge + SceneStore ───────────────┐ │
│ │ headless Zustand store + Zundo │ │
│ │ RAF polyfill at import time │
│ │ zod validation at every boundary
│ └──────────────────────────────────────────
│ │ local SQLite storage at ~/.pascal/data/pascal.db │
│ │ Zod validation at every boundary
│ └──────────────────────────────────────────────────────┘
│ │ │
│ ▼ │
│ @pascal-app/core (unchanged, new subpath exports) │
@@ -93,7 +97,7 @@ bun install
bun run --cwd packages/core build
bun run --cwd packages/mcp build
# Unit + integration tests (142 tests across 27 files)
# Unit + integration tests (248 tests across 40 files)
bun test --cwd packages/mcp
# End-to-end smoke test (spawns stdio server and exercises 4 tools)
@@ -106,7 +110,7 @@ bunx biome check packages/mcp
bunx turbo build --filter=@pascal-app/mcp
```
### Try it with Claude Desktop
### Try it with Claude Desktop, Claude Code, or Codex
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
@@ -114,14 +118,26 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
{
"mcpServers": {
"pascal": {
"command": "node",
"args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"]
"command": "bun",
"args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
Restart Claude Desktop. Ask it: *"Use the Pascal MCP to create a 3-bedroom apartment at 100m²."*
For Codex CLI:
```bash
codex mcp add pascal-dev \
--env PASCAL_DATA_DIR="$HOME/.pascal/data" \
-- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"
```
Run the editor with the same `PASCAL_DATA_DIR`, then ask the MCP host to create
and `save_scene`; the scene is openable at `/scene/<id>`.
## Known limitations
@@ -131,6 +147,7 @@ Restart Claude Desktop. Ask it: *"Use the Pascal MCP to create a 3-bedroom apart
4. **`loadAssetUrl`/`saveAsset` are browser-only.** Items with `asset://<id>` URLs can't be resolved in Node. Supply absolute URLs or `data:` URIs if you need them usable outside the browser.
5. **`SiteNode.children` inconsistency.** Site's children hold full node objects while every other container holds ID strings (see `CROSS_CUTTING.md` §2). MCP works around this by traversing via the flat `nodes` dict. Upstream alignment proposed as a follow-up.
6. **Catalog unavailable in headless mode.** `pascal://catalog/items` and `place_item`'s catalog resolution fall back to a placeholder asset payload until the core exposes a Node-consumable catalog.
7. **Local-only auth boundary.** The HTTP transport and editor scene API are intended for local development in this PR. Do not expose them on a public network without an auth layer.
## Cross-cutting changes
@@ -138,20 +155,24 @@ Documented in [`packages/mcp/CROSS_CUTTING.md`](./CROSS_CUTTING.md):
1. **`packages/core/package.json` — additive subpath exports.** Adds `./schema`, `./store`, `./material-library`, `./spatial-grid`, `./wall`. Needed because the main entry re-exports browser-only systems; subpath entries let Node consumers skip them. Zero impact on existing consumers (`apps/editor`, `@pascal-app/viewer` still use the main entry).
2. **`.github/workflows/mcp-ci.yml` — new CI.** Runs on PRs touching mcp/core; installs with Bun 1.3.0, builds, tests, biome-checks.
3. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2.
3. **`apps/editor` scene routes.** Adds local scene API routes and pages that read from the same SQLite `SceneStore` as MCP.
4. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2.
## Checklist
-`bunx biome check packages/mcp` — clean (73 files, 0 errors)
-`bunx biome check packages/mcp` — clean
-`bun run --cwd packages/mcp build` — tsc OK
-`bunx turbo build --filter=@pascal-app/mcp` — 2/2 tasks successful
-`bun test --cwd packages/mcp`142/142 tests pass across 27 files (328 expects)
-`bun run --cwd packages/mcp smoke` — spawns stdio server, registers 21 tools, exercises `get_scene` / `create_level` / `validate_scene` / `undo` end-to-end
-Docs: README with host configs + tool/resource/prompt tables, CHANGELOG, 3 examples
-`bun test --cwd packages/mcp`248/248 tests pass across 40 files (965 expects)
-`bun run --cwd packages/mcp smoke` — spawns stdio server, registers 30 tools, exercises `get_scene` / `create_level` / `validate_scene` / `undo` end-to-end
-`bun test apps/editor/lib/scene-store-server.test.ts` — editor store singleton test passes
- ✅ Editor smoke — `/api/scenes/<id>` and `/scene/<id>` return 200 for a scene saved through MCP using the shared SQLite DB
- ✅ Local Codex MCP probe with `gpt-5.5` — saved a template scene through `pascal-dev`, then reloaded it and created a wall
- ✅ Docs: README with Claude Desktop, Claude Code, Codex CLI, Cursor configs + tool/resource/prompt tables, CHANGELOG, 3 examples
- ✅ Conventional commit series (9 commits on `feat/mcp-server`)
- ✅ No modifications to `@pascal-app/viewer` or `apps/editor`
-`packages/core` changes are purely additive (subpath exports only)
-Node 18+ compatible; RAF polyfill loads before any core import
- ✅ No Supabase dependency, SQL migrations, or committed test-report artifacts
-`packages/core` changes are additive subpath exports plus URL-schema hardening
-Bun CLI; RAF polyfill loads before any core import
- ✅ Strict TypeScript (no `any` without reason; no `@ts-expect-error`); Zod at every boundary
- ✅ Every mutation goes through the Zustand store (undo-safe via Zundo)
@@ -167,11 +188,14 @@ feat(mcp): add multimodal vision tools via MCP sampling
feat(mcp): add stdio + streamable HTTP transports, CLI, and smoke test
docs(mcp): add README, examples, and changelog
chore(mcp): add CI workflow and document cross-cutting changes
feat(mcp,editor): add local SQLite scene persistence and editor scene routes
fix(mcp): remove Supabase backend and committed test reports
```
## Follow-up (future PRs)
- Align `SiteNode.children` to IDs-only (with `setScene` migration) — CROSS_CUTTING §2.
- Extract shared operation/service layer so MCP, CLI, and future REST/OpenAPI adapters do not duplicate business validation.
- Expose a Node-consumable item catalog from `@pascal-app/core` so `place_item` can resolve real catalog IDs.
- Surface real spatial-grid collision detection (currently a simple AABB pass in `check_collisions`).
- Post-build `chmod +x dist/bin/pascal-mcp.js` step so fresh installs get an executable bin without a manual chmod.
+92 -11
View File
@@ -3,24 +3,28 @@
Model Context Protocol server for the Pascal 3D editor. Drives the
`@pascal-app/core` scene graph from any MCP-compatible AI host.
The server runs headlessly in Node — no browser, no WebGPU, no React — and
exposes the same scene mutations used by the editor UI (create walls, place
items, cut openings, undo, etc.) as MCP tools, resources, and prompts.
The server runs headlessly in Bun with no browser, WebGPU, React, or external
database service. It exposes the same scene mutations used by the editor UI
(create walls, place items, cut openings, undo, etc.) as MCP tools, resources,
and prompts.
## Install
```bash
bun add @pascal-app/mcp # or: npm i @pascal-app/mcp
bun add @pascal-app/mcp
```
`@pascal-app/core` is a peer dependency; Bun workspaces resolve it automatically.
The MCP CLI is intended to run with Bun. When the storage package is consumed by
the Next.js editor server, it opens the same local database through Node's
built-in SQLite driver.
## Quick start
Launch the server over stdio in one line:
```bash
bunx pascal-mcp # or: npx pascal-mcp
bunx pascal-mcp
```
Load an initial scene from disk:
@@ -35,6 +39,29 @@ Expose it as HTTP for remote hosts:
pascal-mcp --http --port 8787
```
## Local scene storage
Scenes saved through MCP are stored in a local SQLite database:
```text
~/.pascal/data/pascal.db
```
Set `PASCAL_DATA_DIR` when you want the MCP server and the running editor to
share a different directory, or `PASCAL_DB_PATH` when you need an exact database
file path. The store uses WAL mode and transactional version checks so separate
local processes can save and open the same scene database.
During workspace development, run both sides with the same data directory:
```bash
# Terminal 1: run the editor
PASCAL_DATA_DIR="$HOME/.pascal/data" bun run dev
# Terminal 2 or an MCP host: run the server
PASCAL_DATA_DIR="$HOME/.pascal/data" bun packages/mcp/dist/bin/pascal-mcp.js
```
## Claude Desktop config
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
@@ -45,14 +72,17 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json`
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
If `bunx` isn't on your PATH, substitute `npx` or point `command` at the
absolute path of the `pascal-mcp` binary inside your project.
If `bunx` is not on your PATH, point `command` at the absolute path to `bun`
and pass the built `dist/bin/pascal-mcp.js` file as the first arg.
## Claude Code config
@@ -69,12 +99,60 @@ Or add to `.mcp.json` at the repo root:
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
For local workspace testing before publish, build first and point Claude Code at
the built binary:
```json
{
"mcpServers": {
"pascal": {
"command": "bun",
"args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
```
## Codex CLI config
Via the CLI:
```bash
codex mcp add pascal --env PASCAL_DATA_DIR="$HOME/.pascal/data" -- bunx pascal-mcp
```
For local workspace testing before publish:
```bash
bun run --cwd packages/mcp build
codex mcp add pascal-dev \
--env PASCAL_DATA_DIR="$HOME/.pascal/data" \
-- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"
```
This writes an entry like this to `~/.codex/config.toml`:
```toml
[mcp_servers.pascal-dev]
command = "bun"
args = ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"]
[mcp_servers.pascal-dev.env]
PASCAL_DATA_DIR = "/Users/you/.pascal/data"
```
## Cursor config
In Cursor settings (`settings.json`):
@@ -84,7 +162,10 @@ In Cursor settings (`settings.json`):
"mcp.servers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
"args": ["pascal-mcp"],
"env": {
"PASCAL_DATA_DIR": "/Users/you/.pascal/data"
}
}
}
}
@@ -92,7 +173,7 @@ In Cursor settings (`settings.json`):
## Programmatic use
Embed the server in your own Node process using the in-memory transport. The
Embed the server in your own Bun process using the in-memory transport. The
example below runs a full client/server pair inside a single script — useful
for agent frameworks and tests.
+1 -1
View File
@@ -2,7 +2,7 @@
* Programmatic `@pascal-app/mcp` usage.
*
* Runs a full MCP client/server pair over the in-memory transport inside a
* single Node process. Useful for agent frameworks and tests that want to
* single Bun process. Useful for agent frameworks and tests that want to
* drive Pascal without spawning a subprocess.
*
* Compile with the package's `tsc --build`, or run directly with Bun:
+1 -2
View File
@@ -28,7 +28,7 @@
"scripts": {
"build": "tsc --build",
"dev": "tsc --build --watch",
"start": "node dist/bin/pascal-mcp.js",
"start": "bun dist/bin/pascal-mcp.js",
"test": "bun test",
"smoke": "bun run scripts/smoke.ts",
"prepublishOnly": "bun run build && bun test"
@@ -38,7 +38,6 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2",
"zod": "^4.3.5"
},
"devDependencies": {
-71
View File
@@ -1,71 +0,0 @@
# 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 <your-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 <https://supabase.com/dashboard>.
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://<ref>.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.
@@ -1,76 +0,0 @@
-- 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();
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env bun
// Load shims FIRST so any subsequent core import sees the RAF polyfill.
import '../bridge/node-shims'
+2 -2
View File
@@ -47,8 +47,8 @@ function createLazySceneStore(): SceneStore {
return cached
}
return {
get backend(): 'filesystem' | 'supabase' {
return 'filesystem'
get backend(): 'sqlite' {
return 'sqlite'
},
async save(options: SceneSaveOptions): Promise<SceneMeta> {
const real = await resolve()
@@ -1,599 +0,0 @@
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> = {}): 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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-test-'))
}
async function rmrf(p: string): Promise<void> {
await fs.rm(p, { recursive: true, force: true })
}
function createStore(rootDir: string, opts: Partial<FilesystemSceneStoreOptions> = {}) {
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<string, unknown> = {}
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)
})
})
@@ -1,388 +0,0 @@
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<typeof PersistedSceneSchema>
/**
* File-backed implementation of `SceneStore`.
*
* Persists each scene as `<root>/scenes/<id>.json` with an optional sidecar
* index file `<root>/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<SceneMeta> {
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<SceneWithGraph | null> {
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<SceneMeta[]> {
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<boolean> {
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<SceneMeta> {
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<void> {
await fs.mkdir(this.scenesDir, { recursive: true })
}
private async atomicWrite(finalPath: string, contents: string): Promise<void> {
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<PersistedScene | null> {
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<SceneMeta[] | null> {
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<SceneMeta[]> {
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<void> {
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`)
}
}
+7 -19
View File
@@ -1,29 +1,17 @@
import type { SceneStore } from './types'
export * from './slug'
export * from './sqlite-scene-store'
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.
* Factory for Pascal's local-first scene store.
*
* Implementations are loaded via dynamic `import()` so consumers only pay the
* cost of the backend they actually use.
* The store is backed by the runtime's built-in SQLite driver. By default it
* writes to `~/.pascal/data/pascal.db`; set `PASCAL_DB_PATH` for an exact file
* path or `PASCAL_DATA_DIR` for a directory containing `pascal.db`.
*/
export async function createSceneStore(env?: NodeJS.ProcessEnv): Promise<SceneStore> {
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()
const mod = await import('./sqlite-scene-store')
return new mod.SqliteSceneStore({ env })
}
+77
View File
@@ -0,0 +1,77 @@
type SqliteBinding = string | number | bigint | boolean | null | Uint8Array
export interface SqliteRunResult {
changes: number
lastInsertRowid: number | bigint
}
export interface SqliteStatement {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
export interface SqliteDatabase {
exec(sql: string): void
query(sql: string): SqliteStatement
close(): void
}
type BunSqliteModule = {
Database: new (
filename: string,
options?: { create?: boolean; readwrite?: boolean },
) => SqliteDatabase
}
type NodeStatementSync = {
all(...params: SqliteBinding[]): unknown[]
get(...params: SqliteBinding[]): unknown
run(...params: SqliteBinding[]): SqliteRunResult
}
type NodeDatabaseSync = {
exec(sql: string): void
prepare(sql: string): NodeStatementSync
close(): void
}
type NodeSqliteModule = {
DatabaseSync: new (filename: string) => NodeDatabaseSync
}
export async function openSqliteDatabase(filename: string): Promise<SqliteDatabase> {
if ('Bun' in globalThis) {
const mod = (await import('bun:sqlite')) as BunSqliteModule
return new mod.Database(filename, { create: true, readwrite: true })
}
try {
const mod = (await import('node:sqlite')) as NodeSqliteModule
return adaptNodeDatabase(new mod.DatabaseSync(filename))
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(
`SQLite requires Bun or a Node runtime with node:sqlite support. Failed to open ${filename}: ${reason}`,
)
}
}
function adaptNodeDatabase(db: NodeDatabaseSync): SqliteDatabase {
return {
exec(sql: string): void {
db.exec(sql)
},
query(sql: string): SqliteStatement {
const stmt = db.prepare(sql)
return {
all: (...params) => stmt.all(...params),
get: (...params) => stmt.get(...params),
run: (...params) => stmt.run(...params),
}
},
close(): void {
db.close()
},
}
}
@@ -0,0 +1,279 @@
import { Database } from 'bun:sqlite'
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 {
resolveDefaultDatabasePath,
SqliteSceneStore,
type SqliteSceneStoreOptions,
} from './sqlite-scene-store'
import { SceneInvalidError, SceneTooLargeError, SceneVersionConflictError } from './types'
function makeGraph(overrides: Partial<SceneGraph> = {}): 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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-sqlite-test-'))
}
async function rmrf(p: string): Promise<void> {
await fs.rm(p, { recursive: true, force: true })
}
function createStore(rootDir: string, opts: Partial<SqliteSceneStoreOptions> = {}) {
return new SqliteSceneStore({
databasePath: path.join(rootDir, 'pascal.db'),
...opts,
})
}
describe('resolveDefaultDatabasePath', () => {
test('respects PASCAL_DB_PATH when set', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DB_PATH: '/tmp/custom.db' })).toBe('/tmp/custom.db')
})
test('resolves PASCAL_DATA_DIR to pascal.db', () => {
expect(resolveDefaultDatabasePath({ PASCAL_DATA_DIR: '/tmp/pascal-data' })).toBe(
path.join('/tmp/pascal-data', 'pascal.db'),
)
})
test('falls back to XDG_DATA_HOME on Unix', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({ XDG_DATA_HOME: '/xdg/share' })).toBe(
path.join('/xdg/share', 'pascal', 'data', 'pascal.db'),
)
})
test('falls back to homedir + .pascal/data/pascal.db', () => {
if (process.platform === 'win32') return
expect(resolveDefaultDatabasePath({}).endsWith(path.join('.pascal', 'data', 'pascal.db'))).toBe(
true,
)
})
})
describe('SqliteSceneStore', () => {
let rootDir: string
let store: SqliteSceneStore
beforeEach(async () => {
rootDir = await mkTmpRoot()
store = createStore(rootDir)
})
afterEach(async () => {
store.close()
await rmrf(rootDir)
})
test('backend is "sqlite"', () => {
expect(store.backend).toBe('sqlite')
})
test('round-trips a saved scene through a reopened database', async () => {
const graph = makeGraph()
const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph })
expect(saved.id).toBe('kitchen')
expect(saved.version).toBe(1)
expect(saved.nodeCount).toBe(2)
expect(saved.sizeBytes).toBe(Buffer.byteLength(JSON.stringify(graph), 'utf8'))
store.close()
store = createStore(rootDir)
const loaded = await store.load('kitchen')
expect(loaded).not.toBeNull()
expect(loaded!.graph).toEqual(graph)
expect(loaded!.name).toBe('Kitchen')
})
test('stores optional metadata 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('generates ids for new scenes and rejects explicit slug collisions', 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)
await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() })
await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
})
test('sanitizes explicit ids', async () => {
const meta = await store.save({ id: '../My Kitchen!', name: 'Kitchen', graph: makeGraph() })
expect(meta.id).toBe('my-kitchen')
expect(await store.load('my-kitchen')).not.toBeNull()
})
test('increments version and preserves createdAt on overwrite', async () => {
const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() })
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await store.save({
id: 'bump',
name: 'Bump 2',
graph: makeGraph(),
expectedVersion: 1,
})
expect(second.version).toBe(2)
expect(second.createdAt).toBe(first.createdAt)
expect(second.updatedAt >= first.updatedAt).toBe(true)
})
test('enforces optimistic locking for save, rename, and delete', async () => {
await store.save({ id: 'locked', name: 'Locked', graph: makeGraph() })
await expect(
store.save({ id: 'locked', name: 'Locked', graph: makeGraph(), expectedVersion: 99 }),
).rejects.toThrow(SceneVersionConflictError)
await expect(store.rename('locked', 'New', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
await expect(store.delete('locked', { expectedVersion: 99 })).rejects.toThrow(
SceneVersionConflictError,
)
})
test('expectedVersion=0 creates a brand-new explicit id', async () => {
const meta = await store.save({
id: 'fresh',
name: 'Fresh',
graph: makeGraph(),
expectedVersion: 0,
})
expect(meta.version).toBe(1)
})
test('lists newest first and supports project, owner, and limit filters', async () => {
await store.save({ id: 'a', name: 'A', graph: makeGraph(), projectId: 'p1', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'b', name: 'B', graph: makeGraph(), projectId: 'p2', ownerId: 'u1' })
await new Promise((resolve) => setTimeout(resolve, 5))
await store.save({ id: 'c', name: 'C', graph: makeGraph(), projectId: 'p1', ownerId: 'u2' })
expect((await store.list()).map((m) => m.id)).toEqual(['c', 'b', 'a'])
expect((await store.list({ projectId: 'p1' })).map((m) => m.id)).toEqual(['c', 'a'])
expect((await store.list({ ownerId: 'u1' })).map((m) => m.id)).toEqual(['b', 'a'])
expect((await store.list({ limit: 2 })).map((m) => m.id)).toEqual(['c', 'b'])
})
test('rename writes a revision row and delete cascades revisions', async () => {
await store.save({ id: 'rev', name: 'Rev', graph: makeGraph() })
await store.rename('rev', 'Renamed', { expectedVersion: 1 })
const dbPath = path.join(rootDir, 'pascal.db')
const db = new Database(dbPath)
try {
const beforeDelete = db
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(beforeDelete.count).toBe(2)
} finally {
db.close()
}
expect(await store.delete('rev', { expectedVersion: 2 })).toBe(true)
const reopened = new Database(dbPath)
try {
const afterDelete = reopened
.query('SELECT COUNT(*) AS count FROM scene_revisions WHERE scene_id = ?')
.get('rev') as { count: number }
expect(afterDelete.count).toBe(0)
} finally {
reopened.close()
}
})
test('validates name and scene size', async () => {
await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError)
await expect(store.save({ name: 'x'.repeat(201), graph: makeGraph() })).rejects.toThrow(
SceneInvalidError,
)
const tinyStore = createStore(rootDir, {
databasePath: path.join(rootDir, 'tiny.db'),
maxSceneBytes: 100,
})
try {
await expect(tinyStore.save({ id: 'big', name: 'Big', graph: makeGraph() })).rejects.toThrow(
SceneTooLargeError,
)
} finally {
tinyStore.close()
}
})
test('load returns null for missing scenes and errors on corrupt graph rows', async () => {
expect(await store.load('missing')).toBeNull()
const db = new Database(path.join(rootDir, 'pascal.db'), { create: true })
try {
db.exec(`
CREATE TABLE IF NOT EXISTS scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
owner_id TEXT,
thumbnail_url TEXT,
version INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
node_count INTEGER NOT NULL,
graph_json TEXT NOT NULL
);
`)
db.query(
`INSERT INTO scenes (
id, name, version, created_at, updated_at, size_bytes, node_count, graph_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run('bad', 'Bad', 1, '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z', 2, 0, '{}')
} finally {
db.close()
}
await expect(store.load('bad')).rejects.toThrow(SceneInvalidError)
})
})
@@ -0,0 +1,495 @@
import { mkdirSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { z } from 'zod'
import { generateSlug, isValidSlug, sanitizeSlug } from './slug'
import { openSqliteDatabase, type SqliteDatabase } from './sqlite-driver'
import {
SceneInvalidError,
type SceneListOptions,
type SceneMeta,
type SceneMutateOptions,
SceneNotFoundError,
type SceneSaveOptions,
type SceneStore,
SceneTooLargeError,
SceneVersionConflictError,
type SceneWithGraph,
} from './types'
const DEFAULT_MAX_SCENE_BYTES = 10 * 1024 * 1024
const DEFAULT_LIST_LIMIT = 100
const MAX_NAME_LENGTH = 200
const MIN_NAME_LENGTH = 1
export interface SqliteSceneStoreOptions {
/** Exact SQLite database file path. If omitted, resolved from env. */
databasePath?: string
/** Optional env override for default path and size-limit resolution. */
env?: NodeJS.ProcessEnv
/** Maximum UTF-8 byte length of graph JSON. Defaults to 10 MB. */
maxSceneBytes?: number
}
interface SceneRow {
id: string
name: string
project_id: string | null
owner_id: string | null
thumbnail_url: string | null
version: number
created_at: string
updated_at: string
size_bytes: number
node_count: number
graph_json: string
}
const GraphSchema = z.object({
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.record(z.string(), z.unknown()).optional(),
})
/**
* Resolves Pascal's local SQLite database path.
*
* Precedence:
* 1. `PASCAL_DB_PATH`
* 2. `PASCAL_DATA_DIR/pascal.db`
* 3. On Windows: `%APPDATA%/Pascal/data/pascal.db`
* 4. `$XDG_DATA_HOME/pascal/data/pascal.db`
* 5. `$HOME/.pascal/data/pascal.db`
*/
export function resolveDefaultDatabasePath(env: NodeJS.ProcessEnv = process.env): string {
if (env.PASCAL_DB_PATH && env.PASCAL_DB_PATH.length > 0) {
return env.PASCAL_DB_PATH
}
if (env.PASCAL_DATA_DIR && env.PASCAL_DATA_DIR.length > 0) {
return path.join(env.PASCAL_DATA_DIR, 'pascal.db')
}
if (process.platform === 'win32') {
const appData = env.APPDATA
if (appData && appData.length > 0) {
return path.join(appData, 'Pascal', 'data', 'pascal.db')
}
return path.join(os.homedir(), '.pascal', 'data', 'pascal.db')
}
const xdg = env.XDG_DATA_HOME
if (xdg && xdg.length > 0) {
return path.join(xdg, 'pascal', 'data', 'pascal.db')
}
return path.join(os.homedir(), '.pascal', 'data', 'pascal.db')
}
function resolveMaxSceneBytes(
env: NodeJS.ProcessEnv | undefined,
explicit: number | undefined,
): number {
if (explicit !== undefined) {
if (!Number.isInteger(explicit) || explicit <= 0) {
throw new SceneInvalidError('maxSceneBytes must be a positive integer')
}
return explicit
}
const raw = env?.PASCAL_MAX_SCENE_BYTES
if (raw === undefined || raw === '') return DEFAULT_MAX_SCENE_BYTES
const parsed = Number.parseInt(raw, 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new SceneInvalidError('PASCAL_MAX_SCENE_BYTES must be a positive integer')
}
return parsed
}
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 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})`,
)
}
}
function serializeGraph(graph: SceneGraph): string {
return JSON.stringify(graph)
}
function parseGraph(raw: string, context: string): SceneGraph {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch (err) {
throw new SceneInvalidError(
`Failed to parse scene graph for ${context}: ${err instanceof Error ? err.message : String(err)}`,
)
}
const result = GraphSchema.safeParse(parsed)
if (!result.success) {
throw new SceneInvalidError(`Scene graph for ${context} has invalid shape: ${result.error}`)
}
const graph = result.data
for (const [nodeId, node] of Object.entries(graph.nodes)) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
throw new SceneInvalidError(`Scene graph for ${context} has non-object node at "${nodeId}"`)
}
const typeField = (node as { type?: unknown }).type
if (typeof typeField !== 'string' || typeField.length === 0) {
throw new SceneInvalidError(
`Scene graph for ${context} has node "${nodeId}" missing a string "type"`,
)
}
}
return graph as SceneGraph
}
function asSceneRow(value: unknown): SceneRow | null {
if (!value || typeof value !== 'object') return null
return value as SceneRow
}
/**
* SQLite-backed implementation of `SceneStore`.
*
* Uses one local database file, WAL mode, and transaction-scoped version checks
* so a local editor and MCP process can safely share scenes on one machine.
*/
export class SqliteSceneStore implements SceneStore {
readonly backend = 'sqlite' as const
readonly databasePath: string
private readonly maxSceneBytes: number
private db: SqliteDatabase | null = null
private dbPromise: Promise<SqliteDatabase> | null = null
constructor(opts: SqliteSceneStoreOptions = {}) {
const env = opts.env ?? process.env
this.databasePath = path.resolve(opts.databasePath ?? resolveDefaultDatabasePath(env))
this.maxSceneBytes = resolveMaxSceneBytes(env, opts.maxSceneBytes)
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
return this.withWriteTransaction((db) => {
assertValidName(opts.name)
if (!opts.graph || typeof opts.graph !== 'object') {
throw new SceneInvalidError('graph is required')
}
const providedId = opts.id
const id = providedId ? sanitizeSlug(providedId) : this.generateUniqueId(db)
if (!isValidSlug(id)) {
throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`)
}
const existing = this.getRow(db, id)
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.`,
)
}
if (opts.expectedVersion !== undefined) {
const currentVersion = existing?.version ?? 0
if (currentVersion !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`,
)
}
}
const graphJson = serializeGraph(opts.graph)
const sizeBytes = Buffer.byteLength(graphJson, 'utf8')
if (sizeBytes > this.maxSceneBytes) {
throw new SceneTooLargeError(
`Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${this.maxSceneBytes} bytes`,
)
}
const now = new Date().toISOString()
const version = (existing?.version ?? 0) + 1
const createdAt = existing?.created_at ?? now
const nodeCount = Object.keys(opts.graph.nodes ?? {}).length
if (existing) {
db.query(
`UPDATE scenes
SET name = ?,
project_id = ?,
owner_id = ?,
thumbnail_url = ?,
version = ?,
updated_at = ?,
size_bytes = ?,
node_count = ?,
graph_json = ?
WHERE id = ?`,
).run(
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
version,
now,
sizeBytes,
nodeCount,
graphJson,
id,
)
} else {
db.query(
`INSERT INTO scenes (
id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id,
opts.name,
opts.projectId ?? null,
opts.ownerId ?? null,
opts.thumbnailUrl ?? null,
version,
createdAt,
now,
sizeBytes,
nodeCount,
graphJson,
)
}
db.query(
`INSERT INTO scene_revisions (
scene_id, version, graph_json, author_kind, author_id, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, version, graphJson, 'mcp', opts.ownerId ?? null, now)
return {
id,
name: opts.name,
projectId: opts.projectId ?? null,
ownerId: opts.ownerId ?? null,
thumbnailUrl: opts.thumbnailUrl ?? null,
version,
createdAt,
updatedAt: now,
sizeBytes,
nodeCount,
}
})
}
async load(id: string): Promise<SceneWithGraph | null> {
const db = await this.database()
const row = this.getRow(db, sanitizeSlug(id))
if (!row) return null
return {
...rowToMeta(row),
graph: parseGraph(row.graph_json, row.id),
}
}
async list(opts: SceneListOptions = {}): Promise<SceneMeta[]> {
const clauses: string[] = []
const bindings: Array<string | number> = []
if (opts.projectId !== undefined) {
clauses.push('project_id = ?')
bindings.push(opts.projectId)
}
if (opts.ownerId !== undefined) {
clauses.push('owner_id = ?')
bindings.push(opts.ownerId)
}
const requestedLimit = opts.limit ?? DEFAULT_LIST_LIMIT
const limit = Number.isInteger(requestedLimit) && requestedLimit >= 0 ? requestedLimit : 0
bindings.push(limit)
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
const db = await this.database()
const rows = db
.query(
`SELECT id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
FROM scenes
${where}
ORDER BY updated_at DESC, id ASC
LIMIT ?`,
)
.all(...bindings)
return rows.map((row) => rowToMeta(row as SceneRow))
}
async delete(id: string, opts: SceneMutateOptions = {}): Promise<boolean> {
return this.withWriteTransaction((db) => {
const safeId = sanitizeSlug(id)
const existing = this.getRow(db, safeId)
if (!existing) return false
if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`,
)
}
db.query('DELETE FROM scenes WHERE id = ?').run(safeId)
return true
})
}
async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise<SceneMeta> {
return this.withWriteTransaction((db) => {
assertValidName(newName)
const safeId = sanitizeSlug(id)
const existing = this.getRow(db, safeId)
if (!existing) {
throw new SceneNotFoundError(`Scene "${safeId}" not found`)
}
if (opts.expectedVersion !== undefined && existing.version !== opts.expectedVersion) {
throw new SceneVersionConflictError(
`Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.version}`,
)
}
const now = new Date().toISOString()
const nextVersion = existing.version + 1
db.query('UPDATE scenes SET name = ?, version = ?, updated_at = ? WHERE id = ?').run(
newName,
nextVersion,
now,
safeId,
)
db.query(
`INSERT INTO scene_revisions (
scene_id, version, graph_json, author_kind, author_id, created_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).run(safeId, nextVersion, existing.graph_json, 'mcp', existing.owner_id, now)
return {
...rowToMeta(existing),
name: newName,
version: nextVersion,
updatedAt: now,
}
})
}
close(): void {
this.db?.close()
this.db = null
this.dbPromise = null
}
private async database(): Promise<SqliteDatabase> {
if (this.db) return this.db
if (!this.dbPromise) {
this.dbPromise = (async () => {
mkdirSync(path.dirname(this.databasePath), { recursive: true })
const db = await openSqliteDatabase(this.databasePath)
db.exec('PRAGMA foreign_keys = ON')
db.exec('PRAGMA journal_mode = WAL')
db.exec('PRAGMA busy_timeout = 5000')
this.migrate(db)
this.db = db
return db
})()
}
return this.dbPromise
}
private migrate(db: SqliteDatabase): void {
db.exec(`
CREATE TABLE IF NOT EXISTS scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL CHECK (length(name) >= 1 AND length(name) <= 200),
project_id TEXT,
owner_id TEXT,
thumbnail_url TEXT,
version INTEGER NOT NULL CHECK (version >= 1),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
size_bytes INTEGER NOT NULL CHECK (size_bytes >= 0),
node_count INTEGER NOT NULL CHECK (node_count >= 0),
graph_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS scenes_project_updated_idx
ON scenes(project_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS scenes_owner_updated_idx
ON scenes(owner_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS scene_revisions (
scene_id TEXT NOT NULL,
version INTEGER NOT NULL CHECK (version >= 1),
graph_json TEXT NOT NULL,
author_kind TEXT NOT NULL,
author_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (scene_id, version),
FOREIGN KEY (scene_id) REFERENCES scenes(id) ON DELETE CASCADE
);
`)
}
private async withWriteTransaction<T>(fn: (db: SqliteDatabase) => T | Promise<T>): Promise<T> {
const db = await this.database()
db.exec('BEGIN IMMEDIATE')
try {
const result = await fn(db)
db.exec('COMMIT')
return result
} catch (err) {
try {
db.exec('ROLLBACK')
} catch {
// Ignore rollback errors so the original failure is preserved.
}
throw err
}
}
private getRow(db: SqliteDatabase, id: string): SceneRow | null {
return asSceneRow(
db
.query(
`SELECT id, name, project_id, owner_id, thumbnail_url, version,
created_at, updated_at, size_bytes, node_count, graph_json
FROM scenes
WHERE id = ?`,
)
.get(id),
)
}
private generateUniqueId(db: SqliteDatabase): string {
for (let attempt = 0; attempt < 20; attempt++) {
const id = generateSlug()
if (!this.getRow(db, id)) return id
}
throw new SceneInvalidError('Failed to generate a unique scene id')
}
}
+3 -4
View File
@@ -119,7 +119,6 @@ describe('generateSlug', () => {
})
})
// 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).
// Note: createSceneStore() factory behavior is covered by the SQLite store
// tests. We avoid mock.module() here because bun's module mocks persist
// process-wide and pollute sibling test files.
@@ -1,333 +0,0 @@
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<string, unknown>
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<string, Row[]>
calls: RecordedCall[]
} {
const tables: Record<string, Row[]> = {}
const calls: RecordedCall[] = []
function buildQuery<T extends Row>(table: string): SupabaseQueryBuilder<T> {
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<T[]> {
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<T> {
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<T> = {
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<T>
},
async single() {
call.terminator = 'single'
calls.push(call)
return executeSingle(true) as SupabaseQueryResult<T>
},
// 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<T extends Row = Row>(table: string) {
return buildQuery<T>(table)
},
}
return { client, tables, calls }
}
function fakeGraph(nodeCount = 2) {
const nodes: Record<string, unknown> = {}
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<typeof createMockClient>
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')
})
})
@@ -1,414 +0,0 @@
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<T> {
data: T | null
error: { message: string; code?: string; details?: string } | null
}
export interface SupabaseQueryBuilder<Row> {
select(columns?: string): SupabaseQueryBuilder<Row>
insert(values: Partial<Row> | Partial<Row>[]): SupabaseQueryBuilder<Row>
update(values: Partial<Row>): SupabaseQueryBuilder<Row>
delete(): SupabaseQueryBuilder<Row>
upsert(values: Partial<Row> | Partial<Row>[]): SupabaseQueryBuilder<Row>
eq(column: string, value: unknown): SupabaseQueryBuilder<Row>
order(column: string, opts?: { ascending?: boolean }): SupabaseQueryBuilder<Row>
limit(count: number): SupabaseQueryBuilder<Row>
maybeSingle(): Promise<SupabaseQueryResult<Row>>
single(): Promise<SupabaseQueryResult<Row>>
then<TResult1 = SupabaseQueryResult<Row[]>, TResult2 = never>(
onfulfilled?:
| ((value: SupabaseQueryResult<Row[]>) => TResult1 | PromiseLike<TResult1>)
| null
| undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined,
): Promise<TResult1 | TResult2>
}
export interface SupabaseLikeClient {
from<Row = Record<string, unknown>>(table: string): SupabaseQueryBuilder<Row>
}
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<SupabaseLikeClient>
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<SupabaseLikeClient>
}
}
private async client(): Promise<SupabaseLikeClient> {
return this.clientPromise
}
async save(opts: SceneSaveOptions): Promise<SceneMeta> {
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<SceneRow>(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<SceneRow>(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<SceneRow>(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<SceneRow>(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<SceneWithGraph | null> {
const client = await this.client()
const result = await client
.from<SceneRow>(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<SceneMeta[]> {
const client = await this.client()
let query = client
.from<SceneRow>(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<SceneRow[]>
if (result.error) {
throw new Error(`Supabase list failed: ${result.error.message}`)
}
return (result.data ?? []).map(rowToMeta)
}
async delete(id: SceneId, opts?: SceneMutateOptions): Promise<boolean> {
const client = await this.client()
if (typeof opts?.expectedVersion === 'number') {
const existing = await client
.from<SceneRow>(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<SceneRow>(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<SceneMeta> {
validateName(newName)
const client = await this.client()
const existing = await client
.from<SceneRow>(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<SceneRow>(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<void> {
const result = await client.from<RevisionRow>(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}`)
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export interface SceneMutateOptions {
}
export interface SceneStore {
readonly backend: 'filesystem' | 'supabase'
readonly backend: 'sqlite'
save(opts: SceneSaveOptions): Promise<SceneMeta>
load(id: SceneId): Promise<SceneWithGraph | null>
list(opts?: SceneListOptions): Promise<SceneMeta[]>
+2 -2
View File
@@ -41,8 +41,8 @@ export function registerCreateWall(server: McpServer, bridge: SceneBridge): void
}
const wall = WallNode.parse({
start,
end,
start: start as [number, number],
end: end as [number, number],
...(thickness !== undefined ? { thickness } : {}),
...(height !== undefined ? { height } : {}),
})
+1 -1
View File
@@ -84,7 +84,7 @@ export function registerPlaceItem(server: McpServer, bridge: SceneBridge): void
: {}
const item = ItemNode.parse({
position,
position: position as [number, number, number],
rotation: [0, rotation ?? 0, 0],
asset: baseAsset,
...wallExtras,
@@ -21,7 +21,7 @@ export function parseToolText(content: StoredTextContent[]): Record<string, unkn
* `expectedVersion`.
*/
export class InMemorySceneStore implements SceneStore {
readonly backend = 'filesystem' as const
readonly backend = 'sqlite' as const
private readonly data = new Map<string, SceneWithGraph>()
private idCounter = 0
+7 -3
View File
@@ -8,11 +8,15 @@ import { z } from 'zod'
/** A node identifier — non-empty string. The core uses `${prefix}_${nanoid}`. */
export const NodeIdSchema = z.string().min(1)
/** 2D point as [x, z] (floor plane). Matches core's tuple convention. */
export const Vec2Schema = z.tuple([z.number(), z.number()])
/**
* 2D point as [x, z] (floor plane). Use array length constraints instead of
* `z.tuple()` so MCP hosts that only accept JSON Schema's common `items` shape
* can register the tools.
*/
export const Vec2Schema = z.array(z.number()).min(2).max(2)
/** 3D point as [x, y, z]. */
export const Vec3Schema = z.tuple([z.number(), z.number(), z.number()])
export const Vec3Schema = z.array(z.number()).min(3).max(3)
/**
* A single patch operation. Union of create / update / delete.
+1 -1
View File
@@ -41,7 +41,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge): void {
const zone = ZoneNode.parse({
name: label,
polygon,
polygon: polygon as Array<[number, number]>,
metadata: properties ?? {},
})
const id = bridge.createNode(zone, levelId as AnyNodeId)
+34
View File
@@ -0,0 +1,34 @@
declare module 'bun:sqlite' {
export type SQLQueryBindings =
| string
| number
| bigint
| boolean
| null
| Uint8Array
| Record<string, unknown>
export interface SQLQueryResult {
changes: number
lastInsertRowid: number | bigint
}
export interface Statement {
all(...params: SQLQueryBindings[]): unknown[]
get(...params: SQLQueryBindings[]): unknown
run(...params: SQLQueryBindings[]): SQLQueryResult
}
export interface DatabaseOptions {
create?: boolean
readwrite?: boolean
readonly?: boolean
}
export class Database {
constructor(filename: string, options?: DatabaseOptions)
exec(sql: string): void
query(sql: string): Statement
close(): void
}
}
@@ -1,154 +0,0 @@
# Casa del Sol — Build Report
Generated: 2026-04-18T16:32:27.015Z
Server: http://localhost:3917/mcp
Transport used: **in-memory** — in-memory fallback — HTTP server returned: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
Initial node count: 3
## Steps
| # | Name | Status | Duration | Summary |
|---|------|--------|----------|---------|
| 1 | discover | OK | 1ms | building=building_hhprs7o5kz0q2qo7, level=level_9ded0vdlfag09tdt |
| 2 | perimeter walls | OK | 1ms | 4 walls via create_wall |
| 3 | interior walls | OK | 0ms | 5 walls via apply_patch |
| 4 | zones | OK | 1ms | 7 zones |
| 5 | openings | OK | 2ms | 6 doors, 6 windows, 0 failures |
| 6 | pool zone+slab | OK | 1ms | zone=zone_av8rfpjgcpmpx4pb, basin slab=slab_tqyxze2hshzm1it3 |
| 7 | privacy fences | OK | 0ms | 5 fences via apply_patch |
| 8 | garden zone | OK | 0ms | zone=zone_gtpdocou2nceicp9 |
| 9 | measure cross-zone | OK | 0ms | distance=12.649m |
| 10 | export json | OK | 0ms | wrote 26761 bytes -> scene.json |
| 11 | duplicate level | OK | 0ms | newLevelId=level_zgyhyd1f4vtrm05v, cloned=37 nodes |
## Per-Step Details
### Step 1 — discover
- Status: **OK**
- Duration: 1ms
- Summary: building=building_hhprs7o5kz0q2qo7, level=level_9ded0vdlfag09tdt
- Node IDs (2): `building_hhprs7o5kz0q2qo7, level_9ded0vdlfag09tdt`
### Step 2 — perimeter walls
- Status: **OK**
- Duration: 1ms
- Summary: 4 walls via create_wall
- Node IDs (4): `wall_vc5l8mk2b3j3ukvq, wall_9jaybb53j2r5j5en, wall_a16b1eirqz5p9f98, wall_yiwfcyw716g5ql5y`
### Step 3 — interior walls
- Status: **OK**
- Duration: 0ms
- Summary: 5 walls via apply_patch
- Node IDs (5): `wall_9vmyxyv2kea0t2vn, wall_53gr0rtp1ssmxvz2, wall_yzuosc4u4z0a9jqo, wall_eeogjzcwm711gzam, wall_3mznfrklw8ar7jrz`
### Step 4 — zones
- Status: **OK**
- Duration: 1ms
- Summary: 7 zones
- Node IDs (7): `zone_b51zjgfr0cgc7ncc, zone_x409m2lnw1jpmi8m, zone_and0s1ux5v8rexj6, zone_n8w3oyzr3c4ovcbu, zone_c1wa4cr91h215zzk, zone_e8e6qqmx85tockrm, zone_ebbidjjln9doosy5`
### Step 5 — openings
- Status: **OK**
- Duration: 2ms
- Summary: 6 doors, 6 windows, 0 failures
- Node IDs (12): `door_v19tfw6dbg60pjka, door_57wgnpcft27n51ui, door_lv8wmjjs6em9ayqg, door_g1ffn08lohho4vf0, door_zuyuxee4afd5ae0o, door_n4kgjkvq18a87rh0, window_6lkp36vh0kpe8vsl, window_8klttdg0qbxw0v6h, window_r9mc8jkp0dem9s8u, window_bbmwfj9hjxfz6y7c, window_19otr8qt84hke69s, window_qy4pp3lwyn5cq2u1`
### Step 6 — pool zone+slab
- Status: **OK**
- Duration: 1ms
- Summary: zone=zone_av8rfpjgcpmpx4pb, basin slab=slab_tqyxze2hshzm1it3
- Node IDs (2): `zone_av8rfpjgcpmpx4pb, slab_tqyxze2hshzm1it3`
### Step 7 — privacy fences
- Status: **OK**
- Duration: 0ms
- Summary: 5 fences via apply_patch
- Node IDs (5): `fence_hnhjl6vicj3fs234, fence_me6wvw6rf93y2um4, fence_af22csiukvc7gjyf, fence_lp6d7gkrado9z0cc, fence_a09o9w183o453oqh`
### Step 8 — garden zone
- Status: **OK**
- Duration: 0ms
- Summary: zone=zone_gtpdocou2nceicp9
- Node IDs (1): `zone_gtpdocou2nceicp9`
### Step 9 — measure cross-zone
- Status: **OK**
- Duration: 0ms
- Summary: distance=12.649m
- Node IDs (2): `wall_vc5l8mk2b3j3ukvq, fence_af22csiukvc7gjyf`
### Step 10 — export json
- Status: **OK**
- Duration: 0ms
- Summary: wrote 26761 bytes -> scene.json
### Step 11 — duplicate level
- Status: **OK**
- Duration: 0ms
- Summary: newLevelId=level_zgyhyd1f4vtrm05v, cloned=37 nodes
- Node IDs (1): `level_zgyhyd1f4vtrm05v`
## Opening attempts
| Label | Kind | Wall | OK | Opening Id / Error |
|-------|------|------|----|--------------------|
| front-door | door | `wall_vc5l8mk2b3j3ukvq` | yes | door_v19tfw6dbg60pjka |
| sliding-pool | door | `wall_vc5l8mk2b3j3ukvq` | yes | door_57wgnpcft27n51ui |
| kitchen-back | door | `wall_9jaybb53j2r5j5en` | yes | door_lv8wmjjs6em9ayqg |
| master-door | door | `wall_53gr0rtp1ssmxvz2` | yes | door_g1ffn08lohho4vf0 |
| bedroom-2-door | door | `wall_yzuosc4u4z0a9jqo` | yes | door_zuyuxee4afd5ae0o |
| bath2-door | door | `wall_3mznfrklw8ar7jrz` | yes | door_n4kgjkvq18a87rh0 |
| living-pic | window | `wall_vc5l8mk2b3j3ukvq` | yes | window_6lkp36vh0kpe8vsl |
| living-2 | window | `wall_vc5l8mk2b3j3ukvq` | yes | window_8klttdg0qbxw0v6h |
| kitchen-w | window | `wall_a16b1eirqz5p9f98` | yes | window_r9mc8jkp0dem9s8u |
| master-w | window | `wall_yiwfcyw716g5ql5y` | yes | window_bbmwfj9hjxfz6y7c |
| bedroom-2-w | window | `wall_yiwfcyw716g5ql5y` | yes | window_19otr8qt84hke69s |
| bath2-high | window | `wall_9jaybb53j2r5j5en` | yes | window_qy4pp3lwyn5cq2u1 |
## Final scene totals
| Node type | Count |
|-----------|-------|
| site | 1 |
| building | 1 |
| level | 2 |
| wall | 18 |
| fence | 10 |
| zone | 18 |
| slab | 2 |
| door | 12 |
| window | 12 |
| **total** | **76** |
## Validation
- Final `validate_scene`: valid=`true`, errors=0
## Duplicate-level
- Pre-duplicate node count: **39**
- Post-duplicate node count: **76**
- New level id: `level_zgyhyd1f4vtrm05v`
- Nodes cloned: 37
## Known discrepancies with DESIGN.md
- Garden zone polygon equals the full site polygon (20x15) — per design brief §Garden zone we set it to the site polygon and rely on the building zones overlapping visually, rather than subtracting the building footprint.
- Build fell back to in-memory MCP transport. The HTTP server at http://localhost:3917/mcp rejected the SDK client's initialize with "Server already initialized" — the server uses the SDK's single-session StreamableHTTPServerTransport which only accepts one `initialize` POST per process lifetime. The tool surface exercised is identical; only the wire transport differs.
## Artifacts
- `scene.json`: full pretty-printed JSON export (26761 bytes)
- `build.log`: stdout from this run
- `BUILD_REPORT.md`: this file
@@ -1,91 +0,0 @@
# Casa del Sol — design brief
A single-story 3-bedroom, 2-bathroom house with a swimming pool and a privacy-screened perimeter, built entirely via `@pascal-app/mcp` HTTP tools against a fresh scene.
## Lot
- Site polygon: 20 × 15 m rectangle centred at origin → corners (10, 7.5) to (10, 7.5).
- Origin axes: +x = east, +z = south (core convention; floor plane is xz).
## Building envelope
- Footprint: 12 × 8 m, centred west-of-origin at about (2, 0).
- Wall thickness: 0.2 m. Wall height: 2.7 m.
- Floor elevation: 0. Single storey.
## Interior rooms (zones)
Origin for interior: building SW corner at (8, 4). All rooms span the 8 m building depth somehow.
| Room | Footprint (x × z) | Polygon corners |
|-----------------|----------------------|-----------------------------------------|
| Living / dining | 7 × 4 | (8, 0) (1, 0) (1, 4) (8, 4) |
| Kitchen | 5 × 4 | (1, 0) ( 4, 0) ( 4, 4) (1, 4) |
| Bedroom 2 | 4 × 4 | (8, 4) (4, 4) (4, 0) (8, 0) |
| Hallway | 3 × 1 | (4, 2) (1, 2) (1, 1) (4, 1) |
| Bathroom 2 | 3 × 2 | (4, 4) (1, 4) (1, 2) (4, 2) |
| Bathroom 1 | 3 × 1 | (4, 1) (1, 1) (1, 0) (4, 0) |
| Master bedroom | 5 × 4 | (1, 4) ( 4, 4) ( 4, 0) (1, 0) |
## Walls (perimeter + partitions, one wall per edge)
**Perimeter (4):**
1. South outer: (8, 4) → (4, 4)
2. North outer: (8, 4) → (4, 4)
3. West outer: (8, 4) → (8, 4)
4. East outer: (4, 4) → (4, 4)
**Interior (partitions):**
5. Living/Kitchen split: (1, 0) → (1, 4)
6. North bedrooms split: (1, 4) → (1, 0)
7. Bedroom 2 east wall: (4, 4) → (4, 0)
8. Hallway north edge: (4, 1) → (1, 1)
9. Hallway south edge: (4, 2) → (1, 2)
## Openings (doors + windows)
All positions are normalised 0..1 along the wall (start → end).
| Kind | Wall | Pos | Width | Height | Purpose |
|---------|----------------------------------------|-----|-------|--------|----------------------|
| door | 1 (south outer) | 0.20| 0.9 | 2.1 | Front entrance |
| door | 1 (south outer) | 0.75| 2.2 | 2.1 | Sliding glass to pool|
| door | 2 (north outer) | 0.65| 0.9 | 2.1 | Kitchen back door |
| door | 6 (N-bedrooms split, middle) | 0.30| 0.8 | 2.1 | Master bedroom door |
| door | 7 (Bedroom 2 east wall) | 0.50| 0.8 | 2.1 | Bedroom 2 door |
| door | 9 (Hallway south edge) | 0.50| 0.7 | 2.0 | Bathroom 2 door |
| window | 1 (south outer, living) | 0.30| 2.0 | 1.4 | Living picture window|
| window | 1 (south outer, living) | 0.45| 1.4 | 1.4 | Living window 2 |
| window | 3 (west outer) | 0.25| 1.0 | 1.1 | Kitchen window |
| window | 4 (east outer) | 0.25| 1.4 | 1.4 | Master window |
| window | 4 (east outer) | 0.75| 1.4 | 1.4 | Bedroom 2 window |
| window | 2 (north outer, bathroom 2) | 0.30| 0.8 | 0.6 | Bathroom 2 high |
(Core validates `cut_opening` arguments; if wall-endpoint geometry makes two openings collide, the tool returns an error and the script logs the conflict.)
## Exterior elements
**Swimming pool (east of house):**
- Zone polygon: (5, 1.5) (10, 1.5) (10, 1.5) (5, 1.5) — 5 × 3 m.
- Metadata: `{ kind: "pool", depthM: 1.8, finish: "tile" }`.
- Additional slab at elevation 1.8 m to represent the pool basin (same polygon).
**Privacy screen fence (lot perimeter):**
- Style: `privacy`, height 1.8 m.
- Four fence segments around the 20 × 15 lot, with a 2 m gap at the south entrance (x ∈ [1, 1]).
- Segments:
1. South-west: (10, 7.5) → (1, 7.5)
2. South-east: (1, 7.5) → (10, 7.5)
3. East: (10, 7.5) → (10, 7.5)
4. North: (10, 7.5) → (10, 7.5)
5. West: (10, 7.5) → (10, 7.5)
**Garden zone** (everything that isn't building or pool):
- One big zone labelled "garden" with the remaining polygon.
## Success criteria
- `validate_scene` returns `valid: true, errors: 0` after every step.
- Final node count ≥ 30 (1 site + 1 building + 1 level + ~14 walls + 5 fences + ~7 zones + 6 doors + 7 windows + 1 pool slab ≈ 43).
- `export_json` produces a valid parseable JSON.
- `duplicate_level` works (produces a second storey at the same layout).
- Scene exports and imports cleanly: write the export to disk, `unloadScene` via the bridge (if exposed) or setScene from the export, re-validate.
## Non-goals
- Catalog items (no furniture — the catalog is unavailable in headless mode).
- Realistic materials / textures (MCP doesn't set `material`; editor picks defaults).
- Roof geometry — the roof system is render-only; we skip for v0.1.
@@ -1,29 +0,0 @@
[casa] connecting to http://localhost:3917/mcp via StreamableHTTPClientTransport
[casa] HTTP transport failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[casa] falling back to in-memory MCP server (same tool surface)
[casa] in-memory MCP server connected
[casa] step 01 discover ok (building=building_hhprs7o5kz0q2qo7, level=level_9ded0vdlfag09tdt, 1ms) ids: building_hhprs7o5kz0q2qo7, level_9ded0vdlfag09tdt
[casa] initial node count: 3
[casa] step 02 perimeter walls ok (4 walls via create_wall, 1ms) ids: wall_vc5l8mk2b3j3ukvq, wall_9jaybb53j2r5j5en, wall_a16b1eirqz5p9f98, wall_yiwfcyw716g5ql5y
[casa] step 03 interior walls ok (5 walls via apply_patch, 0ms) ids: wall_9vmyxyv2kea0t2vn, wall_53gr0rtp1ssmxvz2, wall_yzuosc4u4z0a9jqo, wall_eeogjzcwm711gzam, wall_3mznfrklw8ar7jrz
[casa] wall ids (by designId): {"1":"wall_vc5l8mk2b3j3ukvq","2":"wall_9jaybb53j2r5j5en","3":"wall_a16b1eirqz5p9f98","4":"wall_yiwfcyw716g5ql5y","5":"wall_9vmyxyv2kea0t2vn","6":"wall_53gr0rtp1ssmxvz2","7":"wall_yzuosc4u4z0a9jqo","8":"wall_eeogjzcwm711gzam","9":"wall_3mznfrklw8ar7jrz"}
[casa] validate after walls: valid=true, errors=0
[casa] step 04 zones ok (7 zones, 1ms) ids: zone_b51zjgfr0cgc7ncc, zone_x409m2lnw1jpmi8m, zone_and0s1ux5v8rexj6, zone_n8w3oyzr3c4ovcbu, zone_c1wa4cr91h215zzk, zone_e8e6qqmx85tockrm, zone_ebbidjjln9doosy5
[casa] validate after zones: valid=true, errors=0
[casa] step 05 openings ok (6 doors, 6 windows, 0 failures, 2ms)
[casa] validate after openings: valid=true, errors=0
[casa] step 06 pool zone+slab ok (zone=zone_av8rfpjgcpmpx4pb, basin slab=slab_tqyxze2hshzm1it3, 1ms) ids: zone_av8rfpjgcpmpx4pb, slab_tqyxze2hshzm1it3
[casa] validate after pool: valid=true, errors=0
[casa] step 07 privacy fences ok (5 fences via apply_patch, 0ms) ids: fence_hnhjl6vicj3fs234, fence_me6wvw6rf93y2um4, fence_af22csiukvc7gjyf, fence_lp6d7gkrado9z0cc, fence_a09o9w183o453oqh
[casa] validate after fences: valid=true, errors=0
[casa] step 08 garden zone ok (zone=zone_gtpdocou2nceicp9, 0ms) ids: zone_gtpdocou2nceicp9
[casa] step 09 measure cross-zone ok (distance=12.649m, 0ms) ids: wall_vc5l8mk2b3j3ukvq, fence_af22csiukvc7gjyf
[casa] step 10 export json ok (wrote 26761 bytes -> scene.json, 0ms)
[casa] step 11 duplicate level ok (newLevelId=level_zgyhyd1f4vtrm05v, cloned=37 nodes, 0ms) ids: level_zgyhyd1f4vtrm05v
[casa] validate after final: valid=true, errors=0
[casa] FINAL: totalNodes=76 walls=18 zones=18 doors=12 windows=12 fences=10 slabs=2 levels=2
[casa] pre-duplicate=39, post-duplicate=76
[casa] validation: valid=true, errors=0
[casa] wrote BUILD_REPORT.md
[casa] transport: in-memory (in-memory fallback — HTTP server returned: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null})
[casa] DONE
-872
View File
@@ -1,872 +0,0 @@
/**
* Casa del Sol — end-to-end build via MCP HTTP transport.
*
* Connects to http://localhost:3917/mcp (already running) and constructs
* the scene described in ./DESIGN.md:
* - 12x8 house footprint, 1 storey, 4 perimeter + 5 interior walls
* - 7 interior zones (living, kitchen, bed2, hallway, bath1, bath2, master)
* - 6 doors and 6 windows cut into specific walls
* - 5x3 pool zone + pool basin slab at elevation -1.8
* - 5 privacy fence segments around the 20x15 lot (with south gap)
* - 1 garden zone
*
* Emits:
* - stdout [casa] step log lines
* - ./scene.json (pretty-printed export after fences)
* - ./BUILD_REPORT.md (per-step report)
*
* Run:
* bun packages/mcp/test-reports/casa-sol/build.ts
*/
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'
let TRANSPORT_NOTE = ''
type StepRecord = {
n: number
name: string
ok: boolean
durationMs: number
summary: string
nodeIds?: string[]
errors?: string[]
}
const steps: StepRecord[] = []
function log(line: string): void {
console.log(line)
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n)
}
async function step<T>(
n: number,
name: string,
fn: () => Promise<{ summary: string; nodeIds?: string[]; errors?: string[]; result: T }>,
): Promise<T | null> {
const start = Date.now()
try {
const { summary, nodeIds, errors, result } = await fn()
const durationMs = Date.now() - start
steps.push({ n, name, ok: true, durationMs, summary, nodeIds, errors })
log(
`[casa] step ${pad2(n)} ${name.padEnd(22)} ok (${summary}, ${durationMs}ms)` +
(nodeIds && nodeIds.length > 0 && nodeIds.length <= 10
? ` ids: ${nodeIds.join(', ')}`
: ''),
)
return result
} catch (err) {
const durationMs = Date.now() - start
const msg = err instanceof Error ? err.message : String(err)
steps.push({ n, name, ok: false, durationMs, summary: 'FAILED', errors: [msg] })
log(`[casa] step ${pad2(n)} ${name.padEnd(22)} FAIL (${msg}, ${durationMs}ms)`)
return null
}
}
async function callTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
const res = await client.callTool({ name, arguments: args })
if (res.isError) {
const text = Array.isArray(res.content)
? res.content
.map((c) =>
typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '',
)
.join('\n')
: ''
throw new Error(`tool ${name} error: ${text || 'unknown'}`)
}
return (res.structuredContent ?? {}) as T
}
/**
* Try to call a tool that might fail with a structured error; caller can
* continue on failure. Returns { ok, value | error }.
*/
async function tryCallTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
try {
const v = await callTool<T>(client, name, args)
return { ok: true, value: v }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
// --- Geometry spec ---------------------------------------------------------
type Vec2 = [number, number]
type WallSpec = {
key: string
designId: number // 1..9 per DESIGN.md wall numbering
start: Vec2
end: Vec2
}
const PERIMETER_WALLS: WallSpec[] = [
{ key: 'south-outer', designId: 1, start: [-8, 4], end: [4, 4] },
{ key: 'north-outer', designId: 2, start: [-8, -4], end: [4, -4] },
{ key: 'west-outer', designId: 3, start: [-8, -4], end: [-8, 4] },
{ key: 'east-outer', designId: 4, start: [4, -4], end: [4, 4] },
]
const INTERIOR_WALLS: WallSpec[] = [
{ key: 'living-kitchen-split', designId: 5, start: [-1, 0], end: [-1, 4] },
{ key: 'north-bedrooms-split', designId: 6, start: [-1, -4], end: [-1, 0] },
{ key: 'bedroom2-east', designId: 7, start: [-4, -4], end: [-4, 0] },
{ key: 'hallway-north-edge', designId: 8, start: [-4, -1], end: [-1, -1] },
{ key: 'hallway-south-edge', designId: 9, start: [-4, -2], end: [-1, -2] },
]
type ZoneSpec = {
label: string
polygon: Vec2[]
properties?: Record<string, unknown>
}
const INTERIOR_ZONES: ZoneSpec[] = [
{
label: 'living-dining',
polygon: [
[-8, 0],
[-1, 0],
[-1, 4],
[-8, 4],
],
},
{
label: 'kitchen',
polygon: [
[-1, 0],
[4, 0],
[4, 4],
[-1, 4],
],
},
{
label: 'bedroom-2',
polygon: [
[-8, -4],
[-4, -4],
[-4, 0],
[-8, 0],
],
},
{
label: 'hallway',
polygon: [
[-4, -2],
[-1, -2],
[-1, -1],
[-4, -1],
],
},
{
label: 'bathroom-2',
polygon: [
[-4, -4],
[-1, -4],
[-1, -2],
[-4, -2],
],
},
{
label: 'bathroom-1',
polygon: [
[-4, -1],
[-1, -1],
[-1, 0],
[-4, 0],
],
},
{
label: 'master-bedroom',
polygon: [
[-1, -4],
[4, -4],
[4, 0],
[-1, 0],
],
},
]
// Per DESIGN.md: (wall designId, kind, pos, width, height, label)
type OpeningSpec = {
wallDesignId: number
kind: 'door' | 'window'
position: number
width: number
height: number
label: string
}
const OPENINGS: OpeningSpec[] = [
{ wallDesignId: 1, kind: 'door', position: 0.2, width: 0.9, height: 2.1, label: 'front-door' },
{
wallDesignId: 1,
kind: 'door',
position: 0.75,
width: 2.2,
height: 2.1,
label: 'sliding-pool',
},
{ 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: 9, kind: 'door', position: 0.5, width: 0.7, height: 2.0, label: 'bath2-door' },
{
wallDesignId: 1,
kind: 'window',
position: 0.3,
width: 2.0,
height: 1.4,
label: 'living-pic',
},
{ wallDesignId: 1, kind: 'window', position: 0.45, width: 1.4, height: 1.4, label: 'living-2' },
{ wallDesignId: 3, kind: 'window', position: 0.25, width: 1.0, height: 1.1, label: 'kitchen-w' },
{ wallDesignId: 4, kind: 'window', position: 0.25, width: 1.4, height: 1.4, label: 'master-w' },
{
wallDesignId: 4,
kind: 'window',
position: 0.75,
width: 1.4,
height: 1.4,
label: 'bedroom-2-w',
},
{
wallDesignId: 2,
kind: 'window',
position: 0.3,
width: 0.8,
height: 0.6,
label: 'bath2-high',
},
]
const POOL_POLY: Vec2[] = [
[5, -1.5],
[10, -1.5],
[10, 1.5],
[5, 1.5],
]
type FenceSpec = { label: string; start: Vec2; end: Vec2 }
const FENCES: FenceSpec[] = [
{ label: 'south-west', start: [-10, 7.5], end: [-1, 7.5] },
{ label: 'south-east', start: [1, 7.5], end: [10, 7.5] },
{ label: 'east', start: [10, 7.5], end: [10, -7.5] },
{ label: 'north', start: [10, -7.5], end: [-10, -7.5] },
{ label: 'west', start: [-10, -7.5], end: [-10, 7.5] },
]
const SITE_POLY: Vec2[] = [
[-10, -7.5],
[10, -7.5],
[10, 7.5],
[-10, 7.5],
]
// --- Validation helper -----------------------------------------------------
type ValidationResult = {
valid: boolean
errors: Array<{ nodeId: string; path: string; message: string }>
}
async function runValidate(client: Client, phase: string): Promise<ValidationResult> {
const v = await callTool<ValidationResult>(client, 'validate_scene', {})
log(`[casa] validate after ${phase}: valid=${v.valid}, errors=${v.errors.length}`)
if (!v.valid && v.errors.length > 0) {
for (const e of v.errors.slice(0, 5)) {
log(`[casa] - ${e.nodeId} @ ${e.path}: ${e.message}`)
}
if (v.errors.length > 5) log(`[casa] (+${v.errors.length - 5} more)`)
}
return v
}
// --- Main -----------------------------------------------------------------
async function main(): Promise<void> {
log(`[casa] connecting to ${SERVER_URL} via StreamableHTTPClientTransport`)
const httpTransport = new StreamableHTTPClientTransport(new URL(SERVER_URL))
let client = new Client({ name: 'casa-sol-builder', version: '0.1.0' })
let closers: Array<() => Promise<void>> = []
let usedTransport: 'http' | 'in-memory' = 'http'
try {
await client.connect(httpTransport)
// Smoke probe
await client.listTools()
TRANSPORT_NOTE = 'shared HTTP server at :3917 via StreamableHTTPClientTransport'
log(`[casa] HTTP transport connected`)
closers = [async () => client.close()]
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`[casa] HTTP transport failed: ${msg}`)
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 bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const inMemClient = new Client({ name: 'casa-sol-builder-inmem', version: '0.1.0' })
await Promise.all([server.connect(srvT), inMemClient.connect(cliT)])
client = inMemClient
usedTransport = 'in-memory'
TRANSPORT_NOTE = `in-memory fallback — HTTP server returned: ${msg}`
closers = [async () => client.close(), async () => server.close()]
log(`[casa] in-memory MCP server connected`)
}
void usedTransport
// ----- Step 01: Discover building & level -----
const discovered = await step(1, 'discover', async () => {
const buildings = await callTool<{
nodes: Array<{ id: string; type: string; name?: string }>
}>(client, 'find_nodes', { type: 'building' })
const levels = await callTool<{
nodes: Array<{ id: string; type: string; name?: string; parentId?: string }>
}>(client, 'find_nodes', { type: 'level' })
if (!buildings.nodes.length) throw new Error('no building found in default scene')
if (!levels.nodes.length) throw new Error('no level found in default scene')
const building = buildings.nodes[0]!
const level = levels.nodes.find((l) => l.parentId === building.id) ?? levels.nodes[0]!
return {
summary: `building=${building.id}, level=${level.id}`,
nodeIds: [building.id, level.id],
result: { buildingId: building.id, levelId: level.id },
}
})
if (!discovered) {
log('[casa] cannot continue without discovered ids; aborting')
await client.close()
return
}
const { levelId } = discovered
// Initial scene snapshot for "before" count.
const initialAll = await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {})
const initialCount = initialAll.nodes.length
log(`[casa] initial node count: ${initialCount}`)
// ----- Step 02: Perimeter walls (via create_wall) -----
// DESIGN.md wall IDs 1..4: south-outer, north-outer, west-outer, east-outer
const perimeterIds: Record<string, string> = {}
const perimeter = await step(2, 'perimeter walls', async () => {
const ids: string[] = []
for (const w of PERIMETER_WALLS) {
const r = await callTool<{ wallId: string }>(client, 'create_wall', {
levelId,
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
})
perimeterIds[w.key] = r.wallId
ids.push(r.wallId)
}
return {
summary: `${ids.length} walls via create_wall`,
nodeIds: ids,
result: ids,
}
})
// ----- Step 03: Interior walls (via apply_patch) -----
const interiorIds: Record<string, string> = {}
const interior = await step(3, 'interior walls', async () => {
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: INTERIOR_WALLS.map((w) => ({
op: 'create',
parentId: levelId,
node: {
type: 'wall',
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
},
})),
})
const createdIds = res.createdIds
for (let i = 0; i < INTERIOR_WALLS.length; i++) {
const w = INTERIOR_WALLS[i]!
const id = createdIds[i]
if (id) interiorIds[w.key] = id
}
return {
summary: `${createdIds.length} walls via apply_patch`,
nodeIds: createdIds,
result: createdIds,
}
})
// Map designId -> wallId for opening lookup
const wallByDesignId = new Map<number, string>()
for (const w of PERIMETER_WALLS) {
const id = perimeterIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
for (const w of INTERIOR_WALLS) {
const id = interiorIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
log(`[casa] wall ids (by designId): ${JSON.stringify(Object.fromEntries(wallByDesignId))}`)
const validationAfterWalls = perimeter && interior ? await runValidate(client, 'walls') : null
void validationAfterWalls
// ----- Step 04: Zones -----
const interiorZoneIds: Record<string, string> = {}
const zones = await step(4, 'zones', async () => {
const ids: string[] = []
for (const z of INTERIOR_ZONES) {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: z.label,
polygon: z.polygon,
properties: z.properties ?? {},
})
interiorZoneIds[z.label] = r.zoneId
ids.push(r.zoneId)
}
return {
summary: `${ids.length} zones`,
nodeIds: ids,
result: ids,
}
})
void zones
const validationAfterZones = await runValidate(client, 'zones')
void validationAfterZones
// ----- Step 05: Openings (doors + windows) -----
const openingResults: Array<{
label: string
kind: string
wallId: string
ok: boolean
openingId?: string
error?: string
}> = []
const openings = await step(5, 'openings', async () => {
let doors = 0
let windows = 0
const failures: string[] = []
for (const o of OPENINGS) {
const wallId = wallByDesignId.get(o.wallDesignId)
if (!wallId) {
const err = `skipped ${o.label}: wall designId=${o.wallDesignId} not found`
log(`[casa] ${err}`)
failures.push(err)
openingResults.push({
label: o.label,
kind: o.kind,
wallId: `design-${o.wallDesignId}`,
ok: false,
error: 'wall not found',
})
continue
}
const r = await tryCallTool<{ openingId: string }>(client, 'cut_opening', {
wallId,
type: o.kind,
position: o.position,
width: o.width,
height: o.height,
})
if (r.ok) {
openingResults.push({
label: o.label,
kind: o.kind,
wallId,
ok: true,
openingId: r.value.openingId,
})
if (o.kind === 'door') doors++
else windows++
} else {
log(`[casa] cut_opening failed for ${o.label} (wall=${wallId}): ${r.error}`)
failures.push(`${o.label}: ${r.error}`)
openingResults.push({
label: o.label,
kind: o.kind,
wallId,
ok: false,
error: r.error,
})
}
}
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,
errors: failures,
result: { doors, windows, failures },
}
})
void openings
const validationAfterOpenings = await runValidate(client, 'openings')
void validationAfterOpenings
// ----- Step 06: Pool zone + pool basin slab -----
const poolZoneIdRef: { id?: string } = {}
const poolSlabIdRef: { id?: string } = {}
const poolResult = await step(6, 'pool zone+slab', async () => {
const zoneRes = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'pool',
polygon: POOL_POLY,
properties: { kind: 'pool', depthM: 1.8, finish: 'tile' },
})
poolZoneIdRef.id = zoneRes.zoneId
// Create pool basin slab at elevation -1.8 via apply_patch.
const slabRes = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: [
{
op: 'create',
parentId: levelId,
node: {
type: 'slab',
polygon: POOL_POLY,
elevation: -1.8,
},
},
],
})
const slabId = slabRes.createdIds[0]
if (slabId) poolSlabIdRef.id = slabId
return {
summary: `zone=${zoneRes.zoneId}, basin slab=${slabId ?? 'NONE'}`,
nodeIds: [zoneRes.zoneId, slabId ?? ''].filter(Boolean) as string[],
result: { zoneId: zoneRes.zoneId, slabId },
}
})
void poolResult
const validationAfterPool = await runValidate(client, 'pool')
void validationAfterPool
// ----- Step 07: Fences -----
const fenceIds: string[] = []
const fencesStep = await step(7, 'privacy fences', async () => {
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: FENCES.map((f) => ({
op: 'create',
parentId: levelId,
node: {
type: 'fence',
start: f.start,
end: f.end,
height: 1.8,
style: 'privacy',
thickness: 0.08,
},
})),
})
fenceIds.push(...res.createdIds)
return {
summary: `${res.createdIds.length} fences via apply_patch`,
nodeIds: res.createdIds,
result: res.createdIds,
}
})
void fencesStep
const validationAfterFences = await runValidate(client, 'fences')
void validationAfterFences
// ----- Step 08: Garden zone -----
const gardenZoneIdRef: { id?: string } = {}
const gardenStep = await step(8, 'garden zone', async () => {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'garden',
polygon: SITE_POLY,
properties: { kind: 'garden' },
})
gardenZoneIdRef.id = r.zoneId
return {
summary: `zone=${r.zoneId}`,
nodeIds: [r.zoneId],
result: r.zoneId,
}
})
void gardenStep
// ----- Step 09: Measure — SW building corner wall -> NE lot fence -----
// SW building corner ~= start of first perimeter wall (south-outer: starts at (-8, 4))
// NE lot fence corner = east fence segment (start at (10, 7.5), end at (10, -7.5))
await step(9, 'measure cross-zone', async () => {
const fromId = perimeterIds['south-outer']
const toId = fenceIds[2] // east fence (see FENCES order)
if (!fromId) throw new Error('missing south-outer wall id')
if (!toId) throw new Error('missing east fence id')
const r = await callTool<{ distanceMeters: number; units: string }>(client, 'measure', {
fromId,
toId,
})
return {
summary: `distance=${r.distanceMeters.toFixed(3)}m`,
nodeIds: [fromId, toId],
result: r,
}
})
// ----- Step 10: Export JSON -----
const exportInfo: { bytes?: number } = {}
await step(10, 'export json', async () => {
const r = await callTool<{ json: string }>(client, 'export_json', { pretty: true })
const path = `${HERE}/scene.json`
writeFileSync(path, r.json, 'utf-8')
exportInfo.bytes = r.json.length
return {
summary: `wrote ${r.json.length} bytes -> scene.json`,
result: r.json.length,
}
})
// ----- Step 11: Duplicate level -----
const preDupCount = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const dupRef: { newLevelId?: string; cloned?: number } = {}
await step(11, 'duplicate level', async () => {
const r = await callTool<{ newLevelId: string; newNodeIds: string[] }>(
client,
'duplicate_level',
{ levelId },
)
dupRef.newLevelId = r.newLevelId
dupRef.cloned = r.newNodeIds.length
return {
summary: `newLevelId=${r.newLevelId}, cloned=${r.newNodeIds.length} nodes`,
nodeIds: [r.newLevelId],
result: r,
}
})
const postDupCount = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
// ----- 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 tally: Record<string, number> = {}
for (const n of allNodes) {
tally[n.type] = (tally[n.type] ?? 0) + 1
}
log(
`[casa] FINAL: totalNodes=${allNodes.length} walls=${tally.wall ?? 0} zones=${
tally.zone ?? 0
} doors=${tally.door ?? 0} windows=${tally.window ?? 0} fences=${
tally.fence ?? 0
} slabs=${tally.slab ?? 0} levels=${tally.level ?? 0}`,
)
log(`[casa] pre-duplicate=${preDupCount}, post-duplicate=${postDupCount}`)
log(`[casa] validation: valid=${finalValid.valid}, errors=${finalValid.errors.length}`)
// ----- Write BUILD_REPORT.md -----
const lines: string[] = []
lines.push('# Casa del Sol — Build Report')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Server: ${SERVER_URL}`)
lines.push(`Transport used: **${usedTransport}** — ${TRANSPORT_NOTE}`)
lines.push(`Initial node count: ${initialCount}`)
lines.push('')
lines.push('## Steps')
lines.push('')
lines.push('| # | Name | Status | Duration | Summary |')
lines.push('|---|------|--------|----------|---------|')
for (const s of steps) {
lines.push(
`| ${s.n} | ${s.name} | ${s.ok ? 'OK' : 'FAIL'} | ${s.durationMs}ms | ${s.summary.replace(/\|/g, '\\|')} |`,
)
}
lines.push('')
lines.push('## Per-Step Details')
lines.push('')
for (const s of steps) {
lines.push(`### Step ${s.n}${s.name}`)
lines.push('')
lines.push(`- Status: **${s.ok ? 'OK' : 'FAIL'}**`)
lines.push(`- Duration: ${s.durationMs}ms`)
lines.push(`- Summary: ${s.summary}`)
if (s.nodeIds && s.nodeIds.length > 0) {
const shown = s.nodeIds.slice(0, 15).join(', ')
lines.push(
`- Node IDs (${s.nodeIds.length}): \`${shown}${s.nodeIds.length > 15 ? ' ...' : ''}\``,
)
}
if (s.errors && s.errors.length > 0) {
lines.push(`- Errors/warnings:`)
for (const e of s.errors) lines.push(` - ${e}`)
}
lines.push('')
}
lines.push('## Opening attempts')
lines.push('')
lines.push('| Label | Kind | Wall | OK | Opening Id / Error |')
lines.push('|-------|------|------|----|--------------------|')
for (const r of openingResults) {
lines.push(
`| ${r.label} | ${r.kind} | \`${r.wallId}\` | ${r.ok ? 'yes' : 'no'} | ${
r.ok ? r.openingId : (r.error ?? 'n/a').replace(/\|/g, '\\|')
} |`,
)
}
lines.push('')
lines.push('## Final scene totals')
lines.push('')
lines.push('| Node type | Count |')
lines.push('|-----------|-------|')
const types = [
'site',
'building',
'level',
'wall',
'fence',
'zone',
'slab',
'door',
'window',
'ceiling',
'roof',
'stair',
'item',
'guide',
]
for (const t of types) {
if ((tally[t] ?? 0) > 0) lines.push(`| ${t} | ${tally[t]} |`)
}
lines.push(`| **total** | **${allNodes.length}** |`)
lines.push('')
lines.push('## Validation')
lines.push('')
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):')
lines.push('')
for (const e of finalValid.errors) {
lines.push(`- \`${e.nodeId}\` @ \`${e.path}\`: ${e.message}`)
}
}
lines.push('')
lines.push('## Duplicate-level')
lines.push('')
lines.push(`- Pre-duplicate node count: **${preDupCount}**`)
lines.push(`- Post-duplicate node count: **${postDupCount}**`)
lines.push(`- New level id: \`${dupRef.newLevelId ?? 'N/A'}\``)
lines.push(`- Nodes cloned: ${dupRef.cloned ?? 0}`)
lines.push('')
// Known discrepancies section
const discrepancies: string[] = []
const openingFailures = openingResults.filter((r) => !r.ok)
if (openingFailures.length > 0) {
discrepancies.push(
`${openingFailures.length} cut_opening call(s) failed — see "Opening attempts" table for details. Core may have rejected an opening due to overlap or width exceeding the wall length.`,
)
}
if (!finalValid.valid) {
discrepancies.push(
'Final validate_scene returned valid=false — see Validation section for the verbatim error list.',
)
}
const gardenPolyNote =
'Garden zone polygon equals the full site polygon (20x15) — per design brief §Garden zone we set it to the site polygon and rely on the building zones overlapping visually, rather than subtracting the building footprint.'
discrepancies.push(gardenPolyNote)
if (usedTransport === 'in-memory') {
discrepancies.push(
`Build fell back to in-memory MCP transport. The HTTP server at ${SERVER_URL} rejected the SDK client's initialize with "Server already initialized" — the server uses the SDK's single-session StreamableHTTPServerTransport which only accepts one \`initialize\` POST per process lifetime. The tool surface exercised is identical; only the wire transport differs.`,
)
}
lines.push('## Known discrepancies with DESIGN.md')
lines.push('')
for (const d of discrepancies) lines.push(`- ${d}`)
lines.push('')
lines.push('## Artifacts')
lines.push('')
lines.push(
`- \`scene.json\`: full pretty-printed JSON export (${exportInfo.bytes ?? 'n/a'} bytes)`,
)
lines.push(`- \`build.log\`: stdout from this run`)
lines.push(`- \`BUILD_REPORT.md\`: this file`)
lines.push('')
writeFileSync(`${HERE}/BUILD_REPORT.md`, lines.join('\n'), 'utf-8')
log(`[casa] wrote BUILD_REPORT.md`)
log(`[casa] transport: ${usedTransport} (${TRANSPORT_NOTE})`)
log(`[casa] DONE`)
for (const c of closers) await c()
}
main().catch((err) => {
console.error('[casa] FATAL:', err instanceof Error ? err.stack : String(err))
process.exit(1)
})
@@ -1,836 +0,0 @@
{
"nodes": {
"site_3ss5ro12ozrgtdbf": {
"object": "node",
"id": "site_3ss5ro12ozrgtdbf",
"type": "site",
"parentId": null,
"visible": true,
"metadata": {},
"polygon": {
"type": "polygon",
"points": [[-15, -15], [15, -15], [15, 15], [-15, 15]]
},
"children": [
{
"object": "node",
"id": "building_hhprs7o5kz0q2qo7",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_9ded0vdlfag09tdt"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
}
]
},
"building_hhprs7o5kz0q2qo7": {
"object": "node",
"id": "building_hhprs7o5kz0q2qo7",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_9ded0vdlfag09tdt"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
},
"level_9ded0vdlfag09tdt": {
"object": "node",
"id": "level_9ded0vdlfag09tdt",
"type": "level",
"parentId": null,
"visible": true,
"metadata": {},
"children": [
"wall_vc5l8mk2b3j3ukvq",
"wall_9jaybb53j2r5j5en",
"wall_a16b1eirqz5p9f98",
"wall_yiwfcyw716g5ql5y",
"wall_9vmyxyv2kea0t2vn",
"wall_53gr0rtp1ssmxvz2",
"wall_yzuosc4u4z0a9jqo",
"wall_eeogjzcwm711gzam",
"wall_3mznfrklw8ar7jrz",
"zone_b51zjgfr0cgc7ncc",
"zone_x409m2lnw1jpmi8m",
"zone_and0s1ux5v8rexj6",
"zone_n8w3oyzr3c4ovcbu",
"zone_c1wa4cr91h215zzk",
"zone_e8e6qqmx85tockrm",
"zone_ebbidjjln9doosy5",
"zone_av8rfpjgcpmpx4pb",
"slab_tqyxze2hshzm1it3",
"fence_hnhjl6vicj3fs234",
"fence_me6wvw6rf93y2um4",
"fence_af22csiukvc7gjyf",
"fence_lp6d7gkrado9z0cc",
"fence_a09o9w183o453oqh",
"zone_gtpdocou2nceicp9"
],
"level": 0
},
"wall_vc5l8mk2b3j3ukvq": {
"object": "node",
"id": "wall_vc5l8mk2b3j3ukvq",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": [
"door_v19tfw6dbg60pjka",
"door_57wgnpcft27n51ui",
"window_6lkp36vh0kpe8vsl",
"window_8klttdg0qbxw0v6h"
],
"thickness": 0.2,
"height": 2.7,
"start": [-8, 4],
"end": [4, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_9jaybb53j2r5j5en": {
"object": "node",
"id": "wall_9jaybb53j2r5j5en",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["door_lv8wmjjs6em9ayqg", "window_qy4pp3lwyn5cq2u1"],
"thickness": 0.2,
"height": 2.7,
"start": [-8, -4],
"end": [4, -4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_a16b1eirqz5p9f98": {
"object": "node",
"id": "wall_a16b1eirqz5p9f98",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["window_r9mc8jkp0dem9s8u"],
"thickness": 0.2,
"height": 2.7,
"start": [-8, -4],
"end": [-8, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_yiwfcyw716g5ql5y": {
"object": "node",
"id": "wall_yiwfcyw716g5ql5y",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["window_bbmwfj9hjxfz6y7c", "window_19otr8qt84hke69s"],
"thickness": 0.2,
"height": 2.7,
"start": [4, -4],
"end": [4, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_9vmyxyv2kea0t2vn": {
"object": "node",
"id": "wall_9vmyxyv2kea0t2vn",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [-1, 0],
"end": [-1, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_53gr0rtp1ssmxvz2": {
"object": "node",
"id": "wall_53gr0rtp1ssmxvz2",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["door_g1ffn08lohho4vf0"],
"thickness": 0.2,
"height": 2.7,
"start": [-1, -4],
"end": [-1, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_yzuosc4u4z0a9jqo": {
"object": "node",
"id": "wall_yzuosc4u4z0a9jqo",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["door_zuyuxee4afd5ae0o"],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -4],
"end": [-4, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_eeogjzcwm711gzam": {
"object": "node",
"id": "wall_eeogjzcwm711gzam",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -1],
"end": [-1, -1],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_3mznfrklw8ar7jrz": {
"object": "node",
"id": "wall_3mznfrklw8ar7jrz",
"type": "wall",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"children": ["door_n4kgjkvq18a87rh0"],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -2],
"end": [-1, -2],
"frontSide": "unknown",
"backSide": "unknown"
},
"zone_b51zjgfr0cgc7ncc": {
"object": "node",
"id": "zone_b51zjgfr0cgc7ncc",
"type": "zone",
"name": "living-dining",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-8, 0], [-1, 0], [-1, 4], [-8, 4]],
"color": "#3b82f6"
},
"zone_x409m2lnw1jpmi8m": {
"object": "node",
"id": "zone_x409m2lnw1jpmi8m",
"type": "zone",
"name": "kitchen",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-1, 0], [4, 0], [4, 4], [-1, 4]],
"color": "#3b82f6"
},
"zone_and0s1ux5v8rexj6": {
"object": "node",
"id": "zone_and0s1ux5v8rexj6",
"type": "zone",
"name": "bedroom-2",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-8, -4], [-4, -4], [-4, 0], [-8, 0]],
"color": "#3b82f6"
},
"zone_n8w3oyzr3c4ovcbu": {
"object": "node",
"id": "zone_n8w3oyzr3c4ovcbu",
"type": "zone",
"name": "hallway",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-4, -2], [-1, -2], [-1, -1], [-4, -1]],
"color": "#3b82f6"
},
"zone_c1wa4cr91h215zzk": {
"object": "node",
"id": "zone_c1wa4cr91h215zzk",
"type": "zone",
"name": "bathroom-2",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-4, -4], [-1, -4], [-1, -2], [-4, -2]],
"color": "#3b82f6"
},
"zone_e8e6qqmx85tockrm": {
"object": "node",
"id": "zone_e8e6qqmx85tockrm",
"type": "zone",
"name": "bathroom-1",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-4, -1], [-1, -1], [-1, 0], [-4, 0]],
"color": "#3b82f6"
},
"zone_ebbidjjln9doosy5": {
"object": "node",
"id": "zone_ebbidjjln9doosy5",
"type": "zone",
"name": "master-bedroom",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[-1, -4], [4, -4], [4, 0], [-1, 0]],
"color": "#3b82f6"
},
"door_v19tfw6dbg60pjka": {
"object": "node",
"id": "door_v19tfw6dbg60pjka",
"type": "door",
"parentId": "wall_vc5l8mk2b3j3ukvq",
"visible": true,
"metadata": {},
"position": [0.2, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_vc5l8mk2b3j3ukvq",
"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
},
"door_57wgnpcft27n51ui": {
"object": "node",
"id": "door_57wgnpcft27n51ui",
"type": "door",
"parentId": "wall_vc5l8mk2b3j3ukvq",
"visible": true,
"metadata": {},
"position": [0.75, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_vc5l8mk2b3j3ukvq",
"width": 2.2,
"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
},
"door_lv8wmjjs6em9ayqg": {
"object": "node",
"id": "door_lv8wmjjs6em9ayqg",
"type": "door",
"parentId": "wall_9jaybb53j2r5j5en",
"visible": true,
"metadata": {},
"position": [0.65, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_9jaybb53j2r5j5en",
"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
},
"door_g1ffn08lohho4vf0": {
"object": "node",
"id": "door_g1ffn08lohho4vf0",
"type": "door",
"parentId": "wall_53gr0rtp1ssmxvz2",
"visible": true,
"metadata": {},
"position": [0.3, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_53gr0rtp1ssmxvz2",
"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.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
},
"door_zuyuxee4afd5ae0o": {
"object": "node",
"id": "door_zuyuxee4afd5ae0o",
"type": "door",
"parentId": "wall_yzuosc4u4z0a9jqo",
"visible": true,
"metadata": {},
"position": [0.5, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_yzuosc4u4z0a9jqo",
"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.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
},
"door_n4kgjkvq18a87rh0": {
"object": "node",
"id": "door_n4kgjkvq18a87rh0",
"type": "door",
"parentId": "wall_3mznfrklw8ar7jrz",
"visible": true,
"metadata": {},
"position": [0.5, 1, 0],
"rotation": [0, 0, 0],
"wallId": "wall_3mznfrklw8ar7jrz",
"width": 0.7,
"height": 2,
"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
},
"window_6lkp36vh0kpe8vsl": {
"object": "node",
"id": "window_6lkp36vh0kpe8vsl",
"type": "window",
"parentId": "wall_vc5l8mk2b3j3ukvq",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_8klttdg0qbxw0v6h": {
"object": "node",
"id": "window_8klttdg0qbxw0v6h",
"type": "window",
"parentId": "wall_vc5l8mk2b3j3ukvq",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_r9mc8jkp0dem9s8u": {
"object": "node",
"id": "window_r9mc8jkp0dem9s8u",
"type": "window",
"parentId": "wall_a16b1eirqz5p9f98",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_bbmwfj9hjxfz6y7c": {
"object": "node",
"id": "window_bbmwfj9hjxfz6y7c",
"type": "window",
"parentId": "wall_yiwfcyw716g5ql5y",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_19otr8qt84hke69s": {
"object": "node",
"id": "window_19otr8qt84hke69s",
"type": "window",
"parentId": "wall_yiwfcyw716g5ql5y",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_qy4pp3lwyn5cq2u1": {
"object": "node",
"id": "window_qy4pp3lwyn5cq2u1",
"type": "window",
"parentId": "wall_9jaybb53j2r5j5en",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"zone_av8rfpjgcpmpx4pb": {
"object": "node",
"id": "zone_av8rfpjgcpmpx4pb",
"type": "zone",
"name": "pool",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {
"kind": "pool",
"depthM": 1.8,
"finish": "tile"
},
"polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]],
"color": "#3b82f6"
},
"slab_tqyxze2hshzm1it3": {
"object": "node",
"id": "slab_tqyxze2hshzm1it3",
"type": "slab",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]],
"holes": [],
"holeMetadata": [],
"elevation": -1.8,
"autoFromWalls": false
},
"fence_hnhjl6vicj3fs234": {
"object": "node",
"id": "fence_hnhjl6vicj3fs234",
"type": "fence",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"start": [-10, 7.5],
"end": [-1, 7.5],
"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": "privacy"
},
"fence_me6wvw6rf93y2um4": {
"object": "node",
"id": "fence_me6wvw6rf93y2um4",
"type": "fence",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"start": [1, 7.5],
"end": [10, 7.5],
"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": "privacy"
},
"fence_af22csiukvc7gjyf": {
"object": "node",
"id": "fence_af22csiukvc7gjyf",
"type": "fence",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"start": [10, 7.5],
"end": [10, -7.5],
"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": "privacy"
},
"fence_lp6d7gkrado9z0cc": {
"object": "node",
"id": "fence_lp6d7gkrado9z0cc",
"type": "fence",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"start": [10, -7.5],
"end": [-10, -7.5],
"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": "privacy"
},
"fence_a09o9w183o453oqh": {
"object": "node",
"id": "fence_a09o9w183o453oqh",
"type": "fence",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {},
"start": [-10, -7.5],
"end": [-10, 7.5],
"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": "privacy"
},
"zone_gtpdocou2nceicp9": {
"object": "node",
"id": "zone_gtpdocou2nceicp9",
"type": "zone",
"name": "garden",
"parentId": "level_9ded0vdlfag09tdt",
"visible": true,
"metadata": {
"kind": "garden"
},
"polygon": [[-10, -7.5], [10, -7.5], [10, 7.5], [-10, 7.5]],
"color": "#3b82f6"
}
},
"rootNodeIds": ["site_3ss5ro12ozrgtdbf"],
"collections": {}
}
-95
View File
@@ -1,95 +0,0 @@
/**
* 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)
})
@@ -1,836 +0,0 @@
{
"nodes": {
"site_nxji43wtm3aiv7th": {
"object": "node",
"id": "site_nxji43wtm3aiv7th",
"type": "site",
"parentId": null,
"visible": true,
"metadata": {},
"polygon": {
"type": "polygon",
"points": [[-15, -15], [15, -15], [15, 15], [-15, 15]]
},
"children": [
{
"object": "node",
"id": "building_16mw8oy88f952is9",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_3jbpcuma0wfwclex"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
}
]
},
"building_16mw8oy88f952is9": {
"object": "node",
"id": "building_16mw8oy88f952is9",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_3jbpcuma0wfwclex"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
},
"level_3jbpcuma0wfwclex": {
"object": "node",
"id": "level_3jbpcuma0wfwclex",
"type": "level",
"parentId": null,
"visible": true,
"metadata": {},
"children": [
"wall_j3ymyd8zrn1tm3vw",
"wall_ch18gs2ztca3ev6w",
"wall_5vkgxbr2yy7g73ne",
"wall_kgzojan8e7v59e98",
"wall_3jnpbahwg9lxy986",
"wall_ovunx8saqwfu1v4v",
"wall_zutd5kdbx2arrg9h",
"wall_x0pcifb7x4glmarr",
"wall_f7kmekmgvmfsrm7o",
"zone_o7ke6p48ne5vca23",
"zone_xqv8a3g282kfsqni",
"zone_9cbw5gy7huqn00kn",
"zone_4x1y6af3q099sequ",
"zone_dftxlw40a4iwnqpt",
"zone_2qu75mqpfvty8d1q",
"zone_92x5t8808vyw1ik0",
"zone_lnki9rxnoyq72rwi",
"slab_rfyzzvcxlkbgif1w",
"fence_rj9vb67pvbkxj291",
"fence_4yf9yt0x8i27l8ol",
"fence_b8d9n2j3987egkq4",
"fence_7v9k8hfa4gejy8mb",
"fence_6mosl7txfxj0wxyq",
"zone_6l4ls8mr4rtfvsye"
],
"level": 0
},
"wall_j3ymyd8zrn1tm3vw": {
"object": "node",
"id": "wall_j3ymyd8zrn1tm3vw",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": [
"door_u3esm5o28gjglczx",
"door_d83emijui1hbi4ai",
"window_j75zxpkawiriwu3n",
"window_3ow9tht3fymvnasw"
],
"thickness": 0.2,
"height": 2.7,
"start": [-8, 4],
"end": [4, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_ch18gs2ztca3ev6w": {
"object": "node",
"id": "wall_ch18gs2ztca3ev6w",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["door_hf6glt3wf2yrtbez", "window_ge40ad7xouig550s"],
"thickness": 0.2,
"height": 2.7,
"start": [-8, -4],
"end": [4, -4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_5vkgxbr2yy7g73ne": {
"object": "node",
"id": "wall_5vkgxbr2yy7g73ne",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["window_5cp5kjonpq0pf741"],
"thickness": 0.2,
"height": 2.7,
"start": [-8, -4],
"end": [-8, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_kgzojan8e7v59e98": {
"object": "node",
"id": "wall_kgzojan8e7v59e98",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["window_rma26zrn7dkanen3", "window_sp6a94rwh54afdnj"],
"thickness": 0.2,
"height": 2.7,
"start": [4, -4],
"end": [4, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_3jnpbahwg9lxy986": {
"object": "node",
"id": "wall_3jnpbahwg9lxy986",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [-1, 0],
"end": [-1, 4],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_ovunx8saqwfu1v4v": {
"object": "node",
"id": "wall_ovunx8saqwfu1v4v",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["door_ysujgio81ptoofe7"],
"thickness": 0.2,
"height": 2.7,
"start": [-1, -4],
"end": [-1, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_zutd5kdbx2arrg9h": {
"object": "node",
"id": "wall_zutd5kdbx2arrg9h",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["door_dwn4l10ctn9uqibm"],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -4],
"end": [-4, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_x0pcifb7x4glmarr": {
"object": "node",
"id": "wall_x0pcifb7x4glmarr",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -1],
"end": [-1, -1],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_f7kmekmgvmfsrm7o": {
"object": "node",
"id": "wall_f7kmekmgvmfsrm7o",
"type": "wall",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"children": ["door_gclhf676wdn8643p"],
"thickness": 0.2,
"height": 2.7,
"start": [-4, -2],
"end": [-1, -2],
"frontSide": "unknown",
"backSide": "unknown"
},
"zone_o7ke6p48ne5vca23": {
"object": "node",
"id": "zone_o7ke6p48ne5vca23",
"type": "zone",
"name": "living-dining",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-8, 0], [-1, 0], [-1, 4], [-8, 4]],
"color": "#3b82f6"
},
"zone_xqv8a3g282kfsqni": {
"object": "node",
"id": "zone_xqv8a3g282kfsqni",
"type": "zone",
"name": "kitchen",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-1, 0], [4, 0], [4, 4], [-1, 4]],
"color": "#3b82f6"
},
"zone_9cbw5gy7huqn00kn": {
"object": "node",
"id": "zone_9cbw5gy7huqn00kn",
"type": "zone",
"name": "bedroom-2",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-8, -4], [-4, -4], [-4, 0], [-8, 0]],
"color": "#3b82f6"
},
"zone_4x1y6af3q099sequ": {
"object": "node",
"id": "zone_4x1y6af3q099sequ",
"type": "zone",
"name": "hallway",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-4, -2], [-1, -2], [-1, -1], [-4, -1]],
"color": "#3b82f6"
},
"zone_dftxlw40a4iwnqpt": {
"object": "node",
"id": "zone_dftxlw40a4iwnqpt",
"type": "zone",
"name": "bathroom-2",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-4, -4], [-1, -4], [-1, -2], [-4, -2]],
"color": "#3b82f6"
},
"zone_2qu75mqpfvty8d1q": {
"object": "node",
"id": "zone_2qu75mqpfvty8d1q",
"type": "zone",
"name": "bathroom-1",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-4, -1], [-1, -1], [-1, 0], [-4, 0]],
"color": "#3b82f6"
},
"zone_92x5t8808vyw1ik0": {
"object": "node",
"id": "zone_92x5t8808vyw1ik0",
"type": "zone",
"name": "master-bedroom",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[-1, -4], [4, -4], [4, 0], [-1, 0]],
"color": "#3b82f6"
},
"door_u3esm5o28gjglczx": {
"object": "node",
"id": "door_u3esm5o28gjglczx",
"type": "door",
"parentId": "wall_j3ymyd8zrn1tm3vw",
"visible": true,
"metadata": {},
"position": [0.2, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_j3ymyd8zrn1tm3vw",
"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
},
"door_d83emijui1hbi4ai": {
"object": "node",
"id": "door_d83emijui1hbi4ai",
"type": "door",
"parentId": "wall_j3ymyd8zrn1tm3vw",
"visible": true,
"metadata": {},
"position": [0.75, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_j3ymyd8zrn1tm3vw",
"width": 2.2,
"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
},
"door_hf6glt3wf2yrtbez": {
"object": "node",
"id": "door_hf6glt3wf2yrtbez",
"type": "door",
"parentId": "wall_ch18gs2ztca3ev6w",
"visible": true,
"metadata": {},
"position": [0.65, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_ch18gs2ztca3ev6w",
"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
},
"door_ysujgio81ptoofe7": {
"object": "node",
"id": "door_ysujgio81ptoofe7",
"type": "door",
"parentId": "wall_ovunx8saqwfu1v4v",
"visible": true,
"metadata": {},
"position": [0.3, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_ovunx8saqwfu1v4v",
"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.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
},
"door_dwn4l10ctn9uqibm": {
"object": "node",
"id": "door_dwn4l10ctn9uqibm",
"type": "door",
"parentId": "wall_zutd5kdbx2arrg9h",
"visible": true,
"metadata": {},
"position": [0.5, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_zutd5kdbx2arrg9h",
"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.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
},
"door_gclhf676wdn8643p": {
"object": "node",
"id": "door_gclhf676wdn8643p",
"type": "door",
"parentId": "wall_f7kmekmgvmfsrm7o",
"visible": true,
"metadata": {},
"position": [0.5, 1, 0],
"rotation": [0, 0, 0],
"wallId": "wall_f7kmekmgvmfsrm7o",
"width": 0.7,
"height": 2,
"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
},
"window_j75zxpkawiriwu3n": {
"object": "node",
"id": "window_j75zxpkawiriwu3n",
"type": "window",
"parentId": "wall_j3ymyd8zrn1tm3vw",
"visible": true,
"metadata": {},
"position": [0.3, 0.7, 0],
"rotation": [0, 0, 0],
"wallId": "wall_j3ymyd8zrn1tm3vw",
"width": 2,
"height": 1.4,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_3ow9tht3fymvnasw": {
"object": "node",
"id": "window_3ow9tht3fymvnasw",
"type": "window",
"parentId": "wall_j3ymyd8zrn1tm3vw",
"visible": true,
"metadata": {},
"position": [0.45, 0.7, 0],
"rotation": [0, 0, 0],
"wallId": "wall_j3ymyd8zrn1tm3vw",
"width": 1.4,
"height": 1.4,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_5cp5kjonpq0pf741": {
"object": "node",
"id": "window_5cp5kjonpq0pf741",
"type": "window",
"parentId": "wall_5vkgxbr2yy7g73ne",
"visible": true,
"metadata": {},
"position": [0.25, 0.55, 0],
"rotation": [0, 0, 0],
"wallId": "wall_5vkgxbr2yy7g73ne",
"width": 1,
"height": 1.1,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_rma26zrn7dkanen3": {
"object": "node",
"id": "window_rma26zrn7dkanen3",
"type": "window",
"parentId": "wall_kgzojan8e7v59e98",
"visible": true,
"metadata": {},
"position": [0.25, 0.7, 0],
"rotation": [0, 0, 0],
"wallId": "wall_kgzojan8e7v59e98",
"width": 1.4,
"height": 1.4,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_sp6a94rwh54afdnj": {
"object": "node",
"id": "window_sp6a94rwh54afdnj",
"type": "window",
"parentId": "wall_kgzojan8e7v59e98",
"visible": true,
"metadata": {},
"position": [0.75, 0.7, 0],
"rotation": [0, 0, 0],
"wallId": "wall_kgzojan8e7v59e98",
"width": 1.4,
"height": 1.4,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_ge40ad7xouig550s": {
"object": "node",
"id": "window_ge40ad7xouig550s",
"type": "window",
"parentId": "wall_ch18gs2ztca3ev6w",
"visible": true,
"metadata": {},
"position": [0.3, 0.3, 0],
"rotation": [0, 0, 0],
"wallId": "wall_ch18gs2ztca3ev6w",
"width": 0.8,
"height": 0.6,
"frameThickness": 0.05,
"frameDepth": 0.07,
"columnRatios": [1],
"rowRatios": [1],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"zone_lnki9rxnoyq72rwi": {
"object": "node",
"id": "zone_lnki9rxnoyq72rwi",
"type": "zone",
"name": "pool",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {
"kind": "pool",
"depthM": 1.8,
"finish": "tile"
},
"polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]],
"color": "#3b82f6"
},
"slab_rfyzzvcxlkbgif1w": {
"object": "node",
"id": "slab_rfyzzvcxlkbgif1w",
"type": "slab",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"polygon": [[5, -1.5], [10, -1.5], [10, 1.5], [5, 1.5]],
"holes": [],
"holeMetadata": [],
"elevation": -1.8,
"autoFromWalls": false
},
"fence_rj9vb67pvbkxj291": {
"object": "node",
"id": "fence_rj9vb67pvbkxj291",
"type": "fence",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"start": [-10, 7.5],
"end": [-1, 7.5],
"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": "privacy"
},
"fence_4yf9yt0x8i27l8ol": {
"object": "node",
"id": "fence_4yf9yt0x8i27l8ol",
"type": "fence",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"start": [1, 7.5],
"end": [10, 7.5],
"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": "privacy"
},
"fence_b8d9n2j3987egkq4": {
"object": "node",
"id": "fence_b8d9n2j3987egkq4",
"type": "fence",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"start": [10, 7.5],
"end": [10, -7.5],
"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": "privacy"
},
"fence_7v9k8hfa4gejy8mb": {
"object": "node",
"id": "fence_7v9k8hfa4gejy8mb",
"type": "fence",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"start": [10, -7.5],
"end": [-10, -7.5],
"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": "privacy"
},
"fence_6mosl7txfxj0wxyq": {
"object": "node",
"id": "fence_6mosl7txfxj0wxyq",
"type": "fence",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {},
"start": [-10, -7.5],
"end": [-10, 7.5],
"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": "privacy"
},
"zone_6l4ls8mr4rtfvsye": {
"object": "node",
"id": "zone_6l4ls8mr4rtfvsye",
"type": "zone",
"name": "garden",
"parentId": "level_3jbpcuma0wfwclex",
"visible": true,
"metadata": {
"kind": "garden"
},
"polygon": [[-10, -7.5], [10, -7.5], [10, 7.5], [-10, 7.5]],
"color": "#3b82f6"
}
},
"rootNodeIds": ["site_nxji43wtm3aiv7th"],
"collections": {}
}
@@ -1,37 +0,0 @@
# Phase 8 P1 — templates lifecycle (stdio MCP)
Generated: 2026-04-19T18:18:14.719Z
Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`), data dir `/tmp/pascal-phase8-p1`.
**Summary:** 18/18 PASS, 0 FAIL, 100 ms.
## Steps
| # | Step | Status | Detail |
|---|------|--------|--------|
| 1 | list_templates | PASS | ids=[empty-studio,garden-house,two-bedroom], empty-studio(10), two-bedroom(25), garden-house(18) |
| 2a/empty-studio | create_from_template + save_scene | PASS | templateId=empty-studio, createdNodes=10, sceneId=58d29341f2ac, version=1 |
| 2b/empty-studio | validate_scene | PASS | valid=true, errors=0 |
| 2c/empty-studio | get_scene counts | PASS | nodes=10, zones=1, walls=4, doors=1, windows=1 |
| 2a/two-bedroom | create_from_template + save_scene | PASS | templateId=two-bedroom, createdNodes=25, sceneId=5e1fbd0fd735, version=1 |
| 2b/two-bedroom | validate_scene | PASS | valid=true, errors=0 |
| 2c/two-bedroom | get_scene counts | PASS | nodes=25, zones=4, walls=9, doors=4, windows=5 |
| 2a/garden-house | create_from_template + save_scene | PASS | templateId=garden-house, createdNodes=18, sceneId=b11de24c35c5, version=1 |
| 2b/garden-house | validate_scene | PASS | valid=true, errors=0 |
| 2c/garden-house | get_scene counts | PASS | nodes=18, zones=2, walls=4, doors=2, windows=4 |
| 3 | list_scenes | PASS | scenes=3, names=[p1-empty-studio,p1-garden-house,p1-two-bedroom] |
| 4 | measure between zones | PASS | from=zone_q04o6614gj1k6025, to=zone_zyts5lliy88xf7tj, distance=5.000m |
| 5/empty-studio | delete_scene | PASS | id=58d29341f2ac, deleted=true |
| 5/two-bedroom | delete_scene | PASS | id=5e1fbd0fd735, deleted=true |
| 5/garden-house | delete_scene | PASS | id=b11de24c35c5, deleted=true |
| 5/final | list_scenes empty | PASS | remaining scenes=0 |
| 6a | create_from_template unknown id | PASS | tool_error text="MCP error -32602: unknown_template: nonexistent. Call list_templates for the set of valid ids." |
| 6b | load_scene missing id | PASS | tool_error text="MCP error -32602: scene_not_found" |
## Per-template snapshot (step 2c)
| Template | Scene name | nodes | zones | walls | doors | windows |
|----------|------------|-------|-------|-------|-------|---------|
| empty-studio | p1-empty-studio | 10 | 1 | 4 | 1 | 1 |
| two-bedroom | p1-two-bedroom | 25 | 4 | 9 | 4 | 5 |
| garden-house | p1-garden-house | 18 | 2 | 4 | 2 | 4 |
@@ -1,382 +0,0 @@
/**
* Phase 8 P1 — scene templates lifecycle end-to-end via stdio MCP.
*
* Spawns a dedicated stdio MCP child (isolated PASCAL_DATA_DIR), instantiates
* every seed template, saves/validates/inspects them, exercises `measure`,
* deletes them, and asserts error-path behaviour for unknown template ids
* and missing scene ids.
*
* Run: bun packages/mcp/test-reports/phase8/p1-templates.ts
*/
import { rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { McpError } from '@modelcontextprotocol/sdk/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p1-templates.md')
const DATA_DIR = '/tmp/pascal-phase8-p1'
type StepStatus = 'PASS' | 'FAIL'
type Step = { id: string; title: string; status: StepStatus; detail: string }
const steps: Step[] = []
function record(id: string, title: string, status: StepStatus, detail: string): void {
steps.push({ id, title, status, detail })
const icon = status === 'PASS' ? '[PASS]' : '[FAIL]'
console.log(`${icon} ${id} ${title}${detail}`)
}
type TextContent = Array<{ type?: string; text?: string }>
function parseText(content: unknown): any {
const arr = content as TextContent
const first = Array.isArray(arr) ? arr[0] : undefined
if (!first || typeof first.text !== 'string') return null
try {
return JSON.parse(first.text)
} catch {
return null
}
}
type TemplateSummary = { id: string; name: string; description: string; nodeCount: number }
const TEMPLATE_IDS = ['empty-studio', 'two-bedroom', 'garden-house'] as const
// Per-template snapshot recorded in step 2c.
type SceneSnapshot = {
templateId: string
sceneId: string
sceneName: string
nodeCount: number
zoneCount: number
wallCount: number
doorCount: number
windowCount: number
}
const snapshots: SceneSnapshot[] = []
async function main(): Promise<void> {
// Idempotent cleanup.
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {
// ignore
}
const t0 = Date.now()
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
stderr: 'inherit',
})
const client = new Client({ name: 'p1-templates', version: '0.0.0' })
await client.connect(transport)
// ========================================================================
// 1. list_templates — assert 3 templates with required fields.
// ========================================================================
{
const r = await client.callTool({ name: 'list_templates', arguments: {} })
const payload = parseText(r.content) ?? (r.structuredContent as any)
const list = payload?.templates as TemplateSummary[] | undefined
const ids = (list ?? []).map((t) => t.id).sort()
const expected = [...TEMPLATE_IDS].sort()
const idsOk = JSON.stringify(ids) === JSON.stringify(expected)
const fieldsOk =
!!list &&
list.every(
(t) =>
typeof t.id === 'string' &&
typeof t.name === 'string' &&
typeof t.description === 'string' &&
typeof t.nodeCount === 'number' &&
t.nodeCount > 0,
)
const status: StepStatus = idsOk && fieldsOk ? 'PASS' : 'FAIL'
const summary = list
? list.map((t) => `${t.id}(${t.nodeCount})`).join(', ')
: 'missing templates array'
record('1', 'list_templates', status, `ids=[${ids.join(',')}], ${summary}`)
}
// ========================================================================
// 2. For each template: create_from_template → save_scene → validate → get_scene.
// ========================================================================
for (const id of TEMPLATE_IDS) {
const sceneName = `p1-${id}`
// 2a. create_from_template + save_scene
let sceneId: string | null = null
{
const cr = await client.callTool({
name: 'create_from_template',
arguments: { id, name: sceneName },
})
const cpayload = parseText(cr.content) ?? (cr.structuredContent as any)
const createOk = !cr.isError && cpayload?.templateId === id && (cpayload?.nodeCount ?? 0) > 0
const sr = await client.callTool({
name: 'save_scene',
arguments: { name: sceneName },
})
const spayload = parseText(sr.content) ?? (sr.structuredContent as any)
sceneId = spayload?.id ?? null
const saveOk = !sr.isError && !!sceneId
const status: StepStatus = createOk && saveOk ? 'PASS' : 'FAIL'
record(
`2a/${id}`,
'create_from_template + save_scene',
status,
`templateId=${cpayload?.templateId}, createdNodes=${cpayload?.nodeCount}, sceneId=${sceneId}, version=${spayload?.version}`,
)
}
// 2b. validate_scene
{
const vr = await client.callTool({ name: 'validate_scene', arguments: {} })
const vp = parseText(vr.content) ?? (vr.structuredContent as any)
const ok =
!vr.isError && vp?.valid === true && Array.isArray(vp?.errors) && vp.errors.length === 0
record(
`2b/${id}`,
'validate_scene',
ok ? 'PASS' : 'FAIL',
`valid=${vp?.valid}, errors=${vp?.errors?.length}`,
)
}
// 2c. get_scene — record counts per type.
{
const gr = await client.callTool({ name: 'get_scene', arguments: {} })
const gp = parseText(gr.content) ?? (gr.structuredContent as any)
const nodes = (gp?.nodes as Record<string, { type?: string }> | undefined) ?? {}
const counts = { zone: 0, wall: 0, door: 0, window: 0 }
for (const n of Object.values(nodes)) {
const t = n?.type
if (t === 'zone' || t === 'wall' || t === 'door' || t === 'window') {
counts[t] += 1
}
}
const nodeCount = Object.keys(nodes).length
const ok = !gr.isError && nodeCount > 0
snapshots.push({
templateId: id,
sceneId: sceneId ?? '(missing)',
sceneName,
nodeCount,
zoneCount: counts.zone,
wallCount: counts.wall,
doorCount: counts.door,
windowCount: counts.window,
})
record(
`2c/${id}`,
'get_scene counts',
ok ? 'PASS' : 'FAIL',
`nodes=${nodeCount}, zones=${counts.zone}, walls=${counts.wall}, doors=${counts.door}, windows=${counts.window}`,
)
}
}
// ========================================================================
// 3. list_scenes — expect 3 scenes with our names.
// ========================================================================
{
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
const names = (lp?.scenes ?? []).map((s: any) => s.name).sort()
const expected = TEMPLATE_IDS.map((id) => `p1-${id}`).sort()
const ok =
!lr.isError && names.length === 3 && JSON.stringify(names) === JSON.stringify(expected)
record(
'3',
'list_scenes',
ok ? 'PASS' : 'FAIL',
`scenes=${lp?.scenes?.length}, names=[${names.join(',')}]`,
)
}
// ========================================================================
// 4. Load two-bedroom; measure between two zones; distance > 0.
// ========================================================================
{
const twoBedroom = snapshots.find((s) => s.templateId === 'two-bedroom')
if (!twoBedroom || twoBedroom.sceneId === '(missing)') {
record('4', 'measure between zones', 'FAIL', 'no two-bedroom scene recorded')
} else {
const load = await client.callTool({
name: 'load_scene',
arguments: { id: twoBedroom.sceneId },
})
const loadOk = !load.isError
const gs = await client.callTool({ name: 'get_scene', arguments: {} })
const gsp = parseText(gs.content) ?? (gs.structuredContent as any)
const nodes = (gsp?.nodes as Record<string, { id?: string; type?: string }> | undefined) ?? {}
const zones = Object.values(nodes).filter((n) => n.type === 'zone')
if (!loadOk || zones.length < 2) {
record('4', 'measure between zones', 'FAIL', `loadOk=${loadOk}, zones=${zones.length}`)
} else {
const from = zones[0]!.id as string
const to = zones[1]!.id as string
const mr = await client.callTool({
name: 'measure',
arguments: { fromId: from, toId: to },
})
const mp = parseText(mr.content) ?? (mr.structuredContent as any)
const dist = mp?.distanceMeters as number | undefined
const ok = !mr.isError && typeof dist === 'number' && dist > 0
record(
'4',
'measure between zones',
ok ? 'PASS' : 'FAIL',
`from=${from}, to=${to}, distance=${dist?.toFixed?.(3)}m`,
)
}
}
}
// ========================================================================
// 5. delete each scene, then list_scenes → 0.
// ========================================================================
for (const snap of snapshots) {
if (snap.sceneId === '(missing)') continue
const dr = await client.callTool({
name: 'delete_scene',
arguments: { id: snap.sceneId },
})
const dp = parseText(dr.content) ?? (dr.structuredContent as any)
const ok = !dr.isError && dp?.deleted === true
record(
`5/${snap.templateId}`,
'delete_scene',
ok ? 'PASS' : 'FAIL',
`id=${snap.sceneId}, deleted=${dp?.deleted}`,
)
}
{
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
const count = lp?.scenes?.length ?? -1
const ok = !lr.isError && count === 0
record('5/final', 'list_scenes empty', ok ? 'PASS' : 'FAIL', `remaining scenes=${count}`)
}
// ========================================================================
// 6a. Error: create_from_template with unknown id → McpError(InvalidParams).
// ========================================================================
{
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'create_from_template',
arguments: { id: 'nonexistent' },
})
if (r.isError) {
const textArr = r.content as TextContent
const text = textArr?.[0]?.text ?? ''
const looksInvalid = /unknown_template|InvalidParams|nonexistent/i.test(text)
status = looksInvalid ? 'PASS' : 'FAIL'
detail = `tool_error text="${String(text).slice(0, 160)}"`
} else {
detail = 'unexpected success'
}
} catch (err) {
if (err instanceof McpError) {
// ErrorCode.InvalidParams = -32602
const ok = err.code === -32602
status = ok ? 'PASS' : 'FAIL'
detail = `McpError code=${err.code} msg="${err.message}"`
} else {
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('6a', 'create_from_template unknown id', status, detail)
}
// ========================================================================
// 6b. Error: load_scene missing id → expect error.
// ========================================================================
{
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'load_scene',
arguments: { id: 'missing-id-xyz' },
})
if (r.isError) {
const textArr = r.content as TextContent
const text = textArr?.[0]?.text ?? ''
const looksMissing = /scene_not_found|missing|not found/i.test(text)
status = looksMissing ? 'PASS' : 'FAIL'
detail = `tool_error text="${String(text).slice(0, 160)}"`
} else {
detail = 'unexpected success'
}
} catch (err) {
if (err instanceof McpError) {
const ok = err.code === -32602 || /scene_not_found|not found/i.test(err.message)
status = ok ? 'PASS' : 'FAIL'
detail = `McpError code=${err.code} msg="${err.message}"`
} else {
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('6b', 'load_scene missing id', status, detail)
}
const elapsed = Date.now() - t0
await client.close()
// --- Write markdown report ---
const passed = steps.filter((s) => s.status === 'PASS').length
const failed = steps.filter((s) => s.status === 'FAIL').length
const total = steps.length
const lines: string[] = []
lines.push('# Phase 8 P1 — templates lifecycle (stdio MCP)')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(
`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`), data dir \`${DATA_DIR}\`.`,
)
lines.push('')
lines.push(`**Summary:** ${passed}/${total} PASS, ${failed} FAIL, ${elapsed} ms.`)
lines.push('')
lines.push('## Steps')
lines.push('')
lines.push('| # | Step | Status | Detail |')
lines.push('|---|------|--------|--------|')
for (const s of steps) {
const safe = s.detail.replace(/\|/g, '\\|').replace(/\n/g, ' ')
lines.push(`| ${s.id} | ${s.title} | ${s.status} | ${safe} |`)
}
lines.push('')
lines.push('## Per-template snapshot (step 2c)')
lines.push('')
lines.push('| Template | Scene name | nodes | zones | walls | doors | windows |')
lines.push('|----------|------------|-------|-------|-------|-------|---------|')
for (const snap of snapshots) {
lines.push(
`| ${snap.templateId} | ${snap.sceneName} | ${snap.nodeCount} | ${snap.zoneCount} | ${snap.wallCount} | ${snap.doorCount} | ${snap.windowCount} |`,
)
}
lines.push('')
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`\n[p1] report: ${REPORT_PATH}`)
console.log(`[p1] ${passed}/${total} PASS, ${failed} FAIL in ${elapsed}ms`)
if (failed > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p1] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,122 +0,0 @@
# Phase 8 P10 — full sweep (stdio MCP)
Generated: 2026-04-19T18:21:20.973Z
## Summary
- Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`)
- Data dir: `/tmp/pascal-phase8-p10`
- Sampling: mocked via `client.setRequestHandler(CreateMessageRequestSchema, …)`
- Tools listed: **30**
- Resources listed: **3** static + **1** template
- Prompts listed: **3**
- Total entries exercised: **37**
- PASS: **37** / PARTIAL: **0** / FAIL: **0**
- Run time: **152 ms**
## Listed tools
```
analyze_floorplan_image
analyze_room_photo
apply_patch
check_collisions
create_from_template
create_level
create_wall
cut_opening
delete_node
delete_scene
describe_node
duplicate_level
export_glb
export_json
find_nodes
generate_variants
get_node
get_scene
list_scenes
list_templates
load_scene
measure
photo_to_scene
place_item
redo
rename_scene
save_scene
set_zone
undo
validate_scene
```
## Listed resources
```
(static)
pascal://catalog/items
pascal://scene/current
pascal://scene/current/summary
(templates)
pascal://constraints/{levelId}
```
## Listed prompts
```
from_brief
iterate_on_feedback
renovation_from_photos
```
## Pass matrix — tools
| # | Tool | Status | Note |
|---|------|--------|------|
| 1 | `list_templates` | PASS | 3 templates |
| 2 | `create_from_template` | PASS | templateId=two-bedroom nodes=25 |
| 3 | `get_scene` | PASS | 25 nodes / 1 roots |
| 4 | `get_node` | PASS | type=site |
| 5 | `describe_node` | PASS | type=site children=1 |
| 6 | `find_nodes` | PASS | 1 level(s) |
| 7 | `measure` | PASS | d=6.403m |
| 8 | `apply_patch` | PASS | applied=1 |
| 9 | `create_level` | PASS | levelId=level_7fjtu570j6yyxcm4 |
| 10 | `create_wall` | PASS | wallId=wall_2b2og8j9nrr6jk6t |
| 11 | `place_item` | PASS | itemId=item_efdmfnownqwa3yd4 |
| 12 | `cut_opening` | PASS | openingId=door_c0cul6bctrwf76md |
| 13 | `set_zone` | PASS | zoneId=zone_7ljy8xitu0km2q8d |
| 14 | `duplicate_level` | PASS | newLevelId=level_i5yq9hk2kwd9unpw nodes=28 |
| 15 | `delete_node` | PASS | deleted 28 nodes |
| 16 | `undo` | PASS | undone=1 |
| 17 | `redo` | PASS | redone=1 |
| 18 | `export_json` | PASS | 21051 chars |
| 19 | `export_glb` | PASS | bytes/b64=0 |
| 20 | `validate_scene` | PASS | valid=true errors=0 |
| 21 | `check_collisions` | PASS | 0 collision(s) |
| 22 | `analyze_floorplan_image` | PASS | walls=4 rooms=1 conf=0.82 |
| 23 | `analyze_room_photo` | PASS | w=4m fixtures=2 |
| 24 | `save_scene` | PASS | id=7b7ca35340e3 v=1 nodes=31 |
| 25 | `list_scenes` | PASS | 1 scene(s) |
| 26 | `load_scene` | PASS | id=7b7ca35340e3 nodes=31 |
| 27 | `generate_variants` | PASS | 2 variants, ids=2 |
| 28 | `photo_to_scene` | PASS | sceneId=fbde55412851 walls=4 rooms=1 |
| 29 | `rename_scene` | PASS | name=p10 base renamed |
| 30 | `delete_scene` | PASS | deleted=86366d07b242 |
## Pass matrix — resources
| # | Resource | Status | Note |
|---|----------|--------|------|
| 1 | `pascal://scene/current` | PASS | 8 nodes / 1 roots |
| 2 | `pascal://scene/current/summary` | PASS | 425 chars of markdown |
| 3 | `pascal://catalog/items` | PASS | 0 items |
| 4 | `pascal://constraints/{levelId}` | PASS | slabs=0 wallPolys=4 |
## Pass matrix — prompts
| # | Prompt | Status | Note |
|---|--------|--------|------|
| 1 | `from_brief` | PASS | 1 message(s) |
| 2 | `iterate_on_feedback` | PASS | 1 message(s) |
| 3 | `renovation_from_photos` | PASS | 6 message(s) |
@@ -1,955 +0,0 @@
/**
* Phase 8 P10 — Comprehensive stdio MCP sweep.
*
* Exercises every tool currently registered by the MCP (original 21 from Phase 4
* plus the 9 Phase 7 additions), every resource (4), and every prompt (3).
* Advertises the `sampling` capability with a canned handler so vision /
* photo_to_scene tools can return valid JSON without a real vision API.
*
* Run: PASCAL_DATA_DIR=/tmp/pascal-phase8-p10 \
* bun run packages/mcp/test-reports/phase8/p10-full-sweep.ts
*/
import { rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p10-full-sweep.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p10'
type Status = 'PASS' | 'PARTIAL' | 'FAIL'
type Row = {
kind: 'tool' | 'resource' | 'prompt'
name: string
status: Status
note: string
}
const rows: Row[] = []
function record(kind: Row['kind'], name: string, status: Status, note: string): void {
rows.push({ kind, name, status, note })
const tag = status === 'PASS' ? '[PASS]' : status === 'PARTIAL' ? '[PART]' : '[FAIL]'
console.log(`${tag} ${kind}:${name}${note}`)
}
function pickText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
return content[0]?.text ?? ''
}
/** Canned valid floor-plan response for `analyze_floorplan_image` / `photo_to_scene`. */
const CANNED_FLOORPLAN = {
walls: [
{ start: [0, 0], end: [6, 0], thickness: 0.2 },
{ start: [6, 0], end: [6, 4], thickness: 0.2 },
{ start: [6, 4], end: [0, 4], thickness: 0.2 },
{ start: [0, 4], end: [0, 0], thickness: 0.2 },
],
rooms: [
{
name: 'main room',
polygon: [
[0, 0],
[6, 0],
[6, 4],
[0, 4],
],
approximateAreaSqM: 24,
},
],
approximateDimensions: { widthM: 6, depthM: 4 },
confidence: 0.82,
}
/** Canned valid room-photo response for `analyze_room_photo`. */
const CANNED_ROOM = {
approximateDimensions: { widthM: 4, lengthM: 5, heightM: 2.6 },
identifiedFixtures: [{ type: 'sofa', approximatePosition: [2, 3] }, { type: 'coffee table' }],
identifiedWindows: [{ wallLabel: 'north', approximateWidthM: 1.2, approximateHeightM: 1.5 }],
}
type SamplingKind = 'floorplan' | 'room'
function detectSamplingKind(req: unknown): SamplingKind {
// Inspect the host's systemPrompt to pick which canned payload to return.
const sp = (req as { params?: { systemPrompt?: string } })?.params?.systemPrompt ?? ''
if (sp.includes('floor-plan')) return 'floorplan'
return 'room'
}
async function main(): Promise<void> {
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {
/* ignore */
}
const t0 = Date.now()
console.log('---- Phase 8 P10 full sweep (stdio + mocked sampling) ----')
console.log(`BIN=${BIN_PATH}`)
console.log(`DATA_DIR=${DATA_DIR}`)
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
})
const client = new Client({ name: 'p10', version: '0.0.0' }, { capabilities: { sampling: {} } })
// Canned sampling handler — supports both floorplan and room-photo prompts.
client.setRequestHandler(CreateMessageRequestSchema, async (req) => {
const kind = detectSamplingKind(req)
const json = kind === 'floorplan' ? CANNED_FLOORPLAN : CANNED_ROOM
return {
model: 'canned-test-model',
role: 'assistant',
content: { type: 'text', text: JSON.stringify(json) },
stopReason: 'endTurn',
} as never
})
await client.connect(transport)
console.log('OK client connected (sampling capability advertised)')
// ----------------------------------------------------------------------
// listTools, listResources, listPrompts
// ----------------------------------------------------------------------
const tools = await client.listTools()
const resources = await client.listResources()
const resourceTemplates = await client.listResourceTemplates()
const prompts = await client.listPrompts()
const toolNames = tools.tools.map((t) => t.name).sort()
const resourceNames = resources.resources.map((r) => r.uri).sort()
const resourceTemplateNames = resourceTemplates.resourceTemplates.map((r) => r.uriTemplate).sort()
const promptNames = prompts.prompts.map((p) => p.name).sort()
console.log(`listTools → ${toolNames.length}: ${toolNames.join(', ')}`)
console.log(`listResources → ${resourceNames.length}: ${resourceNames.join(', ')}`)
console.log(
`listResourceTemplates → ${resourceTemplateNames.length}: ${resourceTemplateNames.join(', ')}`,
)
console.log(`listPrompts → ${promptNames.length}: ${promptNames.join(', ')}`)
// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
async function callTool(
name: string,
args: Record<string, unknown>,
): Promise<{ ok: boolean; structured?: any; text: string; err?: string }> {
try {
const res = (await client.callTool({ name, arguments: args })) as any
const text = pickText(res)
if (res.isError) return { ok: false, structured: res.structuredContent, text, err: text }
return { ok: true, structured: res.structuredContent, text }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, text: '', err: msg }
}
}
// ----------------------------------------------------------------------
// 1. list_templates
// ----------------------------------------------------------------------
const lt = await callTool('list_templates', {})
const templateList = lt.structured?.templates as
| Array<{ id: string; nodeCount: number }>
| undefined
if (lt.ok && templateList && templateList.length >= 3) {
record('tool', 'list_templates', 'PASS', `${templateList.length} templates`)
} else {
record('tool', 'list_templates', 'FAIL', lt.err ?? 'no templates')
}
// ----------------------------------------------------------------------
// 2. create_from_template — two-bedroom (no save — bridge mode)
// ----------------------------------------------------------------------
const cft = await callTool('create_from_template', { id: 'two-bedroom' })
if (cft.ok && cft.structured?.nodeCount > 0) {
record(
'tool',
'create_from_template',
'PASS',
`templateId=${cft.structured.templateId} nodes=${cft.structured.nodeCount}`,
)
} else {
record('tool', 'create_from_template', 'FAIL', cft.err ?? 'no nodes')
}
// ----------------------------------------------------------------------
// 3. get_scene
// ----------------------------------------------------------------------
const gs = await callTool('get_scene', {})
const sceneNodes: Record<string, any> =
gs.structured?.nodes ?? (gs.text ? JSON.parse(gs.text).nodes : {})
const sceneRoots: string[] =
gs.structured?.rootNodeIds ?? (gs.text ? JSON.parse(gs.text).rootNodeIds : [])
const nodeCount = Object.keys(sceneNodes).length
if (gs.ok && nodeCount > 0) {
record('tool', 'get_scene', 'PASS', `${nodeCount} nodes / ${sceneRoots.length} roots`)
} else {
record('tool', 'get_scene', 'FAIL', gs.err ?? 'empty')
}
const findFirst = (type: string): any | null => {
for (const n of Object.values(sceneNodes)) if ((n as any).type === type) return n
return null
}
const siteNode = findFirst('site') ?? (sceneRoots[0] ? sceneNodes[sceneRoots[0]] : null)
const buildingNode = findFirst('building')
const levelNode = findFirst('level')
const existingWall = findFirst('wall')
const existingZone = findFirst('zone')
// ----------------------------------------------------------------------
// 4. get_node
// ----------------------------------------------------------------------
const gn = await callTool('get_node', { id: siteNode?.id ?? sceneRoots[0] ?? '' })
if (gn.ok && gn.structured?.node?.id) {
record('tool', 'get_node', 'PASS', `type=${gn.structured.node.type}`)
} else {
record('tool', 'get_node', 'FAIL', gn.err ?? 'no node')
}
// ----------------------------------------------------------------------
// 5. describe_node
// ----------------------------------------------------------------------
const dn = await callTool('describe_node', { id: siteNode?.id ?? sceneRoots[0] ?? '' })
if (dn.ok && dn.structured?.type) {
record(
'tool',
'describe_node',
'PASS',
`type=${dn.structured.type} children=${dn.structured.childrenIds?.length ?? 0}`,
)
} else {
record('tool', 'describe_node', 'FAIL', dn.err ?? 'no type')
}
// ----------------------------------------------------------------------
// 6. find_nodes — levels
// ----------------------------------------------------------------------
const fn = await callTool('find_nodes', { type: 'level' })
const foundLevels = fn.structured?.nodes ?? []
const groundLevelId: string | undefined = foundLevels[0]?.id ?? levelNode?.id ?? undefined
if (fn.ok && foundLevels.length > 0) {
record('tool', 'find_nodes', 'PASS', `${foundLevels.length} level(s)`)
} else {
record('tool', 'find_nodes', 'FAIL', fn.err ?? 'no levels')
}
// ----------------------------------------------------------------------
// 7. measure — two wall nodes if possible
// ----------------------------------------------------------------------
const wallsFoundRes = await callTool('find_nodes', { type: 'wall' })
const walls: any[] = wallsFoundRes.structured?.nodes ?? []
const measureFromId = walls[0]?.id ?? siteNode?.id ?? ''
const measureToId = walls[1]?.id ?? walls[0]?.id ?? siteNode?.id ?? ''
const mm = await callTool('measure', { fromId: measureFromId, toId: measureToId })
if (mm.ok && mm.structured?.distanceMeters !== undefined) {
record('tool', 'measure', 'PASS', `d=${Number(mm.structured.distanceMeters).toFixed(3)}m`)
} else {
record('tool', 'measure', 'FAIL', mm.err ?? 'no distance')
}
// ----------------------------------------------------------------------
// 8. apply_patch — add a wall via patch
// ----------------------------------------------------------------------
const patchWallId = `wall_p10_${Date.now()}`
const ap = await callTool('apply_patch', {
patches: [
{
op: 'create',
parentId: groundLevelId,
node: {
id: patchWallId,
type: 'wall',
children: [],
start: [10, 0],
end: [13, 0],
thickness: 0.12,
height: 2.6,
frontSide: 'unknown',
backSide: 'unknown',
},
},
],
})
if (ap.ok && ap.structured?.appliedOps >= 1) {
record('tool', 'apply_patch', 'PASS', `applied=${ap.structured.appliedOps}`)
} else {
record('tool', 'apply_patch', 'FAIL', ap.err ?? 'no apply')
}
// ----------------------------------------------------------------------
// 9. create_level
// ----------------------------------------------------------------------
let newLevelId: string | undefined
if (buildingNode?.id) {
const cl = await callTool('create_level', {
buildingId: buildingNode.id,
elevation: 6,
height: 3,
})
if (cl.ok && cl.structured?.levelId) {
newLevelId = cl.structured.levelId
record('tool', 'create_level', 'PASS', `levelId=${newLevelId}`)
} else {
record('tool', 'create_level', 'FAIL', cl.err ?? 'no levelId')
}
} else {
record('tool', 'create_level', 'FAIL', 'no building in scene')
}
// ----------------------------------------------------------------------
// 10. create_wall
// ----------------------------------------------------------------------
let newWallId: string | undefined
if (groundLevelId) {
const cw = await callTool('create_wall', {
levelId: groundLevelId,
start: [20, 0],
end: [24, 0],
thickness: 0.14,
height: 2.7,
})
if (cw.ok && cw.structured?.wallId) {
newWallId = cw.structured.wallId
record('tool', 'create_wall', 'PASS', `wallId=${newWallId}`)
} else {
record('tool', 'create_wall', 'FAIL', cw.err ?? 'no wallId')
}
} else {
record('tool', 'create_wall', 'FAIL', 'no ground level')
}
// ----------------------------------------------------------------------
// 11. place_item — on the wall we just made
// ----------------------------------------------------------------------
const targetItemId = newWallId ?? existingWall?.id ?? siteNode?.id
const pi = await callTool('place_item', {
catalogItemId: 'test-chair',
targetNodeId: targetItemId ?? '',
position: [1, 0, 1],
})
// Accept either a full itemId or a status=catalog_unavailable
const piStatus = pi.structured?.status
if (pi.ok && pi.structured?.itemId) {
record('tool', 'place_item', 'PASS', `itemId=${pi.structured.itemId}`)
} else if (pi.ok && piStatus === 'catalog_unavailable') {
record('tool', 'place_item', 'PARTIAL', `status=${piStatus}`)
} else {
record('tool', 'place_item', 'FAIL', pi.err ?? 'no itemId')
}
// ----------------------------------------------------------------------
// 12. cut_opening — on newWall (or existing wall)
// ----------------------------------------------------------------------
const cutWallId = newWallId ?? existingWall?.id
if (cutWallId) {
const co = await callTool('cut_opening', {
wallId: cutWallId,
type: 'door',
position: 0.5,
width: 0.9,
height: 2.1,
})
if (co.ok && co.structured?.openingId) {
record('tool', 'cut_opening', 'PASS', `openingId=${co.structured.openingId}`)
} else {
record('tool', 'cut_opening', 'FAIL', co.err ?? 'no opening')
}
} else {
record('tool', 'cut_opening', 'FAIL', 'no wall available')
}
// ----------------------------------------------------------------------
// 13. set_zone
// ----------------------------------------------------------------------
if (groundLevelId) {
const sz = await callTool('set_zone', {
levelId: groundLevelId,
polygon: [
[100, 100],
[105, 100],
[105, 103],
[100, 103],
],
label: 'p10 zone',
})
if (sz.ok && sz.structured?.zoneId) {
record('tool', 'set_zone', 'PASS', `zoneId=${sz.structured.zoneId}`)
} else {
record('tool', 'set_zone', 'FAIL', sz.err ?? 'no zoneId')
}
} else {
record('tool', 'set_zone', 'FAIL', 'no level')
}
// ----------------------------------------------------------------------
// 14. duplicate_level
// ----------------------------------------------------------------------
let dupLevelId: string | undefined
if (groundLevelId) {
const dl = await callTool('duplicate_level', { levelId: groundLevelId })
if (dl.ok && dl.structured?.newLevelId) {
dupLevelId = dl.structured.newLevelId
record(
'tool',
'duplicate_level',
'PASS',
`newLevelId=${dupLevelId} nodes=${dl.structured?.newNodeIds?.length ?? 0}`,
)
} else {
record('tool', 'duplicate_level', 'FAIL', dl.err ?? 'no dup')
}
} else {
record('tool', 'duplicate_level', 'FAIL', 'no level')
}
// ----------------------------------------------------------------------
// 15. delete_node — delete the duplicated level
// ----------------------------------------------------------------------
if (dupLevelId) {
const del = await callTool('delete_node', { id: dupLevelId, cascade: true })
if (del.ok && (del.structured?.deletedIds?.length ?? 0) > 0) {
record('tool', 'delete_node', 'PASS', `deleted ${del.structured.deletedIds.length} nodes`)
} else {
record('tool', 'delete_node', 'FAIL', del.err ?? 'nothing deleted')
}
} else {
record('tool', 'delete_node', 'FAIL', 'no duplicated level')
}
// ----------------------------------------------------------------------
// 16. undo
// ----------------------------------------------------------------------
const uu = await callTool('undo', {})
if (uu.ok) {
record('tool', 'undo', 'PASS', `undone=${uu.structured?.undone ?? 'n/a'}`)
} else {
record('tool', 'undo', 'FAIL', uu.err ?? 'threw')
}
// ----------------------------------------------------------------------
// 17. redo
// ----------------------------------------------------------------------
const rr = await callTool('redo', {})
if (rr.ok) {
record('tool', 'redo', 'PASS', `redone=${rr.structured?.redone ?? 'n/a'}`)
} else {
record('tool', 'redo', 'FAIL', rr.err ?? 'threw')
}
// ----------------------------------------------------------------------
// 18. export_json
// ----------------------------------------------------------------------
const ej = await callTool('export_json', { pretty: true })
if (ej.ok && typeof ej.structured?.json === 'string' && ej.structured.json.length > 0) {
record('tool', 'export_json', 'PASS', `${ej.structured.json.length} chars`)
} else {
record('tool', 'export_json', 'FAIL', ej.err ?? 'no json')
}
// ----------------------------------------------------------------------
// 19. export_glb
// ----------------------------------------------------------------------
const eg = await callTool('export_glb', {})
if (eg.ok) {
const size =
eg.structured?.base64?.length ?? eg.structured?.sizeBytes ?? eg.structured?.byteLength ?? 0
record('tool', 'export_glb', 'PASS', `bytes/b64=${size}`)
} else {
record('tool', 'export_glb', 'FAIL', eg.err ?? 'threw')
}
// ----------------------------------------------------------------------
// 20. validate_scene
// ----------------------------------------------------------------------
const vs = await callTool('validate_scene', {})
if (vs.ok && vs.structured?.valid !== undefined) {
record(
'tool',
'validate_scene',
vs.structured.valid ? 'PASS' : 'PARTIAL',
`valid=${vs.structured.valid} errors=${vs.structured.errors?.length ?? 0}`,
)
} else {
record('tool', 'validate_scene', 'FAIL', vs.err ?? 'no result')
}
// ----------------------------------------------------------------------
// 21. check_collisions
// ----------------------------------------------------------------------
const cc = await callTool('check_collisions', {})
if (cc.ok) {
record(
'tool',
'check_collisions',
'PASS',
`${cc.structured?.collisions?.length ?? 0} collision(s)`,
)
} else {
record('tool', 'check_collisions', 'FAIL', cc.err ?? 'threw')
}
// ----------------------------------------------------------------------
// 22. analyze_floorplan_image — mocked sampling returns the floorplan canned JSON
// ----------------------------------------------------------------------
const afi = await callTool('analyze_floorplan_image', {
image: 'base64data',
})
if (afi.ok && afi.structured?.walls?.length > 0) {
record(
'tool',
'analyze_floorplan_image',
'PASS',
`walls=${afi.structured.walls.length} rooms=${afi.structured.rooms?.length ?? 0} conf=${afi.structured.confidence}`,
)
} else {
record('tool', 'analyze_floorplan_image', 'FAIL', afi.err ?? 'no walls')
}
// ----------------------------------------------------------------------
// 23. analyze_room_photo — mocked sampling returns the room canned JSON
// ----------------------------------------------------------------------
const arp = await callTool('analyze_room_photo', { image: 'base64data' })
if (arp.ok && arp.structured?.approximateDimensions?.widthM) {
record(
'tool',
'analyze_room_photo',
'PASS',
`w=${arp.structured.approximateDimensions.widthM}m fixtures=${arp.structured.identifiedFixtures?.length ?? 0}`,
)
} else {
record('tool', 'analyze_room_photo', 'FAIL', arp.err ?? 'no dims')
}
// ----------------------------------------------------------------------
// 24. save_scene — current bridge state
// ----------------------------------------------------------------------
const ss = await callTool('save_scene', { name: 'p10 base scene' })
let baseSceneId: string | undefined
if (ss.ok && ss.structured?.id) {
baseSceneId = ss.structured.id
record(
'tool',
'save_scene',
'PASS',
`id=${baseSceneId} v=${ss.structured.version} nodes=${ss.structured.nodeCount}`,
)
} else {
record('tool', 'save_scene', 'FAIL', ss.err ?? 'no id')
}
// ----------------------------------------------------------------------
// 25. list_scenes — should contain base
// ----------------------------------------------------------------------
const ls1 = await callTool('list_scenes', {})
if (ls1.ok && ls1.structured?.scenes?.length >= 1) {
record('tool', 'list_scenes', 'PASS', `${ls1.structured.scenes.length} scene(s)`)
} else {
record('tool', 'list_scenes', 'FAIL', ls1.err ?? 'no scenes')
}
// ----------------------------------------------------------------------
// 26. load_scene — round-trip the base
// ----------------------------------------------------------------------
if (baseSceneId) {
const load = await callTool('load_scene', { id: baseSceneId })
if (load.ok && load.structured?.id === baseSceneId) {
record(
'tool',
'load_scene',
'PASS',
`id=${load.structured.id} nodes=${load.structured.nodeCount}`,
)
} else {
record('tool', 'load_scene', 'FAIL', load.err ?? 'no match')
}
} else {
record('tool', 'load_scene', 'FAIL', 'no baseSceneId')
}
// ----------------------------------------------------------------------
// 27. generate_variants — 2 variants, save=true
// ----------------------------------------------------------------------
const gv = await callTool('generate_variants', {
count: 2,
vary: ['wall-thickness', 'wall-height'],
seed: 42,
save: true,
})
let variantIds: string[] = []
if (gv.ok && gv.structured?.variants?.length === 2) {
variantIds = gv.structured.variants.map((v: any) => v.sceneId).filter(Boolean)
record(
'tool',
'generate_variants',
'PASS',
`${gv.structured.variants.length} variants, ids=${variantIds.length}`,
)
} else {
record('tool', 'generate_variants', 'FAIL', gv.err ?? 'no variants')
}
// ----------------------------------------------------------------------
// 28. photo_to_scene — replaces bridge with mocked floorplan-derived scene
// ----------------------------------------------------------------------
// Use base64 instead of https:// so we don't hit the network.
const pts = await callTool('photo_to_scene', {
image: 'base64floorplandata',
name: 'p10 photo scene',
save: true,
})
let photoSceneId: string | undefined
if (pts.ok && pts.structured?.sceneId) {
photoSceneId = pts.structured.sceneId
record(
'tool',
'photo_to_scene',
'PASS',
`sceneId=${photoSceneId} walls=${pts.structured.walls} rooms=${pts.structured.rooms}`,
)
} else {
record('tool', 'photo_to_scene', 'FAIL', pts.err ?? 'no sceneId')
}
// ----------------------------------------------------------------------
// 29. rename_scene — rename the base
// ----------------------------------------------------------------------
if (baseSceneId) {
const rn = await callTool('rename_scene', {
id: baseSceneId,
newName: 'p10 base renamed',
})
if (rn.ok && rn.structured?.name === 'p10 base renamed') {
record('tool', 'rename_scene', 'PASS', `name=${rn.structured.name}`)
} else {
record('tool', 'rename_scene', 'FAIL', rn.err ?? 'not renamed')
}
} else {
record('tool', 'rename_scene', 'FAIL', 'no baseSceneId')
}
// ----------------------------------------------------------------------
// 30. delete_scene — delete a variant
// ----------------------------------------------------------------------
const toDeleteId = variantIds[0] ?? baseSceneId
if (toDeleteId) {
const ds = await callTool('delete_scene', { id: toDeleteId })
if (ds.ok && ds.structured?.deleted === true) {
record('tool', 'delete_scene', 'PASS', `deleted=${toDeleteId}`)
} else {
record('tool', 'delete_scene', 'FAIL', ds.err ?? 'not deleted')
}
} else {
record('tool', 'delete_scene', 'FAIL', 'no id to delete')
}
// list_scenes again — should see base + remaining variant + photo (base renamed, variant[0] deleted)
const ls2 = await callTool('list_scenes', {})
if (ls2.ok) {
const count = ls2.structured?.scenes?.length ?? 0
console.log(`[verify] list_scenes now = ${count}`)
}
// ========================================================================
// RESOURCES
// ========================================================================
// ----------------------------------------------------------------------
// R1. pascal://scene/current
// ----------------------------------------------------------------------
try {
const r = await client.readResource({ uri: 'pascal://scene/current' })
const text = (r.contents[0] as any)?.text ?? ''
const parsed = text ? JSON.parse(text) : null
if (parsed?.nodes && parsed?.rootNodeIds) {
record(
'resource',
'pascal://scene/current',
'PASS',
`${Object.keys(parsed.nodes).length} nodes / ${parsed.rootNodeIds.length} roots`,
)
} else {
record('resource', 'pascal://scene/current', 'FAIL', 'no scene JSON')
}
} catch (err) {
record(
'resource',
'pascal://scene/current',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ----------------------------------------------------------------------
// R2. pascal://scene/current/summary
// ----------------------------------------------------------------------
try {
const r = await client.readResource({ uri: 'pascal://scene/current/summary' })
const text = (r.contents[0] as any)?.text ?? ''
if (text.includes('Scene summary')) {
record(
'resource',
'pascal://scene/current/summary',
'PASS',
`${text.length} chars of markdown`,
)
} else {
record('resource', 'pascal://scene/current/summary', 'FAIL', 'no "Scene summary" header')
}
} catch (err) {
record(
'resource',
'pascal://scene/current/summary',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ----------------------------------------------------------------------
// R3. pascal://catalog/items
// ----------------------------------------------------------------------
try {
const r = await client.readResource({ uri: 'pascal://catalog/items' })
const text = (r.contents[0] as any)?.text ?? ''
const parsed = text ? JSON.parse(text) : null
// Accept either a real catalog or the advertised `catalog_unavailable` status.
if (parsed?.items || parsed?.status === 'catalog_unavailable' || parsed?.error) {
record(
'resource',
'pascal://catalog/items',
'PASS',
parsed?.items ? `${parsed.items.length} items` : `status=${parsed.status ?? parsed.error}`,
)
} else {
record('resource', 'pascal://catalog/items', 'PARTIAL', `unrecognised payload`)
}
} catch (err) {
record(
'resource',
'pascal://catalog/items',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ----------------------------------------------------------------------
// R4. pascal://constraints/{levelId}
// ----------------------------------------------------------------------
try {
// Use the photo-scene level — bridge is now loaded with that state.
// Fetch fresh level id via find_nodes.
const currentLvlRes = await callTool('find_nodes', { type: 'level' })
const liveLevelId = currentLvlRes.structured?.nodes?.[0]?.id ?? groundLevelId ?? 'unknown-level'
const r = await client.readResource({ uri: `pascal://constraints/${liveLevelId}` })
const text = (r.contents[0] as any)?.text ?? ''
const parsed = text ? JSON.parse(text) : null
if (parsed?.levelId === liveLevelId && Array.isArray(parsed?.wallPolygons)) {
record(
'resource',
'pascal://constraints/{levelId}',
'PASS',
`slabs=${parsed.slabs?.length ?? 0} wallPolys=${parsed.wallPolygons.length}`,
)
} else if (parsed?.error === 'level_not_found') {
record(
'resource',
'pascal://constraints/{levelId}',
'PARTIAL',
`level_not_found for ${liveLevelId}`,
)
} else {
record('resource', 'pascal://constraints/{levelId}', 'FAIL', 'unexpected payload')
}
} catch (err) {
record(
'resource',
'pascal://constraints/{levelId}',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ========================================================================
// PROMPTS
// ========================================================================
// ----------------------------------------------------------------------
// P1. from_brief
// ----------------------------------------------------------------------
try {
const r = await client.getPrompt({
name: 'from_brief',
arguments: { brief: 'a simple 40 m^2 studio with a kitchenette' },
})
if (r.messages?.length >= 1 && (r.messages[0]?.content as any)?.text?.includes('Brief')) {
record('prompt', 'from_brief', 'PASS', `${r.messages.length} message(s)`)
} else {
record('prompt', 'from_brief', 'FAIL', 'no Brief header')
}
} catch (err) {
record(
'prompt',
'from_brief',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ----------------------------------------------------------------------
// P2. iterate_on_feedback
// ----------------------------------------------------------------------
try {
const r = await client.getPrompt({
name: 'iterate_on_feedback',
arguments: { feedback: 'move the kitchen island 1m closer to the window' },
})
if (
r.messages?.length >= 1 &&
(r.messages[0]?.content as any)?.text?.includes('User feedback')
) {
record('prompt', 'iterate_on_feedback', 'PASS', `${r.messages.length} message(s)`)
} else {
record('prompt', 'iterate_on_feedback', 'FAIL', 'no feedback header')
}
} catch (err) {
record(
'prompt',
'iterate_on_feedback',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// ----------------------------------------------------------------------
// P3. renovation_from_photos
// ----------------------------------------------------------------------
try {
const r = await client.getPrompt({
name: 'renovation_from_photos',
arguments: {
currentPhotos: JSON.stringify(['https://example.com/before.jpg']),
referencePhotos: JSON.stringify(['https://example.com/after.jpg']),
goals: 'modernise the kitchen with a large island',
},
})
if (r.messages?.length >= 1) {
record('prompt', 'renovation_from_photos', 'PASS', `${r.messages.length} message(s)`)
} else {
record('prompt', 'renovation_from_photos', 'FAIL', 'no messages')
}
} catch (err) {
record(
'prompt',
'renovation_from_photos',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
await client.close()
console.log('OK client closed')
const elapsedMs = Date.now() - t0
const passed = rows.filter((r) => r.status === 'PASS').length
const partial = rows.filter((r) => r.status === 'PARTIAL').length
const failed = rows.filter((r) => r.status === 'FAIL').length
// ----------------------------------------------------------------------
// Write report
// ----------------------------------------------------------------------
const ts = new Date().toISOString()
const md: string[] = []
md.push('# Phase 8 P10 — full sweep (stdio MCP)')
md.push('')
md.push(`Generated: ${ts}`)
md.push('')
md.push('## Summary')
md.push('')
md.push(`- Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`)`)
md.push(`- Data dir: \`${DATA_DIR}\``)
md.push(`- Sampling: mocked via \`client.setRequestHandler(CreateMessageRequestSchema, …)\``)
md.push(`- Tools listed: **${toolNames.length}**`)
md.push(
`- Resources listed: **${resourceNames.length}** static + **${resourceTemplateNames.length}** template`,
)
md.push(`- Prompts listed: **${promptNames.length}**`)
md.push(`- Total entries exercised: **${rows.length}**`)
md.push(`- PASS: **${passed}** / PARTIAL: **${partial}** / FAIL: **${failed}**`)
md.push(`- Run time: **${elapsedMs} ms**`)
md.push('')
md.push('## Listed tools')
md.push('')
md.push('```')
md.push(toolNames.join('\n'))
md.push('```')
md.push('')
md.push('## Listed resources')
md.push('')
md.push('```')
md.push(['(static)', ...resourceNames, '', '(templates)', ...resourceTemplateNames].join('\n'))
md.push('```')
md.push('')
md.push('## Listed prompts')
md.push('')
md.push('```')
md.push(promptNames.join('\n'))
md.push('```')
md.push('')
md.push('## Pass matrix — tools')
md.push('')
md.push('| # | Tool | Status | Note |')
md.push('|---|------|--------|------|')
rows
.filter((r) => r.kind === 'tool')
.forEach((r, i) => {
md.push(`| ${i + 1} | \`${r.name}\` | ${r.status} | ${r.note.replace(/\|/g, '\\|')} |`)
})
md.push('')
md.push('## Pass matrix — resources')
md.push('')
md.push('| # | Resource | Status | Note |')
md.push('|---|----------|--------|------|')
rows
.filter((r) => r.kind === 'resource')
.forEach((r, i) => {
md.push(`| ${i + 1} | \`${r.name}\` | ${r.status} | ${r.note.replace(/\|/g, '\\|')} |`)
})
md.push('')
md.push('## Pass matrix — prompts')
md.push('')
md.push('| # | Prompt | Status | Note |')
md.push('|---|--------|--------|------|')
rows
.filter((r) => r.kind === 'prompt')
.forEach((r, i) => {
md.push(`| ${i + 1} | \`${r.name}\` | ${r.status} | ${r.note.replace(/\|/g, '\\|')} |`)
})
md.push('')
writeFileSync(REPORT_PATH, md.join('\n'), 'utf8')
console.log(`\nreport written: ${REPORT_PATH}`)
console.log(
`PASS=${passed} PARTIAL=${partial} FAIL=${failed} total=${rows.length} ms=${elapsedMs}`,
)
if (failed > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p10] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,69 +0,0 @@
# P2 Phase 8 — `generate_variants` report
Generated: 2026-04-19T18:19:47.106Z
Data dir: `/tmp/pascal-phase8-p2`
Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`)
Total run time: 32 ms
## Setup
- template: `two-bedroom`
- base nodeCount: **25**
- base saved: **true** (id=`4a051220b1e6`)
## Per-mutation results
| # | Mutation | Status | nodeCounts | Summary |
|---|----------|--------|------------|---------|
| 1 | `wall-thickness` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
| 2 | `wall-height` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
| 3 | `zone-labels` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
| 4 | `room-proportions` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
| 5 | `open-plan` | FAIL | [23, 24] | variant nodeCount 23 < min 24 (base=25) |
| 6 | `door-positions` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
| 7 | `fence-style` | PASS | [25, 25] | 2 variants, nodeCounts=[25,25], min=25 |
### Variant descriptions
- **wall-thickness**: "wall thickness 0.2m", "wall thickness 0.25m"
- **wall-height**: "wall height 2.7m", "wall height 3m"
- **zone-labels**: "zones [Living / Kitchen, Bedroom 2, Bedroom 1, Bath]", "zones [Bath, Bedroom 1, Living / Kitchen, Bedroom 2]"
- **room-proportions**: "room proportions nudged", "room proportions nudged"
- **open-plan**: "open-plan", "open-plan"
- **door-positions**: "doors repositioned", "doors repositioned"
- **fence-style**: "no-op", "no-op"
## Determinism
- Status: **PASS**
- Detail: 3 variant graphs identical (after ID normalization) across calls (wall-thickness, seed=1337)
## Save path
- Status: **PASS**
- Detail: variants saved=3, list_scenes returned 4 (expected 4)
- Variants saved in step: 3
- `list_scenes` after save: 4
## Combined mutation validation
- Status: **PASS**
- Detail: combined variant sceneId=18e32febadc7, valid=true, errors=0, description="wall thickness 0.1m, wall height 2.7m, zones [Bedroom 2, Living / Kitchen, Bath, Bedroom 1], room proportions nudged, open-plan, doors repositioned"
- valid: true, errorCount: 0
## Error path
- Status: **PASS**
- Detail: isError with text: MCP error -32602: scene_not_found
## Totals
- Total variants saved across the run: **4**
## Overall summary
**Summary (≤150 words):**
Per-mutation: 6/7 PASS. Determinism: PASS (identical after id normalization; `forkSceneGraph` regenerates ids so raw JSON can't match). Save path: PASS — 3 variants saved, `list_scenes` returned 4 (expected 4). Combined mutation: PASS (variant validates). Error path: PASS. Total variants saved: 4. Total variants exercised across the run: ~21.
Note: the only failing mutation is `open-plan` — nodeCounts [23, 24] with base=25. The spec rule `>= base - 1` assumes open-plan drops only the wall node, but `applyOpenPlan` also drops any openings (doors/windows) attached to the removed wall — so a variant may drop 2+ nodes. The mutation itself is working correctly; the spec's lower-bound rule is tighter than the implementation.
@@ -1,639 +0,0 @@
/**
* P2 Phase 8 variants test: exercises `generate_variants` across every
* mutation kind, proves determinism with seeds, and proves save=true works.
*
* Run with:
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p2 bun packages/mcp/test-reports/phase8/p2-variants.ts
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p2-variants.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p2'
const MUTATION_KINDS = [
'wall-thickness',
'wall-height',
'zone-labels',
'room-proportions',
'open-plan',
'door-positions',
'fence-style',
] as const
type MutationKind = (typeof MUTATION_KINDS)[number]
type Variant = {
index: number
description: string
nodeCount: number
sceneId?: string
url?: string
graph?: {
nodes: Record<string, unknown>
rootNodeIds: string[]
collections?: unknown
}
}
type MutationResult = {
kind: MutationKind
status: 'pass' | 'fail'
summary: string
descriptions: string[]
nodeCounts: number[]
}
type TestResults = {
baseSceneId: string | null
baseNodeCount: number
baseSaved: boolean
perMutation: MutationResult[]
determinism: { status: 'pass' | 'fail'; detail: string }
savePath: {
status: 'pass' | 'fail'
detail: string
variantsSaved: number
listedAfter: number
}
combined: { status: 'pass' | 'fail'; detail: string; valid?: boolean; errorCount?: number }
errorPath: { status: 'pass' | 'fail'; detail: string }
totalVariantsSaved: number
}
function pickContentText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
const first = content[0]
if (first && typeof first === 'object' && typeof first.text === 'string') return first.text
return ''
}
function parseStructured(result: { structuredContent?: unknown; content?: unknown }): any {
if (result.structuredContent !== undefined) return result.structuredContent
const text = pickContentText(result)
try {
return JSON.parse(text)
} catch {
return null
}
}
/**
* Deep-canonicalize: produce a stable JSON string (sorted keys). Used to
* compare two variant graphs across separate `generate_variants` calls.
*/
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalize(v)).join(',')}]`
}
const obj = value as Record<string, unknown>
const keys = Object.keys(obj).sort()
const parts = keys.map((k) => `${JSON.stringify(k)}:${canonicalize(obj[k])}`)
return `{${parts.join(',')}}`
}
/**
* Normalize node IDs in a SceneGraph. `forkSceneGraph` regenerates random IDs
* on every call, so byte-identical JSON is impossible across runs — but a
* *deterministic* seed must still produce the same mutation outcomes on the
* same structure. This canonicalizer replaces every `id` (and every string
* field that references a node id — `parentId`, `wallId`, `children`,
* `rootNodeIds`) with a stable index based on insertion order.
*/
function normalizeGraphIds(
graph: { nodes: Record<string, any>; rootNodeIds: string[]; collections?: any } | undefined,
): any {
if (!graph) return null
const entries = Object.entries(graph.nodes ?? {})
const idMap = new Map<string, string>()
entries.forEach(([id], i) => {
idMap.set(id, `NODE_${i}`)
})
const mapId = (v: unknown): unknown => (typeof v === 'string' && idMap.has(v) ? idMap.get(v) : v)
const mapChildren = (children: unknown[]): unknown[] =>
children.map((child) => {
if (typeof child === 'string') return mapId(child)
if (child && typeof child === 'object' && 'id' in (child as any)) {
return normalizeNode(child)
}
return child
})
const normalizeNode = (node: unknown): any => {
if (!node || typeof node !== 'object') return node
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(node as Record<string, any>)) {
if (k === 'id' || k === 'parentId' || k === 'wallId') {
out[k] = typeof v === 'string' ? mapId(v) : v
} else if (k === 'children' && Array.isArray(v)) {
out[k] = mapChildren(v)
} else if (Array.isArray(v)) {
out[k] = v.map((x) =>
x && typeof x === 'object'
? normalizeNode(x)
: typeof x === 'string' && idMap.has(x)
? mapId(x)
: x,
)
} else if (v && typeof v === 'object') {
out[k] = normalizeNode(v)
} else {
out[k] = v
}
}
return out
}
const normalizedNodes: Record<string, any> = {}
for (const [id, node] of entries) {
const newId = idMap.get(id) as string
normalizedNodes[newId] = normalizeNode(node)
}
return {
nodes: normalizedNodes,
rootNodeIds: (graph.rootNodeIds ?? []).map((id) => mapId(id)),
...(graph.collections ? { collections: normalizeNode(graph.collections) } : {}),
}
}
async function main(): Promise<void> {
// Fresh data dir so list_scenes counts are deterministic.
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {}
mkdirSync(DATA_DIR, { recursive: true })
const results: TestResults = {
baseSceneId: null,
baseNodeCount: 0,
baseSaved: false,
perMutation: [],
determinism: { status: 'fail', detail: 'not run' },
savePath: { status: 'fail', detail: 'not run', variantsSaved: 0, listedAfter: 0 },
combined: { status: 'fail', detail: 'not run' },
errorPath: { status: 'fail', detail: 'not run' },
totalVariantsSaved: 0,
}
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-p2', version: '0.0.0' })
await client.connect(transport)
const t0 = Date.now()
try {
// ---- Step 1: Setup ---------------------------------------------------
console.log('[p2] step 1: create_from_template two-bedroom')
const tplResult = (await client.callTool({
name: 'create_from_template',
arguments: { id: 'two-bedroom' },
})) as any
if (tplResult.isError) {
throw new Error(`create_from_template failed: ${pickContentText(tplResult)}`)
}
const tplParsed = parseStructured(tplResult)
const baseNodeCount: number = tplParsed?.nodeCount ?? 0
results.baseNodeCount = baseNodeCount
console.log(`[p2] base nodeCount=${baseNodeCount}`)
console.log('[p2] step 1b: save_scene p2-base')
const saveBaseResult = (await client.callTool({
name: 'save_scene',
arguments: { name: 'p2-base' },
})) as any
if (saveBaseResult.isError) {
throw new Error(`save_scene base failed: ${pickContentText(saveBaseResult)}`)
}
const saveBaseParsed = parseStructured(saveBaseResult)
const baseSceneId: string = saveBaseParsed?.id ?? ''
results.baseSceneId = baseSceneId
results.baseSaved = true
console.log(`[p2] baseSceneId=${baseSceneId}`)
// ---- Step 2: Per-mutation isolation ---------------------------------
console.log('[p2] step 2: per-mutation isolation')
for (const kind of MUTATION_KINDS) {
const mutResult: MutationResult = {
kind,
status: 'fail',
summary: '',
descriptions: [],
nodeCounts: [],
}
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 2,
vary: [kind],
seed: 42,
save: false,
},
})) as any
if (r.isError) {
mutResult.summary = `isError: ${pickContentText(r)}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (isError)`)
continue
}
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
if (variants.length !== 2) {
mutResult.summary = `expected 2 variants, got ${variants.length}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (count ${variants.length})`)
continue
}
mutResult.descriptions = variants.map((v) => v.description)
mutResult.nodeCounts = variants.map((v) => v.nodeCount)
// Verify node count constraints. 'open-plan' may remove up to 1 wall.
const minAllowed = kind === 'open-plan' ? baseNodeCount - 1 : baseNodeCount
const bad = variants.find((v) => v.nodeCount < minAllowed)
if (bad) {
mutResult.summary = `variant nodeCount ${bad.nodeCount} < min ${minAllowed} (base=${baseNodeCount})`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (${mutResult.summary})`)
continue
}
mutResult.status = 'pass'
mutResult.summary = `2 variants, nodeCounts=[${mutResult.nodeCounts.join(',')}], min=${minAllowed}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: PASS (${mutResult.summary})`)
} catch (err) {
mutResult.summary = `threw: ${err instanceof Error ? err.message : String(err)}`
results.perMutation.push(mutResult)
console.log(`[p2] ${kind}: FAIL (threw)`)
}
}
// ---- Step 3: Determinism --------------------------------------------
console.log('[p2] step 3: determinism with seed 1337')
try {
const callA = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness'],
seed: 1337,
save: false,
},
})) as any
const callB = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness'],
seed: 1337,
save: false,
},
})) as any
if (callA.isError || callB.isError) {
results.determinism = {
status: 'fail',
detail: `one call errored: A=${callA.isError} B=${callB.isError}`,
}
} else {
const parsedA = parseStructured(callA)
const parsedB = parseStructured(callB)
const variantsA: Variant[] = parsedA?.variants ?? []
const variantsB: Variant[] = parsedB?.variants ?? []
if (variantsA.length !== 3 || variantsB.length !== 3) {
results.determinism = {
status: 'fail',
detail: `expected 3 variants each, got ${variantsA.length} and ${variantsB.length}`,
}
} else {
// `forkSceneGraph` regenerates random node ids on every call, so
// the raw JSON cannot be identical across runs. We normalize ids to
// stable insertion-order indices and then canonicalize keys; any
// remaining difference must come from mutation non-determinism.
const canonA = variantsA.map((v) => canonicalize(normalizeGraphIds(v.graph as any)))
const canonB = variantsB.map((v) => canonicalize(normalizeGraphIds(v.graph as any)))
const allEqual = canonA.every((s, i) => s === canonB[i])
if (allEqual) {
results.determinism = {
status: 'pass',
detail: `3 variant graphs identical (after ID normalization) across calls (wall-thickness, seed=1337)`,
}
} else {
const firstDiff = canonA.findIndex((s, i) => s !== canonB[i])
// Include a short sample for debugging.
const a = canonA[firstDiff] ?? ''
const b = canonB[firstDiff] ?? ''
let diffPos = 0
while (diffPos < Math.min(a.length, b.length) && a[diffPos] === b[diffPos]) diffPos++
results.determinism = {
status: 'fail',
detail: `graphs diverge at variant index ${firstDiff}, char ${diffPos}: A="${a.slice(Math.max(0, diffPos - 20), diffPos + 40)}" vs B="${b.slice(Math.max(0, diffPos - 20), diffPos + 40)}"`,
}
}
}
}
console.log(
`[p2] determinism: ${results.determinism.status}${results.determinism.detail}`,
)
} catch (err) {
results.determinism = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
}
console.log(`[p2] determinism: FAIL (threw)`)
}
// ---- Step 4: Save path ----------------------------------------------
console.log('[p2] step 4: save=true')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 3,
vary: ['wall-thickness', 'wall-height'],
seed: 99,
save: true,
},
})) as any
if (r.isError) {
results.savePath = {
status: 'fail',
detail: `isError: ${pickContentText(r)}`,
variantsSaved: 0,
listedAfter: 0,
}
} else {
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
const allSaved = variants.length === 3 && variants.every((v) => v.sceneId && v.url)
const variantsSaved = variants.filter((v) => v.sceneId).length
results.totalVariantsSaved = variantsSaved
// Verify via list_scenes: should have base (p2-base) + 3 variants = 4.
const listResult = (await client.callTool({
name: 'list_scenes',
arguments: {},
})) as any
const listParsed = parseStructured(listResult)
const scenes: any[] = listParsed?.scenes ?? []
const listedAfter = scenes.length
const pass = allSaved && listedAfter === 4
results.savePath = {
status: pass ? 'pass' : 'fail',
detail: `variants saved=${variantsSaved}, list_scenes returned ${listedAfter} (expected 4)`,
variantsSaved,
listedAfter,
}
}
console.log(`[p2] save path: ${results.savePath.status}${results.savePath.detail}`)
} catch (err) {
results.savePath = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
variantsSaved: 0,
listedAfter: 0,
}
console.log(`[p2] save path: FAIL (threw)`)
}
// ---- Step 5: Combined mutations -------------------------------------
console.log('[p2] step 5: combined mutations (all 7 kinds, count=1)')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId,
count: 1,
vary: [...MUTATION_KINDS],
seed: 7,
save: true,
},
})) as any
if (r.isError) {
results.combined = { status: 'fail', detail: `isError: ${pickContentText(r)}` }
} else {
const parsed = parseStructured(r)
const variants: Variant[] = parsed?.variants ?? []
if (variants.length !== 1 || !variants[0]?.sceneId) {
results.combined = {
status: 'fail',
detail: `expected 1 saved variant, got ${variants.length} with sceneId=${variants[0]?.sceneId}`,
}
} else {
results.totalVariantsSaved += 1
const combinedSceneId = variants[0].sceneId as string
// load_scene + validate_scene
const loadResult = (await client.callTool({
name: 'load_scene',
arguments: { id: combinedSceneId },
})) as any
if (loadResult.isError) {
results.combined = {
status: 'fail',
detail: `load_scene isError: ${pickContentText(loadResult)}`,
}
} else {
const valResult = (await client.callTool({
name: 'validate_scene',
arguments: {},
})) as any
const valParsed = parseStructured(valResult)
const valid = !!valParsed?.valid
const errorCount = valParsed?.errors?.length ?? 0
results.combined = {
status: valid ? 'pass' : 'fail',
detail: `combined variant sceneId=${combinedSceneId}, valid=${valid}, errors=${errorCount}, description="${variants[0].description}"`,
valid,
errorCount,
}
}
}
}
console.log(`[p2] combined: ${results.combined.status}${results.combined.detail}`)
} catch (err) {
results.combined = {
status: 'fail',
detail: `threw: ${err instanceof Error ? err.message : String(err)}`,
}
console.log(`[p2] combined: FAIL (threw)`)
}
// ---- Step 6: Error path ---------------------------------------------
console.log('[p2] step 6: error path (missing baseSceneId)')
try {
const r = (await client.callTool({
name: 'generate_variants',
arguments: {
baseSceneId: 'missing',
count: 1,
vary: ['wall-height'],
},
})) as any
if (r.isError) {
const text = pickContentText(r)
const looksLikeInvalidParams =
text.includes('scene_not_found') ||
text.includes('missing') ||
text.toLowerCase().includes('invalid params') ||
text.includes('-32602')
results.errorPath = {
status: looksLikeInvalidParams ? 'pass' : 'fail',
detail: `isError with text: ${text.slice(0, 240)}`,
}
} else {
results.errorPath = { status: 'fail', detail: `expected error, got success` }
}
console.log(
`[p2] error path: ${results.errorPath.status}${results.errorPath.detail.slice(0, 120)}`,
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// The SDK may throw an McpError-shaped object for InvalidParams.
const looksLikeInvalidParams =
msg.includes('scene_not_found') ||
msg.includes('missing') ||
msg.toLowerCase().includes('invalid params') ||
msg.includes('-32602')
results.errorPath = {
status: looksLikeInvalidParams ? 'pass' : 'fail',
detail: `threw: ${msg.slice(0, 240)}`,
}
console.log(
`[p2] error path: ${results.errorPath.status}${results.errorPath.detail.slice(0, 120)}`,
)
}
} finally {
const elapsedMs = Date.now() - t0
try {
await client.close()
} catch {}
// Render the report ---------------------------------------------------
const ts = new Date().toISOString()
const lines: string[] = []
lines.push('# P2 Phase 8 — `generate_variants` report')
lines.push('')
lines.push(`Generated: ${ts}`)
lines.push(`Data dir: \`${DATA_DIR}\``)
lines.push(`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`)`)
lines.push(`Total run time: ${elapsedMs} ms`)
lines.push('')
lines.push('## Setup')
lines.push('')
lines.push(`- template: \`two-bedroom\``)
lines.push(`- base nodeCount: **${results.baseNodeCount}**`)
lines.push(`- base saved: **${results.baseSaved}** (id=\`${results.baseSceneId ?? 'n/a'}\`)`)
lines.push('')
lines.push('## Per-mutation results')
lines.push('')
lines.push('| # | Mutation | Status | nodeCounts | Summary |')
lines.push('|---|----------|--------|------------|---------|')
results.perMutation.forEach((m, i) => {
lines.push(
`| ${i + 1} | \`${m.kind}\` | ${m.status.toUpperCase()} | [${m.nodeCounts.join(', ')}] | ${m.summary.replace(/\|/g, '\\|')} |`,
)
})
lines.push('')
lines.push('### Variant descriptions')
lines.push('')
results.perMutation.forEach((m) => {
lines.push(`- **${m.kind}**: ${m.descriptions.map((d) => `"${d}"`).join(', ') || '(none)'}`)
})
lines.push('')
lines.push('## Determinism')
lines.push('')
lines.push(`- Status: **${results.determinism.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.determinism.detail}`)
lines.push('')
lines.push('## Save path')
lines.push('')
lines.push(`- Status: **${results.savePath.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.savePath.detail}`)
lines.push(`- Variants saved in step: ${results.savePath.variantsSaved}`)
lines.push(`- \`list_scenes\` after save: ${results.savePath.listedAfter}`)
lines.push('')
lines.push('## Combined mutation validation')
lines.push('')
lines.push(`- Status: **${results.combined.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.combined.detail}`)
if (results.combined.valid !== undefined) {
lines.push(`- valid: ${results.combined.valid}, errorCount: ${results.combined.errorCount}`)
}
lines.push('')
lines.push('## Error path')
lines.push('')
lines.push(`- Status: **${results.errorPath.status.toUpperCase()}**`)
lines.push(`- Detail: ${results.errorPath.detail}`)
lines.push('')
lines.push('## Totals')
lines.push('')
lines.push(`- Total variants saved across the run: **${results.totalVariantsSaved}**`)
lines.push('')
lines.push('## Overall summary')
lines.push('')
const mutationsPass = results.perMutation.every((m) => m.status === 'pass')
const openPlanFailure = results.perMutation.find(
(m) => m.kind === 'open-plan' && m.status === 'fail',
)
const onlyOpenPlanFailed =
!mutationsPass &&
results.perMutation.filter((m) => m.status === 'fail').length === 1 &&
!!openPlanFailure
const overallPass =
mutationsPass &&
results.determinism.status === 'pass' &&
results.savePath.status === 'pass' &&
results.combined.status === 'pass' &&
results.errorPath.status === 'pass'
if (overallPass) {
lines.push(
'**All checks PASSED.** `generate_variants` exercises every mutation kind, is deterministic (after id normalization) under a fixed seed, persists cleanly via `save=true`, survives a combined-mutation variant that passes `validate_scene`, and returns `McpError(InvalidParams)` for a missing `baseSceneId`.',
)
} else {
lines.push('**Summary (≤150 words):**')
lines.push('')
const totalVariantsObserved =
results.perMutation.length * 2 + 3 /*determinism*3*/ + 3 /*save*3*/ + 1 /*combined*/
const mutationPassCount = results.perMutation.filter((m) => m.status === 'pass').length
lines.push(
`Per-mutation: ${mutationPassCount}/${results.perMutation.length} PASS. Determinism: ${results.determinism.status.toUpperCase()} (identical after id normalization; \`forkSceneGraph\` regenerates ids so raw JSON can't match). Save path: ${results.savePath.status.toUpperCase()}${results.savePath.variantsSaved} variants saved, \`list_scenes\` returned ${results.savePath.listedAfter} (expected 4). Combined mutation: ${results.combined.status.toUpperCase()} (variant validates). Error path: ${results.errorPath.status.toUpperCase()}. Total variants saved: ${results.totalVariantsSaved}. Total variants exercised across the run: ~${totalVariantsObserved}.`,
)
if (onlyOpenPlanFailed && openPlanFailure) {
lines.push('')
lines.push(
`Note: the only failing mutation is \`open-plan\` — nodeCounts [${openPlanFailure.nodeCounts.join(', ')}] with base=${results.baseNodeCount}. The spec rule \`>= base - 1\` assumes open-plan drops only the wall node, but \`applyOpenPlan\` also drops any openings (doors/windows) attached to the removed wall — so a variant may drop 2+ nodes. The mutation itself is working correctly; the spec's lower-bound rule is tighter than the implementation.`,
)
}
}
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`[p2] report written: ${REPORT_PATH}`)
if (!overallPass) process.exitCode = 1
}
}
main().catch((err) => {
console.error('[p2] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,127 +0,0 @@
# P3 — Phase 8: Version Conflict / Optimistic Locking Report
Run: 2026-04-19T18:19:49.835Z
## Summary
- PASS: 12
- WARN: 0
- FAIL: 0
- Total: 12
## Matrix
| ID | Part | Description | Verdict |
|----|------|-------------|---------|
| A1 | A | save_scene fresh → version === 1 | PASS |
| A2 | A | save_scene expectedVersion=1 → version === 2 | PASS |
| A3 | A | save_scene expectedVersion=5 (stale) → version_conflict | PASS |
| A4 | A | save_scene WITHOUT expectedVersion on existing id | PASS |
| A5 | A | rename_scene expectedVersion=99 (current=2) → version_conflict | PASS |
| A6 | A | delete_scene expectedVersion=99 (stale) → version_conflict | PASS |
| B1 | B | POST /api/scenes { id: "p3-http-mo63c61z", name: "p3-http" } → 201 | PASS |
| B2 | B | GET /api/scenes/p3-http-mo63c61z — ETag header matches "1" | PASS |
| B3 | B | PUT with If-Match: "1" (matching current) → 200 | PASS |
| B4 | B | PUT with If-Match: "99" (stale) → 409 | PASS |
| B5 | B | DELETE with If-Match: "99" (stale) → 409 | PASS |
| B6 | B | DELETE with correct If-Match: "2" → 204 | PASS |
## Details
### A1 — part A — save_scene fresh → version === 1
**Verdict:** PASS
**Expected:** success, version=1
**Actual:** success, version=1, id=p3-mcp
### A2 — part A — save_scene expectedVersion=1 → version === 2
**Verdict:** PASS
**Expected:** success, version=2
**Actual:** success, version=2
### A3 — part A — save_scene expectedVersion=5 (stale) → version_conflict
**Verdict:** PASS
**Expected:** McpError / tool_error with code=version_conflict
**Actual:** tool_error: MCP error -32600: version_conflict
### A4 — part A — save_scene WITHOUT expectedVersion on existing id
**Verdict:** PASS
**Expected:** Document behaviour: lenient overwrite OR strict reject
**Actual:** tool_error: MCP error -32600: Scene with id "p3-mcp" already exists. Pass a different id or provide expectedVersion to overwrite.
**Note:** STRICT: save without expectedVersion rejected — existing scene protected
### A5 — part A — rename_scene expectedVersion=99 (current=2) → version_conflict
**Verdict:** PASS
**Expected:** McpError / tool_error with code=version_conflict
**Actual:** tool_error: MCP error -32600: version_conflict
### A6 — part A — delete_scene expectedVersion=99 (stale) → version_conflict
**Verdict:** PASS
**Expected:** McpError / tool_error with code=version_conflict
**Actual:** tool_error: MCP error -32600: version_conflict
### B1 — part B — POST /api/scenes { id: "p3-http-mo63c61z", name: "p3-http" } → 201
**Verdict:** PASS
**Expected:** status 201, body has version=1
**Actual:** status=201, body={"id":"p3-http-mo63c61z","name":"p3-http","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:19:49.805Z","updatedAt":"2026-04-19T18:19:49.805Z","ownerId":null,"sizeBytes":928,"nodeCount":1}
### B2 — part B — GET /api/scenes/p3-http-mo63c61z — ETag header matches "1"
**Verdict:** PASS
**Expected:** status 200, ETag: "1"
**Actual:** status=200, ETag="\"1\""
### B3 — part B — PUT with If-Match: "1" (matching current) → 200
**Verdict:** PASS
**Expected:** status 200, version=2, ETag: "2"
**Actual:** status=200, version=2, ETag="\"2\""
### B4 — part B — PUT with If-Match: "99" (stale) → 409
**Verdict:** PASS
**Expected:** status 409, body { error: "version_conflict" }
**Actual:** status=409, body={"error":"version_conflict","currentVersion":2}
### B5 — part B — DELETE with If-Match: "99" (stale) → 409
**Verdict:** PASS
**Expected:** status 409, body { error: "version_conflict" }
**Actual:** status=409, body={"error":"version_conflict","currentVersion":2}
### B6 — part B — DELETE with correct If-Match: "2" → 204
**Verdict:** PASS
**Expected:** status 204, empty body
**Actual:** status=204, body=(empty)
@@ -1,639 +0,0 @@
/**
* P3 — Phase 8: version conflict / optimistic locking tests.
*
* Tests:
* Part A — MCP tool-level version conflicts (save_scene, rename_scene,
* delete_scene) via a dedicated stdio MCP server against PASCAL_DATA_DIR
* = /tmp/pascal-phase8-p3.
* Part B — Editor HTTP API ETag / If-Match semantics on :3002/api/scenes.
*
* Writes the markdown report alongside this script.
* Run with: bun packages/mcp/test-reports/phase8/p3-locking.ts
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { McpError } from '@modelcontextprotocol/sdk/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p3-locking.md')
const DATA_DIR = '/tmp/pascal-phase8-p3'
const EDITOR_URL = 'http://localhost:3002'
type Verdict = 'PASS' | 'WARN' | 'FAIL'
type Row = {
id: string
part: 'A' | 'B'
description: string
expected: string
actual: string
verdict: Verdict
note?: string
}
const rows: Row[] = []
function record(row: Row): void {
rows.push(row)
const icon = row.verdict === 'PASS' ? '[PASS]' : row.verdict === 'WARN' ? '[WARN]' : '[FAIL]'
console.log(`${icon} ${row.id} (part ${row.part}): ${row.description}`)
console.log(` expected: ${row.expected}`)
console.log(` actual: ${row.actual}`)
if (row.note) console.log(` note: ${row.note}`)
}
function shortJson(v: unknown, max = 400): string {
let text: string
try {
text = JSON.stringify(v)
} catch {
text = String(v)
}
if (text && text.length > max) return `${text.slice(0, max)}`
return text
}
/** Minimal valid SceneGraph — a bare site node. */
function minimalGraph(): { nodes: Record<string, unknown>; rootNodeIds: string[] } {
return {
nodes: {
site_p3: {
object: 'node',
id: 'site_p3',
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: {
type: 'polygon',
points: [
[-10, -10],
[10, -10],
[10, 10],
[-10, 10],
],
},
children: [],
},
},
rootNodeIds: ['site_p3'],
}
}
// ---------------------------------------------------------------------------
// Helpers for MCP stdio side
// ---------------------------------------------------------------------------
type CallOutcome =
| {
kind: 'success'
structuredContent?: unknown
content?: unknown
}
| {
kind: 'tool_error'
message: string
rawContent?: unknown
}
| {
kind: 'mcp_error'
code: number
message: string
data?: unknown
}
| {
kind: 'client_error'
message: string
}
async function callTool(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<CallOutcome> {
try {
const result = (await client.callTool({ name, arguments: args })) as {
isError?: boolean
content?: unknown
structuredContent?: unknown
}
if (result.isError) {
const rawContent = (result.content ?? []) as Array<{ type: string; text?: string }>
const textBlock = rawContent.find((b) => b?.type === 'text')
return {
kind: 'tool_error',
message: textBlock?.text ?? JSON.stringify(rawContent),
rawContent: result.content,
}
}
return {
kind: 'success',
structuredContent: result.structuredContent,
content: result.content,
}
} catch (err) {
if (err instanceof McpError) {
return {
kind: 'mcp_error',
code: err.code,
message: err.message,
data: err.data,
}
}
return {
kind: 'client_error',
message: err instanceof Error ? err.message : String(err),
}
}
}
function getStructured<T = Record<string, unknown>>(o: CallOutcome): T | null {
if (o.kind !== 'success') return null
if (!o.structuredContent || typeof o.structuredContent !== 'object') return null
return o.structuredContent as T
}
// ---------------------------------------------------------------------------
// Part A: MCP tools
// ---------------------------------------------------------------------------
async function runPartA(): Promise<void> {
console.log('\n=== Part A — MCP save_scene / rename_scene / delete_scene ===')
// Reset data dir for deterministic test.
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {
// ignore
}
mkdirSync(DATA_DIR, { recursive: true })
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: {
...process.env,
PASCAL_DATA_DIR: DATA_DIR,
},
stderr: 'inherit',
})
const client = new Client({ name: 'p3-locking', version: '0.0.0' })
await client.connect(transport)
console.log('[p3] connected via stdio')
const graph = minimalGraph()
const sceneId = 'p3-mcp'
const newGraph = {
nodes: {
site_p3: {
...(graph.nodes.site_p3 as Record<string, unknown>),
metadata: { updated: 'v2' },
},
},
rootNodeIds: ['site_p3'],
}
// --- A1: fresh save → version 1 ---
{
const out = await callTool(client, 'save_scene', {
id: sceneId,
name: 'p3-mcp-original',
includeCurrentScene: false,
graph,
})
const sc = getStructured<{ version: number; id: string }>(out)
const ok = out.kind === 'success' && sc?.version === 1 && sc?.id === sceneId
record({
id: 'A1',
part: 'A',
description: 'save_scene fresh → version === 1',
expected: 'success, version=1',
actual:
out.kind === 'success'
? `success, version=${sc?.version}, id=${sc?.id}`
: `${out.kind}: ${JSON.stringify(out).slice(0, 200)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- A2: save with expectedVersion: 1 → version 2 ---
{
const out = await callTool(client, 'save_scene', {
id: sceneId,
name: 'p3-mcp-v2',
includeCurrentScene: false,
expectedVersion: 1,
graph: newGraph,
})
const sc = getStructured<{ version: number }>(out)
const ok = out.kind === 'success' && sc?.version === 2
record({
id: 'A2',
part: 'A',
description: 'save_scene expectedVersion=1 → version === 2',
expected: 'success, version=2',
actual:
out.kind === 'success'
? `success, version=${sc?.version}`
: `${out.kind}: ${JSON.stringify(out).slice(0, 200)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- A3: save with expectedVersion=5 (stale) → version_conflict ---
{
const out = await callTool(client, 'save_scene', {
id: sceneId,
name: 'p3-mcp-stale',
includeCurrentScene: false,
expectedVersion: 5,
graph: newGraph,
})
const msg =
out.kind === 'mcp_error'
? out.message
: out.kind === 'tool_error'
? out.message
: out.kind === 'client_error'
? out.message
: 'success (unexpected)'
const ok =
(out.kind === 'mcp_error' && out.message.includes('version_conflict')) ||
(out.kind === 'tool_error' && out.message.includes('version_conflict'))
record({
id: 'A3',
part: 'A',
description: 'save_scene expectedVersion=5 (stale) → version_conflict',
expected: 'McpError / tool_error with code=version_conflict',
actual: `${out.kind}: ${msg}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- A4: save WITHOUT expectedVersion — document actual behaviour ---
{
const out = await callTool(client, 'save_scene', {
id: sceneId,
name: 'p3-mcp-no-expect',
includeCurrentScene: false,
graph: newGraph,
})
let actualDesc: string
let verdict: Verdict = 'WARN'
let note: string | undefined
if (out.kind === 'success') {
const sc = getStructured<{ version: number }>(out)
actualDesc = `overwrite success, version=${sc?.version}`
verdict = 'PASS'
note = 'LENIENT: save without expectedVersion silently overwrote the existing scene'
} else if (out.kind === 'mcp_error' || out.kind === 'tool_error') {
actualDesc = `${out.kind}: ${out.message}`
verdict = 'PASS'
note = 'STRICT: save without expectedVersion rejected — existing scene protected'
} else {
actualDesc = `${out.kind}: ${JSON.stringify(out).slice(0, 200)}`
verdict = 'FAIL'
}
record({
id: 'A4',
part: 'A',
description: 'save_scene WITHOUT expectedVersion on existing id',
expected: 'Document behaviour: lenient overwrite OR strict reject',
actual: actualDesc,
verdict,
note,
})
}
// --- A5: rename with stale expectedVersion → version_conflict ---
{
// Get current version
const listOut = await callTool(client, 'list_scenes', {})
const listSc = getStructured<{ scenes: { id: string; version: number }[] }>(listOut)
const currentVersion = listSc?.scenes.find((s) => s.id === sceneId)?.version ?? -1
const out = await callTool(client, 'rename_scene', {
id: sceneId,
newName: 'p3-mcp-rename',
expectedVersion: 99, // stale
})
const msg =
out.kind === 'mcp_error'
? out.message
: out.kind === 'tool_error'
? out.message
: out.kind === 'client_error'
? out.message
: 'success (unexpected)'
const ok =
(out.kind === 'mcp_error' && out.message.includes('version_conflict')) ||
(out.kind === 'tool_error' && out.message.includes('version_conflict'))
record({
id: 'A5',
part: 'A',
description: `rename_scene expectedVersion=99 (current=${currentVersion}) → version_conflict`,
expected: 'McpError / tool_error with code=version_conflict',
actual: `${out.kind}: ${msg}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- A6: delete with stale expectedVersion → version_conflict ---
{
const out = await callTool(client, 'delete_scene', {
id: sceneId,
expectedVersion: 99, // stale
})
const msg =
out.kind === 'mcp_error'
? out.message
: out.kind === 'tool_error'
? out.message
: out.kind === 'client_error'
? out.message
: 'success (unexpected)'
const ok =
(out.kind === 'mcp_error' && out.message.includes('version_conflict')) ||
(out.kind === 'tool_error' && out.message.includes('version_conflict'))
record({
id: 'A6',
part: 'A',
description: 'delete_scene expectedVersion=99 (stale) → version_conflict',
expected: 'McpError / tool_error with code=version_conflict',
actual: `${out.kind}: ${msg}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
await client.close()
console.log('[p3] Part A disconnected')
}
// ---------------------------------------------------------------------------
// Part B: editor HTTP API
// ---------------------------------------------------------------------------
type HttpResult = {
status: number
headers: Record<string, string>
bodyText: string
bodyJson: unknown
}
async function http(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
path: string,
body?: unknown,
extraHeaders: Record<string, string> = {},
): Promise<HttpResult> {
const headers: Record<string, string> = { ...extraHeaders }
if (body !== undefined) headers['Content-Type'] = 'application/json'
const res = await fetch(`${EDITOR_URL}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
const text = await res.text()
let json: unknown = null
if (text) {
try {
json = JSON.parse(text)
} catch {
json = null
}
}
const hdrs: Record<string, string> = {}
res.headers.forEach((v, k) => {
hdrs[k] = v
})
return {
status: res.status,
headers: hdrs,
bodyText: text,
bodyJson: json,
}
}
async function runPartB(): Promise<void> {
console.log('\n=== Part B — Editor HTTP API ETag/If-Match ===')
const id = `p3-http-${Date.now().toString(36)}`
const graph = minimalGraph()
// --- B1: POST /api/scenes → 201 ---
let v1Meta: { id: string; version: number } | null = null
{
const res = await http('POST', '/api/scenes', {
id,
name: 'p3-http',
graph,
})
const ok = res.status === 201
if (res.bodyJson && typeof res.bodyJson === 'object') {
v1Meta = res.bodyJson as { id: string; version: number }
}
record({
id: 'B1',
part: 'B',
description: `POST /api/scenes { id: "${id}", name: "p3-http" } → 201`,
expected: 'status 201, body has version=1',
actual: `status=${res.status}, body=${shortJson(res.bodyJson)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- B2: GET /api/scenes/<id> → ETag: "1" ---
{
const res = await http('GET', `/api/scenes/${id}`)
const etag = res.headers.etag ?? res.headers.ETag ?? ''
const ok = res.status === 200 && etag === '"1"'
record({
id: 'B2',
part: 'B',
description: `GET /api/scenes/${id} — ETag header matches "1"`,
expected: 'status 200, ETag: "1"',
actual: `status=${res.status}, ETag=${JSON.stringify(etag)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- B3: PUT with If-Match: "1" → 200, new version 2 ---
{
const updatedGraph = {
nodes: {
site_p3: {
...(graph.nodes.site_p3 as Record<string, unknown>),
metadata: { updated: 'http-v2' },
},
},
rootNodeIds: ['site_p3'],
}
const res = await http(
'PUT',
`/api/scenes/${id}`,
{ name: 'p3-http-updated', graph: updatedGraph },
{ 'If-Match': '"1"' },
)
const etag = res.headers.etag ?? res.headers.ETag ?? ''
const body = res.bodyJson as { version?: number } | null
const ok = res.status === 200 && body?.version === 2 && etag === '"2"'
record({
id: 'B3',
part: 'B',
description: 'PUT with If-Match: "1" (matching current) → 200',
expected: 'status 200, version=2, ETag: "2"',
actual: `status=${res.status}, version=${body?.version}, ETag=${JSON.stringify(etag)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- B4: PUT with If-Match: "99" (stale) → 409 ---
{
const res = await http(
'PUT',
`/api/scenes/${id}`,
{ name: 'p3-http-stale', graph },
{ 'If-Match': '"99"' },
)
const body = res.bodyJson as { error?: string } | null
const ok = res.status === 409 && body?.error === 'version_conflict'
record({
id: 'B4',
part: 'B',
description: 'PUT with If-Match: "99" (stale) → 409',
expected: 'status 409, body { error: "version_conflict" }',
actual: `status=${res.status}, body=${shortJson(res.bodyJson)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- B5: DELETE with If-Match: "99" → 409 ---
{
const res = await http('DELETE', `/api/scenes/${id}`, undefined, { 'If-Match': '"99"' })
const body = res.bodyJson as { error?: string } | null
const ok = res.status === 409 && body?.error === 'version_conflict'
record({
id: 'B5',
part: 'B',
description: 'DELETE with If-Match: "99" (stale) → 409',
expected: 'status 409, body { error: "version_conflict" }',
actual: `status=${res.status}, body=${shortJson(res.bodyJson)}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
// --- B6: DELETE with correct If-Match: "2" → 204 ---
{
const res = await http('DELETE', `/api/scenes/${id}`, undefined, { 'If-Match': '"2"' })
const ok = res.status === 204
record({
id: 'B6',
part: 'B',
description: 'DELETE with correct If-Match: "2" → 204',
expected: 'status 204, empty body',
actual: `status=${res.status}, body=${res.bodyText || '(empty)'}`,
verdict: ok ? 'PASS' : 'FAIL',
})
}
}
// ---------------------------------------------------------------------------
// Report writer
// ---------------------------------------------------------------------------
function writeReport(): void {
const pass = rows.filter((r) => r.verdict === 'PASS').length
const warn = rows.filter((r) => r.verdict === 'WARN').length
const fail = rows.filter((r) => r.verdict === 'FAIL').length
const lines: string[] = []
lines.push('# P3 — Phase 8: Version Conflict / Optimistic Locking Report')
lines.push('')
lines.push(`Run: ${new Date().toISOString()}`)
lines.push('')
lines.push('## Summary')
lines.push('')
lines.push(`- PASS: ${pass}`)
lines.push(`- WARN: ${warn}`)
lines.push(`- FAIL: ${fail}`)
lines.push(`- Total: ${rows.length}`)
lines.push('')
lines.push('## Matrix')
lines.push('')
lines.push('| ID | Part | Description | Verdict |')
lines.push('|----|------|-------------|---------|')
for (const r of rows) {
const safe = r.description.replace(/\|/g, '\\|')
lines.push(`| ${r.id} | ${r.part} | ${safe} | ${r.verdict} |`)
}
lines.push('')
lines.push('## Details')
lines.push('')
for (const r of rows) {
lines.push(`### ${r.id} — part ${r.part}${r.description}`)
lines.push('')
lines.push(`**Verdict:** ${r.verdict}`)
lines.push('')
lines.push(`**Expected:** ${r.expected}`)
lines.push('')
lines.push(`**Actual:** ${r.actual}`)
if (r.note) {
lines.push('')
lines.push(`**Note:** ${r.note}`)
}
lines.push('')
}
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`\n[p3] wrote report: ${REPORT_PATH}`)
console.log(`[p3] PASS=${pass} WARN=${warn} FAIL=${fail}`)
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
console.log(`[p3] DATA_DIR=${DATA_DIR}`)
console.log(`[p3] EDITOR_URL=${EDITOR_URL}`)
console.log(`[p3] BIN_PATH=${BIN_PATH}`)
try {
await runPartA()
} catch (err) {
console.error('[p3] Part A crashed:', err)
record({
id: 'A-crash',
part: 'A',
description: 'Part A runner threw',
expected: 'all A tests complete',
actual: err instanceof Error ? err.message : String(err),
verdict: 'FAIL',
})
}
try {
await runPartB()
} catch (err) {
console.error('[p3] Part B crashed:', err)
record({
id: 'B-crash',
part: 'B',
description: 'Part B runner threw',
expected: 'all B tests complete',
actual: err instanceof Error ? err.message : String(err),
verdict: 'FAIL',
})
}
writeReport()
const fail = rows.filter((r) => r.verdict === 'FAIL').length
if (fail > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p3] fatal:', err)
process.exit(2)
})
@@ -1,267 +0,0 @@
# Phase 8 P4 — URL Hardening Report
Worktree: `/Users/adrian/Desktop/editor/.worktrees/mcp-server`
Data dir: `/tmp/pascal-phase8-p4`
Total checks: **95** — pass **59**, fail **36**
## Scope
Verify A7's `AssetUrl` validator rejects dangerous URLs at every boundary:
- `AnyNode.safeParse` (core schema)
- `apply_patch` MCP tool (bridge dry-run)
- `save_scene` MCP tool (includeCurrentScene=false path)
- editor `POST /api/scenes` (HTTP envelope)
- `PASCAL_ALLOWED_ASSET_ORIGINS` env narrowing
## Verdict table
| URL | node_field | injected_via | rejected_by | expected | actual | result |
|---|---|---|---|---|---|---|
| `javascript:alert(1)` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `file:///etc/passwd` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `http://evil.com/beacon.glb` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `ftp://a.b.com/file` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `vbscript:msgbox("x")` | ItemNode.asset.src | AnyNode.safeParse | ItemNode + AnyNode | reject | reject | PASS |
| `asset://12345abcde/model.glb` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `blob:http://localhost/x-y-z` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `data:image/png;base64,iVBOR` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `https://cdn.example.com/model.glb` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `http://localhost:3002/public/a.glb` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `/static/model.glb` | ItemNode.asset.src | AnyNode.safeParse | — | accept | accept | PASS |
| `javascript:alert(1)` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `file:///etc/passwd` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `http://evil.com/beacon.glb` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `ftp://a.b.com/file` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `vbscript:msgbox("x")` | ScanNode.url | AnyNode.safeParse | ScanNode + AnyNode | reject | reject | PASS |
| `asset://12345abcde/model.glb` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `blob:http://localhost/x-y-z` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `data:image/png;base64,iVBOR` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `https://cdn.example.com/model.glb` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `http://localhost:3002/public/a.glb` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `/static/model.glb` | ScanNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `javascript:alert(1)` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `file:///etc/passwd` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `http://evil.com/beacon.glb` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `ftp://a.b.com/file` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `vbscript:msgbox("x")` | GuideNode.url | AnyNode.safeParse | GuideNode + AnyNode | reject | reject | PASS |
| `asset://12345abcde/model.glb` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `blob:http://localhost/x-y-z` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `data:image/png;base64,iVBOR` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `https://cdn.example.com/model.glb` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `http://localhost:3002/public/a.glb` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `/static/model.glb` | GuideNode.url | AnyNode.safeParse | — | accept | accept | PASS |
| `javascript:alert(1)` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `file:///etc/passwd` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `http://evil.com/beacon.glb` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `ftp://a.b.com/file` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `vbscript:msgbox("x")` | ItemNode.asset.src | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `javascript:alert(1)` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `file:///etc/passwd` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `http://evil.com/beacon.glb` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `ftp://a.b.com/file` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `vbscript:msgbox("x")` | ScanNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `javascript:alert(1)` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `file:///etc/passwd` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `http://evil.com/beacon.glb` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `data:text/html,<script>alert(1)</script>` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `ftp://a.b.com/file` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `vbscript:msgbox("x")` | GuideNode.url | apply_patch | apply_patch (AssetUrl) | reject | reject | PASS |
| `javascript:alert(1)` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | ItemNode.asset.src | save_scene | NONE | reject | accept | FAIL |
| `javascript:alert(1)` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | ScanNode.url | save_scene | NONE | reject | accept | FAIL |
| `javascript:alert(1)` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | GuideNode.url | save_scene | NONE | reject | accept | FAIL |
| `javascript:alert(1)` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | ItemNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `javascript:alert(1)` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | ScanNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `javascript:alert(1)` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `file:///etc/passwd` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `http://evil.com/beacon.glb` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `data:text/html,<script>alert(1)</script>` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `ftp://a.b.com/file` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `vbscript:msgbox("x")` | GuideNode | editor POST /api/scenes | NONE | reject | accept | FAIL |
| `https://cdn.pascal.app/x.glb` | env allowlist | spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app | — | accept | accept | PASS |
| `https://otherhost.com/x.glb` | env allowlist | spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app | AssetUrl (env) | reject | reject | PASS |
| `https://cdn.pascal.app.evil.com/x` | env allowlist | spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app | AssetUrl (env) | reject | reject | PASS |
| `asset://abc` | env allowlist | spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app | — | accept | accept | PASS |
| `https://cdn.pascal.app/deep/path?q=1` | env allowlist | spawnSync + PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app | — | accept | accept | PASS |
## Summary of findings
- Schema layer (`AssetUrl``ItemNode`/`ScanNode`/`GuideNode``AnyNode`)
rejects every bad URL vector (javascript:, file:, foreign http:, data:text/html,
ftp:, vbscript:) in every slot (asset.src, scan.url, guide.url).
- `apply_patch` forwards the rejection: `SceneBridge.applyPatch` re-parses each
create node with `AnyNode` before mutating the store, so the bad URL is
caught before the scene mutates.
- `save_scene` with `includeCurrentScene: false` does NOT re-run
`AnyNode.safeParse` on the provided graph — it treats the graph as opaque
and hands it to the storage layer. See next section.
- `PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app` correctly narrows
`https:` URLs to that origin; other schemes remain accepted.
- Editor `POST /api/scenes` uses `graphSchema = z.unknown().refine(...object)`
which also does NOT re-validate per-node schema. It relies on the editor UI
having generated a validated graph.
## Layer that catches bad URLs in `save_scene`
When `includeCurrentScene: false` is used, the only URL-validation layer hit
is the in-memory `AnyNode` pre-parse inside `save_scene`'s `validateScene()`
path — but that branch is ONLY run when `includeCurrentScene=true`. With
`includeCurrentScene: false`, the graph is passed through to
`FilesystemSceneStore.save` which enforces only size + node-envelope checks
(type is a non-empty string, node is an object). This means a malicious
`graph` can bypass `AssetUrl` at the save_scene boundary.
The A7 hardening therefore is fully effective at `apply_patch` and at
`save_scene` with `includeCurrentScene: true` (bridge validate); but when a
caller supplies `graph` directly, URL validation is deferred until the scene
is later loaded into the bridge (`setScene` → editor renderer). The same gap
applies to the editor `POST /api/scenes` endpoint.
## Recommendations
1. `save_scene` should re-parse each node of the incoming `graph` with
`AnyNode` when `includeCurrentScene === false` before calling
`store.save`, matching the strictness of `apply_patch`.
2. The editor's `POST /api/scenes` route should apply the same per-node
validation instead of treating the graph as opaque.
3. `FilesystemSceneStore.save` could optionally validate node shape with
`AnyNode` as a defence-in-depth layer (size-bounded and acceptably cheap).
## Run log
```
==== Phase 8 P4 URL hardening ====
BIN_PATH=/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/dist/bin/pascal-mcp.js
PASCAL_DATA_DIR=/tmp/pascal-phase8-p4
==== Tier 1: AssetUrl / AnyNode.safeParse schema layer ====
[ItemNode.asset.src] BAD url javascript:alert(1) → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] BAD url file:///etc/passwd → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] BAD url http://evil.com/beacon.glb → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] BAD url data:text/html,<script>alert(1)</script> → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] BAD url ftp://a.b.com/file → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] BAD url vbscript:msgbox("x") → ItemNode=reject / AnyNode=reject OK
[ItemNode.asset.src] GOOD url asset://12345abcde/model.glb → ItemNode=accept / AnyNode=accept OK
[ItemNode.asset.src] GOOD url blob:http://localhost/x-y-z → ItemNode=accept / AnyNode=accept OK
[ItemNode.asset.src] GOOD url data:image/png;base64,iVBOR → ItemNode=accept / AnyNode=accept OK
[ItemNode.asset.src] GOOD url https://cdn.example.com/model.glb → ItemNode=accept / AnyNode=accept OK
[ItemNode.asset.src] GOOD url http://localhost:3002/public/a.glb → ItemNode=accept / AnyNode=accept OK
[ItemNode.asset.src] GOOD url /static/model.glb → ItemNode=accept / AnyNode=accept OK
[ScanNode.url] BAD url javascript:alert(1) → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] BAD url file:///etc/passwd → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] BAD url http://evil.com/beacon.glb → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] BAD url data:text/html,<script>alert(1)</script> → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] BAD url ftp://a.b.com/file → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] BAD url vbscript:msgbox("x") → ScanNode=reject / AnyNode=reject OK
[ScanNode.url] GOOD url asset://12345abcde/model.glb → ScanNode=accept / AnyNode=accept OK
[ScanNode.url] GOOD url blob:http://localhost/x-y-z → ScanNode=accept / AnyNode=accept OK
[ScanNode.url] GOOD url data:image/png;base64,iVBOR → ScanNode=accept / AnyNode=accept OK
[ScanNode.url] GOOD url https://cdn.example.com/model.glb → ScanNode=accept / AnyNode=accept OK
[ScanNode.url] GOOD url http://localhost:3002/public/a.glb → ScanNode=accept / AnyNode=accept OK
[ScanNode.url] GOOD url /static/model.glb → ScanNode=accept / AnyNode=accept OK
[GuideNode.url] BAD url javascript:alert(1) → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] BAD url file:///etc/passwd → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] BAD url http://evil.com/beacon.glb → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] BAD url data:text/html,<script>alert(1)</script> → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] BAD url ftp://a.b.com/file → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] BAD url vbscript:msgbox("x") → GuideNode=reject / AnyNode=reject OK
[GuideNode.url] GOOD url asset://12345abcde/model.glb → GuideNode=accept / AnyNode=accept OK
[GuideNode.url] GOOD url blob:http://localhost/x-y-z → GuideNode=accept / AnyNode=accept OK
[GuideNode.url] GOOD url data:image/png;base64,iVBOR → GuideNode=accept / AnyNode=accept OK
[GuideNode.url] GOOD url https://cdn.example.com/model.glb → GuideNode=accept / AnyNode=accept OK
[GuideNode.url] GOOD url http://localhost:3002/public/a.glb → GuideNode=accept / AnyNode=accept OK
[GuideNode.url] GOOD url /static/model.glb → GuideNode=accept / AnyNode=accept OK
==== Tier 2+3: apply_patch + save_scene via stdio MCP ====
apply_patch create ItemNode.asset.src BAD javascript:alert(1) → reject OK
apply_patch create ItemNode.asset.src BAD file:///etc/passwd → reject OK
apply_patch create ItemNode.asset.src BAD http://evil.com/beacon.glb → reject OK
apply_patch create ItemNode.asset.src BAD data:text/html,<script>alert(1)</script> → reject OK
apply_patch create ItemNode.asset.src BAD ftp://a.b.com/file → reject OK
apply_patch create ItemNode.asset.src BAD vbscript:msgbox("x") → reject OK
apply_patch create ScanNode.url BAD javascript:alert(1) → reject OK
apply_patch create ScanNode.url BAD file:///etc/passwd → reject OK
apply_patch create ScanNode.url BAD http://evil.com/beacon.glb → reject OK
apply_patch create ScanNode.url BAD data:text/html,<script>alert(1)</script> → reject OK
apply_patch create ScanNode.url BAD ftp://a.b.com/file → reject OK
apply_patch create ScanNode.url BAD vbscript:msgbox("x") → reject OK
apply_patch create GuideNode.url BAD javascript:alert(1) → reject OK
apply_patch create GuideNode.url BAD file:///etc/passwd → reject OK
apply_patch create GuideNode.url BAD http://evil.com/beacon.glb → reject OK
apply_patch create GuideNode.url BAD data:text/html,<script>alert(1)</script> → reject OK
apply_patch create GuideNode.url BAD ftp://a.b.com/file → reject OK
apply_patch create GuideNode.url BAD vbscript:msgbox("x") → reject OK
save_scene graph with ItemNode.asset.src BAD javascript:alert(1) → accept FAIL
save_scene graph with ItemNode.asset.src BAD file:///etc/passwd → accept FAIL
save_scene graph with ItemNode.asset.src BAD http://evil.com/beacon.glb → accept FAIL
save_scene graph with ItemNode.asset.src BAD data:text/html,<script>alert(1)</script> → accept FAIL
save_scene graph with ItemNode.asset.src BAD ftp://a.b.com/file → accept FAIL
save_scene graph with ItemNode.asset.src BAD vbscript:msgbox("x") → accept FAIL
save_scene graph with ScanNode.url BAD javascript:alert(1) → accept FAIL
save_scene graph with ScanNode.url BAD file:///etc/passwd → accept FAIL
save_scene graph with ScanNode.url BAD http://evil.com/beacon.glb → accept FAIL
save_scene graph with ScanNode.url BAD data:text/html,<script>alert(1)</script> → accept FAIL
save_scene graph with ScanNode.url BAD ftp://a.b.com/file → accept FAIL
save_scene graph with ScanNode.url BAD vbscript:msgbox("x") → accept FAIL
save_scene graph with GuideNode.url BAD javascript:alert(1) → accept FAIL
save_scene graph with GuideNode.url BAD file:///etc/passwd → accept FAIL
save_scene graph with GuideNode.url BAD http://evil.com/beacon.glb → accept FAIL
save_scene graph with GuideNode.url BAD data:text/html,<script>alert(1)</script> → accept FAIL
save_scene graph with GuideNode.url BAD ftp://a.b.com/file → accept FAIL
save_scene graph with GuideNode.url BAD vbscript:msgbox("x") → accept FAIL
==== Tier 4: editor POST /api/scenes ====
POST /api/scenes ItemNode BAD javascript:alert(1) → HTTP 201 ACCEPT (bad!)
POST /api/scenes ItemNode BAD file:///etc/passwd → HTTP 201 ACCEPT (bad!)
POST /api/scenes ItemNode BAD http://evil.com/beacon.glb → HTTP 201 ACCEPT (bad!)
POST /api/scenes ItemNode BAD data:text/html,<script>alert(1)</script> → HTTP 201 ACCEPT (bad!)
POST /api/scenes ItemNode BAD ftp://a.b.com/file → HTTP 201 ACCEPT (bad!)
POST /api/scenes ItemNode BAD vbscript:msgbox("x") → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD javascript:alert(1) → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD file:///etc/passwd → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD http://evil.com/beacon.glb → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD data:text/html,<script>alert(1)</script> → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD ftp://a.b.com/file → HTTP 201 ACCEPT (bad!)
POST /api/scenes ScanNode BAD vbscript:msgbox("x") → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD javascript:alert(1) → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD file:///etc/passwd → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD http://evil.com/beacon.glb → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD data:text/html,<script>alert(1)</script> → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD ftp://a.b.com/file → HTTP 201 ACCEPT (bad!)
POST /api/scenes GuideNode BAD vbscript:msgbox("x") → HTTP 201 ACCEPT (bad!)
==== Tier 5: PASCAL_ALLOWED_ASSET_ORIGINS narrowing ====
env-narrow https://cdn.pascal.app/x.glb expected=accept got=accept OK
env-narrow https://otherhost.com/x.glb expected=reject got=reject OK
env-narrow https://cdn.pascal.app.evil.com/x expected=reject got=reject OK
env-narrow asset://abc expected=accept got=accept OK
env-narrow https://cdn.pascal.app/deep/path?q=1 expected=accept got=accept OK
```
@@ -1,557 +0,0 @@
/**
* Phase 8 P4 — URL hardening test.
*
* Verifies that the `AssetUrl` validator from `@pascal-app/core/schema` is
* applied at every boundary a hostile scene graph could traverse:
* 1. `AnyNode.safeParse` directly (core schema layer)
* 2. `apply_patch` tool (MCP bridge create op)
* 3. `save_scene` tool (includeCurrentScene=false, graph arg)
* 4. editor `POST /api/scenes` (if the editor is reachable)
*
* Also checks the `PASCAL_ALLOWED_ASSET_ORIGINS` env narrowing via a child
* process.
*
* Run: PASCAL_DATA_DIR=/tmp/pascal-phase8-p4 \
* bun run packages/mcp/test-reports/phase8/p4-url-hardening.ts
*/
import { spawnSync } from 'node:child_process'
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { AnyNode as AnyNodeSchema, GuideNode, ItemNode, ScanNode } from '@pascal-app/core/schema'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p4-url-hardening.md')
const EDITOR_URL = process.env.EDITOR_URL ?? 'http://localhost:3002'
// -------- Dangerous & good URL vectors --------
const BAD_URLS: readonly string[] = [
'javascript:alert(1)',
'file:///etc/passwd',
'http://evil.com/beacon.glb',
'data:text/html,<script>alert(1)</script>',
'ftp://a.b.com/file',
'vbscript:msgbox("x")',
]
const GOOD_URLS: readonly string[] = [
'asset://12345abcde/model.glb',
'blob:http://localhost/x-y-z',
'data:image/png;base64,iVBOR',
'https://cdn.example.com/model.glb',
'http://localhost:3002/public/a.glb',
'/static/model.glb',
]
// -------- Report plumbing --------
type VerdictRow = {
url: string
nodeField: string
injectedVia: string
rejectedBy: string
expected: 'reject' | 'accept'
actual: 'reject' | 'accept'
pass: boolean
note?: string
}
const verdicts: VerdictRow[] = []
const logLines: string[] = []
function log(line: string): void {
// eslint-disable-next-line no-console
console.log(line)
logLines.push(line)
}
// -------- Node builders (unparsed input objects, ready for safeParse) --------
function buildItemNodeWith(url: string): unknown {
// The Zod default() calls on id/object/type fire during safeParse — we only
// need to include the non-default required fields and the `asset.src` URL.
return {
object: 'node',
type: 'item',
parentId: null,
asset: {
id: 'a1',
category: 'decor',
name: 'nope',
thumbnail: 'asset://thumb/x.png',
src: url,
dimensions: [1, 1, 1],
offset: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
children: [],
}
}
function buildScanNodeWith(url: string): unknown {
return {
object: 'node',
type: 'scan',
parentId: null,
url,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
opacity: 100,
}
}
function buildGuideNodeWith(url: string): unknown {
return {
object: 'node',
type: 'guide',
parentId: null,
url,
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: 1,
opacity: 50,
}
}
const NODE_BUILDERS: ReadonlyArray<{
label: string
type: 'item' | 'scan' | 'guide'
field: string
build: (url: string) => unknown
schema: typeof ItemNode | typeof ScanNode | typeof GuideNode
}> = [
{
label: 'ItemNode',
type: 'item',
field: 'asset.src',
build: buildItemNodeWith,
schema: ItemNode,
},
{ label: 'ScanNode', type: 'scan', field: 'url', build: buildScanNodeWith, schema: ScanNode },
{ label: 'GuideNode', type: 'guide', field: 'url', build: buildGuideNodeWith, schema: GuideNode },
]
// -------- Tier 1: Direct schema checks --------
function testSchemaLayer(): void {
log('\n==== Tier 1: AssetUrl / AnyNode.safeParse schema layer ====')
for (const { label, field, build, schema } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const raw = build(url)
const perNode = schema.safeParse(raw)
const anyNode = AnyNodeSchema.safeParse(raw)
const rejectedByPer = !perNode.success
const rejectedByAny = !anyNode.success
const pass = rejectedByPer && rejectedByAny
const rejectedBy =
rejectedByPer && rejectedByAny
? `${label} + AnyNode`
: rejectedByPer
? label
: rejectedByAny
? 'AnyNode'
: 'NONE'
log(
` [${label}.${field}] BAD url ${url.padEnd(50)}${label}=${
rejectedByPer ? 'reject' : 'accept'
} / AnyNode=${rejectedByAny ? 'reject' : 'accept'} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'AnyNode.safeParse',
rejectedBy,
expected: 'reject',
actual: pass ? 'reject' : 'accept',
pass,
})
}
for (const url of GOOD_URLS) {
const raw = build(url)
const perNode = schema.safeParse(raw)
const anyNode = AnyNodeSchema.safeParse(raw)
const pass = perNode.success && anyNode.success
log(
` [${label}.${field}] GOOD url ${url.padEnd(50)}${label}=${
perNode.success ? 'accept' : 'reject'
} / AnyNode=${anyNode.success ? 'accept' : 'reject'} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'AnyNode.safeParse',
rejectedBy: pass ? '—' : 'AssetUrl',
expected: 'accept',
actual: pass ? 'accept' : 'reject',
pass,
})
}
}
}
// -------- Tier 2 & 3: MCP stdio boundary --------
type McpResult = { isError?: boolean; content?: Array<{ type?: string; text?: string }> }
async function testMcpLayer(): Promise<void> {
log('\n==== Tier 2+3: apply_patch + save_scene via stdio MCP ====')
const transport = new StdioClientTransport({
command: process.execPath,
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: {
...process.env,
PASCAL_DATA_DIR: process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4',
} as Record<string, string>,
})
const client = new Client({ name: 'p4-url-hardening', version: '0.0.0' })
await client.connect(transport)
async function call(name: string, args: Record<string, unknown>): Promise<McpResult> {
try {
return (await client.callTool({ name, arguments: args })) as McpResult
} catch (err) {
// Treat thrown MCP errors as a structured error result so the reporter
// records it as a rejection.
return {
isError: true,
content: [{ type: 'text', text: String((err as Error).message ?? err) }],
}
}
}
// 2a. apply_patch on BAD URLs → expect isError=true (AssetUrl in AnyNode
// dryrun via SceneBridge.applyPatch throws synchronously).
for (const { label, field, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record<string, unknown>
const res = await call('apply_patch', {
patches: [{ op: 'create', node }],
})
const rejected = Boolean(res.isError)
const pass = rejected
log(
` apply_patch create ${label}.${field} BAD ${url.padEnd(50)}${
rejected ? 'reject' : 'accept'
} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'apply_patch',
rejectedBy: rejected ? 'apply_patch (AssetUrl)' : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass,
})
}
}
// 2b. apply_patch on GOOD URLs: parent wiring is fiddly, so skip create of
// ItemNode (which normally needs a wall/ceiling/level host). We still
// verify the URL layer doesn't block them: for scan+guide, a bare
// `parentId: null` is accepted and the node can attach to the site/level
// root. If that call fails for NON-url reasons (e.g. parent missing),
// we don't count it here — we already exercised the schema path.
// 3. save_scene with graph containing a bad URL (includeCurrentScene=false)
// Expected: save_scene rejects with an MCP error (either at the bridge's
// internal validate, at the storage layer, or at the route envelope).
for (const { label, field, type, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record<string, unknown> & { id?: string }
node.id = `${type}_phase8p4bad`
const badGraph = {
nodes: { [node.id as string]: node },
rootNodeIds: [node.id],
collections: {},
}
const res = await call('save_scene', {
name: `phase8-p4-${label}-bad`,
includeCurrentScene: false,
graph: badGraph,
})
const rejected = Boolean(res.isError)
const text = res.content?.[0]?.text ?? ''
const layer = rejected
? text.includes('scene_invalid') || text.includes('validate')
? 'save_scene (validate)'
: 'save_scene (storage)'
: 'NONE'
const pass = rejected
log(
` save_scene graph with ${label}.${field} BAD ${url.padEnd(48)}${
rejected ? `reject [${layer}]` : 'accept'
} ${pass ? 'OK' : 'FAIL'}`,
)
verdicts.push({
url,
nodeField: `${label}.${field}`,
injectedVia: 'save_scene',
rejectedBy: rejected ? layer : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass,
note: rejected ? text.slice(0, 120) : undefined,
})
}
}
// 4. Editor /api/scenes POST — best-effort (depends on editor being up).
log('\n==== Tier 4: editor POST /api/scenes ====')
let editorUp = false
try {
const hc = await fetch(`${EDITOR_URL}/api/health`, { signal: AbortSignal.timeout(1000) })
editorUp = hc.ok
} catch {
editorUp = false
}
if (!editorUp) {
log(` editor at ${EDITOR_URL} not reachable — skipping HTTP boundary test`)
} else {
for (const { label, type, build } of NODE_BUILDERS) {
for (const url of BAD_URLS) {
const node = build(url) as Record<string, unknown> & { id?: string }
node.id = `${type}_phase8p4http`
const badGraph = {
nodes: { [node.id as string]: node },
rootNodeIds: [node.id],
collections: {},
}
const res = await fetch(`${EDITOR_URL}/api/scenes`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: `phase8-p4-${label}-bad-http`,
graph: badGraph,
}),
})
const rejected = !res.ok
log(
` POST /api/scenes ${label} BAD ${url.padEnd(48)} → HTTP ${res.status} ${
rejected ? 'reject' : 'ACCEPT (bad!)'
}`,
)
verdicts.push({
url,
nodeField: `${label}`,
injectedVia: 'editor POST /api/scenes',
rejectedBy: rejected ? `HTTP ${res.status}` : 'NONE',
expected: 'reject',
actual: rejected ? 'reject' : 'accept',
pass: rejected,
})
}
}
}
await client.close()
}
// -------- Tier 5: env allowlist via child process --------
function testEnvAllowlist(): void {
log('\n==== Tier 5: PASCAL_ALLOWED_ASSET_ORIGINS narrowing ====')
// Run a short Node.js script that imports the compiled asset-url module
// directly (resolving @pascal-app/core via its dist path). Using an
// absolute path sidesteps workspace-linking issues in the child process.
const assetUrlModulePath = resolve(REPO_ROOT, 'packages/core/dist/schema/asset-url.js')
const childScript = `
import { AssetUrl } from ${JSON.stringify(assetUrlModulePath)}
const cases = [
['https://cdn.pascal.app/x.glb', 'accept'],
['https://otherhost.com/x.glb', 'reject'],
['https://cdn.pascal.app.evil.com/x', 'reject'],
['asset://abc', 'accept'],
['https://cdn.pascal.app/deep/path?q=1', 'accept'],
]
const out = []
for (const [u, exp] of cases) {
const ok = AssetUrl.safeParse(u).success
const got = ok ? 'accept' : 'reject'
out.push({ url: u, expected: exp, got, pass: got === exp })
}
process.stdout.write(JSON.stringify(out))
`
const child = spawnSync(process.execPath, ['--input-type=module', '--eval', childScript], {
env: {
...process.env,
PASCAL_ALLOWED_ASSET_ORIGINS: 'https://cdn.pascal.app',
},
encoding: 'utf8',
cwd: REPO_ROOT,
})
if (child.status !== 0) {
log(` FAIL spawnSync: exit=${child.status}, stderr=${child.stderr?.slice(0, 200)}`)
verdicts.push({
url: '(PASCAL_ALLOWED_ASSET_ORIGINS)',
nodeField: 'env allowlist',
injectedVia: 'spawnSync',
rejectedBy: 'FAIL_TO_SPAWN',
expected: 'reject',
actual: 'accept',
pass: false,
note: `${child.stderr?.slice(0, 200)}`,
})
return
}
try {
const parsed = JSON.parse(child.stdout) as Array<{
url: string
expected: 'accept' | 'reject'
got: 'accept' | 'reject'
pass: boolean
}>
for (const row of parsed) {
log(
` env-narrow ${row.url.padEnd(48)} expected=${row.expected} got=${row.got} ${
row.pass ? 'OK' : 'FAIL'
}`,
)
verdicts.push({
url: row.url,
nodeField: 'env allowlist',
injectedVia: `spawnSync + ${'PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app'}`,
rejectedBy: row.got === 'reject' ? 'AssetUrl (env)' : '—',
expected: row.expected,
actual: row.got,
pass: row.pass,
})
}
} catch (err) {
log(` FAIL parse child stdout: ${String(err)}; raw=${child.stdout}`)
}
}
// -------- Report writer --------
function writeReport(): void {
mkdirSync(dirname(REPORT_PATH), { recursive: true })
const passCount = verdicts.filter((v) => v.pass).length
const failCount = verdicts.length - passCount
// Group verdicts by injectedVia for the bad-URL table rows.
const tableRows = verdicts
.map(
(v) =>
`| \`${v.url}\` | ${v.nodeField} | ${v.injectedVia} | ${v.rejectedBy} | ${v.expected} | ${v.actual} | ${v.pass ? 'PASS' : 'FAIL'} |`,
)
.join('\n')
const md = `# Phase 8 P4 — URL Hardening Report
Worktree: \`/Users/adrian/Desktop/editor/.worktrees/mcp-server\`
Data dir: \`${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4'}\`
Total checks: **${verdicts.length}** — pass **${passCount}**, fail **${failCount}**
## Scope
Verify A7's \`AssetUrl\` validator rejects dangerous URLs at every boundary:
- \`AnyNode.safeParse\` (core schema)
- \`apply_patch\` MCP tool (bridge dry-run)
- \`save_scene\` MCP tool (includeCurrentScene=false path)
- editor \`POST /api/scenes\` (HTTP envelope)
- \`PASCAL_ALLOWED_ASSET_ORIGINS\` env narrowing
## Verdict table
| URL | node_field | injected_via | rejected_by | expected | actual | result |
|---|---|---|---|---|---|---|
${tableRows}
## Summary of findings
- Schema layer (\`AssetUrl\`\`ItemNode\`/\`ScanNode\`/\`GuideNode\`\`AnyNode\`)
rejects every bad URL vector (javascript:, file:, foreign http:, data:text/html,
ftp:, vbscript:) in every slot (asset.src, scan.url, guide.url).
- \`apply_patch\` forwards the rejection: \`SceneBridge.applyPatch\` re-parses each
create node with \`AnyNode\` before mutating the store, so the bad URL is
caught before the scene mutates.
- \`save_scene\` with \`includeCurrentScene: false\` does NOT re-run
\`AnyNode.safeParse\` on the provided graph — it treats the graph as opaque
and hands it to the storage layer. See next section.
- \`PASCAL_ALLOWED_ASSET_ORIGINS=https://cdn.pascal.app\` correctly narrows
\`https:\` URLs to that origin; other schemes remain accepted.
- Editor \`POST /api/scenes\` uses \`graphSchema = z.unknown().refine(...object)\`
which also does NOT re-validate per-node schema. It relies on the editor UI
having generated a validated graph.
## Layer that catches bad URLs in \`save_scene\`
When \`includeCurrentScene: false\` is used, the only URL-validation layer hit
is the in-memory \`AnyNode\` pre-parse inside \`save_scene\`'s \`validateScene()\`
path — but that branch is ONLY run when \`includeCurrentScene=true\`. With
\`includeCurrentScene: false\`, the graph is passed through to
\`FilesystemSceneStore.save\` which enforces only size + node-envelope checks
(type is a non-empty string, node is an object). This means a malicious
\`graph\` can bypass \`AssetUrl\` at the save_scene boundary.
The A7 hardening therefore is fully effective at \`apply_patch\` and at
\`save_scene\` with \`includeCurrentScene: true\` (bridge validate); but when a
caller supplies \`graph\` directly, URL validation is deferred until the scene
is later loaded into the bridge (\`setScene\` → editor renderer). The same gap
applies to the editor \`POST /api/scenes\` endpoint.
## Recommendations
1. \`save_scene\` should re-parse each node of the incoming \`graph\` with
\`AnyNode\` when \`includeCurrentScene === false\` before calling
\`store.save\`, matching the strictness of \`apply_patch\`.
2. The editor's \`POST /api/scenes\` route should apply the same per-node
validation instead of treating the graph as opaque.
3. \`FilesystemSceneStore.save\` could optionally validate node shape with
\`AnyNode\` as a defence-in-depth layer (size-bounded and acceptably cheap).
## Run log
\`\`\`
${logLines.join('\n')}
\`\`\`
`
writeFileSync(REPORT_PATH, md, 'utf8')
log(`\nReport written: ${REPORT_PATH}`)
}
// -------- Main --------
async function main(): Promise<void> {
log(`==== Phase 8 P4 URL hardening ====`)
log(`BIN_PATH=${BIN_PATH}`)
log(`PASCAL_DATA_DIR=${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p4'}`)
testSchemaLayer()
await testMcpLayer()
testEnvAllowlist()
writeReport()
const failCount = verdicts.filter((v) => !v.pass).length
log(`\nDONE. ${verdicts.length} checks, ${failCount} failures.`)
if (failCount > 0) process.exit(2)
}
main().catch((err) => {
log(`FATAL: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`)
try {
writeReport()
} catch {}
process.exit(1)
})
// Silence unused-import warnings in environments where appendFileSync isn't
// needed (the log() writer path uses writeFileSync instead).
void appendFileSync
@@ -1,157 +0,0 @@
# Phase 8 P5 — `photo_to_scene` via stdio with mocked sampling
Generated: 2026-04-19T18:18:43.532Z
## Summary
- Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`)
- Data dir: `/tmp/pascal-phase8-p5`
- Sampling: mocked via `client.setRequestHandler(CreateMessageRequestSchema, …)`
- Passed: **6/6**
- Failed: **0/6**
- Total run time: **172 ms**
- Observed sceneId: `02c817a2772b`
- Node count after load_scene: **8**
## Tests
| # | Test | Status | Summary |
|---|------|--------|---------|
| 1 | 1. happy path photo_to_scene(save:true) | PASS | sceneId=02c817a2772b url=/scene/02c817a2772b walls=4 rooms=1 confidence=0.85 |
| 2 | 2. list_scenes includes new scene | PASS | found id=02c817a2772b name="p5-photo" (total=1) |
| 3 | 3. load_scene + validate_scene | PASS | nodeCount=8 valid=true errors=0 |
| 4 | 4. save:false returns graph inline | PASS | inline graph nodes=8, rootIds=1, walls=4, rooms=1 |
| 5 | 5. invalid sampling JSON → sampling_response_unparseable | PASS | received expected error |
| 6 | 6. no sampling capability → sampling_unavailable | PASS | received expected error |
## Details
### 1. 1. happy path photo_to_scene(save:true) — PASS
Summary: sceneId=02c817a2772b url=/scene/02c817a2772b walls=4 rooms=1 confidence=0.85
```json
{"sceneId":"02c817a2772b","url":"/scene/02c817a2772b","walls":4,"rooms":1,"confidence":0.85}
```
### 2. 2. list_scenes includes new scene — PASS
Summary: found id=02c817a2772b name="p5-photo" (total=1)
```json
{"total":1,"match":{"id":"02c817a2772b","name":"p5-photo","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:18:43.447Z","updatedAt":"2026-04-19T18:18:43.447Z","ownerId":null,"sizeBytes":4740,"nodeCount":8}}
```
### 3. 3. load_scene + validate_scene — PASS
Summary: nodeCount=8 valid=true errors=0
```json
{"load":{"id":"02c817a2772b","name":"p5-photo","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:18:43.447Z","updatedAt":"2026-04-19T18:18:43.447Z","ownerId":null,"sizeBytes":4740,"nodeCount":8},"validate":{"valid":true,"errors":[]}}
```
### 4. 4. save:false returns graph inline — PASS
Summary: inline graph nodes=8, rootIds=1, walls=4, rooms=1
```json
{"walls":4,"rooms":1,"confidence":0.85,"nodes":8,"roots":1}
```
### 5. 5. invalid sampling JSON → sampling_response_unparseable — PASS
Summary: received expected error
```json
MCP error -32603: sampling_response_unparseable
```
### 6. 6. no sampling capability → sampling_unavailable — PASS
Summary: received expected error
```json
MCP error -32600: sampling_unavailable
```
## Canned sampling payload
```json
{
"walls": [
{
"start": [
0,
0
],
"end": [
5,
0
],
"thickness": 0.2
},
{
"start": [
5,
0
],
"end": [
5,
3
],
"thickness": 0.2
},
{
"start": [
5,
3
],
"end": [
0,
3
],
"thickness": 0.2
},
{
"start": [
0,
3
],
"end": [
0,
0
],
"thickness": 0.2
}
],
"rooms": [
{
"name": "living room",
"polygon": [
[
0,
0
],
[
5,
0
],
[
5,
3
],
[
0,
3
]
],
"approximateAreaSqM": 15
}
],
"approximateDimensions": {
"widthM": 5,
"depthM": 3
},
"confidence": 0.85
}
```
@@ -1,463 +0,0 @@
/**
* Phase 8 P5: exercise `photo_to_scene` over stdio with a mocked MCP sampling
* response. The client advertises the `sampling` capability and installs a
* request handler that returns a canned floor-plan JSON — no real vision API
* is contacted.
*
* Run:
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p5 \
* bun run packages/mcp/test-reports/phase8/p5-photo-to-scene.ts
*/
import { writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p5-photo-to-scene.md')
type Row = {
name: string
status: 'pass' | 'fail'
summary: string
detail?: string
}
const rows: Row[] = []
function record(name: string, status: 'pass' | 'fail', summary: string, detail?: string): void {
rows.push({ name, status, summary, detail })
const tag = status === 'pass' ? 'OK' : 'FAIL'
console.log(`${tag} ${name}${summary}`)
}
function pickText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
return content[0]?.text ?? ''
}
/** Canned valid floor-plan reply. */
const CANNED_FLOORPLAN = {
walls: [
{ start: [0, 0], end: [5, 0], thickness: 0.2 },
{ start: [5, 0], end: [5, 3], thickness: 0.2 },
{ start: [5, 3], end: [0, 3], thickness: 0.2 },
{ start: [0, 3], end: [0, 0], thickness: 0.2 },
],
rooms: [
{
name: 'living room',
polygon: [
[0, 0],
[5, 0],
[5, 3],
[0, 3],
],
approximateAreaSqM: 15,
},
],
approximateDimensions: { widthM: 5, depthM: 3 },
confidence: 0.85,
}
type SamplingReplyBuilder = (req: unknown) => unknown
function makeClient(opts: {
withSampling: boolean
samplingReply?: SamplingReplyBuilder
name: string
}): { client: Client; transport: StdioClientTransport } {
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: {
...process.env,
PASCAL_DATA_DIR: process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p5',
},
})
const client = new Client(
{ name: opts.name, version: '0.0.0' },
{
capabilities: opts.withSampling ? { sampling: {} } : {},
},
)
if (opts.withSampling && opts.samplingReply) {
const build = opts.samplingReply
client.setRequestHandler(CreateMessageRequestSchema, async (req) => (await build(req)) as never)
}
return { client, transport }
}
async function main(): Promise<void> {
const t0 = Date.now()
console.log('---- P5 photo_to_scene (stdio + mocked sampling) ----')
console.log(`BIN=${BIN_PATH}`)
console.log(`PASCAL_DATA_DIR=${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p5'}`)
// Primary client with sampling + valid handler. A mutable holder lets us swap
// the behaviour of the handler between tests without reconnecting.
type Mode = 'valid' | 'not-json'
let mode: Mode = 'valid'
const { client, transport } = makeClient({
name: 'p5',
withSampling: true,
samplingReply: () => {
if (mode === 'not-json') {
return {
model: 'test-model',
role: 'assistant',
content: { type: 'text', text: 'not json at all' },
stopReason: 'endTurn',
}
}
return {
model: 'test-model',
role: 'assistant',
content: { type: 'text', text: JSON.stringify(CANNED_FLOORPLAN) },
stopReason: 'endTurn',
}
},
})
await client.connect(transport)
console.log('OK connected primary client (sampling enabled)')
let observedSceneId: string | undefined
let observedNodeCount: number | undefined
// --- Test 1: happy path, save: true -------------------------------------
// Per the P5 plan the input was `https://example.com/plan.png`, but the
// sandbox has no outbound network so `resolveImageBlock` cannot fetch it.
// Send a data URI instead — the mocked sampling handler ignores the image
// bytes, so the end-to-end contract under test (vision JSON → scene) is
// unchanged.
try {
const res: any = await client.callTool({
name: 'photo_to_scene',
arguments: {
image: 'data:image/png;base64,aGVsbG8=',
name: 'p5-photo',
save: true,
},
})
if (res.isError) throw new Error(`tool error: ${pickText(res)}`)
const s = res.structuredContent as {
sceneId?: string
url?: string
walls: number
rooms: number
confidence: number
}
const problems: string[] = []
if (!s.sceneId) problems.push('sceneId missing')
if (!s.url) problems.push('url missing')
if (s.walls !== 4) problems.push(`walls=${s.walls} (expected 4)`)
if (s.rooms !== 1) problems.push(`rooms=${s.rooms} (expected 1)`)
if (Math.abs(s.confidence - 0.85) > 1e-6) {
problems.push(`confidence=${s.confidence} (expected 0.85)`)
}
if (problems.length === 0) {
observedSceneId = s.sceneId
record(
'1. happy path photo_to_scene(save:true)',
'pass',
`sceneId=${s.sceneId} url=${s.url} walls=${s.walls} rooms=${s.rooms} confidence=${s.confidence}`,
JSON.stringify(s),
)
} else {
record(
'1. happy path photo_to_scene(save:true)',
'fail',
problems.join('; '),
JSON.stringify(s),
)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
record('1. happy path photo_to_scene(save:true)', 'fail', `threw: ${msg}`)
}
// --- Test 2: list_scenes sees the new scene ------------------------------
try {
const res: any = await client.callTool({ name: 'list_scenes', arguments: {} })
if (res.isError) throw new Error(`tool error: ${pickText(res)}`)
const s = res.structuredContent as { scenes: Array<{ id: string; name: string }> }
const match = s.scenes.find((x) => x.id === observedSceneId)
if (match) {
record(
'2. list_scenes includes new scene',
'pass',
`found id=${match.id} name="${match.name}" (total=${s.scenes.length})`,
JSON.stringify({ total: s.scenes.length, match }),
)
} else {
record(
'2. list_scenes includes new scene',
'fail',
`id=${observedSceneId} not found among ${s.scenes.length}`,
JSON.stringify(s).slice(0, 300),
)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
record('2. list_scenes includes new scene', 'fail', `threw: ${msg}`)
}
// --- Test 3: load_scene + validate_scene ---------------------------------
try {
if (!observedSceneId) throw new Error('no sceneId from test 1')
const load: any = await client.callTool({
name: 'load_scene',
arguments: { id: observedSceneId },
})
if (load.isError) throw new Error(`load_scene: ${pickText(load)}`)
observedNodeCount = (load.structuredContent as { nodeCount?: number }).nodeCount
const valid: any = await client.callTool({ name: 'validate_scene', arguments: {} })
if (valid.isError) throw new Error(`validate_scene: ${pickText(valid)}`)
const v = valid.structuredContent as { valid: boolean; errors?: unknown[] }
if (v.valid === true) {
record(
'3. load_scene + validate_scene',
'pass',
`nodeCount=${observedNodeCount} valid=true errors=${v.errors?.length ?? 0}`,
JSON.stringify({ load: load.structuredContent, validate: v }),
)
} else {
record(
'3. load_scene + validate_scene',
'fail',
`valid=${v.valid} errors=${JSON.stringify(v.errors)}`,
)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
record('3. load_scene + validate_scene', 'fail', `threw: ${msg}`)
}
// --- Test 4: save:false variant, base64 input ---------------------------
try {
const res: any = await client.callTool({
name: 'photo_to_scene',
arguments: {
image: 'base64here',
save: false,
name: 'p5-photo-inline',
},
})
if (res.isError) throw new Error(`tool error: ${pickText(res)}`)
const s = res.structuredContent as {
sceneId?: string
url?: string
walls: number
rooms: number
confidence: number
graph?: { nodes?: Record<string, unknown>; rootNodeIds?: string[] }
}
const problems: string[] = []
if (s.sceneId !== undefined) problems.push(`sceneId should be absent, got ${s.sceneId}`)
if (s.url !== undefined) problems.push(`url should be absent, got ${s.url}`)
if (!s.graph) problems.push('graph missing')
if (s.graph && !s.graph.nodes) problems.push('graph.nodes missing')
if (s.graph && !s.graph.rootNodeIds) problems.push('graph.rootNodeIds missing')
if (s.walls !== 4) problems.push(`walls=${s.walls}`)
if (s.rooms !== 1) problems.push(`rooms=${s.rooms}`)
const nodeCount = s.graph?.nodes ? Object.keys(s.graph.nodes).length : 0
if (problems.length === 0) {
record(
'4. save:false returns graph inline',
'pass',
`inline graph nodes=${nodeCount}, rootIds=${s.graph?.rootNodeIds?.length}, walls=${s.walls}, rooms=${s.rooms}`,
JSON.stringify({
walls: s.walls,
rooms: s.rooms,
confidence: s.confidence,
nodes: nodeCount,
roots: s.graph?.rootNodeIds?.length,
}),
)
} else {
record('4. save:false returns graph inline', 'fail', problems.join('; '))
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
record('4. save:false returns graph inline', 'fail', `threw: ${msg}`)
}
// --- Test 5: invalid (non-JSON) sampling response -----------------------
try {
mode = 'not-json'
const res: any = await client.callTool({
name: 'photo_to_scene',
arguments: {
image: 'base64here',
save: false,
},
})
const text = pickText(res)
if (res.isError && text.includes('sampling_response_unparseable')) {
record(
'5. invalid sampling JSON → sampling_response_unparseable',
'pass',
'received expected error',
text.slice(0, 240),
)
} else {
record(
'5. invalid sampling JSON → sampling_response_unparseable',
'fail',
`isError=${res.isError} text=${text.slice(0, 200)}`,
)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('sampling_response_unparseable')) {
record(
'5. invalid sampling JSON → sampling_response_unparseable',
'pass',
'thrown with expected code',
msg.slice(0, 240),
)
} else {
record(
'5. invalid sampling JSON → sampling_response_unparseable',
'fail',
`unexpected throw: ${msg.slice(0, 240)}`,
)
}
} finally {
mode = 'valid'
}
await client.close()
console.log('OK closed primary client')
// --- Test 6: secondary client WITHOUT sampling capability ---------------
try {
const { client: noSampleClient, transport: noSampleTransport } = makeClient({
name: 'p5-nosample',
withSampling: false,
})
await noSampleClient.connect(noSampleTransport)
try {
const res: any = await noSampleClient.callTool({
name: 'photo_to_scene',
arguments: {
image: 'base64here',
save: false,
},
})
const text = pickText(res)
if (res.isError && text.includes('sampling_unavailable')) {
record(
'6. no sampling capability → sampling_unavailable',
'pass',
'received expected error',
text.slice(0, 240),
)
} else {
record(
'6. no sampling capability → sampling_unavailable',
'fail',
`isError=${res.isError} text=${text.slice(0, 200)}`,
)
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('sampling_unavailable')) {
record(
'6. no sampling capability → sampling_unavailable',
'pass',
'thrown with expected code',
msg.slice(0, 240),
)
} else {
record(
'6. no sampling capability → sampling_unavailable',
'fail',
`unexpected throw: ${msg.slice(0, 240)}`,
)
}
} finally {
await noSampleClient.close()
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
record('6. no sampling capability → sampling_unavailable', 'fail', `setup threw: ${msg}`)
}
const elapsedMs = Date.now() - t0
const passed = rows.filter((r) => r.status === 'pass').length
const failed = rows.filter((r) => r.status === 'fail').length
// Write report
const ts = new Date().toISOString()
const md: string[] = []
md.push('# Phase 8 P5 — `photo_to_scene` via stdio with mocked sampling')
md.push('')
md.push(`Generated: ${ts}`)
md.push('')
md.push('## Summary')
md.push('')
md.push(`- Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`)`)
md.push(`- Data dir: \`${process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p5'}\``)
md.push(`- Sampling: mocked via \`client.setRequestHandler(CreateMessageRequestSchema, …)\``)
md.push(`- Passed: **${passed}/${rows.length}**`)
md.push(`- Failed: **${failed}/${rows.length}**`)
md.push(`- Total run time: **${elapsedMs} ms**`)
if (observedSceneId) md.push(`- Observed sceneId: \`${observedSceneId}\``)
if (observedNodeCount !== undefined) {
md.push(`- Node count after load_scene: **${observedNodeCount}**`)
}
md.push('')
md.push('## Tests')
md.push('')
md.push('| # | Test | Status | Summary |')
md.push('|---|------|--------|---------|')
rows.forEach((row, i) => {
const tag = row.status === 'pass' ? 'PASS' : 'FAIL'
const safe = row.summary.replace(/\|/g, '\\|')
md.push(`| ${i + 1} | ${row.name} | ${tag} | ${safe} |`)
})
md.push('')
md.push('## Details')
md.push('')
rows.forEach((row, i) => {
md.push(`### ${i + 1}. ${row.name}${row.status.toUpperCase()}`)
md.push('')
md.push(`Summary: ${row.summary}`)
if (row.detail) {
md.push('')
md.push('```json')
md.push(row.detail)
md.push('```')
}
md.push('')
})
md.push('## Canned sampling payload')
md.push('')
md.push('```json')
md.push(JSON.stringify(CANNED_FLOORPLAN, null, 2))
md.push('```')
md.push('')
writeFileSync(REPORT_PATH, md.join('\n'), 'utf8')
console.log(`\nreport written: ${REPORT_PATH}`)
console.log(
`passed=${passed}/${rows.length} failed=${failed}/${rows.length} elapsedMs=${elapsedMs}`,
)
if (failed > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p5] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,77 +0,0 @@
# Phase 8 P6 — Casa del Sol via save_scene
Generated: 2026-04-19T18:20:19.643Z
Transport: stdio (spawned `bun /Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/dist/bin/pascal-mcp.js --stdio`)
PASCAL_DATA_DIR: `/tmp/pascal-phase8`
Editor URL: http://localhost:3002
## Result summary
- Steps passed: **13/13**
- Initial node count: 3
- Final node count: **39** (threshold ≥ 30)
- doors=6, windows=6, zones=9, walls=9, fences=5, slabs=1
- All validate_scene calls valid=true: **true**
- Saved scene id: `6f87c59c1535`
- Scene file: `/tmp/pascal-phase8/scenes/6f87c59c1535.json`
### Open in browser: http://localhost:3002/scene/6f87c59c1535
## Per-step results
| # | Step | Status | Duration | Summary |
|---|------|--------|----------|---------|
| 1 | discover | PASS | 2ms | building=building_16mw8oy88f952is9, level=level_3jbpcuma0wfwclex |
| 2 | perimeter walls | PASS | 2ms | 4 walls |
| 3 | interior walls | PASS | 0ms | 5 walls |
| 4 | zones | PASS | 2ms | 7 zones |
| 5 | openings | PASS | 3ms | 6 doors, 6 windows, 0 failures |
| 6 | pool zone + slab | PASS | 1ms | zone=zone_lnki9rxnoyq72rwi, slab=slab_rfyzzvcxlkbgif1w |
| 7 | privacy fences | PASS | 1ms | 5 fences |
| 8 | garden zone | PASS | 0ms | zone=zone_6l4ls8mr4rtfvsye |
| 9 | save_scene | PASS | 6ms | id=6f87c59c1535, nodeCount=39, size=29677B, url=/scene/6f87c59c1535 |
| 10 | file on disk | PASS | 0ms | /tmp/pascal-phase8/scenes/6f87c59c1535.json (29677B) |
| 11 | GET /api/scenes/:id | PASS | 9ms | 200 OK, 39 nodes |
| 12 | GET /scene/:id (HTML) | PASS | 237ms | 200 OK, text/html; charset=utf-8, 72918B |
| 13 | write v2 scene.json | PASS | 1ms | 26761B -> /Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/casa-sol-v2.json |
## Validation history
| Phase | valid | errors |
|-------|-------|--------|
| initial | true | 0 |
| afterWalls | true | 0 |
| afterZones | true | 0 |
| afterOpenings | true | 0 |
| afterPool | true | 0 |
| afterFences | true | 0 |
| afterGarden | true | 0 |
| final | true | 0 |
## save_scene response
```json
{
"id": "6f87c59c1535",
"name": "Casa del Sol",
"projectId": null,
"thumbnailUrl": null,
"version": 1,
"createdAt": "2026-04-19T18:20:19.389Z",
"updatedAt": "2026-04-19T18:20:19.389Z",
"ownerId": null,
"sizeBytes": 29677,
"nodeCount": 39,
"url": "/scene/6f87c59c1535"
}
```
## Assertions
- [x] ≥30 nodes total
- [x] validate_scene valid:true at every phase
- [x] save_scene returned id=`6f87c59c1535`
- [x] file exists on disk at `/tmp/pascal-phase8/scenes/6f87c59c1535.json`
- [x] GET /api/scenes/<id> returned 200 with matching node count
- [x] GET /scene/<id> returned 200 HTML
- [x] wrote `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/casa-sol-v2.json`
@@ -1,603 +0,0 @@
/**
* Phase 8 P6 — Casa del Sol via save_scene.
*
* Replicates the Casa del Sol build (packages/mcp/test-reports/casa-sol/DESIGN.md)
* but spawns the stdio MCP transport and persists through `save_scene` instead
* of export_json + window.__pascalScene injection.
*
* Transport: StdioClientTransport spawning `bun dist/bin/pascal-mcp.js --stdio`
* with PASCAL_DATA_DIR=/tmp/pascal-phase8 so the editor (same shared dir) can
* load the scene back via /api/scenes/[id].
*
* Run:
* PASCAL_DATA_DIR=/tmp/pascal-phase8 \
* bun run packages/mcp/test-reports/phase8/p6-casa-sol-save.ts
*/
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const HERE = dirname(__filename)
const REPO_ROOT = resolve(HERE, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(HERE, 'p6-casa-sol-save.md')
const SCENE_JSON_PATH = resolve(HERE, 'casa-sol-v2.json')
const PASCAL_DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8'
const EDITOR_URL = process.env.EDITOR_URL ?? 'http://localhost:3002'
// Pre-create the shared dir so both the spawned MCP and the running editor see it.
if (!existsSync(PASCAL_DATA_DIR)) {
mkdirSync(PASCAL_DATA_DIR, { recursive: true })
}
type Vec2 = [number, number]
type WallSpec = { key: string; designId: number; start: Vec2; end: Vec2 }
const PERIMETER_WALLS: WallSpec[] = [
{ key: 'south-outer', designId: 1, start: [-8, 4], end: [4, 4] },
{ key: 'north-outer', designId: 2, start: [-8, -4], end: [4, -4] },
{ key: 'west-outer', designId: 3, start: [-8, -4], end: [-8, 4] },
{ key: 'east-outer', designId: 4, start: [4, -4], end: [4, 4] },
]
const INTERIOR_WALLS: WallSpec[] = [
{ key: 'living-kitchen-split', designId: 5, start: [-1, 0], end: [-1, 4] },
{ key: 'north-bedrooms-split', designId: 6, start: [-1, -4], end: [-1, 0] },
{ key: 'bedroom2-east', designId: 7, start: [-4, -4], end: [-4, 0] },
{ key: 'hallway-north-edge', designId: 8, start: [-4, -1], end: [-1, -1] },
{ key: 'hallway-south-edge', designId: 9, start: [-4, -2], end: [-1, -2] },
]
type ZoneSpec = { label: string; polygon: Vec2[]; properties?: Record<string, unknown> }
const INTERIOR_ZONES: ZoneSpec[] = [
{
label: 'living-dining',
polygon: [
[-8, 0],
[-1, 0],
[-1, 4],
[-8, 4],
],
},
{
label: 'kitchen',
polygon: [
[-1, 0],
[4, 0],
[4, 4],
[-1, 4],
],
},
{
label: 'bedroom-2',
polygon: [
[-8, -4],
[-4, -4],
[-4, 0],
[-8, 0],
],
},
{
label: 'hallway',
polygon: [
[-4, -2],
[-1, -2],
[-1, -1],
[-4, -1],
],
},
{
label: 'bathroom-2',
polygon: [
[-4, -4],
[-1, -4],
[-1, -2],
[-4, -2],
],
},
{
label: 'bathroom-1',
polygon: [
[-4, -1],
[-1, -1],
[-1, 0],
[-4, 0],
],
},
{
label: 'master-bedroom',
polygon: [
[-1, -4],
[4, -4],
[4, 0],
[-1, 0],
],
},
]
type OpeningSpec = {
wallDesignId: number
kind: 'door' | 'window'
position: number
width: number
height: number
label: string
}
const OPENINGS: OpeningSpec[] = [
{ wallDesignId: 1, kind: 'door', position: 0.2, width: 0.9, height: 2.1, label: 'front-door' },
{ wallDesignId: 1, kind: 'door', position: 0.75, width: 2.2, height: 2.1, label: 'sliding-pool' },
{ 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: 9, kind: 'door', position: 0.5, width: 0.7, height: 2.0, label: 'bath2-door' },
{ wallDesignId: 1, kind: 'window', position: 0.3, width: 2.0, height: 1.4, label: 'living-pic' },
{ wallDesignId: 1, kind: 'window', position: 0.45, width: 1.4, height: 1.4, label: 'living-2' },
{ wallDesignId: 3, kind: 'window', position: 0.25, width: 1.0, height: 1.1, label: 'kitchen-w' },
{ wallDesignId: 4, kind: 'window', position: 0.25, width: 1.4, height: 1.4, label: 'master-w' },
{
wallDesignId: 4,
kind: 'window',
position: 0.75,
width: 1.4,
height: 1.4,
label: 'bedroom-2-w',
},
{ wallDesignId: 2, kind: 'window', position: 0.3, width: 0.8, height: 0.6, label: 'bath2-high' },
]
const POOL_POLY: Vec2[] = [
[5, -1.5],
[10, -1.5],
[10, 1.5],
[5, 1.5],
]
type FenceSpec = { label: string; start: Vec2; end: Vec2 }
const FENCES: FenceSpec[] = [
{ label: 'south-west', start: [-10, 7.5], end: [-1, 7.5] },
{ label: 'south-east', start: [1, 7.5], end: [10, 7.5] },
{ label: 'east', start: [10, 7.5], end: [10, -7.5] },
{ label: 'north', start: [10, -7.5], end: [-10, -7.5] },
{ label: 'west', start: [-10, -7.5], end: [-10, 7.5] },
]
const SITE_POLY: Vec2[] = [
[-10, -7.5],
[10, -7.5],
[10, 7.5],
[-10, 7.5],
]
type Validation = {
valid: boolean
errors: Array<{ nodeId: string; path: string; message: string }>
}
type StepEntry = { n: number; name: string; ok: boolean; summary: string; durationMs: number }
const steps: StepEntry[] = []
function log(msg: string): void {
console.log(msg)
}
async function recordStep<T>(
n: number,
name: string,
fn: () => Promise<{ summary: string; result: T }>,
): Promise<T> {
const t0 = Date.now()
try {
const { summary, result } = await fn()
const durationMs = Date.now() - t0
steps.push({ n, name, ok: true, summary, durationMs })
log(`[p6] ${String(n).padStart(2, '0')} ${name.padEnd(22)} OK (${summary}, ${durationMs}ms)`)
return result
} catch (err) {
const durationMs = Date.now() - t0
const msg = err instanceof Error ? err.message : String(err)
steps.push({ n, name, ok: false, summary: `FAILED: ${msg}`, durationMs })
log(`[p6] ${String(n).padStart(2, '0')} ${name.padEnd(22)} FAIL (${msg}, ${durationMs}ms)`)
throw err
}
}
async function callTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
const res = (await client.callTool({ name, arguments: args })) as {
isError?: boolean
content?: Array<{ text?: string }>
structuredContent?: unknown
}
if (res.isError) {
const text = Array.isArray(res.content) ? res.content.map((c) => c.text ?? '').join('\n') : ''
throw new Error(`tool ${name} error: ${text || 'unknown'}`)
}
return (res.structuredContent ?? {}) as T
}
async function validate(client: Client, phase: string): Promise<Validation> {
const v = await callTool<Validation>(client, 'validate_scene', {})
log(`[p6] validate(${phase}): valid=${v.valid}, errors=${v.errors.length}`)
if (!v.valid) {
for (const e of v.errors.slice(0, 5)) {
log(`[p6] - ${e.nodeId} @ ${e.path}: ${e.message}`)
}
}
return v
}
async function main(): Promise<void> {
log(`[p6] spawning stdio MCP: bun ${BIN_PATH} --stdio`)
log(`[p6] PASCAL_DATA_DIR=${PASCAL_DATA_DIR}`)
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: { ...process.env, PASCAL_DATA_DIR },
})
const client = new Client({ name: 'p6-casa-sol-save', version: '0.1.0' })
await client.connect(transport)
log('[p6] stdio transport connected')
const validations: Record<string, Validation> = {}
// --- 1. Discover building + level ---
const { buildingId, levelId } = await recordStep(1, 'discover', async () => {
const buildings = await callTool<{ nodes: Array<{ id: string }> }>(client, 'find_nodes', {
type: 'building',
})
const levels = await callTool<{ nodes: Array<{ id: string; parentId?: string }> }>(
client,
'find_nodes',
{ type: 'level' },
)
if (!buildings.nodes.length) throw new Error('no building in default scene')
if (!levels.nodes.length) throw new Error('no level in default scene')
const b = buildings.nodes[0]!
const l = levels.nodes.find((lv) => lv.parentId === b.id) ?? levels.nodes[0]!
return {
summary: `building=${b.id}, level=${l.id}`,
result: { buildingId: b.id, levelId: l.id },
}
})
void buildingId
const initialCount = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
validations.initial = await validate(client, 'initial')
// --- 2. Perimeter walls ---
const perimeterIds: Record<string, string> = {}
await recordStep(2, 'perimeter walls', async () => {
const ids: string[] = []
for (const w of PERIMETER_WALLS) {
const r = await callTool<{ wallId: string }>(client, 'create_wall', {
levelId,
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
})
perimeterIds[w.key] = r.wallId
ids.push(r.wallId)
}
return { summary: `${ids.length} walls`, result: ids }
})
// --- 3. Interior walls via apply_patch ---
const interiorIds: Record<string, string> = {}
await recordStep(3, 'interior walls', async () => {
const res = await callTool<{ createdIds: string[] }>(client, 'apply_patch', {
patches: INTERIOR_WALLS.map((w) => ({
op: 'create',
parentId: levelId,
node: {
type: 'wall',
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
},
})),
})
INTERIOR_WALLS.forEach((w, i) => {
const id = res.createdIds[i]
if (id) interiorIds[w.key] = id
})
return { summary: `${res.createdIds.length} walls`, result: res.createdIds }
})
const wallByDesignId = new Map<number, string>()
for (const w of PERIMETER_WALLS) {
const id = perimeterIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
for (const w of INTERIOR_WALLS) {
const id = interiorIds[w.key]
if (id) wallByDesignId.set(w.designId, id)
}
validations.afterWalls = await validate(client, 'walls')
// --- 4. Zones ---
await recordStep(4, 'zones', async () => {
const ids: string[] = []
for (const z of INTERIOR_ZONES) {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: z.label,
polygon: z.polygon,
properties: z.properties ?? {},
})
ids.push(r.zoneId)
}
return { summary: `${ids.length} zones`, result: ids }
})
validations.afterZones = await validate(client, 'zones')
// --- 5. Openings (doors + windows) ---
let doorCount = 0
let windowCount = 0
await recordStep(5, 'openings', async () => {
const failures: string[] = []
for (const o of OPENINGS) {
const wallId = wallByDesignId.get(o.wallDesignId)
if (!wallId) {
failures.push(`${o.label}: wall designId=${o.wallDesignId} not found`)
continue
}
try {
await callTool<{ openingId: string }>(client, 'cut_opening', {
wallId,
type: o.kind,
position: o.position,
width: o.width,
height: o.height,
})
if (o.kind === 'door') doorCount++
else windowCount++
} catch (err) {
failures.push(`${o.label}: ${err instanceof Error ? err.message : String(err)}`)
}
}
return {
summary: `${doorCount} doors, ${windowCount} windows, ${failures.length} failures`,
result: { doorCount, windowCount, failures },
}
})
validations.afterOpenings = await validate(client, 'openings')
// --- 6. Pool zone + pool slab ---
await recordStep(6, 'pool zone + slab', async () => {
const zoneRes = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'pool',
polygon: POOL_POLY,
properties: { kind: 'pool', depthM: 1.8, finish: 'tile' },
})
const slabRes = await callTool<{ createdIds: string[] }>(client, 'apply_patch', {
patches: [
{
op: 'create',
parentId: levelId,
node: { type: 'slab', polygon: POOL_POLY, elevation: -1.8 },
},
],
})
return {
summary: `zone=${zoneRes.zoneId}, slab=${slabRes.createdIds[0]}`,
result: { zoneId: zoneRes.zoneId, slabId: slabRes.createdIds[0] },
}
})
validations.afterPool = await validate(client, 'pool')
// --- 7. Privacy fences ---
await recordStep(7, 'privacy fences', async () => {
const res = await callTool<{ createdIds: string[] }>(client, 'apply_patch', {
patches: FENCES.map((f) => ({
op: 'create',
parentId: levelId,
node: {
type: 'fence',
start: f.start,
end: f.end,
height: 1.8,
style: 'privacy',
thickness: 0.08,
},
})),
})
return { summary: `${res.createdIds.length} fences`, result: res.createdIds }
})
validations.afterFences = await validate(client, 'fences')
// --- 8. Garden zone ---
await recordStep(8, 'garden zone', async () => {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: 'garden',
polygon: SITE_POLY,
properties: { kind: 'garden' },
})
return { summary: `zone=${r.zoneId}`, result: r.zoneId }
})
validations.afterGarden = await validate(client, 'garden')
// --- Assertion: ≥30 nodes ---
const allNodes = (await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {}))
.nodes
const tally: Record<string, number> = {}
for (const n of allNodes) tally[n.type] = (tally[n.type] ?? 0) + 1
log(
`[p6] total nodes=${allNodes.length}, walls=${tally.wall ?? 0} zones=${tally.zone ?? 0} doors=${tally.door ?? 0} windows=${tally.window ?? 0} fences=${tally.fence ?? 0} slabs=${tally.slab ?? 0}`,
)
if (allNodes.length < 30) {
throw new Error(`node count ${allNodes.length} < 30`)
}
// --- 9. save_scene ---
const saved = await recordStep(9, 'save_scene', async () => {
const r = await callTool<{
id: string
name: string
version: number
nodeCount: number
sizeBytes: number
url: string
}>(client, 'save_scene', { name: 'Casa del Sol' })
return {
summary: `id=${r.id}, nodeCount=${r.nodeCount}, size=${r.sizeBytes}B, url=${r.url}`,
result: r,
}
})
// --- 10. File on disk ---
const expectedPath = resolve(PASCAL_DATA_DIR, 'scenes', `${saved.id}.json`)
const fileOk = await recordStep(10, 'file on disk', async () => {
if (!existsSync(expectedPath)) {
throw new Error(`scene file missing at ${expectedPath}`)
}
const st = statSync(expectedPath)
return {
summary: `${expectedPath} (${st.size}B)`,
result: { path: expectedPath, size: st.size },
}
})
void fileOk
// --- 11. GET /api/scenes/<id> ---
const apiOk = await recordStep(11, 'GET /api/scenes/:id', async () => {
const res = await fetch(`${EDITOR_URL}/api/scenes/${saved.id}`)
if (res.status !== 200) throw new Error(`status=${res.status}`)
const body = (await res.json()) as { graph: { nodes: Record<string, unknown> } }
const got = Object.keys(body.graph.nodes).length
if (got !== saved.nodeCount) {
throw new Error(`nodeCount mismatch: saved=${saved.nodeCount}, api=${got}`)
}
return { summary: `200 OK, ${got} nodes`, result: got }
})
void apiOk
// --- 12. GET /scene/<id> (HTML) ---
await recordStep(12, 'GET /scene/:id (HTML)', async () => {
const res = await fetch(`${EDITOR_URL}/scene/${saved.id}`)
if (res.status !== 200) throw new Error(`status=${res.status}`)
const ct = res.headers.get('content-type') ?? ''
if (!ct.includes('text/html')) throw new Error(`content-type=${ct}`)
const txt = await res.text()
return { summary: `200 OK, ${ct}, ${txt.length}B`, result: txt.length }
})
// --- 13. Write casa-sol-v2.json evidence ---
await recordStep(13, 'write v2 scene.json', async () => {
const r = await callTool<{ json: string }>(client, 'export_json', { pretty: true })
writeFileSync(SCENE_JSON_PATH, r.json, 'utf8')
return { summary: `${r.json.length}B -> ${SCENE_JSON_PATH}`, result: r.json.length }
})
// --- Final validate ---
validations.final = await validate(client, 'final')
await client.close()
// --- Write report ---
const allValid = Object.values(validations).every((v) => v.valid)
const passed = steps.filter((s) => s.ok).length
const total = steps.length
const md: string[] = []
md.push('# Phase 8 P6 — Casa del Sol via save_scene')
md.push('')
md.push(`Generated: ${new Date().toISOString()}`)
md.push(`Transport: stdio (spawned \`bun ${BIN_PATH} --stdio\`)`)
md.push(`PASCAL_DATA_DIR: \`${PASCAL_DATA_DIR}\``)
md.push(`Editor URL: ${EDITOR_URL}`)
md.push('')
md.push('## Result summary')
md.push('')
md.push(`- Steps passed: **${passed}/${total}**`)
md.push(`- Initial node count: ${initialCount}`)
md.push(`- Final node count: **${allNodes.length}** (threshold ≥ 30)`)
md.push(
`- doors=${tally.door ?? 0}, windows=${tally.window ?? 0}, zones=${tally.zone ?? 0}, walls=${tally.wall ?? 0}, fences=${tally.fence ?? 0}, slabs=${tally.slab ?? 0}`,
)
md.push(`- All validate_scene calls valid=true: **${allValid}**`)
md.push(`- Saved scene id: \`${saved.id}\``)
md.push(`- Scene file: \`${expectedPath}\``)
md.push('')
md.push(`### Open in browser: ${EDITOR_URL}/scene/${saved.id}`)
md.push('')
md.push('## Per-step results')
md.push('')
md.push('| # | Step | Status | Duration | Summary |')
md.push('|---|------|--------|----------|---------|')
for (const s of steps) {
md.push(
`| ${s.n} | ${s.name} | ${s.ok ? 'PASS' : 'FAIL'} | ${s.durationMs}ms | ${s.summary.replace(/\|/g, '\\|')} |`,
)
}
md.push('')
md.push('## Validation history')
md.push('')
md.push('| Phase | valid | errors |')
md.push('|-------|-------|--------|')
for (const [phase, v] of Object.entries(validations)) {
md.push(`| ${phase} | ${v.valid} | ${v.errors.length} |`)
}
md.push('')
md.push('## save_scene response')
md.push('')
md.push('```json')
md.push(JSON.stringify(saved, null, 2))
md.push('```')
md.push('')
md.push('## Assertions')
md.push('')
md.push(`- [${allNodes.length >= 30 ? 'x' : ' '}] ≥30 nodes total`)
md.push(`- [${allValid ? 'x' : ' '}] validate_scene valid:true at every phase`)
md.push(`- [${saved.id ? 'x' : ' '}] save_scene returned id=\`${saved.id}\``)
md.push(`- [${existsSync(expectedPath) ? 'x' : ' '}] file exists on disk at \`${expectedPath}\``)
md.push(
`- [${steps.find((s) => s.name === 'GET /api/scenes/:id')?.ok ? 'x' : ' '}] GET /api/scenes/<id> returned 200 with matching node count`,
)
md.push(
`- [${steps.find((s) => s.name === 'GET /scene/:id (HTML)')?.ok ? 'x' : ' '}] GET /scene/<id> returned 200 HTML`,
)
md.push(`- [${existsSync(SCENE_JSON_PATH) ? 'x' : ' '}] wrote \`${SCENE_JSON_PATH}\``)
md.push('')
writeFileSync(REPORT_PATH, md.join('\n'), 'utf8')
log(`[p6] report written: ${REPORT_PATH}`)
log(`[p6] final URL: ${EDITOR_URL}/scene/${saved.id}`)
log(`[p6] DONE — ${passed}/${total} steps passed`)
}
main().catch((err) => {
console.error('[p6] FATAL:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(1)
})
@@ -1,76 +0,0 @@
# Phase 8 P7 — Editor HTTP API Verification
**Agent**: P7 (Editor API direct-fetch)
**Scope**: Exercise every verb + error path on the editor's `/api/scenes` and
`/api/scenes/[id]` routes via native `fetch` against
`http://localhost:3002`, using the shared data dir `/tmp/pascal-phase8`.
**No MCP involved** — this report verifies the HTTP contract the MCP server
consumes.
**Script**: `packages/mcp/test-reports/phase8/p7-editor-api.ts`
**Result**: **18 / 18 PASS**
## Setup notes
- The editor dev server was already running against the shared data dir.
- Parallel P-agents share `/tmp/pascal-phase8`; the list-count test observed
**39 scenes** at time of run (P1-P6 + P7 + sibling writers).
- Cleanup: the script unlinks `/tmp/pascal-phase8/scenes/p7-my-id.json`
before running. We discovered that a malformed file on disk (nodes missing
a string `type`) wedges the store — `GET`/`PUT`/`PATCH`/`DELETE` all return
`400 invalid` because the filesystem backend's `readPersisted` validates on
every read. The direct `unlink` is necessary because even the DELETE route
reads-before-unlink.
- **Important graph shape contract**: each node value must be a non-null
object with a non-empty string `type` field (see
`packages/mcp/src/storage/filesystem-scene-store.ts:325-335`). Using `kind`
in place of `type` passes POST (since POST only does a `typeof === 'object'`
check) but poisons subsequent reads — a real gotcha for clients.
## HTTP status-code matrix
| # | Test | Expected | Actual | Pass |
|----|-----------------------------------|-------------------------|--------------------------------------|------|
| 1 | POST happy | 201 + Location header | 201, Location=/scene/<id> | PASS |
| 2 | POST missing `name` | 400 invalid_request | 400 invalid_request | PASS |
| 3 | POST graph is string (not object) | 400 invalid_request | 400 invalid_request | PASS |
| 4 | POST explicit `id: 'p7-my-id'` | 201 with id preserved | 201 id=p7-my-id | PASS |
| 5 | POST duplicate id | 409 or 400 (document) | **400 `invalid`** (documented) | PASS |
| 6 | GET list | 200 scenes >= 2 | 200 count=39 | PASS |
| 7 | GET ?limit=1 | 200 scenes == 1 | 200 count=1 | PASS |
| 8 | GET ?projectId=nope (document) | 200 | 200 count=0 (**strict filter**) | PASS |
| 9 | GET by id | 200 + ETag: "1" | 200, ETag="1", version=1 | PASS |
| 10 | GET missing id | 404 not_found | 404 not_found | PASS |
| 11 | PUT If-Match: "1" | 200 version=2 | 200 version=2 | PASS |
| 12 | PUT body expectedVersion=2 | 200 version=3 | 200 version=3 | PASS |
| 13 | PUT no If-Match, no body version | 200 or 4xx (document) | **400 `invalid`** (**strict**) | PASS |
| 14 | PUT If-Match: "99" (stale) | 409 version_conflict | 409 version_conflict | PASS |
| 15 | PATCH name: 'renamed' | 200 name=renamed | 200 name=renamed | PASS |
| 16 | PATCH name: '' | 400 invalid_request | 400 invalid_request | PASS |
| 17 | DELETE happy + re-GET | 204 then 404 | DELETE=204, GET=404 | PASS |
| 18 | DELETE already-deleted | 404 not_found | 404 not_found | PASS |
## Documented behaviours
- **Duplicate-id (#5)**: editor returns **`400 invalid`**, not `409`. The store
layer throws `SceneInvalidError` on slug collision when no
`expectedVersion` is supplied (see
`filesystem-scene-store.ts:131-135`). Clients expecting `409 Conflict` for
duplicate-id per REST convention should be aware this API uses `400`.
- **`projectId` filter (#8)**: the filter is **strict** — unknown `projectId`
returns an empty list rather than ignoring the filter.
- **PUT without version (#13)**: **strict**. The editor rejects a PUT that
provides neither `If-Match` nor `expectedVersion` after the first write with
`400 invalid`. Callers must always supply a concurrency token to mutate an
existing scene.
## Headers verified
- `Location: /scene/<id>` on 201 from POST.
- `ETag: "<version>"` on 200 from GET, PUT, and PATCH.
- `If-Match: "<version>"` accepted and honored on PUT; weak form `W/"..."`
parsed by the route per RFC 7232 (not separately tested here).
## Files
- Script: `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/p7-editor-api.ts`
- Report: `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/p7-editor-api.md`
@@ -1,371 +0,0 @@
/**
* Phase 8 P7: Editor HTTP API verification (no MCP, just fetch).
*
* Exercises every verb on /api/scenes + /api/scenes/[id] against a running
* editor dev server (localhost:3002) that reads the SHARED data dir
* (/tmp/pascal-phase8). Records an HTTP status-code matrix for the report.
*
* Run with: bun run packages/mcp/test-reports/phase8/p7-editor-api.ts
*/
import { existsSync, unlinkSync } from 'node:fs'
const BASE = 'http://localhost:3002'
const SHARED_DIR = '/tmp/pascal-phase8/scenes'
type TestResult = {
test: string
expected: string
actual: string
pass: boolean
note?: string
}
const results: TestResult[] = []
function minimalGraph() {
// Minimal SceneGraph: a single root node. The filesystem store's parseRecord
// requires every node value to be a non-null object with a non-empty string
// `type` field.
const rootId = 'n-root'
return {
nodes: {
[rootId]: {
id: rootId,
type: 'project',
name: 'P7 Project',
childIds: [],
},
},
rootNodeIds: [rootId],
}
}
async function record(
test: string,
expected: string,
fn: () => Promise<{ actual: string; pass: boolean; note?: string }>,
): Promise<void> {
try {
const { actual, pass, note } = await fn()
results.push({ test, expected, actual, pass, note })
console.log(
`[${pass ? 'PASS' : 'FAIL'}] ${test}: expected=${expected} actual=${actual}${note ? ` (${note})` : ''}`,
)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
results.push({ test, expected, actual: `THREW: ${msg}`, pass: false })
console.error(`[FAIL] ${test}: threw ${msg}`)
}
}
async function main(): Promise<void> {
// Health check.
const health = await fetch(`${BASE}/api/scenes`)
if (!health.ok) {
console.error(`Editor not reachable at ${BASE}: ${health.status}`)
process.exit(1)
}
// Best-effort cleanup of p7 artifacts from a prior run. The filesystem
// store's DELETE path also reads + validates the existing record, so if a
// previous run left a malformed file on disk the API-level DELETE returns
// 400. We nuke known p7 fixture files directly on the shared dir to keep
// tests deterministic.
for (const name of ['p7-my-id.json']) {
const p = `${SHARED_DIR}/${name}`
if (existsSync(p)) {
try {
unlinkSync(p)
} catch {
/* best effort */
}
}
}
await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' }).catch(() => {})
// -------- POST /api/scenes --------
// 1. Happy create
let createdAId: string | undefined
await record('1 POST happy', '201', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'p7-a', graph: minimalGraph() }),
})
const loc = res.headers.get('Location') ?? ''
const body = (await res.json().catch(() => ({}))) as { id?: string }
if (body.id) createdAId = body.id
const locOk = loc.startsWith('/scene/') && !!body.id && loc === `/scene/${body.id}`
return {
actual: String(res.status),
pass: res.status === 201 && locOk,
note: `Location=${loc} id=${body.id}`,
}
})
// 2. Invalid — missing name
await record('2 POST missing name', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// 3. Invalid — graph not an object (string)
await record('3 POST bad graph', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'p7-bad', graph: 'not-an-object' }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// 4. Explicit id
await record('4 POST explicit id', '201 id=p7-my-id', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 'p7-my-id', name: 'p7-explicit', graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { id?: string }
return {
actual: `${res.status} id=${body.id}`,
pass: res.status === 201 && body.id === 'p7-my-id',
}
})
// 5. Duplicate id
await record('5 POST duplicate id', '409 or 400', async () => {
const res = await fetch(`${BASE}/api/scenes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: 'p7-my-id', name: 'p7-dup', graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
const pass = res.status === 409 || res.status === 400
return {
actual: `${res.status} ${body.error ?? ''}`,
pass,
note: `duplicate returned status=${res.status} error=${body.error}`,
}
})
// -------- GET /api/scenes --------
// 6. List — P7 has just added 2 scenes; expect >=2. Target of >=3 (from
// P1P6 + our own) only holds when siblings have also written; we log the
// observed count in the note either way.
await record('6 GET list', '200 scenes>=2', async () => {
const res = await fetch(`${BASE}/api/scenes`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200 && count >= 2,
note: `observed ${count} scenes in shared dir (P7 contributed 2; target was ≥3)`,
}
})
// 7. Limit=1 — expect 1 when list has ≥1 scene.
await record('7 GET ?limit=1', '200 scenes==min(total,1)', async () => {
const allRes = await fetch(`${BASE}/api/scenes`)
const allBody = (await allRes.json().catch(() => ({}))) as { scenes?: unknown[] }
const total = Array.isArray(allBody.scenes) ? allBody.scenes.length : 0
const res = await fetch(`${BASE}/api/scenes?limit=1`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
const expected = Math.min(total, 1)
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200 && count === expected,
note: `total=${total}, limit=1 returned ${count}`,
}
})
// 8. projectId=nope — document semantics
await record('8 GET ?projectId=nope', '200 (document)', async () => {
const res = await fetch(`${BASE}/api/scenes?projectId=nope`)
const body = (await res.json().catch(() => ({}))) as { scenes?: unknown[] }
const count = Array.isArray(body.scenes) ? body.scenes.length : -1
return {
actual: `${res.status} count=${count}`,
pass: res.status === 200,
note: `filter returned ${count} scenes — ${count === 0 ? 'strict filter' : 'permissive / ignored'}`,
}
})
// -------- GET /api/scenes/[id] --------
// 9. Load happy
await record('9 GET by id', '200 ETag:"1"', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`)
const etag = res.headers.get('ETag') ?? ''
const body = (await res.json().catch(() => ({}))) as { version?: number; graph?: unknown }
return {
actual: `${res.status} ETag=${etag} version=${body.version}`,
pass: res.status === 200 && etag === '"1"' && body.version === 1 && !!body.graph,
}
})
// 10. Missing id
await record('10 GET missing id', '404 not_found', async () => {
const res = await fetch(`${BASE}/api/scenes/does-not-exist-p7`)
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 404 && body.error === 'not_found',
}
})
// -------- PUT /api/scenes/[id] --------
// 11. With If-Match: "1"
await record('11 PUT If-Match "1"', '200 version=2', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': '"1"' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { version?: number }
return {
actual: `${res.status} version=${body.version}`,
pass: res.status === 200 && body.version === 2,
}
})
// 12. expectedVersion in body (current version should now be 2)
await record('12 PUT expectedVersion body', '200 version=3', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph(), expectedVersion: 2 }),
})
const body = (await res.json().catch(() => ({}))) as { version?: number }
return {
actual: `${res.status} version=${body.version}`,
pass: res.status === 200 && body.version === 3,
}
})
// 13. No If-Match, no expectedVersion — document lenient/strict. The task
// says "200 (lenient) or error (strict) — document"; we accept 200, 4xx,
// and 409 and note the observed policy.
await record('13 PUT no version', '200 or 4xx (document)', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string; version?: number }
const pass = res.status === 200 || (res.status >= 400 && res.status < 500)
let policy: string
if (res.status === 200) policy = 'LENIENT: missing version accepted'
else if (res.status === 409) policy = 'STRICT(conflict): missing version rejected as conflict'
else policy = `STRICT(${res.status}): missing version rejected (${body.error})`
return {
actual: `${res.status} ${body.error ?? `version=${body.version}`}`,
pass,
note: policy,
}
})
// 14. If-Match stale
await record('14 PUT If-Match "99"', '409 version_conflict', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': '"99"' },
body: JSON.stringify({ graph: minimalGraph() }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 409 && body.error === 'version_conflict',
}
})
// -------- PATCH /api/scenes/[id] --------
// 15. Rename happy
await record('15 PATCH rename', '200 name=renamed', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'renamed' }),
})
const body = (await res.json().catch(() => ({}))) as { name?: string }
return {
actual: `${res.status} name=${body.name}`,
pass: res.status === 200 && body.name === 'renamed',
}
})
// 16. Invalid empty name
await record('16 PATCH empty name', '400 invalid_request', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
})
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 400 && body.error === 'invalid_request',
}
})
// -------- DELETE /api/scenes/[id] --------
// 17. Delete happy → subsequent GET → 404
await record('17 DELETE + re-GET', '204 then 404', async () => {
const del = await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' })
const get = await fetch(`${BASE}/api/scenes/p7-my-id`)
return {
actual: `DELETE=${del.status} GET=${get.status}`,
pass: del.status === 204 && get.status === 404,
}
})
// 18. Already-deleted
await record('18 DELETE already-deleted', '404 not_found', async () => {
const res = await fetch(`${BASE}/api/scenes/p7-my-id`, { method: 'DELETE' })
const body = (await res.json().catch(() => ({}))) as { error?: string }
return {
actual: `${res.status} ${body.error ?? ''}`,
pass: res.status === 404 && body.error === 'not_found',
}
})
// Cleanup: best-effort delete the 'p7-a' scene created in test 1.
if (createdAId) {
await fetch(`${BASE}/api/scenes/${createdAId}`, { method: 'DELETE' }).catch(() => {})
}
// -------- Summary --------
const passed = results.filter((r) => r.pass).length
const total = results.length
console.log(`\n=== P7 Summary: ${passed}/${total} passed ===`)
// Emit JSON for report generation.
console.log('\n---RESULTS_JSON_START---')
console.log(JSON.stringify(results, null, 2))
console.log('---RESULTS_JSON_END---')
if (passed < total) process.exit(1)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -1,88 +0,0 @@
# Phase 8 P8 — concurrency stress report
- Generated: 2026-04-19T18:21:00.128Z
- Transport: stdio (`bun /Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/dist/bin/pascal-mcp.js --stdio`)
- Data dir: `/tmp/pascal-phase8-p8`
- Elapsed: 110 ms
- Scenarios: **4/5 pass**, 1 fail
## Matrix
| # | Scenario | Status | Summary |
|---|----------|--------|---------|
| 1 | Parallel saves of 10 different ids | PASS | 10/10 succeeded, list_scenes shows 10 scenes (all 10 present: true) |
| 2 | Parallel saves to SAME id (version race) | FAIL | 5 winner, 0 loser(s), 0 version_conflict — finalVersion=2 (expected 2) |
| 3 | Parallel delete + rename of same id | PASS | winners=1 (delete=true, rename=false); loser reports structured error: true |
| 4 | 20 parallel distinct saves + per-id load | PASS | saves=20/20, loads=20/20, corrupt=0, stray .tmp=0 |
| 5 | Index sidecar consistency | PASS | index=31 ids, disk=31 ids, missingFromDisk=0, missingFromIndex=0, versionMismatches=0 |
## Detail
### 1. Parallel saves of 10 different ids — PASS
10/10 succeeded, list_scenes shows 10 scenes (all 10 present: true)
```
saves succeeded: 10/10
saves failed: 0
list_scenes returned 10 ids: parallel-00, parallel-01, parallel-02, parallel-03, parallel-04, parallel-05, parallel-06, parallel-07, parallel-08, parallel-09
```
### 2. Parallel saves to SAME id (version race) — FAIL
5 winner, 0 loser(s), 0 version_conflict — finalVersion=2 (expected 2)
```
baseline version after initial save: 1
race winners (ok=true): 5
race losers (ok=false): 0
losers reporting version_conflict: 0
final version on disk: 2
```
### 3. Parallel delete + rename of same id — PASS
winners=1 (delete=true, rename=false); loser reports structured error: true
```
delete_scene ok=true err=
rename_scene ok=false err=MCP error -32600: version_conflict
post-race load_scene({id:'mix'}).id=null name=null
```
### 4. 20 parallel distinct saves + per-id load — PASS
saves=20/20, loads=20/20, corrupt=0, stray .tmp=0
### 5. Index sidecar consistency — PASS
index=31 ids, disk=31 ids, missingFromDisk=0, missingFromIndex=0, versionMismatches=0
## Flakiness note
Scenarios 1 and 5 are both symptoms of the same index-drift bug. Which one surfaces (or both, or neither) depends on timing — on repeated runs I observed: run A had `3/5 pass` with scenarios 2 and 5 failing; run B had `3/5 pass` with scenarios 1 and 2 failing. Scenario 2 is deterministic and always fails. Scenario 3 is deterministic and always passes. Scenario 4 (file bytes) is deterministic and always passes.
## Findings / bugs
### BUG 1 — `expectedVersion` check is racy (scenario 2)
Five parallel `save_scene({ id: "race", expectedVersion: 1 })` calls ALL returned `ok:true`. Only one of them actually produced a durable bump (final on-disk version is 2, not 6), so we do not see corruption — but the server silently accepts writes that should be rejected with `version_conflict`.
Root cause is in `FilesystemSceneStore.save()`: the check reads `existing.meta.version` at the top of the function and writes much later. Because `fs.readFile` and `fs.writeFile` each `await`, interleaved invocations all observe the same pre-race version, all pass the check, all claim `version = existing+1`, and the last `fs.rename` wins. There is no mutex / lock-file / compare-and-swap at the filesystem level. Expected behavior: exactly 1 success + 4 `version_conflict` errors.
### BUG 2 — `.index.json` sidecar drifts under load (scenario 5)
After 20 concurrent distinct saves (all files present on disk), `.index.json` was missing 3 of the scenes that DID make it to disk. `list_scenes` calls `readIndex()` first and only falls back to `collectAllMeta()` if the index file is absent — so those 3 scenes would also be hidden from `list_scenes` callers. The filter inside `readIndex` (drop entries whose file vanished) cannot paper this over because the problem is the opposite direction: files exist, index entry is missing.
Root cause: `save()` calls `writeIndex(await collectAllMeta())` at the end. When two `save()` calls race, call A may snapshot the directory while call B has not yet renamed its file into place; call A then writes an index that omits B. Call B then writes its own index that DOES include both — but if A's write happens to lose the final `rename` race (or B's write lands first and A's lands second) the loser's index is the one that sticks. This is exactly `index=28, disk=31` in the run above. `delete_scene` and `rename_scene` repeat the same pattern.
### Non-bugs observed
- Parallel saves of distinct ids (scenarios 1 + 4): all 10 / 20 files land on disk, no corruption, no stray `.tmp` files (atomic-rename does its job). The problem is not the file bytes — it is the `.index.json` denormalisation.
- Parallel `delete_scene` + `rename_scene` of the same id (scenario 3): delete wins, rename loses cleanly with a structured `version_conflict` error (rename uses `expectedVersion = current`, and delete removed the record, so the compare yields `0 !== 1`). No process crash, no half-state.
## Observations on the implementation
- `FilesystemSceneStore.save` serializes through a tmp+rename atomic write, then rewrites `.index.json` from a fresh directory listing. That is correct for single-writer, wrong for multi-writer.
- Optimistic concurrency relies on re-reading the existing record inside `save()` without any lock, so the check-then-write window is always a race.
- Suggested fix surface: serialize mutating operations per-id via an in-process queue (`Promise` chain keyed by id), or move the expectedVersion check to the final rename (`fs.rename` with a sentinel). The supabase backend is not affected because Postgres does the compare-and-swap server-side.
@@ -1,511 +0,0 @@
/**
* Phase 8 P8 — concurrency stress test.
*
* Hammer the MCP stdio server with parallel save/delete/rename calls and
* verify the FilesystemSceneStore keeps its invariants:
* - parallel saves of distinct ids → all succeed, listing matches
* - parallel saves to the same id with optimistic concurrency → exactly one
* winner, N-1 version_conflict losers
* - parallel delete + rename of the same id → only one winner, the other
* reports a clean structured error
* - parallel saves of many distinct ids → no corruption / no drift between
* the on-disk `<id>.json` files and the `.index.json` sidecar
*
* Run (from worktree root):
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p8 \
* bun run packages/mcp/test-reports/phase8/p8-concurrency.ts
*/
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import * as fs from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p8-concurrency.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p8'
const SCENES_DIR = `${DATA_DIR}/scenes`
const INDEX_PATH = `${SCENES_DIR}/.index.json`
type SceneMeta = {
id: string
name: string
projectId: string | null
thumbnailUrl: string | null
version: number
createdAt: string
updatedAt: string
ownerId: string | null
sizeBytes: number
nodeCount: number
}
type CallOutcome =
| { ok: true; text: string; json: unknown }
| { ok: false; error: string; json?: unknown }
type Scenario = {
name: string
status: 'pass' | 'fail'
summary: string
details: string[]
}
const scenarios: Scenario[] = []
function pickText(result: { content?: unknown; isError?: boolean }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
const first = content[0]
if (first && typeof first === 'object' && typeof first.text === 'string') return first.text
return ''
}
async function callTool(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<CallOutcome> {
try {
const result = (await client.callTool({ name, arguments: args })) as {
content?: unknown
structuredContent?: unknown
isError?: boolean
}
const text = pickText(result)
let parsed: unknown = null
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// keep parsed = null
}
}
if (result.isError) {
return { ok: false, error: text || 'isError', json: parsed ?? result.structuredContent }
}
return { ok: true, text, json: parsed ?? result.structuredContent }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, error: msg }
}
}
function cleanDataDir(): void {
if (existsSync(DATA_DIR)) {
rmSync(DATA_DIR, { recursive: true, force: true })
}
mkdirSync(SCENES_DIR, { recursive: true })
}
function record(scenario: Scenario): void {
scenarios.push(scenario)
const sym = scenario.status === 'pass' ? 'PASS' : 'FAIL'
console.log(`[${sym}] ${scenario.name}${scenario.summary}`)
for (const d of scenario.details) console.log(` ${d}`)
}
async function main(): Promise<void> {
cleanDataDir()
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-p8', version: '0.0.0' })
const t0 = Date.now()
await client.connect(transport)
console.log(`[p8] connected to MCP stdio — data dir: ${DATA_DIR}`)
// Bootstrap a small scene once. For the rest of the test we will save with
// `includeCurrentScene: true` (the bridge's current scene graph).
const getScene = await callTool(client, 'get_scene', {})
if (!getScene.ok) {
throw new Error(`bootstrap get_scene failed: ${getScene.error}`)
}
// ---- Scenario 1: parallel saves of 10 different ids -------------------
{
const ids = Array.from({ length: 10 }, (_, i) => `parallel-${i.toString().padStart(2, '0')}`)
const results = await Promise.all(
ids.map((id) =>
callTool(client, 'save_scene', { id, name: `Parallel ${id}`, includeCurrentScene: true }),
),
)
const successes = results.filter((r) => r.ok)
const failures = results.filter((r) => !r.ok)
// list_scenes should at least show all 10 ids. (Other scenarios below
// may add more later, but right now these should be the only scenes.)
const list = await callTool(client, 'list_scenes', { limit: 1000 })
const listedIds = list.ok
? ((list.json as { scenes?: Array<{ id: string }> })?.scenes ?? []).map((s) => s.id).sort()
: []
const allPresent = ids.every((id) => listedIds.includes(id))
const details: string[] = [
`saves succeeded: ${successes.length}/10`,
`saves failed: ${failures.length}`,
`list_scenes returned ${listedIds.length} ids: ${listedIds.join(', ')}`,
]
if (failures.length > 0) {
details.push(...failures.map((f) => `fail: ${!f.ok ? f.error : ''}`))
}
record({
name: 'Parallel saves of 10 different ids',
status: successes.length === 10 && allPresent ? 'pass' : 'fail',
summary: `${successes.length}/10 succeeded, list_scenes shows ${listedIds.length} scenes (all 10 present: ${allPresent})`,
details,
})
}
// ---- Scenario 2: parallel save to the SAME id — race ------------------
{
const initial = await callTool(client, 'save_scene', {
id: 'race',
name: 'Race initial',
includeCurrentScene: true,
})
const baselineVersion = initial.ok
? ((initial.json as { version?: number })?.version ?? -1)
: -1
const NUM_RACERS = 5
const racers = await Promise.all(
Array.from({ length: NUM_RACERS }, (_, i) =>
callTool(client, 'save_scene', {
id: 'race',
name: `Race #${i}`,
includeCurrentScene: true,
expectedVersion: baselineVersion,
}),
),
)
const winners = racers.filter((r) => r.ok)
const losers = racers.filter((r) => !r.ok)
const conflicts = losers.filter((r) => !r.ok && r.error.includes('version_conflict'))
// After the race, the scene should be at version baseline+1 (exactly one bump).
const postLoad = await callTool(client, 'load_scene', { id: 'race' })
const finalVersion = postLoad.ok ? ((postLoad.json as { version?: number })?.version ?? -1) : -1
const details: string[] = [
`baseline version after initial save: ${baselineVersion}`,
`race winners (ok=true): ${winners.length}`,
`race losers (ok=false): ${losers.length}`,
`losers reporting version_conflict: ${conflicts.length}`,
`final version on disk: ${finalVersion}`,
...losers.map((l, i) => `loser[${i}]: ${!l.ok ? l.error.slice(0, 180) : ''}`),
]
const passed =
winners.length === 1 &&
losers.length === NUM_RACERS - 1 &&
conflicts.length === NUM_RACERS - 1 &&
finalVersion === baselineVersion + 1
record({
name: 'Parallel saves to SAME id (version race)',
status: passed ? 'pass' : 'fail',
summary: `${winners.length} winner, ${losers.length} loser(s), ${conflicts.length} version_conflict — finalVersion=${finalVersion} (expected ${baselineVersion + 1})`,
details,
})
}
// ---- Scenario 3: parallel delete + rename of the same id ---------------
{
const saved = await callTool(client, 'save_scene', {
id: 'mix',
name: 'Mix baseline',
includeCurrentScene: true,
})
if (!saved.ok) {
record({
name: 'Parallel delete + rename of same id',
status: 'fail',
summary: `baseline save_scene failed: ${saved.error}`,
details: [],
})
} else {
const [delResult, renResult] = await Promise.all([
callTool(client, 'delete_scene', { id: 'mix' }),
callTool(client, 'rename_scene', { id: 'mix', newName: 'mix2' }),
])
const delOk = delResult.ok
const renOk = renResult.ok
// What actually survives on disk? load_scene should be deterministic.
const postLoad = await callTool(client, 'load_scene', { id: 'mix' })
const stillExists = postLoad.ok && (postLoad.json as { id?: string } | null)?.id === 'mix'
const postLoadName =
postLoad.ok && postLoad.json ? ((postLoad.json as { name?: string }).name ?? null) : null
// Acceptable outcomes: (delOk=true && renOk=false) OR (delOk=false && renOk=true).
// The *loser* must report a structured error string, never a process crash.
const exactlyOneWinner = Number(delOk) + Number(renOk) === 1
const loserErr = !delOk ? delResult.error : !renOk ? renResult.error : ''
const loserCleanError =
loserErr.includes('scene_not_found') ||
loserErr.includes('version_conflict') ||
loserErr.includes('not found') ||
loserErr.includes('version mismatch')
const details: string[] = [
`delete_scene ok=${delOk} err=${!delOk ? delResult.error.slice(0, 160) : ''}`,
`rename_scene ok=${renOk} err=${!renOk ? renResult.error.slice(0, 160) : ''}`,
`post-race load_scene({id:'mix'}).id=${stillExists ? 'mix' : 'null'} name=${postLoadName}`,
]
record({
name: 'Parallel delete + rename of same id',
status: exactlyOneWinner && loserCleanError ? 'pass' : 'fail',
summary: `winners=${Number(delOk) + Number(renOk)} (delete=${delOk}, rename=${renOk}); loser reports structured error: ${loserCleanError}`,
details,
})
}
}
// ---- Scenario 4: 20 parallel saves, then load each ---------------------
{
const ids = Array.from({ length: 20 }, (_, i) => `bulk-${i.toString().padStart(2, '0')}`)
const saveResults = await Promise.all(
ids.map((id) =>
callTool(client, 'save_scene', { id, name: `Bulk ${id}`, includeCurrentScene: true }),
),
)
const saveSuccesses = saveResults.filter((r) => r.ok)
// Ensure every scene file is readable and has the same id it was saved with.
// `load_scene` returns only the meta envelope (no `graph` field); the graph
// is loaded into the bridge as a side-effect. We verify id + version ≥ 1.
const loadResults = await Promise.all(ids.map((id) => callTool(client, 'load_scene', { id })))
const loadedOk: string[] = []
const loadedBad: string[] = []
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!
const r = loadResults[i]!
if (r.ok) {
const payload = r.json as { id?: string; version?: number } | null
if (payload?.id === id && typeof payload.version === 'number' && payload.version >= 1) {
loadedOk.push(id)
} else {
loadedBad.push(`${id}: payload mismatch (got ${JSON.stringify(payload)})`)
}
} else {
loadedBad.push(`${id}: ${r.error.slice(0, 120)}`)
}
}
// Direct on-disk read — look for bad JSON (the atomic write path should
// never leave half-written files behind).
const entries = await fs.readdir(SCENES_DIR)
const jsonFiles = entries.filter((e) => e.endsWith('.json') && e !== '.index.json')
const tmpFiles = entries.filter((e) => e.endsWith('.tmp'))
const corruptFiles: string[] = []
for (const f of jsonFiles) {
const full = `${SCENES_DIR}/${f}`
try {
const raw = await fs.readFile(full, 'utf8')
JSON.parse(raw)
} catch (err) {
corruptFiles.push(`${f}: ${err instanceof Error ? err.message : String(err)}`)
}
}
const pass =
saveSuccesses.length === ids.length &&
loadedOk.length === ids.length &&
loadedBad.length === 0 &&
corruptFiles.length === 0 &&
tmpFiles.length === 0
record({
name: '20 parallel distinct saves + per-id load',
status: pass ? 'pass' : 'fail',
summary: `saves=${saveSuccesses.length}/20, loads=${loadedOk.length}/20, corrupt=${corruptFiles.length}, stray .tmp=${tmpFiles.length}`,
details: [
...loadedBad.slice(0, 5).map((b) => `load issue: ${b}`),
...corruptFiles.slice(0, 5).map((b) => `corrupt: ${b}`),
...tmpFiles.slice(0, 5).map((b) => `stray tmp: ${b}`),
],
})
}
// ---- Scenario 5: index sidecar <-> file drift --------------------------
{
let indexRaw = ''
let indexParsed: SceneMeta[] | null = null
try {
indexRaw = readFileSync(INDEX_PATH, 'utf8')
const parsed = JSON.parse(indexRaw)
if (Array.isArray(parsed)) indexParsed = parsed as SceneMeta[]
} catch (err) {
record({
name: 'Index sidecar consistency',
status: 'fail',
summary: `could not read .index.json: ${err instanceof Error ? err.message : String(err)}`,
details: [],
})
}
if (indexParsed) {
const entries = await fs.readdir(SCENES_DIR)
const fileIds = entries
.filter((e) => e.endsWith('.json') && e !== '.index.json')
.map((e) => e.slice(0, -'.json'.length))
.sort()
const indexIds = indexParsed.map((m) => m.id).sort()
const missingFromDisk = indexIds.filter((id) => !fileIds.includes(id))
const missingFromIndex = fileIds.filter((id) => !indexIds.includes(id))
// Also verify each indexed entry's `version` matches what's in the file
// — a weaker but useful check against "index points to stale version".
const versionMismatches: string[] = []
for (const m of indexParsed.slice(0, 30)) {
const filePath = `${SCENES_DIR}/${m.id}.json`
try {
const raw = await fs.readFile(filePath, 'utf8')
const fileMeta = (JSON.parse(raw) as { meta?: { version?: number } }).meta
if (fileMeta?.version !== m.version) {
versionMismatches.push(`${m.id}: index=${m.version} file=${fileMeta?.version}`)
}
} catch (err) {
versionMismatches.push(
`${m.id}: could not verify (${err instanceof Error ? err.message : String(err)})`,
)
}
}
const pass =
missingFromDisk.length === 0 &&
missingFromIndex.length === 0 &&
versionMismatches.length === 0
record({
name: 'Index sidecar consistency',
status: pass ? 'pass' : 'fail',
summary: `index=${indexIds.length} ids, disk=${fileIds.length} ids, missingFromDisk=${missingFromDisk.length}, missingFromIndex=${missingFromIndex.length}, versionMismatches=${versionMismatches.length}`,
details: [
...missingFromDisk.slice(0, 10).map((id) => `missing-from-disk: ${id}`),
...missingFromIndex.slice(0, 10).map((id) => `missing-from-index: ${id}`),
...versionMismatches.slice(0, 10).map((m) => `version-mismatch: ${m}`),
],
})
}
}
const elapsedMs = Date.now() - t0
await client.close()
// ---- Write the report --------------------------------------------------
const passCount = scenarios.filter((s) => s.status === 'pass').length
const failCount = scenarios.length - passCount
const lines: string[] = []
lines.push('# Phase 8 P8 — concurrency stress report')
lines.push('')
lines.push(`- Generated: ${new Date().toISOString()}`)
lines.push(`- Transport: stdio (\`bun ${BIN_PATH} --stdio\`)`)
lines.push(`- Data dir: \`${DATA_DIR}\``)
lines.push(`- Elapsed: ${elapsedMs} ms`)
lines.push(`- Scenarios: **${passCount}/${scenarios.length} pass**, ${failCount} fail`)
lines.push('')
lines.push('## Matrix')
lines.push('')
lines.push('| # | Scenario | Status | Summary |')
lines.push('|---|----------|--------|---------|')
scenarios.forEach((s, i) => {
const safeSummary = s.summary.replace(/\|/g, '\\|')
const sym = s.status === 'pass' ? 'PASS' : 'FAIL'
lines.push(`| ${i + 1} | ${s.name} | ${sym} | ${safeSummary} |`)
})
lines.push('')
lines.push('## Detail')
lines.push('')
scenarios.forEach((s, i) => {
lines.push(`### ${i + 1}. ${s.name}${s.status.toUpperCase()}`)
lines.push('')
lines.push(s.summary)
if (s.details.length > 0) {
lines.push('')
lines.push('```')
for (const d of s.details) lines.push(d)
lines.push('```')
}
lines.push('')
})
lines.push('## Flakiness note')
lines.push('')
lines.push(
'Scenarios 1 and 5 are both symptoms of the same index-drift bug. Which one surfaces (or both, or neither) depends on timing — on repeated runs I observed: run A had `3/5 pass` with scenarios 2 and 5 failing; run B had `3/5 pass` with scenarios 1 and 2 failing. Scenario 2 is deterministic and always fails. Scenario 3 is deterministic and always passes. Scenario 4 (file bytes) is deterministic and always passes.',
)
lines.push('')
lines.push('## Findings / bugs')
lines.push('')
lines.push('### BUG 1 — `expectedVersion` check is racy (scenario 2)')
lines.push('')
lines.push(
'Five parallel `save_scene({ id: "race", expectedVersion: 1 })` calls ALL returned `ok:true`. Only one of them actually produced a durable bump (final on-disk version is 2, not 6), so we do not see corruption — but the server silently accepts writes that should be rejected with `version_conflict`.',
)
lines.push('')
lines.push(
'Root cause is in `FilesystemSceneStore.save()`: the check reads `existing.meta.version` at the top of the function and writes much later. Because `fs.readFile` and `fs.writeFile` each `await`, interleaved invocations all observe the same pre-race version, all pass the check, all claim `version = existing+1`, and the last `fs.rename` wins. There is no mutex / lock-file / compare-and-swap at the filesystem level. Expected behavior: exactly 1 success + 4 `version_conflict` errors.',
)
lines.push('')
lines.push('### BUG 2 — `.index.json` sidecar drifts under load (scenario 5)')
lines.push('')
lines.push(
'After 20 concurrent distinct saves (all files present on disk), `.index.json` was missing 3 of the scenes that DID make it to disk. `list_scenes` calls `readIndex()` first and only falls back to `collectAllMeta()` if the index file is absent — so those 3 scenes would also be hidden from `list_scenes` callers. The filter inside `readIndex` (drop entries whose file vanished) cannot paper this over because the problem is the opposite direction: files exist, index entry is missing.',
)
lines.push('')
lines.push(
"Root cause: `save()` calls `writeIndex(await collectAllMeta())` at the end. When two `save()` calls race, call A may snapshot the directory while call B has not yet renamed its file into place; call A then writes an index that omits B. Call B then writes its own index that DOES include both — but if A's write happens to lose the final `rename` race (or B's write lands first and A's lands second) the loser's index is the one that sticks. This is exactly `index=28, disk=31` in the run above. `delete_scene` and `rename_scene` repeat the same pattern.",
)
lines.push('')
lines.push('### Non-bugs observed')
lines.push('')
lines.push(
'- Parallel saves of distinct ids (scenarios 1 + 4): all 10 / 20 files land on disk, no corruption, no stray `.tmp` files (atomic-rename does its job). The problem is not the file bytes — it is the `.index.json` denormalisation.',
)
lines.push(
'- Parallel `delete_scene` + `rename_scene` of the same id (scenario 3): delete wins, rename loses cleanly with a structured `version_conflict` error (rename uses `expectedVersion = current`, and delete removed the record, so the compare yields `0 !== 1`). No process crash, no half-state.',
)
lines.push('')
lines.push('## Observations on the implementation')
lines.push('')
lines.push(
'- `FilesystemSceneStore.save` serializes through a tmp+rename atomic write, then rewrites `.index.json` from a fresh directory listing. That is correct for single-writer, wrong for multi-writer.',
)
lines.push(
'- Optimistic concurrency relies on re-reading the existing record inside `save()` without any lock, so the check-then-write window is always a race.',
)
lines.push(
'- Suggested fix surface: serialize mutating operations per-id via an in-process queue (`Promise` chain keyed by id), or move the expectedVersion check to the final rename (`fs.rename` with a sentinel). The supabase backend is not affected because Postgres does the compare-and-swap server-side.',
)
lines.push('')
const { writeFileSync } = await import('node:fs')
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`\n[p8] report written: ${REPORT_PATH}`)
console.log(`[p8] ${passCount}/${scenarios.length} scenarios pass, elapsed ${elapsedMs} ms`)
if (failCount > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p8] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,36 +0,0 @@
# Phase 8 P9 — edge cases & error-handling depth (stdio MCP)
Generated: 2026-04-19T18:20:30.398Z
Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`), data dir `/tmp/pascal-phase8-p9`.
**Summary:** 13/13 PASS, 0 WARN, 0 FAIL, 419 ms.
Scene files on disk after bulk tests: 50
## Test cases
| # | Case | Status | Detail |
|---|------|--------|--------|
| 1 | save 5k-node scene | PASS | nodeCount=5003 (expected 5003), sizeBytes=2392313, version=1 |
| 2 | save 10 MB-ish scene rejected | PASS | tool_error text="MCP error -32600: Scene "too-big-scene" is 12754914 bytes, exceeds cap of 10485760 bytes" |
| 3 | save_scene path-traversal id | PASS | sanitised id="etcpasswd", fileExists=true, noEscape=true |
| 4 | save_scene dirty id sanitisation | PASS | sanitised id="upper-case", fileExists=true |
| 5 | save_scene empty id rejected | PASS | tool_error: MCP error -32602: Input validation error: Invalid arguments for tool save_scene: [ { "origin": "string", "code": "too_small", "minimum": 1, "inclusive": true, "path": [ "id… |
| 6 | save_scene empty name rejected | PASS | tool_error: MCP error -32602: Input validation error: Invalid arguments for tool save_scene: [ { "origin": "string", "code": "too_small", "minimum": 1, "inclusive": true, "path": [ "na… |
| 7 | save_scene name length 500 rejected | PASS | tool_error: MCP error -32602: Input validation error: Invalid arguments for tool save_scene: [ { "origin": "string", "code": "too_big", "maximum": 200, "inclusive": true, "path": [ "na… |
| 8 | create_from_template null id rejected | PASS | tool_error: MCP error -32602: Input validation error: Invalid arguments for tool create_from_template: [ { "expected": "string", "code": "invalid_type", "path": [ "id" ], "message": "I… |
| 9 | rename_scene empty newName rejected | PASS | tool_error: MCP error -32602: Input validation error: Invalid arguments for tool rename_scene: [ { "origin": "string", "code": "too_small", "minimum": 1, "inclusive": true, "path": [ "… |
| 10 | list 50 scenes updatedAt DESC | PASS | count=50, descOk=true |
| 11 | list_scenes limit=10 | PASS | count=10 |
| 12 | list_scenes limit=-1 | PASS | rejected: MCP error -32602: Input validation error: Invalid arguments for tool list_scenes: [ { "origin": "number", "code": "too_small", "minimum": 0, "inclusive": false, "path": [ "… |
| 13 | PASCAL_DATA_DIR nonexistent root | PASS | auto-created=true, file=true, id=first-scene |
## Notes
- Case 1 (5k nodes) constructs walls programmatically and saves via
`save_scene({ includeCurrentScene: false, graph })`.
- Case 2 pads `metadata.padding` on each of 500 walls to push past 10 MB.
PASS = structured error mentioning `too_large`; WARN = other rejection reason.
- Cases 3-5 exercise slug hygiene (`sanitizeSlug` in `storage/slug.ts`).
- Case 13 spawns a second stdio child with a deep nonexistent data dir.
PASS if the dir is auto-created by the filesystem store or the call fails with a
clear error (ENOENT/EACCES/etc.).
@@ -1,746 +0,0 @@
/**
* Phase 8 P9 — edge cases & error-handling depth (stdio MCP).
*
* Spawns an isolated stdio MCP child using PASCAL_DATA_DIR=/tmp/pascal-phase8-p9
* and drives every edge case in the P9 plan:
* - scene-size limits (5k nodes, 10 MB cap)
* - slug safety (path-traversal, dirty chars, empty id)
* - invalid inputs (empty/too-long names, null template id, empty rename)
* - listing at scale (50 scenes, pagination, negative limit)
* - data-dir issues (nonexistent root directory)
*
* Run: bun packages/mcp/test-reports/phase8/p9-edges.ts
*/
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, resolve } 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 { McpError } from '@modelcontextprotocol/sdk/types.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'p9-edges.md')
const DATA_DIR = '/tmp/pascal-phase8-p9'
const NONEXIST_DATA_DIR = '/tmp/pascal-phase8-p9-nonexistent-root/sub/dir'
type StepStatus = 'PASS' | 'FAIL' | 'WARN'
type Step = { id: string; title: string; status: StepStatus; detail: string }
const steps: Step[] = []
function record(id: string, title: string, status: StepStatus, detail: string): void {
steps.push({ id, title, status, detail })
const icon = status === 'PASS' ? '[PASS]' : status === 'WARN' ? '[WARN]' : '[FAIL]'
console.log(`${icon} ${id} ${title}${detail}`)
}
type TextContent = Array<{ type?: string; text?: string }>
function parseText(content: unknown): any {
const arr = content as TextContent
const first = Array.isArray(arr) ? arr[0] : undefined
if (!first || typeof first.text !== 'string') return null
try {
return JSON.parse(first.text)
} catch {
return null
}
}
function truncate(s: string, max = 220): string {
return s.length <= max ? s : `${s.slice(0, max)}`
}
/**
* Build a raw SceneGraph with N wall nodes hanging off a minimal level.
* We bypass the bridge and save via `includeCurrentScene: false, graph: …`
* so we can create 5k nodes in one tool call.
*/
function buildWallyGraph(wallCount: number, padBytesPerNode = 0): unknown {
const siteId = 'site_bulk'
const buildingId = 'building_bulk'
const levelId = 'level_bulk'
const wallIds: string[] = []
const nodes: Record<string, unknown> = {}
const padding = padBytesPerNode > 0 ? 'x'.repeat(padBytesPerNode) : ''
for (let i = 0; i < wallCount; i++) {
const id = `wall_bulk_${i}`
wallIds.push(id)
const x = (i % 100) * 0.5
const z = Math.floor(i / 100) * 0.5
nodes[id] = {
object: 'node',
id,
type: 'wall',
parentId: levelId,
visible: true,
metadata: padding ? { padding } : {},
start: [x, z],
end: [x + 0.4, z],
thickness: 0.1,
height: 2.5,
frontSide: 'unknown',
backSide: 'unknown',
children: [],
}
}
nodes[levelId] = {
object: 'node',
id: levelId,
type: 'level',
parentId: buildingId,
visible: true,
metadata: {},
elevation: 0,
height: 3,
children: wallIds,
}
nodes[buildingId] = {
object: 'node',
id: buildingId,
type: 'building',
parentId: siteId,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
children: [levelId],
}
nodes[siteId] = {
object: 'node',
id: siteId,
type: 'site',
parentId: null,
visible: true,
metadata: {},
polygon: {
type: 'polygon',
points: [
[-50, -50],
[50, -50],
[50, 50],
[-50, 50],
],
},
children: [buildingId],
}
return { nodes, rootNodeIds: [siteId] }
}
async function withClient<T>(
dataDir: string,
label: string,
fn: (client: Client) => Promise<T>,
): Promise<T> {
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
env: { ...process.env, PASCAL_DATA_DIR: dataDir },
stderr: 'inherit',
})
const client = new Client({ name: `p9-edges-${label}`, version: '0.0.0' })
await client.connect(transport)
try {
return await fn(client)
} finally {
await client.close()
}
}
async function main(): Promise<void> {
// Idempotent cleanup.
try {
rmSync(DATA_DIR, { recursive: true, force: true })
} catch {
// ignore
}
try {
rmSync('/tmp/pascal-phase8-p9-nonexistent-root', { recursive: true, force: true })
} catch {
// ignore
}
const t0 = Date.now()
await withClient(DATA_DIR, 'main', async (client) => {
// ======================================================================
// 1. Scene with 5,000 walls — save, check sizeBytes + nodeCount.
// ======================================================================
{
const big = buildWallyGraph(5000, 0)
const saveRes = await client.callTool({
name: 'save_scene',
arguments: {
id: 'big-scene-5k',
name: '5k walls',
includeCurrentScene: false,
graph: big,
},
})
const payload = parseText(saveRes.content) ?? (saveRes.structuredContent as any)
const nodeCount = payload?.nodeCount ?? -1
// 5000 walls + 1 level + 1 building + 1 site = 5003 nodes
const expectedNodeCount = 5003
const sizeBytes = payload?.sizeBytes ?? 0
const ok =
!saveRes.isError &&
nodeCount === expectedNodeCount &&
typeof sizeBytes === 'number' &&
sizeBytes > 0
record(
'1',
'save 5k-node scene',
ok ? 'PASS' : 'FAIL',
`nodeCount=${nodeCount} (expected ${expectedNodeCount}), sizeBytes=${sizeBytes}, version=${payload?.version}`,
)
}
// ======================================================================
// 2. Scene approaching / exceeding 10 MB — expect SceneTooLargeError.
// ======================================================================
{
// 10 MB cap. 500 nodes × 25 KB padding ≈ 12.5 MB → must exceed cap.
const oversize = buildWallyGraph(500, 25_000)
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
id: 'too-big-scene',
name: 'oversize',
includeCurrentScene: false,
graph: oversize,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
const isTooLarge =
/too_large|exceeds cap|10\s*MB|10485760|SceneTooLarge/i.test(text) ||
/\d{7,}/.test(text) // large byte count in message
status = isTooLarge ? 'PASS' : 'WARN'
detail = `tool_error text="${truncate(text, 240)}"`
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
detail = `UNEXPECTED success: sizeBytes=${p?.sizeBytes}`
}
} catch (err) {
if (err instanceof McpError) {
const msg = err.message ?? ''
const dataStr = JSON.stringify(err.data ?? {})
const dataCode = (err.data as { code?: string } | undefined)?.code
const isTooLarge =
/too_large|exceeds cap|10\s*MB|SceneTooLarge/i.test(msg) ||
dataCode === 'too_large' ||
/too_large/.test(dataStr)
status = isTooLarge ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(msg, 200)}" data=${truncate(dataStr, 160)}`
} else {
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('2', 'save 10 MB-ish scene rejected', status, detail)
}
// ======================================================================
// 3. save_scene({ id: '../etc/passwd' }) — sanitised or rejected.
// ======================================================================
{
// Use an empty (valid) graph so name/id branches are exercised.
const emptyGraph = buildWallyGraph(0, 0)
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
id: '../etc/passwd',
name: 'trav',
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `rejected (tool_error): ${truncate(text, 200)}`
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
const id = p?.id as string | undefined
// Slug sanitation rules: lowercase alnum + hyphens. Any traversal-style
// prefix (`..`, `/`) must be stripped.
const safe =
typeof id === 'string' &&
!id.includes('..') &&
!id.includes('/') &&
/^[a-z0-9][a-z0-9-]*$/.test(id)
// Verify the file lives inside scenesDir (no escape).
const scenesDir = `${DATA_DIR}/scenes`
const fileExists = existsSync(`${scenesDir}/${id}.json`)
const noEscape = !existsSync(`${DATA_DIR}/etc/passwd`)
status = safe && fileExists && noEscape ? 'PASS' : 'FAIL'
detail = `sanitised id="${id}", fileExists=${fileExists}, noEscape=${noEscape}`
}
} catch (err) {
if (err instanceof McpError) {
status = 'PASS'
detail = `rejected McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError throw: ${err instanceof Error ? err.message : String(err)}`
}
}
record('3', 'save_scene path-traversal id', status, detail)
}
// ======================================================================
// 4. save_scene({ id: 'UPPER Case! &^', name: 'bad' }) — sanitised.
// ======================================================================
{
const emptyGraph = buildWallyGraph(0, 0)
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
id: 'UPPER Case! &^',
name: 'bad',
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
detail = `tool_error: ${truncate(text, 200)}`
// Acceptable outcome: reject.
status = 'PASS'
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
const id = p?.id as string | undefined
const sanitised =
typeof id === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) && !/[A-Z!&^ ]/.test(id)
// Expected slug: 'UPPER Case! &^' → 'upper-case'
const expectedContains = 'upper' // allow 'upper-case' or variants
const scenesDir = `${DATA_DIR}/scenes`
const fileExists = typeof id === 'string' && existsSync(`${scenesDir}/${id}.json`)
status =
sanitised && fileExists && typeof id === 'string' && id.includes(expectedContains)
? 'PASS'
: 'FAIL'
detail = `sanitised id="${id}", fileExists=${fileExists}`
}
} catch (err) {
if (err instanceof McpError) {
status = 'PASS'
detail = `rejected McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError throw: ${err instanceof Error ? err.message : String(err)}`
}
}
record('4', 'save_scene dirty id sanitisation', status, detail)
}
// ======================================================================
// 5. save_scene({ id: '' }) — rejected (Zod min(1)).
// ======================================================================
{
const emptyGraph = buildWallyGraph(0, 0)
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
id: '',
name: 'empty-id',
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `tool_error: ${truncate(text, 200)}`
} else {
detail = 'UNEXPECTED success for empty id'
}
} catch (err) {
if (err instanceof McpError) {
const codeOk = err.code === -32602 || err.code === -32600
status = codeOk ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError throw: ${err instanceof Error ? err.message : String(err)}`
}
}
record('5', 'save_scene empty id rejected', status, detail)
}
// ======================================================================
// 6. save_scene({ name: '' }) — rejected (Zod min(1)).
// ======================================================================
{
const emptyGraph = buildWallyGraph(0, 0)
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
name: '',
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `tool_error: ${truncate(text, 200)}`
} else {
detail = 'UNEXPECTED success for empty name'
}
} catch (err) {
if (err instanceof McpError) {
status = err.code === -32602 ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('6', 'save_scene empty name rejected', status, detail)
}
// ======================================================================
// 7. save_scene({ name: 'a'.repeat(500) }) — rejected (Zod max(200)).
// ======================================================================
{
const emptyGraph = buildWallyGraph(0, 0)
let status: StepStatus = 'FAIL'
let detail = ''
const longName = 'a'.repeat(500)
try {
const r = await client.callTool({
name: 'save_scene',
arguments: {
name: longName,
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `tool_error: ${truncate(text, 200)}`
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
// If not rejected, check whether truncated.
const returnedName = p?.name as string | undefined
if (typeof returnedName === 'string' && returnedName.length <= 200) {
status = 'PASS'
detail = `truncated to length=${returnedName.length}`
} else {
detail = `UNEXPECTED accepted full length=${returnedName?.length}`
}
}
} catch (err) {
if (err instanceof McpError) {
status = err.code === -32602 ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('7', 'save_scene name length 500 rejected', status, detail)
}
// ======================================================================
// 8. create_from_template({ id: null }) — Zod error.
// ======================================================================
{
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'create_from_template',
// @ts-expect-error deliberately pass null
arguments: { id: null },
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `tool_error: ${truncate(text, 200)}`
} else {
detail = 'UNEXPECTED success for null id'
}
} catch (err) {
if (err instanceof McpError) {
status = err.code === -32602 ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('8', 'create_from_template null id rejected', status, detail)
}
// ======================================================================
// 9. rename_scene({ id: 'real-scene', newName: '' }) — rejected.
// First create a real scene, then attempt the empty-name rename.
// ======================================================================
{
const emptyGraph = buildWallyGraph(0, 0)
const createRes = await client.callTool({
name: 'save_scene',
arguments: {
id: 'rename-target',
name: 'original name',
includeCurrentScene: false,
graph: emptyGraph,
},
})
const cPayload = parseText(createRes.content) ?? (createRes.structuredContent as any)
const realId = cPayload?.id as string | undefined
let status: StepStatus = 'FAIL'
let detail = ''
if (!realId) {
detail = `setup failed: saved scene has no id (${truncate(JSON.stringify(cPayload ?? {}), 120)})`
} else {
try {
const r = await client.callTool({
name: 'rename_scene',
arguments: { id: realId, newName: '' },
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `tool_error: ${truncate(text, 200)}`
} else {
detail = 'UNEXPECTED success for empty newName'
}
} catch (err) {
if (err instanceof McpError) {
status = err.code === -32602 ? 'PASS' : 'WARN'
detail = `McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
}
record('9', 'rename_scene empty newName rejected', status, detail)
}
// ======================================================================
// 10. Save 50 small scenes, list_scenes({ limit: 100 }) → all 50,
// in updated_at DESC.
// ======================================================================
const bulkIds: string[] = []
{
// First wipe the existing scenes directory so we can count cleanly.
try {
rmSync(`${DATA_DIR}/scenes`, { recursive: true, force: true })
} catch {
// ignore
}
const emptyGraph = buildWallyGraph(0, 0)
for (let i = 0; i < 50; i++) {
const sid = `bulk-${String(i).padStart(2, '0')}`
bulkIds.push(sid)
await client.callTool({
name: 'save_scene',
arguments: {
id: sid,
name: `bulk ${i}`,
includeCurrentScene: false,
graph: emptyGraph,
},
})
// Small wait so updated_at timestamps differ slightly.
if (i % 10 === 9) await new Promise((r) => setTimeout(r, 5))
}
const lr = await client.callTool({
name: 'list_scenes',
arguments: { limit: 100 },
})
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
const scenes = (lp?.scenes ?? []) as Array<{ id: string; updatedAt: string }>
const count = scenes.length
// Verify updatedAt DESC.
let descOk = true
for (let i = 1; i < scenes.length; i++) {
if ((scenes[i - 1]?.updatedAt ?? '') < (scenes[i]?.updatedAt ?? '')) {
descOk = false
break
}
}
const ok = !lr.isError && count === 50 && descOk
record(
'10',
'list 50 scenes updatedAt DESC',
ok ? 'PASS' : 'FAIL',
`count=${count}, descOk=${descOk}`,
)
}
// ======================================================================
// 11. list_scenes({ limit: 10 }) → exactly 10.
// ======================================================================
{
const lr = await client.callTool({
name: 'list_scenes',
arguments: { limit: 10 },
})
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
const count = lp?.scenes?.length ?? -1
const ok = !lr.isError && count === 10
record('11', 'list_scenes limit=10', ok ? 'PASS' : 'FAIL', `count=${count}`)
}
// ======================================================================
// 12. list_scenes({ limit: -1 }) — rejected or defaulted.
// ======================================================================
{
let status: StepStatus = 'FAIL'
let detail = ''
try {
const r = await client.callTool({
name: 'list_scenes',
arguments: { limit: -1 },
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
status = 'PASS'
detail = `rejected: ${truncate(text, 200)}`
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
const count = p?.scenes?.length ?? -1
// Defaulted: either returns default limit of results (≥ 1) or empty.
if (typeof count === 'number' && count >= 0) {
status = 'PASS'
detail = `defaulted: count=${count}`
} else {
detail = `UNEXPECTED response: ${truncate(JSON.stringify(p ?? {}), 160)}`
}
}
} catch (err) {
if (err instanceof McpError) {
status = 'PASS'
detail = `rejected McpError code=${err.code} msg="${truncate(err.message, 160)}"`
} else {
detail = `non-McpError: ${err instanceof Error ? err.message : String(err)}`
}
}
record('12', 'list_scenes limit=-1', status, detail)
}
})
// ========================================================================
// 13. Data-dir issue: PASCAL_DATA_DIR=nonexistent/root; first save_scene
// should auto-create the directory OR fail with a clear error.
// ========================================================================
{
let status: StepStatus = 'FAIL'
let detail = ''
try {
await withClient(NONEXIST_DATA_DIR, 'nonexist', async (client) => {
const emptyGraph = buildWallyGraph(0, 0)
const r = await client.callTool({
name: 'save_scene',
arguments: {
id: 'first-scene',
name: 'first',
includeCurrentScene: false,
graph: emptyGraph,
},
})
if (r.isError) {
const text = (r.content as TextContent)?.[0]?.text ?? ''
const looksClear = /ENOENT|not found|directory|permission|EACCES|invalid/i.test(text)
status = looksClear ? 'PASS' : 'WARN'
detail = `failed gracefully: ${truncate(text, 240)}`
} else {
const p = parseText(r.content) ?? (r.structuredContent as any)
const created = existsSync(NONEXIST_DATA_DIR)
const fileThere = created && existsSync(`${NONEXIST_DATA_DIR}/scenes/${p?.id ?? ''}.json`)
status = created && fileThere ? 'PASS' : 'FAIL'
detail = `auto-created=${created}, file=${fileThere}, id=${p?.id}`
}
})
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// A clean error at connect/tool time is also acceptable.
const looksClear = /ENOENT|not found|directory|permission|EACCES|invalid/i.test(msg)
status = looksClear ? 'PASS' : 'WARN'
detail = `error: ${truncate(msg, 240)}`
}
record('13', 'PASCAL_DATA_DIR nonexistent root', status, detail)
}
const elapsed = Date.now() - t0
// Sanity listing on the data dir for the report.
let scenesInDataDir = -1
try {
const files = readdirSync(`${DATA_DIR}/scenes`).filter(
(f) => f.endsWith('.json') && !f.startsWith('.'),
)
scenesInDataDir = files.length
} catch {
// ignore
}
// ========================================================================
// Write markdown report.
// ========================================================================
const passed = steps.filter((s) => s.status === 'PASS').length
const warned = steps.filter((s) => s.status === 'WARN').length
const failed = steps.filter((s) => s.status === 'FAIL').length
const total = steps.length
const lines: string[] = []
lines.push('# Phase 8 P9 — edge cases & error-handling depth (stdio MCP)')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(
`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`), data dir \`${DATA_DIR}\`.`,
)
lines.push('')
lines.push(`**Summary:** ${passed}/${total} PASS, ${warned} WARN, ${failed} FAIL, ${elapsed} ms.`)
lines.push(`Scene files on disk after bulk tests: ${scenesInDataDir}`)
lines.push('')
lines.push('## Test cases')
lines.push('')
lines.push('| # | Case | Status | Detail |')
lines.push('|---|------|--------|--------|')
for (const s of steps) {
const safe = s.detail.replace(/\|/g, '\\|').replace(/\n/g, ' ')
lines.push(`| ${s.id} | ${s.title} | ${s.status} | ${safe} |`)
}
lines.push('')
lines.push('## Notes')
lines.push('')
lines.push('- Case 1 (5k nodes) constructs walls programmatically and saves via')
lines.push(' `save_scene({ includeCurrentScene: false, graph })`.')
lines.push('- Case 2 pads `metadata.padding` on each of 500 walls to push past 10 MB.')
lines.push(' PASS = structured error mentioning `too_large`; WARN = other rejection reason.')
lines.push('- Cases 3-5 exercise slug hygiene (`sanitizeSlug` in `storage/slug.ts`).')
lines.push('- Case 13 spawns a second stdio child with a deep nonexistent data dir.')
lines.push(' PASS if the dir is auto-created by the filesystem store or the call fails with a')
lines.push(' clear error (ENOENT/EACCES/etc.).')
lines.push('')
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`\n[p9] report: ${REPORT_PATH}`)
console.log(`[p9] ${passed}/${total} PASS, ${warned} WARN, ${failed} FAIL in ${elapsed}ms`)
if (failed > 0) process.exitCode = 1
}
main().catch((err) => {
console.error('[p9] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,33 +0,0 @@
# A1 — Secrets + PII scan
## Summary
SAFE TO PUSH. No real secrets, tokens, credentials, JWTs, PEM blocks, or PII leaked. Personal email `rexinacho@gmail.com` appears only in `Co-Authored-By`-equivalent git author metadata (a public identity the user already uses for GitHub). Several MEDIUM-severity absolute-path hardcodes (`/Users/adrian/...`) in test-report scripts do not reveal secrets but do reveal local machine layout.
## BLOCKERS
None.
## HIGH
None. Author email `rexinacho@gmail.com` is the committer identity on all 18 commits — treated as acceptable public identity (same address baked into git log of any fork). No other email, no user UUID, no machine hostname, no cookie values leaked.
## MEDIUM
1. `/Users/adrian/Desktop/editor/.worktrees/mcp-server/...` hardcoded in 7 committed TS scripts and ~10 committed MD reports. Not secrets, but exposes local filesystem layout and worktree name. Files: `packages/mcp/test-reports/villa-azul/{v2-geometry,v3-dimensions,v4-openings,v5-http}.ts`, `packages/mcp/test-reports/casa-sol/build.ts`, `packages/mcp/test-reports/phase8/p4-url-hardening.ts`, `packages/mcp/test-reports/t2-http/run.ts`, plus md files under `test-reports/phase8/` and `test-reports/villa-azul/`. Redact with sed replacing `/Users/adrian/Desktop/editor/.worktrees/mcp-server` -> `<repo>` or move absolute paths behind `process.cwd()`.
2. Hardcoded dev URLs `http://localhost:3917` and `http://localhost:3002` appear in test-reports only (never in production source under `apps/editor/app/**` or `packages/mcp/src/**` shipped code). Acceptable for test fixtures; flag for follow-up.
## LOW
1. `/tmp/pascal-*` paths in test scripts — not user-specific (generic tmp); fine to ship.
2. `apps/editor/env.mjs` correctly references env-var names (`SUPABASE_SERVICE_ROLE_KEY`, `BETTER_AUTH_SECRET`, `RESEND_API_KEY`, `GOOGLE_CLIENT_SECRET`) via `process.env.*` — no values.
## Files scanned
- diff size: 40768 lines, 176 files
- untracked files: none
- .env files present in diff: none; `.env.example` at repo root (placeholder comments only, not in diff)
- direct reads: `.github/workflows/mcp-ci.yml` (clean, no secret values), `packages/mcp/sql/migrations/0001_scenes.sql` (schema + RLS only), `packages/mcp/package.json` (no tokens in scripts), `apps/editor/public/dev/casa-sol.json` (scene geometry only), `packages/mcp/test-reports/villa-azul/build-summary.json` (synthetic IDs)
- git authors: all 18 commits by `Adrian Perez <rexinacho@gmail.com>` — consistent, no stray identities
- no `.orig`, `.swp`, `.DS_Store`, binary blobs staged
- regex scans for `sk_live_`, `sk_test_`, `ghp_`, `AKIA`, `AIza`, `xoxb-`, `eyJ...`, `-----BEGIN`, JWTs, `npm_[A-Za-z0-9]{36}`, `Authorization: Bearer` — all zero matches
## Confidence
high
---
**One-line verdict for integrator: SAFE TO PUSH** (optional MEDIUM cleanup: redact `/Users/adrian/...` paths from committed test-reports before publishing a polished PR)
@@ -1,76 +0,0 @@
# A2 Pre-push Security Audit — `feat/mcp-server`
**Verdict: FIX BEFORE PUSH** (1 HIGH, 2 MEDIUM-HIGH bugs that materially weaken the A7/P4 hardening). The rest are MEDIUM/LOW follow-ups acceptable after push.
Scope: `git diff main..HEAD`, focused on new attack surface. No secret-scan (A1 owns).
---
## Findings
### HIGH-1 — PUT `/api/scenes/[id]` skips `AnyNode` revalidation
`apps/editor/app/api/scenes/[id]/route.ts:9-18`
`graphSchema` is `z.unknown().refine(v is object)`. POST route added `AnyNode.safeParse` per-node (P4 fix), PUT/PATCH did not. Attacker re-submits a hostile `ItemNode.asset.src: javascript:…` or `ScanNode.url: file:///etc/passwd` via PUT — every URL-hardening gate introduced in A7 is bypassed for updates. Impact: equivalent to the original P4 CVE but on the update path.
**Fix:** share `graphSchema` (with the `superRefine` loop from `route.ts:15-34`) between POST and PUT; treat `graph` on PUT as required and revalidate identically. Add a regression test that submits `javascript:alert(1)` via PUT and asserts 400.
### HIGH-2 — SSRF via `photo_to_scene` / `analyze_floorplan_image` / `analyze_room_photo`
`packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts:102-116`, `packages/mcp/src/tools/vision/analyze-floorplan-image.ts:76-90` (analyze-room-photo is analogous).
`resolveImageBlock` does a raw `fetch(image)` for any `http(s)` URL with **no**:
- host allowlist / loopback+link-local denylist (`127.0.0.0/8`, `169.254.169.254`, `::1`, `fc00::/7`, `10.0.0.0/8`, `172.16/12`, `192.168/16`)
- IPv6 literal check (`http://[::1]/`, `http://[::ffff:169.254.169.254]`)
- redirect-chain validation (`fetch` follows redirects by default — `http://attacker.com/ → http://169.254.169.254/...`)
- response size cap (full `arrayBuffer()` into memory — DoS vector; attacker serves a 10 GB stream)
- content-type validation (server will base64-encode anything and ship to the LLM)
- timeout
On a shared dev machine this is the exact cloud-metadata / internal-network exfil primitive we closed for `AssetUrl`. Because these tools run server-side (not browser), the `AssetUrl` validator is NOT applied to the `image` argument.
**Fix:** reuse the hardening from `AssetUrl` — only accept `https://` + optional `PASCAL_ALLOWED_IMAGE_ORIGINS` env allowlist, reject private/link-local/loopback ranges (resolve DNS first, check against `ipaddr.js`/equivalent), set `redirect: 'manual'` and re-validate each hop, enforce `Content-Length` ≤ e.g. 20 MB, `AbortSignal.timeout(10_000)`.
### MEDIUM-1 — Editor API routes have no authentication, rate limit, body cap, or CORS policy
`apps/editor/app/api/scenes/route.ts` and `[id]/route.ts`, also `apps/editor/next.config.ts:16-20` (`bodySizeLimit: '100mb'`).
- No auth (TODO is documented but still shipping — on a shared LAN dev box anyone can POST/DELETE/rename). Default-deny recommended with an env flag `PASCAL_ALLOW_UNAUTH=1` for solo-dev.
- `request.json()` enforces only Next's global `100mb` limit; even with `MAX_SCENE_BYTES=10 MB` inside the store, the parser already allocated the full request body. DoS vector.
- No `Content-Type` validation — if the client sends `text/plain` Next still parses; fine in practice but log a warning.
- No CORS headers: Next default is same-origin only, which is safe for now; when we ship a CDN we'll need to add this. Document it.
- No rate limit (A1 flagged in Phase 3, still unfixed).
**Fix after push** is acceptable if we land an auth stub + body-size check before public demo. Do add a 1 MB soft cap on request body for now (`Content-Length` header check) — cheap, prevents trivial DoS.
### MEDIUM-2 — `SceneLoader` fetches a scene and passes directly to the editor without re-validating the graph
`apps/editor/components/scene-loader.tsx:40-46` + `apps/editor/app/scene/[id]/page.tsx:25-36`.
`fetchScene` → JSON.parse → `<SceneLoader initialScene=...>`. The editor store's `setScene` does NOT run `AnyNode.safeParse`. Since our store only accepts Zod-validated payloads on write, today this is mostly defense-in-depth — but a pre-existing corrupted row or a future non-revalidating ingest path would render attacker-controlled node data directly into the 3D scene, where `ItemNode.asset.src` becomes a `<model-viewer src=…>` / three.js loader URL. With HIGH-1 open, an attacker CAN land a hostile URL via PUT; this route then renders it.
**Fix:** run the same `graphSchema.safeParse(scene.graph)` in the server component (`page.tsx`) before handing to `<SceneLoader>`. On failure, render "corrupted scene" 500. Cheap belt-and-braces.
### MEDIUM-3 — `apply_patch` has no batch-size or graph-size quota
`packages/mcp/src/tools/apply-patch.ts:8-16`. `patches` is `z.array(PatchSchema)` with no `.max()`. A 100k-op batch runs under the server's `Event` loop, blocks every other tool, and can push the in-memory graph past `MAX_SCENE_BYTES` only at `save_scene` time (so the work is wasted but the DoS is real).
**Fix:** `z.array(PatchSchema).max(1000)`; reject when post-apply `nodeCount > 50_000`.
### MEDIUM-4 — `next.config.ts` sets `bodySizeLimit: '100mb'` globally for Server Actions
`apps/editor/next.config.ts:16-20`. Too permissive. With no auth this gives every network neighbour a 100 MB write primitive.
**Fix:** lower to `'10mb'` to match `MAX_SCENE_BYTES`.
### LOW-1 — `sanitizeSlug` drops unicode silently; edge cases are safe but worth a test
`packages/mcp/src/storage/slug.ts:17-32`. `\u0000` → stripped. `../``.` stripped → collapse `-` → safe. Emoji → stripped. Confusables (`а` Cyrillic → stripped since not `[a-z]`). No path traversal possible because the regex only admits `[a-z0-9-]`. Good. But because `isValidSlug` is called post-sanitize in `save()` (line 120) and the slug alphabet excludes `_`, confirm no caller expects underscores. Add explicit tests for `null byte`, `\\`, and multi-code-point inputs.
### LOW-2 — SQL RLS: `service_role` bypass is correct but `scene_revisions` lacks a write policy
`packages/mcp/sql/migrations/0001_scenes.sql:63-66`. Only a SELECT policy exists. `service_role` still writes fine (bypasses RLS), but if a future code path runs under `authenticated` it will silently fail inserts. Add `revisions_service_write` or an `insert` policy tied to owner. No injection surface — migration is DDL only, no dynamic SQL. Grants not explicitly set (relies on Supabase defaults); recommend explicit `revoke all … grant select … on scenes to anon`.
### LOW-3 — CI workflow permissions
`.github/workflows/mcp-ci.yml`. Uses `pull_request` (NOT `pull_request_target` — safe), `permissions: contents: read` (minimum). Good. No secret use. Green.
### LOW-4 — Residuals check
- `window.__pascalScene`: grep of src code returns zero hits in ship paths — only in `test-reports/**` and docs. Confirmed gone.
- Supabase dep pinned `^2` is loose. Lock to `2.x.y` at next dep-hygiene pass. No known active CVE on `@supabase/supabase-js@2` as of 2026-04-18.
- `@ts-expect-error` additions are limited to `packages/core/src/schema/asset-url.test.ts:1` (bun:test import) — benign.
---
## Unfixed from Phase 3 (surfaced but shipping)
- Editor API auth (HIGH, tracked). See MEDIUM-1.
- Rate limit (MEDIUM, tracked).
- Thumbnail upload endpoint is a stub (`scene-loader.tsx:84-89`) — not a vuln, just non-functional.
## Recommended before push
1. HIGH-1: share graphSchema between POST and PUT.
2. HIGH-2: SSRF hardening on the three vision URL-fetch paths.
3. MEDIUM-4: lower bodySizeLimit to 10 MB.
4. Add regression tests for HIGH-1 and HIGH-2 (mirror `asset-url.test.ts` style).
@@ -1,51 +0,0 @@
# A3 — Code-Quality & Production-Readiness Audit
**Scope:** `git diff main..HEAD` on `feat/mcp-server` (18 commits, ~38.7k LOC added).
**Verdict:** **READY FOR REVIEW** (with two small follow-ups suggested pre-merge).
---
## Strengths
- **TypeScript discipline is exemplary.** Zero `: any` / `as any` / `@ts-ignore` / `@ts-expect-error` anywhere in `packages/mcp/src/**` non-test code. All 28 `as any` hits are confined to `scene-bridge.test.ts` and `templates.test.ts` where fixtures intentionally construct malformed input (the right place for them). `tsconfig.json:9` extends `@pascal/typescript-config/base.json` which sets `strict: true` and `noUncheckedIndexedAccess: true` (tooling/typescript/base.json:11-12). `unknown` narrowing uses real guards everywhere (e.g. `apps/editor/app/api/scenes/[id]/route.ts:161`, `scene-bridge.ts:140-149`).
- **API design is consistent and well-layered.** 30/30 MCP tools register with BOTH `inputSchema` and `outputSchema` Zod objects (Grep confirmed). Tool names follow `snake_case` uniformly (`get_scene`, `apply_patch`, `save_scene`, ...). Editor REST API (`apps/editor/app/api/scenes/**`) uses proper verbs + correct status codes (201 with `Location` on POST, 204 on DELETE, 404/409/413/400/500 via `handleStoreError`, ETag + `If-Match` for concurrency control — route.ts:33, 82, 99, 145-155).
- **Error handling is uniform.** `packages/mcp/src/tools/errors.ts:7` provides a single `throwMcpError` + `toolError` helper; every tool either throws `McpError(ErrorCode.XXX, ...)` or returns `{isError: true}`. Two bare `catch {}` sites (`prompts/renovation-from-photos.ts:72`, `transports/http.ts:37`) are intentional fall-throughs with explicit comments. No silent failures in the request path.
- **Security hardening is layered defensively.** `save_scene` re-validates every node with `AnyNode.safeParse` when `includeCurrentScene=false` (save-scene.ts:79-93); the editor POST does the same with `superRefine` (route.ts:21-34); scene-bridge rejects prototype-pollution keys (scene-bridge.ts:82-87); `FilesystemSceneStore` enforces a 10MB `MAX_SCENE_BYTES` cap and atomic tmp+rename writes (filesystem-scene-store.ts:19, 186-189). The fix commit `0b84e7b` specifically closes two URL-validation bypasses surfaced by Phase 8 P4 — good shift-left behaviour.
- **Transports are clean.** `connectStdio` is 18 lines with proper comment about stdout ownership (transports/stdio.ts:8-10). `connectHttp` listens on ephemeral port for tests, tracks port via `httpServer.address()`, exposes graceful `close()`, defends against double-response on errors (http.ts:44-72). CLI (`bin/pascal-mcp.ts`) loads the RAF shim FIRST (line 3), validates `--port`, handles SIGINT/SIGTERM.
- **Observability has the minimum viable floor.** All operator logs go to stderr (`pascal-mcp.ts:65, 78, 83`; `http.ts:33`) — stdout is reserved for JSON-RPC. No PII / secrets in error messages.
- **Configuration.** Env consumption is centralized: `storage/index.ts:16-17` reads `SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY` and falls back to filesystem — no required vars with no fallback. `resolveDefaultRootDir` (filesystem-scene-store.ts:45) has a documented 4-step precedence (PASCAL_DATA_DIR → APPDATA/XDG_DATA_HOME → ~/.pascal/data).
- **Commit hygiene.** All 18 commits follow `type(scope): subject` conventional-commits style. 100% carry `Co-Authored-By: Claude Opus 4.7 (1M context)` trailers. Semantic grouping (scaffold → tools → resources → transports → storage → fixes) is merge-friendly.
- **Docs & CI.** README.md:1-55 is runnable as-is (`bunx pascal-mcp`, `claude_desktop_config.json` snippet). CHANGELOG conforms to Keep a Changelog + SemVer (`packages/mcp/CHANGELOG.md:5-7`). `.github/workflows/mcp-ci.yml` runs install → build core → build mcp → test → biome check on any `packages/mcp/**` or `packages/core/**` change.
- **Migration risk is minimal.** All `@pascal-app/core` changes (`packages/core/package.json:8-44`) are **additive subpath exports** (`./schema`, `./store`, `./clone-scene-graph`, `./material-library`, `./spatial-grid`, `./wall`). The existing main export is untouched. `apps/editor` gets new routes/components — no existing route is altered.
---
## Improvements recommended BEFORE PR (blocking)
1. **Flag the `O(n²)` collision/patch behaviours in docs, not code.** `check-collisions.ts:58-70` is pairwise (n²); `apply_patch` dry-run is linear per patch but does `_collectDescendants` inside the cascade=false branch (`scene-bridge.ts:322`). Both are fine at 5k nodes (P9 verified), but the CHANGELOG or README should list the current soft ceiling (≈10k nodes, <10MB scene) so reviewers can evaluate the SLA commitment. Add one sentence to `packages/mcp/README.md`. Non-destructive, 2 lines.
2. **`apps/editor/components/save-button.tsx` and `scene-loader.tsx` have zero tests** (Grep confirmed only `lib/scene-store-server.test.ts` exists under `apps/editor`). MCP-side storage has 70+ tests, but the editor React components that *call* the new API are uncovered. Add at minimum one happy-path + one conflict (409) test each using RTL or Playwright. Not blocking the PR title, but a reviewer will rightly ask.
---
## Improvements recommended AFTER PR / in review (non-blocking)
- **`phase7-e2e.ts` requires externally-running MCP + editor servers.** Document the prerequisites at the top of the file (it already has a one-liner on line 4 but no "requires `bun dev` in one terminal, `pascal-mcp --http` in another" note). Or gate with an env check that prints setup instructions.
- **`lib/scene-store-server.ts:18-64` duplicates the `SceneStore` contract.** Already acknowledged in comments (scene-store-server.ts:11-17) — consider publishing the types from `@pascal-app/mcp/storage` as a separate sub-path so the editor can import them instead of redeclaring.
- **`scene-loader.tsx:82-89` has a swallowed `fetch(...).catch(() => {})`** for thumbnail upload. It's commented "best-effort" but this is the one place a silent failure is fine — just add a `console.warn` for dev visibility.
- **No structured logging.** Current logging is `console.error` with `[pascal-mcp]` prefix. Sufficient for v0.1; for production HTTP deployments a pluggable logger (pino/winston-compatible interface) would let operators ship to Datadog/OTEL. File an issue, don't block.
- **Small sleep-based tests** in `bridge/scene-bridge.test.ts:14`, `filesystem-scene-store.test.ts:155,435`, `undo.test.ts:29`, `redo.test.ts:29,32`, `apply-patch.test.ts:42` use 5-10ms sleeps to space undo timestamps. These are deterministic on dev hardware but could flake on slow CI runners. Consider an abstractable clock or a `flushUndoDebounce()` helper; track in an issue.
- **`packages/mcp/package.json:39-43` deps:** `@supabase/supabase-js@^2` is the only non-trivial runtime dep and pulls ~750KB unpacked. Consider moving it to `peerDependenciesMeta.optional` or gating behind a subpath so stdio-only users don't ship it. Size audit, not a correctness issue.
---
## Open Questions for Maintainers
1. **SemVer posture for `@pascal-app/core` sub-path exports** — are the new exports (`./store`, `./schema`, `./spatial-grid`, `./wall`) contractually stable from 0.5.1 onward, or should we bump to 0.6.0 to signal "new surface area"? Additive but still expands the public API.
2. **Version bump timing**`package.json:3` pins `@pascal-app/mcp@0.1.0`. Is the intent to publish at PR merge, or to land unpublished and release on a follow-up tag? CHANGELOG dates `2026-04-18` which is today.
3. **CI coverage gate**`.github/workflows/mcp-ci.yml` runs tests but does not collect coverage. Should we add `bun test --coverage` + a codecov step, or deliberately defer?
4. **`apps/editor/components/scene-loader.tsx:82` — thumbnail endpoint** is explicitly a v0.1 stub. Is there a tracking issue for phase 7.1 implementation, or should the route + button be wired up before shipping?
5. **`save-scene.ts:63` & `route.ts:76` cast `graph as SceneGraph as never`.** The `as never` is a deliberate width-silencer after Zod validation. Is there appetite to land a tighter `GraphSchema` in `@pascal-app/core/schema` (matching `SceneGraph` exactly) so we can drop the casts?
---
**Bottom line:** This PR is production-quality TypeScript with exhaustive Zod validation at every boundary, layered defense-in-depth security, clean transport separation, and comprehensive test coverage on the server side (excluding the two small editor component gaps noted above). No blockers; ship with confidence after the two pre-PR items.
@@ -1,67 +0,0 @@
# A4 — Pre-push performance review (feat/mcp-server)
Date: 2026-04-18 · Scope: `git diff main..HEAD` (18 commits, +38,785 LOC) · Evidence: `test-reports/{t1-stdio,t2-http,phase8,villa-azul}`.
## Verdict
**SHIP WITH NOTES.** At v0.1 scale (≤ 56 nodes, ≤ 50 scenes) every path is sub-200 ms. Scaling liabilities appear above ~1k scenes or under concurrent writes — neither is the launch target.
## Hot paths (measured / estimated)
| Path | Time | Source |
|---|---|---|
| T1 stdio, 21 tools round-trip | **106 ms** (~5 ms/tool) | t1-stdio/REPORT.md L11 |
| P10 full sweep, 30 tools + 3 resources + 3 prompts | **152 ms** | p10-full-sweep.md L15 |
| P9 edges, 13 cases incl. 5k-node save | **419 ms** | p9-edges.md L5 |
| `/scene/:id` SSR (56 nodes) | **58.3 ms**, 81.7 KB HTML | v6-page.md L12 |
| `/scenes` list SSR | **20.9 ms**, 20.0 KB | v6-page.md L14 |
| MCP stdio cold-start to ready | ~1.0-1.5 s (Bun + SDK + core + Zod) | implied |
| HTTP startup + 1st session | ~2 s; 2nd client rejected | t2-http REPORT.md L14 |
106 ms for 21 tools is reasonable — stdio RTT dominates. Slowest non-vision tool paths: `validate_scene` (Zod-parse every node), `export_json` (`JSON.parse(JSON.stringify)` clone), `apply_patch` (2-pass dry-run + apply), `check_collisions` (O(n²) AABB, bounded by items/level).
## Scaling concerns (severity order)
1. **Filesystem index rebuild on every mutation.** `save`/`delete`/`rename` call `collectAllMeta()``readFile` every scene (filesystem-scene-store.ts L192-193, L233). At 1k scenes ≈ 200-400 ms/save; at 10k ≈ 2-4 s. Fix: incremental index patch.
2. **`.index.json` drift under concurrent writes** (P8 BUG 2). 3/20 scenes hidden from `list_scenes` after parallel burst. Correctness, not perf, but fix depends on #1.
3. **expectedVersion race** (P8 BUG 1). 5 parallel saves all claim success; only one `rename` wins. Need per-id mutex or `O_EXCL` lockfile. Supabase unaffected (server-side CAS via `.eq('version', …)`).
4. **`findNodes({levelId})`** calls `resolveLevelId` per node → full ancestry walk each time. O(n × depth). ~25k walks at 5k nodes. Memoize per call.
5. **`getChildren`/`_collectDescendants`** iterate all nodes per call. O(n) each; fine today, slow at 50k.
6. **`exportJSON` uses `JSON.parse(JSON.stringify)`** (scene-bridge.ts L39-46). Replace with `structuredClone` for ~2× speedup.
7. **Fixed-point serialize loop in `save`** (L168-184) stringifies up to 5× per save to settle `sizeBytes`. At 2.4 MB that's 125 ms wasted.
8. **`check_collisions` O(n²)** — bounded by items/level; degenerate at 1k+ items.
9. **5k-node client render is unverified.** P9 saved 5k-node scene (2.4 MB) but no FPS measurement. Villa Azul 56 nodes = 120 FPS. This is the single biggest unknown for client perf.
## Build-size
- `packages/mcp/dist/`: **904 KB** total (66 JS files, 210 KB code + 91 KB `.d.ts` + maps).
- Top 5 JS: `bridge/scene-bridge.js` 18.2 KB · `storage/filesystem-scene-store.js` 13.3 KB · `tools/photo-to-scene/photo-to-scene.js` 12.6 KB · `tools/variants/mutations.js` 11.3 KB · `storage/supabase-scene-store.js` 10.8 KB.
- Average file 3.2 KB — no bundle bloat.
- **`@supabase/supabase-js`**: MCP-only (package.json L41), lazy-imported (supabase-scene-store.ts L141). **Zero editor bundle impact.**
## Memory
- `SceneBridge` is a singleton `useScene` store shared across MCP sessions. Zundo `limit: 50` bounds history. 5k-node graph × 50 = ~120 MB upper bound — bounded, not leaked.
- `urlCache` (packages/core/src/lib/asset-storage.ts L6) unbounded, browser-only; pre-existing Phase 3 flag, not regressed.
- `atomicWrite` cleans `.tmp` on failure (L287); P8 observed `stray .tmp=0`.
## Recommended follow-ups
1. **(P1)** Incremental `.index.json` patch on save/delete/rename (fixes concerns #1 + #2).
2. **(P1)** Per-id `Promise`-chain mutex in Filesystem store to close expectedVersion race (#3).
3. **(P2)** Lazy-load vision/photo-to-scene/variants tools behind first-call gate. Saves ~40 KB + ~100 ms cold-start.
4. **(P2)** Swap `JSON.parse(JSON.stringify)` in `exportJSON``structuredClone`.
5. **(P3)** Memoize `levelId` per node in `SceneBridge`. 10-50× speedup on `find_nodes({levelId})`.
6. **(P3)** Fix StreamableHTTP single-session; add multi-session dispatcher or clear 503.
## Benchmarks to add
1. **`bench/scene-bridge.bench.ts`** — `findNodes({levelId})` + `_collectDescendants` at 1k/5k/10k nodes; assert p99 < 50 ms.
2. **`bench/filesystem-store.bench.ts`** — `save()`+`list()` at 100/1k/10k scenes; current code will fail at 10k (concern #1).
3. **`bench/client-render.bench.ts`** — React profiler over `applySceneGraphToEditor` at 1k/5k nodes; assert first-paint < 500 ms and steady FPS ≥ 30.
## Red flags
None ship-blocking. Scaling liabilities (#1-#3) well-understood. **Client render at 5k nodes is the only material unknown** and should be verified pre-GA, not pre-push.
Report: `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/pre-push/a4-performance.md`
@@ -1,257 +0,0 @@
# feat(mcp): add `@pascal-app/mcp` — Model Context Protocol server
## TL;DR
This PR adds a new workspace package `@pascal-app/mcp` (v0.1.0) that exposes the Pascal scene graph as MCP **tools**, **resources**, and **prompts** so any MCP-compatible AI host — Claude Desktop, Claude Code, Cursor, or a custom agent — can build and modify Pascal projects programmatically, with no browser required. It also adds scene persistence (filesystem + Supabase adapters) and the editor routes to load MCP-built scenes directly. The only changes outside `packages/mcp/` are two additive exports on `@pascal-app/core`, a URL-scheme allowlist on core schema fields, two new Next.js routes and API handlers in `apps/editor`, and a new CI workflow.
## Motivation
Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/editor/issues/74) opens the question of how external consumers should drive Pascal. The viewer answers "embed in a React app." This PR answers the complementary case: **drive Pascal from anything, without a browser** — AI agents, CLI tools, background services, or IDE plugins. An agent can now build a complete scene (walls, zones, doors, windows) and have it immediately openable in the editor via a URL.
## Architecture
```
┌─────────── MCP host (Claude Desktop / Claude Code / Cursor / custom) ───────────┐
│ stdio | HTTP │
│ │ │
│ packages/mcp/src/bin/pascal-mcp.ts (CLI entry) │
│ │ │
│ ┌──── createPascalMcpServer({ bridge, store }) ────┐ │
│ │ 30 tools · 4 resources · 3 prompts │ │
│ └────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ SceneBridge SceneStore │
│ (headless Zustand ┌──────────────────┐ │
│ store + Zundo) │ FilesystemStore │ ← PASCAL_DATA_DIR │
│ Zod validation at │ SupabaseStore │ ← env: SUPABASE_* │
│ every boundary └──────────────────┘ │
│ │ │
│ ▼ │
│ @pascal-app/core (subpath exports: ./schema, ./store, ./wall …) │
│ │ │
│ ▼ │
│ apps/editor — /api/scenes CRUD + /scene/[id] page │
│ (ETag / If-Match optimistic locking) │
└──────────────────────────────────────────────────────────────────────────────────┘
```
The server runs headlessly in Node — no WebGPU, no React, no Three.js. The `SceneBridge` wraps a Zustand store with the same Zundo temporal middleware the editor uses, so `undo`/`redo` work correctly. Derived geometry (wall mitering, CSG cutouts) is recomputed only when the scene is opened in a browser via `@pascal-app/viewer`.
## What's in the box
### Package `@pascal-app/mcp` v0.1.0
**Tools (30)** — [full table in README](../../README.md#tools)
| Group | Tools |
|---|---|
| Query | `get_scene`, `get_node`, `describe_node`, `find_nodes`, `measure` |
| Mutation | `apply_patch`, `create_level`, `create_wall`, `place_item`, `cut_opening`, `set_zone`, `duplicate_level`, `delete_node` |
| History | `undo`, `redo` |
| Export | `export_json`, `export_glb` (stub — see limitations) |
| Validation | `validate_scene`, `check_collisions` |
| Scene lifecycle | `save_scene`, `load_scene`, `list_scenes`, `rename_scene`, `delete_scene` |
| Templates | `list_templates`, `create_from_template` |
| Vision (sampling) | `analyze_floorplan_image`, `analyze_room_photo`, `photo_to_scene` |
| Variants | `generate_variants` |
**Resources:** `pascal://scene/current`, `pascal://scene/current/summary`, `pascal://catalog/items`, `pascal://constraints/{levelId}`
**Prompts:** `from_brief`, `iterate_on_feedback`, `renovation_from_photos`
**Transports:** stdio (default) + Streamable HTTP (`--http --port N`)
**Storage adapters:** `FilesystemSceneStore` (default, `PASCAL_DATA_DIR`) + `SupabaseSceneStore` (`SUPABASE_URL` + `SUPABASE_SERVICE_ROLE_KEY`)
**SQL migration:** `packages/mcp/sql/migrations/0001_scenes.sql``scenes` table + `scene_revisions` table + RLS policies for the Supabase adapter
### Changes outside `packages/mcp/` (transparent disclosure)
All are additive. None modify existing behavior.
#### `packages/core/package.json` — 5 new subpath exports (CROSS_CUTTING §1)
Added `./schema`, `./store`, `./material-library`, `./spatial-grid`, `./wall` entries to the `exports` map. The main `"."` entry is unchanged. Without these, `import('@pascal-app/core')` in Node crashes because the main entry transitively imports Three.js CJS globals that don't resolve outside a browser context. `apps/editor` and `@pascal-app/viewer` are unaffected — they use `"."` and don't reference these subpaths.
#### `packages/core/src/schema/asset-url.ts` — URL scheme allowlist (CROSS_CUTTING §5)
Introduces a shared `AssetUrl` Zod validator replacing bare `z.string()` on every URL field in core's schemas (`scan.url`, `guide.url`, `item.asset.src`, `material.texture.url`, all material map fields). Rejects `javascript:`, `file:`, `ftp:`, `data:text/html`, foreign `http:`, `vbscript:`, and similar. Accepts `asset://`, `blob:`, `data:image/`, `/` (app-relative), `https:`, and `http://localhost` for dev. Optional per-origin narrowing via `PASCAL_ALLOWED_ASSET_ORIGINS`.
This closes the security finding from the Phase 3 audit: a crafted scene with `javascript:alert(1)` for a texture URL would have beaconed or exfiltrated when rendered. Phase 10 A2 further extended the validator to the `save_scene(includeCurrentScene: false)`, `POST /api/scenes`, and `PUT /api/scenes/[id]` boundaries via a shared `apiGraphSchema` (see Security notes).
#### `apps/editor` — persistence routes + scene page (CROSS_CUTTING §4)
- `apps/editor/app/api/scenes/route.ts``GET /api/scenes` (list), `POST /api/scenes` (create)
- `apps/editor/app/api/scenes/[id]/route.ts``GET`, `PUT`, `PATCH`, `DELETE` with ETag / `If-Match` optimistic locking
- `apps/editor/app/scene/[id]/page.tsx` — server-rendered page that fetches a scene by ID and passes its graph to the editor via `applySceneGraphToEditor`
- `apps/editor/app/scenes/page.tsx` — scene list page
- `apps/editor/lib/scene-store-server.ts` — server-side factory that picks filesystem or Supabase adapter based on env
- `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency (for the `./storage` subpath)
- `packages/mcp/package.json` exports `./storage` subpath so editor can import just the storage adapter without the full MCP surface
#### `.github/workflows/mcp-ci.yml` — new CI workflow (CROSS_CUTTING §3)
Runs on PRs and pushes touching `packages/mcp/`, `packages/core/`, or `bun.lock`. Installs with Bun 1.3.0, builds core then mcp, runs `bun test`, runs `bunx biome check`. Does not modify `release.yml`.
## How to test
```bash
# From repo root
bun install
bun run --cwd packages/core build
bun run --cwd packages/mcp build
# Unit + integration tests (302 tests, 41 files)
bun test --cwd packages/mcp
# Biome lint
bunx biome check packages/mcp
# End-to-end smoke test (spawns stdio server, exercises 4 tools)
bun run --cwd packages/mcp smoke
# Full sweep — 30 tools, 4 resources, 3 prompts, all PASS
# (requires the built binary at packages/mcp/dist/bin/pascal-mcp.js)
bun packages/mcp/test-reports/phase8/p10-full-sweep.ts
# Try with Claude Desktop
# Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
# { "mcpServers": { "pascal": { "command": "bunx", "args": ["pascal-mcp"] } } }
# Then ask: "Use the Pascal MCP to create a 3-bedroom apartment at 100 m²."
```
## Verification evidence
| Evidence | Result |
|---|---|
| `bun test --cwd packages/mcp` | **302/302 pass** across 41 test files |
| Biome check | 0 errors (73 source files checked) |
| TypeScript build | `tsc` clean, strict mode, no `any` without documented reason |
| T1 stdio smoke | 21/21 tools PASS, 106 ms |
| T2 HTTP smoke | transport verified |
| T3 scenario | 2-bed apartment built end-to-end over HTTP |
| T4 error paths | structured error codes verified |
| Phase 8 P10 full sweep | **37/37 PASS** (30 tools + 4 resources + 3 prompts) |
| Phase 8 P3 locking | **12/12 PASS** (version conflict, ETag/If-Match) |
| Phase 8 P8 concurrency | 4/5 PASS — 1 known fail (see limitations) |
| Phase 8 P9 edge cases | **13/13 PASS** (path traversal, size cap, bad input) |
| Phase 8 P4 URL hardening | 59/95 checks PASS at audit time; 36 fails at `save_scene`/POST boundary **all CLOSED** in later commits (see Security notes) |
| Phase 10 A2 security audit | 2 HIGH findings (PUT-route bypass + SSRF in vision tools) — **both fixed** before push |
| SSRF guard tests | 8/8 PASS (`safe-fetch.test.ts`) |
| **Casa del Sol** | 76-node residential scene built end-to-end; `validate_scene` = valid, 0 errors; `duplicate_level` clones 37 nodes correctly |
| **Villa Azul** | 56-node scene; **108/108 checks** across 10 verification agents (schema, geometry, dimensions, openings, HTTP API, Next.js page, parentage, round-trip, spatial, visual) |
| Secrets audit (A1) | SAFE TO PUSH — no tokens, credentials, or PII in diff |
Committed reports: `packages/mcp/test-reports/` (t1-t5, casa-sol, villa-azul, phase8, research, pre-push).
## Known limitations / non-goals for v0.1
1. **GLB export is not implemented.** Three.js is browser-only; `export_glb` returns a structured `{ status: 'not_implemented' }` response.
2. **Vision tools require host sampling support.** `analyze_floorplan_image`, `analyze_room_photo`, and `photo_to_scene` delegate to the host via MCP sampling (`createMessage`). Hosts without sampling capability receive a structured `sampling_unavailable` error. No vision model is bundled.
3. **Headless mode doesn't regenerate derived geometry.** Wall mitering, slab triangulation, and CSG cutouts run inside React hooks in the editor renderer. Headless MCP manipulates node data freely; rendered geometry is recomputed when a browser opens the scene via `@pascal-app/viewer`.
4. **HTTP transport is single-session.** The Streamable HTTP transport uses the SDK's `StreamableHTTPServerTransport`, which only accepts one `initialize` per process lifetime. Spinning up a second MCP client hits a `Server already initialized` error. For multi-client scenarios, run one process per client or use stdio.
5. **Concurrent same-id writes race.** `FilesystemSceneStore.save()` checks `expectedVersion` optimistically without a per-id lock. Five simultaneous `save_scene({ id: "x", expectedVersion: 1 })` calls may all return `ok: true`; only one durable bump lands (Phase 8 P8, scenario 2). The Supabase backend is not affected — Postgres provides the compare-and-swap. Fix tracked as follow-up.
6. **`.index.json` drift under load.** Concurrent distinct saves can leave the index sidecar missing entries that exist on disk. `list_scenes` falls back to a full directory scan when the index is absent, but not when it is merely stale (Phase 8 P8, scenario 5). Fix tracked with same lock-queue follow-up.
7. **No authentication.** The HTTP transport and editor API routes have no auth layer. The filesystem store relies on OS-level file permissions; Supabase RLS enforces ownership, but the `ownerId` field is null until an auth layer is wired (env vars for Supabase Auth / Better Auth are declared; zero code exists yet).
8. **`item.asset.thumbnail` not yet validated.** The `thumbnail` field on `ItemNode` is still bare `z.string()`. The `src` field is fully validated by `AssetUrl`. Follow-up: apply the same validator to `thumbnail` and fix the `place_item` tool's `thumbnail: ''` default.
9. **Catalog unavailable headless.** `pascal://catalog/items` returns `{ status: 'catalog_unavailable', items: [] }` until `@pascal-app/core` exposes a Node-consumable catalog.
10. **`SiteNode.children` inconsistency.** `SiteNode.children` holds full node objects while every other container holds ID strings. MCP works around this by traversing the flat `nodes` dict. Upstream alignment proposed as a follow-up (CROSS_CUTTING §2).
## Security notes
**In this PR:**
- `AssetUrl` Zod validator on all URL fields in core schemas — rejects `javascript:`, `file:`, `ftp:`, `data:text/html`, foreign `http:` (Phase 8 P4: 36/36 schema-layer checks PASS)
- `apply_patch` re-parses each node with `AnyNode` before mutating the store — URL validation fires here
- `save_scene` (both `includeCurrentScene: true` and `false`) re-parses every node at the save boundary
- `POST /api/scenes` AND `PUT /api/scenes/[id]` share `apiGraphSchema` that Zod-validates every node before the store is touched
- `safeFetch` for all user-supplied image URLs in `photo_to_scene`, `analyze_floorplan_image`, `analyze_room_photo`:
- Blocks loopback, private IP ranges, link-local (incl. cloud-metadata `169.254.169.254`), `.local`/`.internal`/`.corp` hostnames, v4-mapped IPv6 loopback
- Manual redirects (max 3), allowlist revalidated per hop
- 20 MB streamed size cap, 10 s timeout
- `PASCAL_ALLOWED_ASSET_ORIGINS` env var for per-origin `https:` narrowing (applies to both `AssetUrl` and `safeFetch`)
- `FilesystemSceneStore` sanitizes slugs to prevent path traversal (Phase 8 P9, case 3: PASS)
- 10 MB size cap per scene enforced at `save_scene` (Phase 8 P9, case 2: PASS)
- ETag / `If-Match` on all editor API mutating verbs (Phase 8 P3: 12/12 PASS)
- CI workflow runs with `permissions: contents: read` only
**Tracked as follow-ups (not blocking merge):**
- `item.asset.thumbnail` still bare `z.string()``src` is validated; apply `AssetUrl` to `thumbnail` too
- No auth layer on HTTP transport or editor API routes (env vars declared; implementation pending)
## Follow-ups (GitHub issues after merge)
- Fix `FilesystemSceneStore` same-id write race with per-id in-process lock queue
- Fix `.index.json` drift: use lock-protected index write or rebuild index from disk on stale reads
- Apply `AssetUrl` to `item.asset.thumbnail`; fix `place_item` empty-thumbnail default
- Align `SiteNode.children` to `z.string()` IDs + `setScene` migration (breaking change, separate PR)
- Expose a Node-consumable item catalog from `@pascal-app/core`
- Add auth layer to HTTP transport and editor API (Supabase Auth / Better Auth env already declared)
- Post-build `chmod +x dist/bin/pascal-mcp.js` so fresh installs don't need a manual chmod
- Add adjacency check to `cut_opening` to catch overlapping openings on the same wall
- Consider `@pascal-app/systems` split so `@pascal-app/core` goes data-only (breaking, larger scope)
## Checklist
- [x] 302/302 `bun test --cwd packages/mcp` pass
- [x] `bunx biome check packages/mcp` — 0 errors (73 files)
- [x] `bun run --cwd packages/mcp build` — tsc clean
- [x] `bunx turbo build --filter=@pascal-app/mcp` — 2/2 tasks successful
- [x] End-to-end smoke test passes (`bun run --cwd packages/mcp smoke`)
- [x] Phase 8 full sweep: 37/37 PASS (`packages/mcp/test-reports/phase8/p10-full-sweep.md`)
- [x] Villa Azul: 108/108 verification checks (`packages/mcp/test-reports/villa-azul/SUMMARY.md`)
- [x] Casa del Sol built end-to-end (`packages/mcp/test-reports/casa-sol/BUILD_REPORT.md`)
- [x] Secrets audit clean (`packages/mcp/test-reports/pre-push/a1-secrets.md`)
- [x] No modifications to `@pascal-app/viewer`
- [x] `packages/core` changes are additive only (subpath exports + `AssetUrl` validator)
- [x] Node 18+ compatible; RAF polyfill loads before any core import
- [x] All mutations go through Zustand store (undo-safe via Zundo)
- [x] Cross-cutting changes documented in `packages/mcp/CROSS_CUTTING.md`
- [x] `save_scene` / `POST /api/scenes` / `PUT /api/scenes/[id]` per-node URL validation (Phase 10 A2)
- [x] SSRF protection on all image-URL fetches (Phase 10 A2)
- [ ] Same-id concurrent write race in filesystem store (tracked follow-up)
- [ ] Auth layer on HTTP transport (tracked follow-up)
## Commit series (20 commits on `feat/mcp-server`)
Phase 1 — scaffold and core MCP (9 commits):
- `feat(mcp): scaffold package and confirm headless bridge viability`
- `feat(mcp): finalize scaffolding and factory entry`
- `feat(mcp): add headless scene bridge with RAF polyfill`
- `feat(mcp): implement 19 scene query and mutation tools`
- `feat(mcp): add resources and prompts`
- `feat(mcp): add multimodal vision tools via MCP sampling`
- `feat(mcp): add stdio + streamable HTTP transports, CLI, and smoke test`
- `docs(mcp): add README, examples, and changelog`
- `chore(mcp): add CI workflow and document cross-cutting changes`
Phases 510 — scenes, verification, hardening (11 commits):
- `test(mcp): Casa del Sol — full house built end-to-end via MCP`
- `feat(editor): expose useScene on window in dev for MCP-editor bridging` (subsequently removed)
- `fix(mcp): apply_patch preserves schema-defaulted ids in multi-op batches`
- `docs(mcp): add PR_DESCRIPTION.md`
- `docs(mcp): add 10-agent research on scene-save workflow`
- `feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7)`
- `fix(mcp,editor): close URL-validation bypasses surfaced by Phase 8 P4`
- `test(mcp): Villa Azul + 10-agent deep verification`
- `test(mcp): add populate-gallery script for post-ship demo`
- `fix(mcp,editor): close PUT-route URL bypass + vision-tool SSRF (Phase 10 A2)`
- `docs(mcp): add Phase 10 pre-push audit reports (5 agents)`
## Report index
- `packages/mcp/test-reports/t1-stdio/REPORT.md` — stdio: 21/21 tools PASS
- `packages/mcp/test-reports/t2-http/REPORT.md` — HTTP transport
- `packages/mcp/test-reports/t3-scenario/REPORT.md` — 2-bed apartment end-to-end
- `packages/mcp/test-reports/t4-errors/REPORT.md` — structured error codes
- `packages/mcp/test-reports/casa-sol/BUILD_REPORT.md` — Casa del Sol (76 nodes)
- `packages/mcp/test-reports/villa-azul/SUMMARY.md` — Villa Azul (56 nodes, 108 checks)
- `packages/mcp/test-reports/phase8/p3-locking.md` — version conflict / ETag (12/12)
- `packages/mcp/test-reports/phase8/p4-url-hardening.md` — URL validation (59/95, gaps disclosed)
- `packages/mcp/test-reports/phase8/p8-concurrency.md` — concurrency (4/5, bug disclosed)
- `packages/mcp/test-reports/phase8/p9-edges.md` — edge cases (13/13)
- `packages/mcp/test-reports/phase8/p10-full-sweep.md` — full sweep (37/37)
- `packages/mcp/test-reports/pre-push/a1-secrets.md` — secrets audit
- `packages/mcp/CROSS_CUTTING.md` — every change outside `packages/mcp/`
- `packages/mcp/README.md` — host configs, tool/resource/prompt tables, examples
@@ -1,26 +0,0 @@
# A5 — Review notes: existing PR_DESCRIPTION.md vs final a5-pr-description.md
## What was weak in the original
**Scope mismatch.** The original described `@pascal-app/mcp` as if it were only a headless query/mutation server. The branch actually also ships scene persistence (filesystem + Supabase adapters), scene lifecycle tools (save/load/list/rename/delete), templates, variants, a `photo_to_scene` workflow, editor API routes, two new Next.js pages, and an SQL migration. The original PR description didn't mention any of these, leaving reviewers to discover them in the diff.
**Stale numbers.** The original cited "142/142 tests, 27 files." The actual count after Phase 8 additions is 294 tests across 40 files, and 30 tools (not 21). Stale numbers undermine credibility with careful reviewers.
**No honest failure disclosure.** The original listed known limitations but said nothing about the concurrency race condition that the P8 audit found and documented. A security-minded reviewer who finds that themselves will trust the PR less. The final version names the bug, its root cause, and the test report that found it.
**Cross-cutting changes were buried.** The original had a short "Cross-cutting changes" section that linked to `CROSS_CUTTING.md` for three items and missed two (the `./storage` subpath export on `packages/mcp` itself, and the `AssetUrl` validator on core schemas). The final version expands each item with what changed, why, and impact, so reviewers don't have to open a separate file to decide whether to approve.
**Security gaps were not disclosed.** The `AssetUrl` work is mentioned as a benefit, but the P4 URL hardening audit found 36 FAILs at the `save_scene(includeCurrentScene: false)` and `POST /api/scenes` boundaries. Omitting this would leave the maintainer unaware of a real attack surface.
**No TL;DR or orientation aid.** A maintainer unfamiliar with MCP had to read several paragraphs before understanding what this PR does or whether it belongs in this repo.
## What the final version improves
- Opens with a 3-sentence TL;DR that answers "what" and "why here"
- Architecture diagram updated to show `SceneStore` and adapter selection
- All 30 tools listed with accurate groupings; stale 21-tool list removed
- Verification table covers all evidence with honest pass/fail ratios
- Known limitations expanded to 10 items with the concurrency race called out explicitly
- Security notes split into "in this PR" vs "tracked follow-up" — reviewers see what's done and what isn't
- Report index with direct file paths so reviewers can navigate without searching
- Checklist has three unchecked items reflecting real gaps, not a clean sweep
@@ -1,82 +0,0 @@
# 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<SceneMeta>
load(id: SceneId): Promise<SceneWithGraph | null>
list(opts?: { projectId?: string; ownerId?: string; limit?: number }): Promise<SceneMeta[]>
delete(id: SceneId, opts?: { expectedVersion?: number }): Promise<boolean>
rename(id: SceneId, newName: string, opts?: { expectedVersion?: number }): Promise<SceneMeta>
}
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/<id>` works without `window.__pascalScene`.
@@ -1,49 +0,0 @@
# R1 — Persistence layer
## TL;DR
- **Scene data:** single-key localStorage, `pascal-editor-scene`, shape `{ nodes, rootNodeIds }`. Written by the autosave hook with 1 s debounce; flushed on `beforeunload`.
- **UI preferences, viewer prefs, audio:** three separate Zustand-persist stores, each with its own localStorage key.
- **Asset binaries (textures):** IndexedDB via `idb-keyval`, keys `asset_data:<uuid>`.
- **Backend persistence:** NONE in this repo. No Supabase calls, no API routes for scenes, no database integration.
- **Scene identity / listing:** NONE. One scene per origin per browser.
## Write pathways
| When | What | Where |
|---|---|---|
| 1 s after any scene mutation | `{ nodes, rootNodeIds }``onSave` callback (if provided) else `localStorage['pascal-editor-scene']` | `packages/editor/src/hooks/use-auto-save.ts:104135` |
| Every UI state mutation | `pascal-editor-ui-preferences` | Zustand persist in `use-editor.tsx:372607` |
| Every viewer state mutation | `viewer-preferences` | Zustand persist in `use-viewer.ts:81220` |
| Every audio state mutation | `pascal-audio-settings` | `use-audio.tsx:2243` |
| On `beforeunload` | Final scene snapshot | `use-auto-save.ts:137147` |
## Read pathways
1. **Editor mount** (`editor/index.tsx:765796`):
- If host supplied `onLoad``await onLoad()`
- Else → `loadSceneFromLocalStorage()`
- Apply via `useScene.setScene(nodes, rootNodeIds)`
2. **Selection hydration**`syncEditorSelectionFromCurrentScene()` (`lib/scene.ts:251332`)
3. **Zustand persist** hydrates UI/viewer/audio stores automatically on first subscriber
## What's NOT persisted
- Undo/redo history (`useScene.temporal` — in-memory only)
- Active tool state (`movingNode`, `editingHole`, `curvingWall`)
- Camera position/rotation
- Collections (stored in nodes array but not in the persist partialize)
- Three.js mesh/material cache
## Multi-scene support
- Single global key `pascal-editor-scene`. No scene id, name, thumbnail, version.
- `projectId` prop scopes UI **selection** (building/level/zone) but NOT scene data.
- No listing, no metadata, no per-project isolation of the scene itself.
## Gap to "MCP writes → user opens saved scene"
Needs:
1. Scene entity layer: id, name, projectId, created_at, thumbnail_url.
2. Backend table (or filesystem for local dev).
3. MCP tools for scene lifecycle (`save_scene`, `list_scenes`, `load_scene`, `delete_scene`).
4. Editor route `/scene/[id]` that reads scene by id on mount.
5. Host-app `onLoad(() => fetchScene(sceneId))`.
Foundation is solid — `SceneGraph` type + `applySceneGraphToEditor` are production-ready; only the entity layer is missing.
@@ -1,309 +0,0 @@
# R10 — Ideas, Edges, and Unlocks
*Agent: Research Agent R10*
*Date: 2026-04-18*
*Scope: brainstorm, not specification*
Grounded in the `@pascal-app/mcp` package. MCP today exposes 21 tools, 4 resources, 3 prompts, runs headless in Node, mutates a Zustand (+ Zundo) scene graph, and can round-trip JSON. The editor is Next.js + R3F + WebGPU, persists to IndexedDB, has three Zustand stores (`useScene`, `useViewer`, `useEditor`), and has no user-account backend today beyond a `health` API route. GLB export is stubbed; catalog resolves `asset://` URLs only in the browser. That surface area is the substrate I brainstorm against.
Legend: **S** ≤ 2 dev-days, **M** 12 weeks, **L** 3+ weeks / cross-cutting.
---
## 1. Workflows unlocked
1.1 **Prompt → Pascal one-shot studio**
Value: a landing page textarea ("design me a 90 m² south-facing apartment in Barcelona") that spawns a scene and drops the user into the editor with orbit camera pre-aimed. Lowest-friction wedge for consumer acquisition.
Effort: M (needs hosted MCP + auth + an agent loop that calls `from_brief`).
Risk: expectations calibration — generated scenes will look schematic without finishes.
1.2 **Photo → Pascal via `analyze_floorplan_image`**
Value: the tool exists but currently only the MCP host calls it. Exposing a drag-and-drop upload in the editor (`viewer-overlay`) that posts to MCP and returns a ready-made scene turns Pascal into a floorplan digitiser. Realtors and renovators will pay for this alone.
Effort: M (client upload, MCP host with sampling, progress UI).
Risk: the vision model is approximate; users will expect dimensional exactness and blame Pascal for mis-reads.
1.3 **Listing URL → Pascal (Zillow / Idealista / Rightmove)**
Value: paste a listing URL, a scraper extracts the floor plan image + listed area, `analyze_floorplan_image` produces the scene, then "remodel" prompts run on top. Enormous cold-start value — the user arrives with a house they already care about.
Effort: L (scraping layer, anti-bot, per-site parsers, legal).
Risk: ToS / legal on scraping; drives a category of "renovation-before-offer" anxiety that may alienate listings.
1.4 **Multi-variant generation**
Value: "give me 5 kitchen variations" → 5 forked scenes saved as siblings, tiled in a comparison view. Pattern-matches Midjourney's grid. Encourages exploration, sells more generations.
Effort: M (needs scene forking + a comparison UI; the `forkSceneGraph` helper exists already in `core/clone-scene-graph`).
Risk: without a scored objective ("cheapest", "most storage"), users get lost choosing; need ranking.
1.5 **Regulatory/accessibility lints**
Value: "ensure this scene complies with Spanish Código Técnico de la Edificación accessibility." MCP walks zones/doors/stairs, flags minimum door widths, corridor widths, ramp slopes, stair rise/run. Sells to architects and BIM shops.
Effort: L (per-jurisdiction rule packs; `check_collisions` is a proof the traversal works).
Risk: false confidence — a lint pass is not a stamped permit; liability exposure.
1.6 **Live co-design ("AI architect next to me")**
Value: editor sidebar chat pane; user edits walls, AI proposes adjustments ("you lost the light well — shall I add a skylight?") via MCP on a debounced scene diff. Screen-share-ready demo.
Effort: L (streaming agent, scene-diff prompts, throttling).
Risk: agents nagging mid-edit is the fastest route to churn; needs carefully tuned interventions.
1.7 **Voice-driven redlines on a phone**
Value: open a scene on mobile (preview-button path exists), talk into the mic ("turn the office into a nursery, softer colours"), MCP applies patches, renderer reflows on reload. Wins the "showing mum the renovation" moment.
Effort: M (Whisper → text → `from_brief`/`iterate_on_feedback`, mobile-friendly result).
Risk: WebGPU on low-end Android will fail; need a fallback still renderer.
1.8 **Cost + BOM synthesis**
Value: after scene generation, MCP walks catalog items and zones → exports a parts-list CSV with Spanish supplier SKUs and regional labour rates. Converts the toy into a quoteable artefact.
Effort: M (catalog → pricing mapping, jurisdictional labour constants).
Risk: pricing drifts; must be explicit about "indicative".
1.9 **Time-lapse tours**
Value: MCP emits a keyframed camera flythrough script (camera node already exists in `BaseNode.camera`). One click → shareable MP4. Drives social acquisition.
Effort: M (stitch recorder in the viewer; `apply_patch` can set cameras).
Risk: WebGPU video capture is finicky across Safari.
---
## 2. Novel primitives
2.1 **Scene branches & forks (Pascal-Git)**
Value: "save as branch" on every MCP mutation; user can compare, merge, or revert branches visually. The current temporal middleware gives us a linear undo stack; exposing a DAG unlocks nondestructive exploration and is a natural home for multi-variant results.
Effort: L (schema for branches, UI, storage beyond IndexedDB).
Risk: merge semantics for geometry are unsolved — walls and openings resist 3-way merge.
2.2 **Scene templates catalog**
Value: a resource `pascal://templates/*` — studio apartment, ADU, Japanese machiya, Barcelona eixample flat. `from_brief` prompts seed from the nearest template, drastically improving first-shot quality.
Effort: SM (author ~20 templates, register as MCP resources).
Risk: templates can anchor the generator; need variety + randomisation.
2.3 **Component library / "sub-scenes"**
Value: save a kitchen layout as a reusable component that carries its own sub-graph. MCP tool `instantiate_component` drops it onto a level with a transform. Mirrors Figma components.
Effort: M (schema addition for component refs or instance-of nodes; invalidation when parent changes).
Risk: local-vs-shared component propagation; ownership of community components.
2.4 **Scene diff view**
Value: built on top of `export_json` + a structured differ — show "AI added 3 walls, removed 2 doors, reshaped zone X". Makes AI suggestions reviewable like a PR.
Effort: M (diff algo, UI, inline accept/reject per patch).
Risk: diff UIs require high polish to feel trustworthy.
2.5 **`explain_scene` tool**
Value: a new MCP tool (or prompt) returning a natural-language summary: "A 92 m² duplex with the kitchen facing west; accessibility score 6/10; conspicuously no closet space." Turns scenes into legible artefacts for non-3D users.
Effort: S (wrapping `scene-summary` resource with an LLM prompt).
Risk: hallucinated detail; must be grounded strictly in `find_nodes` data.
2.6 **Semantic scene search**
Value: "find every wall in the scene longer than 4 m that faces south" → `find_nodes` is spec'd narrowly (type/parent/zone/level); extend with predicates + embedding search over `metadata`.
Effort: M (predicate DSL or JSON-logic filter, optional embeddings).
Risk: query DSLs get complex fast; keep it constrained.
2.7 **Real-world anchor nodes**
Value: a `SiteOrigin` node carrying lat/lon/heading/altitude so MCP can reason about sun path, climate, zoning. Enables solar analysis and jurisdictional rules.
Effort: S schema, L downstream (solar calc, sun path widget).
Risk: accidentally leaking address when scenes are shared.
2.8 **Commentable scene nodes**
Value: add a `comment` or `annotation` edge to any node: "client wants this moved 20 cm". Makes Pascal a review surface for human + AI collaboration. Dovetails with 2.4.
Effort: S (new schema node; UI pin).
Risk: scope creep into full comments system.
---
## 3. Edge cases
3.1 **Concurrent MCP writers**
Two agents holding the same `SceneBridge` both call `apply_patch` on the same node. Zundo coalesces at the store level; there is no lock. Result: lost updates, order-dependent chaos.
Mitigation: per-bridge operation mutex, or optimistic version stamps inside `UpdatePatch`.
3.2 **Invalid scene crashes editor**
MCP writes a `DoorNode` with `parentId` pointing to a slab. `validate_scene` catches it, but a misuse of `apply_patch` with a forged parent slips through. Editor hooks assume doors under walls and blow up.
Mitigation: the editor should hydrate through the same Zod validator, not trust the JSON. Add an "editor-safe boot" path that falls back to a recovery scene.
3.3 **10k-node performance cliff**
The Zustand store keeps a flat `nodes` dict; most tools iterate linearly. `check_collisions` is O(n²) on item bounds. Agents might produce hundreds of chairs in an office.
Mitigation: budget + warn inside `apply_patch`, or cap nodes per type with a clear error.
3.4 **Circular parent-child refs**
`validate_scene` covers Zod shape; a patch chain can still create a cycle (A.parent=B, B.parent=A). Traversal hangs.
Mitigation: cycle detection pass in `apply_patch` dry-run before commit.
3.5 **Camera points at nothing**
AI creates a 5 cm tall decorative bowl on the second floor and the last-placed-camera convention zooms there. First impression: black screen.
Mitigation: always auto-frame to root bounding box on MCP-opened scenes; store a `pascal://scene/current/recommendedCamera` resource.
3.6 **Broken external assets**
`ItemNode` can reference `asset://` URLs; in Node, the core asset loaders are browser-only and return nothing. A scene saved in the browser with asset URIs, then opened headless, displays placeholders; a scene passed back to the browser still references dead IDs.
Mitigation: MCP must round-trip asset URIs opaquely (never create new `asset://` IDs), and the editor should show a "missing asset" fallback.
3.7 **User edits after MCP — merge or clobber?**
The current bridge is stateful with a linear undo stack. If an agent re-runs `from_brief` on a user-modified scene, it rewrites from scratch. Either we implement 3-way merge (2.1) or we lock the scene and make MCP operate on a branch.
Mitigation: default to fork-on-regenerate; never overwrite user edits.
3.8 **PII in shared scenes**
A floor plan with lat/lon (2.7) or matching a real home is a privacy liability. Exporting `export_json` strips nothing. Agents uploading scenes to a shared LLM vendor leaks data.
Mitigation: a `strip_pii` utility that blanks address/gps/photos; explicit consent dialog before MCP sends images to remote hosts.
3.9 **Offline + cloud scenes**
If we add cloud persistence (§5), MCP runs against a server-only scene when user is offline. Writes queue; reconciliation becomes a merge problem (3.7). IndexedDB persistence covers local, not cross-device.
Mitigation: conflict-free writes via CRDT-style patch log keyed by node ID.
3.10 **Sampling unavailable**
`analyze_floorplan_image` gracefully errors when the host lacks sampling. Users who paid for this feature on a non-Claude host get a brick.
Mitigation: publish a supported-host matrix; provide a first-party web host for users without one.
3.11 **GLB export stub**
`export_glb` throws `not_implemented`. An AR-preview (§4.6) or a glTF-requiring downstream (Unity, Blender) falls off a cliff. This is the single largest productisation gap.
Mitigation: stand up a headless renderer worker (puppeteer + WebGPU) or build a geometry exporter independent of three-mesh-bvh.
3.12 **Prompt injection via scene metadata**
`BaseNode.metadata` is arbitrary JSON. A hostile scene file seeds strings into `describe_node` output, which an LLM later reads. Classic indirect prompt injection.
Mitigation: sanitise/escape metadata when emitting into model-visible surfaces; strip control tokens.
3.13 **Temporal stack explosion**
Zundo caps history but MCP could batch thousands of operations per "patch" — one undo reverts huge changes invisibly. Users panic when Cmd-Z throws away the whole room.
Mitigation: each MCP mutation shows a visible "AI step" badge; undo granularity documented.
3.14 **Units mismatch**
MCP tools say meters; catalog items may carry cm internally. Silent drift of 100x.
Mitigation: enforce units at the schema boundary; add a unit-assertion test in CI.
---
## 4. Integrations
4.1 **Figma → Pascal**
Value: a Figma plugin lets designers hand a 2D mood board to an MCP scene. The palette, materials, and key dimensions flow in. Wins the handoff from 2D to 3D.
Effort: M.
Risk: Figma plugin review; limited 3D fidelity.
4.2 **Revit / SketchUp / IFC import**
Value: architects live in these tools; Pascal becomes the "redline + present" layer. IFC in particular is the lingua franca of BIM.
Effort: L (IFC parser; map to Pascal nodes).
Risk: schema mismatch; Pascal is lighter-weight than full BIM.
4.3 **USD / glTF / IFC export**
Value: outward compatibility = easier adoption. USD for Pixar/Nvidia Omniverse pipelines, glTF for web, IFC for construction. Unlocks 4.2 reciprocally.
Effort: ML per format.
Risk: 3.11 — geometry derivation still lives in the browser renderer; export-by-transpile is needed.
4.4 **MCP tool marketplace**
Value: third parties publish style MCPs ("Japandi kitchen", "Brutalist staircase", "Zaha-Hadid-ish"). They compose as sub-tools callable from `from_brief`. Pascal becomes a platform.
Effort: L (registry, sandboxing, review).
Risk: quality dilution; security (3.12).
4.5 **Planning-permission APIs (UK Planning Portal, Spain Sede Electrónica)**
Value: generated scene → pre-filled planning application PDF. Brutal time-saver. Differentiator vs Canva-for-architecture competitors.
Effort: L.
Risk: compliance; rules differ per council.
4.6 **AR preview — Apple RoomPlan / ARKit / ARCore**
Value: phone scans the room with RoomPlan → MCP ingests the plist → user redesigns in Pascal → AR overlays result onto the real room. Tactile "buy this sofa here" moment.
Effort: L (iOS/Android apps; glTF export 3.11 prerequisite).
Risk: needs native apps Pascal doesn't have.
4.7 **E-commerce catalog bridges (IKEA, Wayfair, Kave Home)**
Value: map `ItemNode.catalogItemId` to retailer SKUs. "Checkout this room" button. Affiliate revenue.
Effort: M (catalog mapping, retailer API quirks).
Risk: SKU churn; regional availability.
4.8 **Google Earth / OSM site context**
Value: lat/lon (2.7) + MapBox → Pascal renders the adjacent buildings, street, sun path. Scene gains real-world context. Critical for facade design.
Effort: L.
Risk: licensing maps data.
---
## 5. Monetization
5.1 **Pay per generation**
Value: $0.50$2 per `from_brief` call. Low commitment, matches OpenAI/Midjourney consumer norms.
Effort: S (Stripe + credits; MCP host tracks).
Risk: commoditised unless paired with templates (2.2) or regulatory value (1.5).
5.2 **Pro subscription (unlimited AI + cloud saves)**
Value: $15/mo predictable ARR.
Effort: M.
Risk: balance cost; fair-use caps for heavy users.
5.3 **Template / component marketplace**
Value: creators sell templates (2.2) and components (2.3). Pascal takes 20%.
Effort: M (payments, tax, takedowns).
Risk: content moderation; cold-start supply.
5.4 **Enterprise BIM seat**
Value: firms pay per seat; access to IFC import, compliance packs, branded export. $50200/seat/mo.
Effort: L.
Risk: SOC2, procurement cycles.
5.5 **Lead-gen for contractors**
Value: after a scene is generated, Pascal matches to local contractors with quote requests. Contractors pay per lead.
Effort: M.
Risk: lemons-market; must vet contractors.
5.6 **Branded MCP for retailers**
Value: IKEA white-labels Pascal's MCP under "IKEA Studio". Licensing fee. Pascal stays the engine, retailer owns the UI.
Effort: M (API + licensing).
Risk: channel conflict; retailers might eat Pascal.
---
## 6. Ecosystem beyond current scope
6.1 **Pascal MCP becomes a standard for spatial editors**
Value: the tool verbs (`create_wall`, `place_item`, `cut_opening`) generalise. Onshape, SketchUp, Rhino could adopt a "spatial MCP" profile. Pascal authors the spec.
Effort: L (ecosystem work, not code).
Risk: platforms resist standards that commoditise their moats.
6.2 **Open-source Pascal Scene Format**
Value: USD is overkill for interior/architectural scenes; glTF lacks building semantics. A Pascal-flavoured JSON schema (already Zod-native) becomes the "Markdown of interiors". Could ship as `@pascal-app/scene-format`.
Effort: M to carve out; L to evangelise.
Risk: yet-another-format fatigue; ties ecosystem to Pascal's semantic choices.
6.3 **Educational channel — "Design school in Pascal"**
Value: a publisher (or Pascal) ships a curriculum — "design a studio flat", "analyse daylight". Classrooms teach design thinking with an MCP agent as tutor.
Effort: M (curriculum; content).
Risk: sales-motion mismatch with a B2C/B2B tool.
6.4 **Scene replay / provenance logs**
Value: every MCP patch is signed, stored, and replayable. A regulator or client can audit the design history. Opens procurement doors.
Effort: M (append-only log; signing keys).
Risk: GDPR implications of retention.
6.5 **Physical fabrication downstream**
Value: furniture/cabinet generation → CAM-ready DXF → CNC shop. Unlocks "AI designed and built my kitchen" as a narrative.
Effort: L.
Risk: tolerances; liability.
6.6 **Agent-to-agent Pascal**
Value: a procurement agent talks to a designer agent talks to a contractor agent — Pascal scenes are the shared artefact. Pushes Pascal as infrastructure for the agent web.
Effort: L (policy, auth between agents).
Risk: sounds crazy today; will be obvious by 2027.
---
## 7. Crazy-but-maybe ideas
7.1 **"Sceneprint"** — give Pascal a photo of you standing in your room; it infers which room, auto-positions the camera, and starts redesign from there. Sells to TikTok.
7.2 **"Phantom move-in"** — MCP grafts your furniture (measured from another Pascal scene) into a listing. Realtors hand buyers a pre-staged version of the home they're considering.
7.3 **Insurance-linked scenes** — an insurance app ingests your Pascal scene to price contents cover faster and more accurately. Scene = digital twin = underwriting data.
7.4 **Agent-on-call** — you email pascal@your.domain with a photo; MCP replies with a scene URL. No app, no login, pure asynchronous co-design. Drives discovery far beyond the editor.
7.5 **"Haunted" scenes** — designer publishes a scene; buyers walk through in AR; on replacement of an item, MCP whispers "the designer disagrees — here's why". Opinionated design, monetised.
7.6 **Voice-coded CAD for blind users** — purely spoken design, Pascal describes the scene back via `explain_scene` (2.5). Genuine accessibility win and possibly grant-fundable.
7.7 **Multi-player Pascal** — Yjs/CRDT layer on the scene graph, MCP agents as first-class collaborators alongside humans. Google Docs for interiors.
---
## Top 10 ideas ranked by (value × feasibility)
1. **1.2 Photo → Pascal (floor plan upload)** — MCP already has `analyze_floorplan_image`; only the UI entry point and host plumbing are missing. Highest value-for-effort unlock in the repo right now.
2. **2.2 Scene templates catalog** — SM effort, dramatically improves `from_brief` output quality, and doubles as marketplace seed inventory (5.3).
3. **3.5 Auto-framing camera on MCP-opened scenes** — a tiny fix for the single most embarrassing failure mode ("black screen"). S effort, huge UX.
4. **1.1 Prompt → Pascal one-shot studio** — the canonical "MCP creates scene" workflow. Build it as the hosted front door, not a side feature.
5. **1.4 Multi-variant generation**`forkSceneGraph` already exists; a comparison grid lights up exploration and creates upsell moments (pay-per-variant).
6. **2.4 Scene diff view** — makes every AI action reviewable and is a prerequisite for 1.6 and 3.7 merge flows.
7. **1.8 Cost + BOM synthesis** — converts toy scenes into quoteable artefacts; directly monetisable via retailer affiliates (4.7).
8. **2.1 Scene branches & forks** — larger, but it dissolves the "overwrite vs merge" edge (3.7) and is the structural foundation for multi-variant and AI co-design.
9. **3.11 GLB / glTF export via headless renderer** — unlocks AR (4.6), USD/IFC bridges (4.3), and removes the most cited "limitation" in the README.
10. **1.5 Regulatory/accessibility lints (pilot: one jurisdiction)** — pick Spain or the UK, ship one rule pack; opens the B2B architect segment where budgets live.
Honourable mention: **1.6 Live co-design** — the single most defensible long-term product vision, but it depends on 2.1, 2.4, and a streaming agent layer the repo doesn't have yet. Build the pieces, then assemble.
@@ -1,78 +0,0 @@
# R2 — `projectId` semantics + Editor public API
## TL;DR
- **`projectId` is a namespace**, not a scene identifier.
- The Editor is **scene-agnostic** — it loads/saves via `onLoad` / `onSave` callbacks.
- The editor defaults to `loadSceneFromLocalStorage()` / `saveSceneToLocalStorage()` when callbacks aren't supplied.
- One project can contain many scenes (1:N). That mapping is a **host-app concern**, not an Editor concern.
## `<Editor>` public props
| Prop | Type | Default | Purpose |
|---|---|---|---|
| `projectId` | `string \| null` | none | Namespace key for UI-state localStorage, passed to host callbacks |
| `layoutVersion` | `'v1' \| 'v2'` | `'v1'` | Sidebar layout flavour |
| `onLoad` | `() => Promise<SceneGraph \| null>` | `loadSceneFromLocalStorage()` | Fetch initial scene on mount, and when `onLoad` identity changes (scene switch) |
| `onSave` | `(scene: SceneGraph) => Promise<void>` | `saveSceneToLocalStorage()` | Debounced (1000 ms) autosave after every scene change |
| `onDirty` | `() => void` | — | First change after last save |
| `onSaveStatusChange` | `(status: SaveStatus) => void` | — | `'idle' \| 'pending' \| 'saving' \| 'saved' \| 'paused' \| 'error'` |
| `onThumbnailCapture` | `(blob: Blob, cameraData) => void` | — | Auto-fires after 10 s idle OR manual "Generate thumbnail" |
| `previewScene` | `SceneGraph` | — | Read-only version-preview mode |
| `isVersionPreviewMode` | `boolean` | `false` | Locks scene graph |
| `isLoading` | `boolean` | `false` | Spinner overlay |
| `sidebarTabs` | `SidebarTab[]` | `[]` | v2 sidebar tabs w/ custom components |
| `appMenuButton`, `sidebarTop`, `navbarSlot`, `viewerToolbarLeft`, `viewerToolbarRight`, `sidebarOverlay`, `viewerBanner` | `ReactNode` | — | UI slots |
| `settingsPanelProps` | `{ projectId?, projectVisibility?, onVisibilityChange? }` | — | Settings-panel config |
| `sitePanelProps` | `{ projectId?, onUploadAsset?, onDeleteAsset? }` | — | Asset callbacks |
| `presetsAdapter` | `PresetsAdapter` | localStorage | Presets backend |
| `extraSidebarPanels` | `ExtraPanel[]` | `[]` | Additional v1 sidebar panels |
| `commandPaletteEmptyAction` | `CommandPaletteEmptyAction` | — | Fallback on no-match search |
## Data flow
```
host page → <Editor projectId="…"> → useEffect sync (index.tsx:757)
useViewer.setProjectId() (packages/viewer/src/store/use-viewer.ts:147157)
localStorage keys prefixed with `pascal-editor-selection:${projectId}` (lib/scene.ts:32)
Host callbacks: onUploadAsset(projectId, levelId, file, type), onDeleteAsset(projectId, url)
```
## Scene vs project
- Scene = `{ nodes, rootNodeIds, collections? }` — the graph
- Project = namespace (which buildings/levels/zones this user can select; which assets belong)
- 1 project → N scenes (via different `onLoad` identities → scene switch)
## Host-app integration — the minimal server-backed example
```tsx
const [sceneId, setSceneId] = useState<string | null>(null)
<Editor
projectId={projectId}
onLoad={sceneId
? () => fetch(`/api/projects/${projectId}/scenes/${sceneId}`).then(r => r.json())
: () => null} // null → blank
onSave={async (scene) => {
if (!sceneId) {
const r = await fetch(`/api/projects/${projectId}/scenes`, { method: 'POST', body: JSON.stringify(scene) })
setSceneId((await r.json()).id)
} else {
await fetch(`/api/projects/${projectId}/scenes/${sceneId}`, { method: 'PUT', body: JSON.stringify(scene) })
}
}}
/>
```
## Verdict
**The Editor already gives us everything we need on the client side.** The "scene save → open" workflow just needs:
1. A backend table / API keyed by `(projectId, sceneId)`.
2. A scene-picker UI in the host app (route `/scene/[id]` or a dropdown).
3. MCP writes to the same backend.
**No Editor changes required** for the baseline flow. The missing UI (scene list, naming) can be added to the Settings panel (per R3) when we want it inside the Editor package.
@@ -1,34 +0,0 @@
# R3 — Scene-management UI
## What exists today
| Feature | File | Line |
|---|---|---|
| "Save Build" → download `layout_YYYY-MM-DD.json` | `packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx` | 205216, 362365 |
| "Load Build" → file picker `.json``setScene` | `…settings-panel/index.tsx` | 218239, 367383 |
| "Export Scene (JSON)" in command palette | `editor-commands.tsx` | 328346 |
| "Export GLB / STL / OBJ" | `export-manager.tsx` | — |
| "Clear & Start New" destructive button | `settings-panel/index.tsx` | 427434 |
| "Explore scene graph" read-only tree dialog | `settings-panel/index.tsx` | 398421 |
| Autosave hook (1 s debounce → onSave or localStorage fallback) | `use-auto-save.ts` | 1191 |
| Scene-dirty tracking exposed to host via `onDirty` / `onSaveStatusChange` | `use-auto-save.ts` | 56187 |
## What's missing from a typical editor
1. **New scene dialog with naming.** Saves are all date-stamped; no user-given names.
2. **Scene list / picker.** No sidebar panel that lists saved scenes.
3. **Open recent.**
4. **Delete scene** (the destructive button clears current, can't delete a stored scene).
5. **No `Ctrl+S` / `Cmd+S` save shortcut** registered in `use-keyboard.ts`.
6. **No top-bar File menu.** `appMenuButton` slot exists for host to inject one, but nothing ships by default.
## Where new UI should live
- **Scene picker panel** via `extraSidebarPanels` prop — non-invasive, no Editor-core change.
- **Quick actions** via `useCommandRegistry().register([...])``editor.scene.open`, `editor.scene.new`, `editor.scene.save-as`.
- **Save-status badge** next to the navbar via `navbarSlot` (v2 layout).
- **"Created by MCP" toast** via a new `Toast` provider in the editor's runtime init.
## Verdict
UI foundation is **~40%** of the way there. The infrastructure (autosave, callbacks, dialogs, palette extensibility) is all present. The missing pieces are UI-only: a scene-list panel (~150 LOC) + a few palette commands + a status indicator. No Editor core changes required if we keep it callback-driven and plug a host-side scene switcher.
@@ -1,38 +0,0 @@
# R4 — Routing & URLs
## TL;DR
**Zero dynamic routes today.** Every user lands on `/` which hardcodes `projectId="local-editor"`.
## Route tree
```
apps/editor/app/
├── page.tsx (Home: <Editor projectId="local-editor">)
├── layout.tsx
├── privacy/page.tsx
├── terms/page.tsx
├── api/health/route.ts
└── fonts/
```
- No `[id]`, `[projectId]`, `[sceneId]`, or `[[...slug]]` segments.
- No middleware.
- No `rewrites()` / `redirects()` in `next.config.ts`.
- No query-param driven state.
- No hash routing.
## Latent expectation (dead code)
`packages/editor/src/components/ui/action-menu/view-toggles.tsx:79`:
```ts
const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0]
```
Code expects URLs of shape `/editor/<projectId>/…`. No such routes exist. Falls through to `undefined` gracefully today but signals a planned structure.
## Options to add
| Option | Route | Effort |
|---|---|---|
| A | `/?sceneId=<id>` — reuse `/` + `useSearchParams` | XS |
| B | `/editor/[projectId]` | S |
| C | `/editor/[projectId]/[sceneId]` | M |
| D | `/scene/[id]` — flat, project-agnostic | S |
Recommended: **B for projects, `/scene/[id]` short-link for sharing**. Matches the latent expectation in view-toggles.tsx.
@@ -1,40 +0,0 @@
# R5 — Backend / Supabase
## TL;DR
**Infrastructure declared, ZERO backend code.** `env.mjs` lists Supabase + Postgres + BetterAuth + Resend secrets as REQUIRED, the privacy policy claims scene data is stored in Supabase, `turbo.json` invalidates cache on those secrets — but the repo contains **no Supabase client, no schema, no migrations, no scene CRUD API**.
## Evidence
### Declared infra
- `apps/editor/env.mjs:1819``POSTGRES_URL`, `SUPABASE_SERVICE_ROLE_KEY` (server-only, `.min(1)`)
- `env.mjs:1214``BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_*`
- `env.mjs:2731``NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `turbo.json:920` — same vars listed as build-cache keys
- `apps/editor/app/privacy/page.tsx:9597` — "Your data is stored using Supabase (PostgreSQL database)"
- `.gitignore:2224` — references `supabase/.branches/`, `supabase/.temp/` dirs (not present)
### What's absent
- Zero `createClient(` / `import.*supabase` matches across `apps/editor/**` and `packages/**`
- Zero `.sql` schema files
- Zero `drizzle/` / `prisma/` / `migrations/` directories
- Zero server actions (`'use server'` grep returns nothing)
- Zero API routes other than `/api/health` (returns `{ status: 'ok' }`)
## API surface today
| Route | Method | Purpose | Auth |
|---|---|---|---|
| `/api/health` | GET | Liveness | none |
## Required to enable MCP → cloud scene
1. Provision a Supabase project (or alternative Postgres).
2. Schema: `projects`, `scenes` (id, project_id, name, data jsonb, version, thumbnail_url, created_at, updated_at, owner_id), `scene_versions` (for history).
3. Supabase client singletons:
- `apps/editor/lib/supabase-browser.ts` (uses `ANON_KEY`)
- `apps/editor/lib/supabase-server.ts` (uses `SERVICE_ROLE_KEY` in server components / API routes)
4. Auth via BetterAuth + Google OAuth (env is there, unused).
5. API routes: `POST/GET/PUT/DELETE /api/projects/[id]/scenes/[sceneId]`.
6. RLS policies: scene rows readable only by owner + collaborators.
7. `SceneBridge` in MCP gets optional `persistenceAdapter: SupabaseAdapter` — replaces the in-memory store with a writeback to Supabase.
## Verdict
Groundwork is in place (env vars, privacy policy, turbo cache keys) but **every line of actual backend code is missing**. This is a greenfield opportunity: the team clearly planned for Supabase but hasn't implemented it yet.
@@ -1,55 +0,0 @@
# R6 — File I/O pathways
## TL;DR
- Export: 2 JSON pathways + 3 binary (GLB/STL/OBJ) pathways.
- Import: 1 JSON pathway ("Load Build"), **no Zod validation** at boundary.
- No drag-drop, no clipboard-paste JSON import.
- Round-trip export → re-import works; MCP-written JSON loads cleanly IF structure matches.
## Exports
| Trigger | Handler | File | Output |
|---|---|---|---|
| Settings → "Save Build" | `handleSaveBuild` | `settings-panel/index.tsx:205216` | `layout_YYYY-MM-DD.json` |
| Cmd palette → "Export Scene (JSON)" | `editor.export.json` | `command-palette/editor-commands.tsx:329346` | `scene_YYYY-MM-DD.json` |
| Settings/palette → "Export GLB/STL/OBJ" | `export-manager.tsx` | `editor/export-manager.tsx:7178` | binary 3D geometry |
Both JSON paths serialise `{ nodes: useScene.getState().nodes, rootNodeIds: useScene.getState().rootNodeIds }`. No metadata (no name, no created_at, no projectId).
## Import
### "Load Build"
- `settings-panel/index.tsx:218239`
- Accept: `application/json`
- Handler: `JSON.parse` → check `data.nodes && data.rootNodeIds` → call `useScene.setScene(nodes, rootNodeIds)`
- **No Zod validation.** Confirmed the security-audit flag from Phase 3.
### `setScene` behaviour (`core/store/use-scene.ts:242271`)
1. `migrateNodes()` — runs a few backward-compat patches. Stair nodes are zod-safeParsed and SILENTLY DROPPED on failure; other types are unvalidated.
2. Orphan pruning — deletes any node whose `parentId` isn't present in the dict.
3. `setState` with cleaned nodes + `dirtyNodes: new Set()`.
4. Marks every node dirty to trigger re-render.
**Critical gap:** Invalid `type` strings silently load. Systems will later fail to find a renderer for them and the node will be invisible but consume state.
## Round-trip fidelity
| Scenario | Loads clean? |
|---|---|
| Export → re-import | ✅ |
| MCP writes well-formed `{ nodes, rootNodeIds }` | ✅ (confirmed: Casa del Sol scene.json loads into the editor) |
| MCP writes bad `node.type` | ⚠️ Silent load, invisible node |
| MCP writes broken parentId chain | ⚠️ Orphans silently deleted |
| MCP writes missing `children: []` on container | ⚠️ Core treats as `undefined` → system ignores |
## Missing / nice-to-have
- Drag-drop JSON onto viewport
- URL-param load: `?load=<publicJsonUrl>`
- Clipboard paste of JSON blob
- `importFromFile` with Zod validation at the boundary
- File-format version field (`formatVersion: "1"`) for forward-compat
## Recommendation
Every "save to cloud" implementation MUST Zod-validate at the boundary with `AnyNode.safeParse` per node + structural checks on `rootNodeIds`. The tool in MCP (`validate_scene`) already does this — run it before any save.
@@ -1,46 +0,0 @@
# R7 — `@pascal-app/editor` public API
## Exports (packages/editor/src/index.tsx)
**Components:** `Editor` (default), `SettingsPanel`, `SitePanel`, `FloatingLevelSelector`, `SceneLoader`, `ViewerToolbarLeft`, `ViewerToolbarRight`, `Slider`, `SliderControl`
**Hooks/Stores:** `useEditor`, `useCommandRegistry`, `useSidebarStore`, `useUploadStore`, `useAudio`, `usePaletteViewRegistry`, `useCommandPalette`
**Utilities:** `applySceneGraphToEditor`, `SceneGraph` (type), `CATALOG_ITEMS`, `PresetsProvider`, `PresetsAdapter` (type)
## `<Editor>` host integration points
| Prop | Signature | Trigger | Host opportunity |
|---|---|---|---|
| `onLoad` | `() => Promise<SceneGraph \| null>` | Mount + `onLoad` identity change | Fetch scene by id from backend |
| `onSave` | `(scene) => Promise<void>` | 1s debounce + `beforeunload` | Persist to backend |
| `onDirty` | `() => void` | First edit after save | Show "unsaved" badge |
| `onSaveStatusChange` | `(status) => void` | `idle/pending/saving/saved/paused/error` | Top-bar status indicator |
| `onThumbnailCapture` | `(blob, cameraData) => void` | ~10s idle after camera/scene stable, 1920×1080 SSGI | Upload to cloud, use in scene list |
| `appMenuButton`, `sidebarTop`, `navbarSlot`, `viewerToolbarLeft`, `viewerToolbarRight`, `sidebarOverlay`, `viewerBanner` | `ReactNode` | Render slots | **Drop in a "Scene picker"** |
| `settingsPanelProps.onVisibilityChange` | `(visible) => void` | User toggles project visibility | Project-level permissions |
| `sitePanelProps.onUploadAsset` | `(projectId, levelId, file, type)` | Scan/guide image upload | S3/Supabase Storage |
| `sitePanelProps.onDeleteAsset` | `(projectId, url)` | User deletes scan/guide | Clean up backend |
| `presetsAdapter` | `PresetsAdapter` | Preset CRUD | Replace localStorage with backend-backed presets |
| `commandPaletteEmptyAction` | fn | No-match search | Route to AI / search |
| `extraSidebarPanels` | `ExtraPanel[]` | Always visible | Add "Saved scenes" panel |
## Sidebar slots suited for a scene switcher
- **Layout v1:** `appMenuButton` (top-left) or `sidebarTop` (above tabs)
- **Layout v2:** `navbarSlot` (full-width top nav)
- **Both:** `extraSidebarPanels` to add a dedicated "Scenes" tab
## Command palette extension
```ts
useCommandRegistry().register([
{ id: 'editor.scene.open', label: 'Open scene…', group: 'Scene', execute: () => setShowSceneList(true) },
{ id: 'editor.scene.new', label: 'New scene', group: 'Scene', shortcut: ['Meta', 'N'], execute: createNewScene },
{ id: 'editor.scene.save-as', label: 'Save as…', group: 'Scene', execute: saveAs },
])
```
## Effort to ship a minimal scene switcher inside this package
- Add `extraSidebarPanels` consumer that takes a `scenes: SceneMeta[]` + `onOpen(id)` + `onDelete(id)` + `onCreate()` — ~150 lines.
- Wire three palette commands — ~50 lines.
- Consume `onThumbnailCapture` in the example host to populate list thumbnails — ~30 lines host-side.
- **Total ≈ 12 days** for an in-editor scene browser, OR host-side if we keep the Editor scene-agnostic.
## Verdict
The Editor's architecture is **backend-agnostic by design**. Every persistence decision is a host callback. Implementing the user's vision is 100% about wiring up what already exists — no Editor refactor needed.
@@ -1,476 +0,0 @@
# R8 — MCP ↔ Editor Integration Design
**Vision.** "Call MCP → scene is saved → I open the scene in the editor, no injection."
Today `apps/editor/app/page.tsx` runs a dev-only `window.__pascalScene = useScene` hack so an MCP running in the same browser can `setScene()`. That has to die. MCP lives in Node (its own `SceneBridge` over `useScene`), editor lives in the browser (its own instance of `useScene`). They never share memory. So "saving" means **serializing a `SceneGraph` to a shared medium the editor can read**.
Below are five concrete options, ranked, with a recommendation.
---
## Option A — Filesystem handoff (`~/.pascal/scenes/<slug>.json`)
### Description
MCP writes `SceneGraph` JSON to `~/.pascal/scenes/<slug>.json` via a new `save_scene` tool. Next.js API route `GET /api/scenes/[slug]` reads the file from disk using `node:fs`. `/scene/[slug]` page fetches and calls `applySceneGraphToEditor()` on mount.
### Architecture
```
Claude Desktop / Cursor
│ stdio
pascal-mcp (Node)
SceneBridge.exportJSON()
~/.pascal/scenes/kitchen-v3.json ◄── shared disk
│ fs.readFile (server-side)
Next.js API route /api/scenes/[slug]
│ fetch()
/scene/[slug] page ──► applySceneGraphToEditor()
```
### Pros
- Zero new infra, zero network between MCP and editor
- Works offline; trivial to debug (`cat ~/.pascal/scenes/foo.json`)
- No auth, no RLS, no API contracts beyond "JSON on disk"
- Editor's existing `applySceneGraphToEditor()` already accepts a `SceneGraph`; reusing `packages/mcp/src/bridge/scene-bridge.ts` `exportJSON()` is a one-liner
- Ships this week
### Cons
- Same-machine only. Breaks the second the editor is deployed to Vercel
- No multi-user, no sharing, no "open on phone"
- Filesystem becomes the source of truth with no history, diffs, or transactions
- Vercel/serverless deployment of the editor cannot read a local user directory (dead on arrival for production)
### Dependencies
- New `save_scene` / `list_scenes` tools in `packages/mcp/src/tools/`
- New `apps/editor/app/api/scenes/[slug]/route.ts`
- New `apps/editor/app/scene/[slug]/page.tsx`
- No new npm packages. No breaking changes.
### Effort: **S** (12 days)
### Security / auth / multi-user
- No auth (filesystem ACLs only). Anyone on the box can read the scenes
- Path traversal risk on the slug — must sanitize (`slugify`, reject `..`)
- No multi-user story whatsoever
### Production readiness: **low (local dev only)**
Valid as a transitional internal tool for solo use. Not shippable as the real product path. Use it as a **stepping stone to B**.
### 5-step v0.1 plan
1. Add `save_scene({ slug })` and `list_scenes()` tools that write to `~/.pascal/scenes/<slug>.json` (Node `fs/promises`, slug sanitization, `XDG_DATA_HOME` fallback)
2. Add `load_scene({ slug })` tool that calls `bridge.loadJSON(readFileSync(...))`
3. `apps/editor/app/api/scenes/[slug]/route.ts` — GET reads `~/.pascal/scenes/<slug>.json` with path-traversal guard, returns JSON
4. `apps/editor/app/scene/[slug]/page.tsx``use client`, fetches `/api/scenes/[slug]` on mount, calls `applySceneGraphToEditor()`
5. Delete the `window.__pascalScene` injection hack from `apps/editor/app/page.tsx`
---
## Option B — Shared Supabase backend (RECOMMENDED)
### Description
MCP and editor both talk to a Supabase `scenes` table. MCP has a `save_scene` tool that `upsert`s via the service role; editor's `/scene/[id]` page SSR-fetches the row with `anon` key and RLS. Existing `env.mjs` already declares `POSTGRES_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID`. The rails are laid.
### Architecture
```
Claude / agent
pascal-mcp (Node) ─────► Supabase REST (service role)
│ INSERT scenes { id, owner_id, graph_json, updated_at }
│ returns { sceneId, url: "https://.../scene/<id>" }
User pastes / clicks URL
Next.js editor (Vercel)
│ SSR with anon key + RLS
/scene/[id]/page.tsx ──► applySceneGraphToEditor()
│ on save-in-editor
PATCH scenes (owner_id == auth.uid)
```
Tables:
```
scenes (id uuid pk, owner_id uuid fk auth.users, slug text, title text,
graph_json jsonb, updated_at timestamptz, created_at timestamptz)
scene_revisions (id uuid pk, scene_id fk, author_id fk, graph_json jsonb,
created_at timestamptz, author_kind text) -- "mcp" | "editor"
```
RLS: `owner_id = auth.uid()` for select/update; public-read if `public = true`.
### Pros
- One-machine and cross-device works the same way
- Multi-user, sharing via URL, versioning via `scene_revisions` — all free with Postgres
- Auth already half-wired: `BETTER_AUTH_SECRET` + `GOOGLE_CLIENT_ID` in env.mjs suggest Better Auth planned
- Editor can deploy to Vercel unchanged
- `graph_json` is a `jsonb` column — indexable, queryable, diffable
- Natural extension path to realtime (`supabase.channel`) and presence
### Cons
- Requires running Supabase (local via CLI, or hosted project)
- MCP now has a network dependency; offline stops working unless you layer IndexedDB cache on the editor side
- RLS policy mistakes are a classic data-leak vector
- MCP needs an `owner_id` — how does a stdio MCP know who the user is? Need a device-pairing or API-key bootstrap
### Dependencies
- New `@supabase/supabase-js` dep in `packages/mcp` (dependency) and `apps/editor` (already probably pulling it or easy add)
- Supabase project or local `supabase` CLI for dev
- Migration SQL for `scenes` + `scene_revisions`
- Small change to `packages/mcp` — env bootstrap, service-role key from `~/.pascal/config.json` or `PASCAL_SUPABASE_URL` / `PASCAL_SUPABASE_KEY` env
- No breaking changes to the `SceneBridge``exportJSON()` already produces exactly what we need
### Effort: **M** (1 week for v0.1, 23 weeks to production-harden RLS and auth bootstrap)
### Security / auth / multi-user
- Full multi-user with RLS
- MCP auth problem: solve with **device-pairing** — editor generates a short-lived token in UI ("paste this into Claude Desktop config"), MCP exchanges it for a long-lived machine token. Never put the service-role key in MCP; use per-user tokens
- Supabase handles rate limiting, backups, Point-in-time recovery
- Audit trail via `scene_revisions`
### Production readiness: **high**
Right-sized for a v1 product. Same story as Figma/Linear/Notion — server of record, optional local cache.
### 5-step v0.1 plan
1. Create `supabase/migrations/001_scenes.sql` with `scenes` and `scene_revisions` tables + RLS policies (owner read/write, public-slug read)
2. Add `packages/mcp/src/adapters/supabase.ts` using `@supabase/supabase-js`, driven by `PASCAL_SUPABASE_URL` + `PASCAL_SUPABASE_USER_TOKEN` env (not service-role). Wrap in `save_scene` / `load_scene` / `list_scenes` tools
3. Add `apps/editor/lib/supabase.ts` server client; add `apps/editor/app/scene/[id]/page.tsx` (server component) that fetches and passes `graph_json` to a client `<SceneLoader>` that runs `applySceneGraphToEditor()`
4. Editor-side "save" handler: on `Cmd+S` / debounced dirty, `UPDATE scenes SET graph_json, updated_at WHERE id=... AND owner_id=auth.uid()`. Append a row to `scene_revisions`
5. Add `/settings/mcp` route in editor that mints a device token and shows the JSON block the user pastes into `claude_desktop_config.json` (`env: { PASCAL_SUPABASE_USER_TOKEN: "..." }`)
---
## Option C — MCP *is* the backend (HTTP service)
### Description
Run `pascal-mcp` permanently as an HTTP service (already supported via `connectHttp` in `packages/mcp/src/transports/http.ts`). Add non-MCP REST endpoints `GET /scenes/:id`, `POST /scenes` alongside the MCP Streamable HTTP endpoint. Editor treats the MCP host as its backend.
### Architecture
```
Claude / agent ──MCP/Streamable HTTP──┐
pascal-mcp (Node HTTP :3917)
│ in-memory SceneBridge(s) + on-disk store
Next.js editor ────GET /scenes/:id────►│
◄───JSON {nodes,rootNodeIds}
────POST /scenes───────►│
```
### Pros
- One process owns the scene graph; no dual-store problem
- Offline-friendly if MCP runs on localhost
- MCP already has HTTP transport; extending the Node `http.createServer` with a side route is ~30 lines
- Can introspect the scene via the same bridge MCP tools use → perfect consistency
### Cons
- `SceneBridge` is a singleton — multi-user requires either a bridge-per-request or a separate process per user. Neither is trivial
- Scaling story is poor: you'd have to build session isolation, persistence layer, auth, rate limits — you're rebuilding Supabase badly
- Deploying MCP-as-backend to production means running Node long-lived; no more stdio simplicity
- Ties the editor's backend lifecycle to whatever host is running MCP — if user closes Claude Desktop, the backend dies
- The MCP protocol is for tool invocation, not CRUD; layering both on one port muddies concerns
### Dependencies
- Expand `packages/mcp/src/transports/http.ts` with non-MCP REST routes (or a second `http.createServer`)
- Add a persistence adapter behind the `SceneBridge` (sqlite? jsonfile?)
- Requires an auth story if it ever leaves localhost
- No new external deps if sticking with `node:http`
### Effort: **M** (localhost dev) / **XL** (multi-user production)
### Security / auth / multi-user
- Localhost: none needed; bind to `127.0.0.1`
- Multi-user: has to add bridge sessions, auth, CORS, TLS — effectively builds a toy Supabase
- Exposing MCP HTTP publicly is a big liability (the MCP protocol itself has no native auth)
### Production readiness: **low for production, fine for local**
Reasonable for a "single-developer laptop" loop. Do not ship this as the multi-tenant story.
### 5-step v0.1 plan
1. Split `connectHttp` into `connectMcpHttp` (existing MCP route) and `connectApiHttp` (new REST). Share the same `SceneBridge` instance
2. Add `GET /api/scenes/:id`, `POST /api/scenes`, `GET /api/scenes` backed by an on-disk map `~/.pascal/scenes/*.json` (Option A persistence reused)
3. Add CORS allowlist for `http://localhost:3002` (editor dev port)
4. In the editor, `apps/editor/app/scene/[id]/page.tsx` calls `fetch('http://localhost:3917/api/scenes/[id]')` client-side
5. Wire a CLI flag `pascal-mcp --serve --port 3917 --data-dir ~/.pascal/scenes` that launches both transports
---
## Option D — Editor-as-MCP-client (live subscription)
### Description
Editor imports `@modelcontextprotocol/sdk/client` and connects to a long-running MCP server (the same `pascal-mcp --http`). It `list_tools`, `call_tool('get_scene')`, and subscribes to `notifications/resources/updated` for `pascal://scene/current`. Every change pushed from MCP triggers `applySceneGraphToEditor()`.
### Architecture
```
Claude / agent ─(MCP stdio/HTTP)─► pascal-mcp ◄─(MCP streamable HTTP)─ Editor
SceneBridge
single in-memory scene
notifications/resources/updated (push on change)
```
### Pros
- Real-time: agent edits a wall, editor redraws within an RTT
- One source of truth (MCP process)
- Reuses the MCP protocol on both sides — consistent model
- Feels magical for demos
### Cons
- MCP's resource-update notification contract is still thin in v1; `pascal://scene/current` would need to be a subscribable resource. Our server doesn't currently implement `notifications/resources/updated` — meaningful work to add
- MCP SDK was designed for tool hosts (Claude Desktop, etc.), not for browser long-running clients — running the MCP client in the browser via streamable HTTP is possible but fragile (CORS, SSE, browser-tab lifecycle)
- `SceneBridge` still isn't multi-tenant — same singleton problem as C
- MCP server outages = editor is broken. Tight coupling
- Authentication for the browser → MCP HTTP transport is not a solved problem
- Overkill if you just want "save and open"; this is real-time collab territory
### Dependencies
- `@modelcontextprotocol/sdk/client` in `apps/editor` (new dep in browser bundle — SDK is Node-first, bundle size TBD)
- Add resource subscription support to `packages/mcp/src/server.ts` and `scene-current.ts`
- CORS + auth for MCP HTTP
### Effort: **L** (mostly in MCP server — subscriptions, auth, CORS; plus editor client integration)
### Security / auth / multi-user
- Same singleton problem as C
- Browser → MCP exposes a new attack surface if public
- Each user needs their own MCP process or their own `SceneBridge` session (requires server refactor)
### Production readiness: **low**
Looks cool in a demo. Doesn't compose with Vercel-style deployments. Consider this a **phase 3 add-on** for real-time collab, layered on top of B.
### 5-step v0.1 plan
1. Add `server.sendResourceUpdated('pascal://scene/current')` hooks inside `SceneBridge` mutation methods
2. Implement `resources/subscribe` handler in `packages/mcp/src/server.ts` tracking per-transport subscriptions
3. Add browser MCP client to `apps/editor/lib/mcp-client.ts`; wire auth token header
4. `apps/editor/app/scene/live/page.tsx` — connects, calls `get_scene`, subscribes, re-applies on each notification
5. Add a toggle in `/settings/mcp` for "live mode" — falls back to Option B polling when MCP host is unreachable
---
## Option E — Local-first CRDT via Yjs/Automerge
### Description
`SceneGraph` becomes a Y.Map. MCP writes operations into a Y.Doc; editor loads the same Y.Doc from IndexedDB (same-machine) or a y-websocket server (cross-device). Conflicts resolve automatically.
### Architecture
```
Claude / agent ──► pascal-mcp (Node) ──► Y.Doc ─┐
│ y-indexeddb / y-websocket
Editor tab ─────────────────────────────► Y.Doc ┘
Custom awareness/presence
```
### Pros
- Offline-first, collaborative, real-time — best-in-class UX
- No server needed for same-machine (y-indexeddb); optional y-websocket for cross-device
- Conflict-free merges: two agents + a human editing simultaneously Just Work
- Proven architecture (Figma-ish, but open-source)
### Cons
- `SceneGraph` needs a **full rewrite** to become a CRDT-friendly structure. `nodes: Record<id, AnyNode>` + `rootNodeIds: []` map cleanly to `Y.Map<Y.Map>` and `Y.Array`, but every `AnyNode.parse()` and every Zustand mutation in `packages/core/src/store/actions/*` is currently written against plain objects
- Zundo's temporal middleware doesn't compose with Yjs's own undo manager — you'd pick one, and switching temporal layer affects every editor interaction
- Large scenes: Y.Doc updates are fast but the schema migration + cross-host Zod revalidation is nontrivial
- Doesn't solve auth/identity — you still need Better Auth or similar
- Huge blast radius: this is touching `packages/core` at its heart
### Dependencies
- `yjs`, `y-indexeddb`, optionally `y-websocket`, `y-protocols`
- Major rewrite of `packages/core/src/store` — bridging Zustand ⇄ Yjs
- New MCP adapter `packages/mcp/src/bridge/yjs-bridge.ts` mirroring `SceneBridge` but against Y.Doc
- Potentially replace Zundo with `Y.UndoManager`
- Breaking changes throughout `@pascal-app/core` — every consumer affected
### Effort: **XL** (13 months)
### Security / auth / multi-user
- Auth: still need Better Auth / Supabase Auth for identity; y-websocket needs an auth middleware
- Multi-user: best-in-class once implemented
- Server storage: y-websocket + Postgres persistence (e.g., `y-postgresql`) or S3 snapshots
### Production readiness: **high if you commit**; **trap if you don't**
If you want Figma-quality multiplayer, this is the right answer long-term. But it is a complete rearchitecture of the core store. Not a starter move.
### 5-step v0.1 plan
1. Prototype a Yjs binding for `packages/core/src/store/use-scene.ts` behind a feature flag; keep the plain Zustand path default
2. Build a minimal Y.Doc ⇄ `SceneGraph` serializer and prove round-trip parity against existing scenes
3. Write `packages/mcp/src/bridge/yjs-bridge.ts` that wraps Y.Doc in the same interface as `SceneBridge`
4. Run a same-machine POC: MCP writes to Y.Doc → y-indexeddb → editor tab observes via `Y.Map.observe`
5. Defer y-websocket to phase 2 after same-machine parity is proven
---
## Option F (bonus) — Local daemon + shared SQLite
### Description
`pascal-mcp` runs as a background daemon on localhost. Scenes persist into a single SQLite file (`~/.pascal/pascal.db`) via better-sqlite3. The editor SSR or a Next.js API route opens the same SQLite file read-only. Pure local, durable, queryable, zero network.
### Architecture
```
Claude ──► pascal-mcp (daemon) ──► SQLite (~/.pascal/pascal.db) ◄── Next.js API route
```
### Pros vs A
- Transactional; no torn writes
- Indexable (JSON1 extension in SQLite); fast list/search
- Natural version table (`revisions` table)
- Still zero network, zero external infra
### Cons
- Two readers can deadlock on SQLite unless WAL mode + careful opens; Next.js dev server and MCP both holding the file needs care
- Still same-machine only; doesn't deploy to Vercel
### Effort: **SM**. Worth listing because it's a **better A** without much more work.
---
## Recommended approach + rationale
**Ship Option A this week as a dev loop, commit to Option B as the product path, and keep Option D on the roadmap for a phase-3 "live mode".** Concretely:
1. **Week 1:** Option A (filesystem). Two days of work, ships the "no injection" promise for your own machine. Kills the `window.__pascalScene` hack. Unblocks every subsequent demo. Treat it as a local cache layer that survives option B — keep the `~/.pascal/scenes/` format stable so offline mode later just reads from it.
2. **Weeks 24:** Option B (Supabase). This is the only option that:
- Scales past your laptop
- Composes with the existing `env.mjs` (Supabase, Better Auth already declared)
- Supports multi-user without rearchitecting `@pascal-app/core`
- Matches the deployment target (Next.js on Vercel)
Supabase's `jsonb` + RLS + auth is a perfect fit for "store a scene, open it at `/scene/<id>`". The MCP side is ~200 lines: `save_scene` / `load_scene` / `list_scenes` tools calling `supabase.from('scenes').upsert({...})`.
3. **Quarter 2:** Option D live mode as a pro feature. Layer real-time on top of B via Supabase Realtime (`supabase.channel('scene:<id>').on('postgres_changes', ...)`), not via MCP resource subscriptions — avoids the browser-as-MCP-client rabbit hole.
4. **Option E (CRDT)** is only right if you commit to Figma-grade multiplayer. It's a 3-month project across `@pascal-app/core` and shouldn't be started until you have product-market signal that multiplayer is the moat.
5. **Option C** is tempting as a "one process runs everything" story but rebuilds Supabase badly. Skip unless the product demands a fully-local air-gapped build.
### Why B over C
C asks `SceneBridge` to become a multi-tenant database. `SceneBridge` is a thin wrapper over a Zustand singleton — making it multi-tenant is a rewrite. Supabase already is one.
### Why B over E (for now)
E makes the *editor* collaborative, but the user's stated problem is "save → open", not "co-edit in real time". B solves the stated problem in 10% of the effort and leaves the door open for E later (you can replace the `graph_json` column with a `y_doc` column in a migration and nothing else in the app has to change, because `applySceneGraphToEditor()` still accepts a `SceneGraph`).
---
## 30/60/90 day roadmap
### Day 030 — "Unblock the loop"
- **A (12d):** filesystem handoff; MCP `save_scene` + editor `/scene/[slug]`. Delete `window.__pascalScene`.
- **B-alpha (2w):** Supabase migrations for `scenes` + `scene_revisions`, RLS, MCP tools using user tokens, editor `/scene/[id]` server component.
- **Dogfood:** the team uses `save_scene` from Claude Desktop every day. Bugs get filed.
### Day 3160 — "Productionize"
- **Auth bootstrap:** `/settings/mcp` device-pairing flow. Mints a scoped Supabase user JWT (not service-role) for MCP.
- **Editor → Supabase save:** debounced on-change writes + "Save" button. Append `scene_revisions` rows with `author_kind: 'editor'`.
- **History UI:** simple diff viewer across `scene_revisions`.
- **Sharing:** `public = true` flag → public read. Share URL works.
- **Offline cache (optional):** keep Option A filesystem writes as a redundant cache so scenes survive Supabase outages.
### Day 6190 — "Live mode + polish"
- **Realtime via Supabase channels (not MCP D):** editor subscribes to `postgres_changes` on the current scene and applies remote updates. Gives near-realtime collab without Yjs effort.
- **Presence:** Supabase Realtime presence for "agent is editing".
- **Figma-style undo across actors:** scoped per-user temporal history (use `clearSceneHistory()` when switching scenes; keep Zundo per tab).
- **Decide on E:** if usage shows genuine concurrent-agent-plus-human friction, spike Yjs in Q2. Otherwise defer.
---
## Risks + mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| MCP needs a user identity to write to Supabase with RLS | High | Device-pairing UI in editor that mints a short-lived JWT → long-lived machine token. Never ship the service-role key. |
| Concurrent MCP + editor writes clobber each other | Medium | Optimistic concurrency: include `updated_at` in UPDATE `WHERE` clause; if mismatch, append a revision instead of overwriting and surface a merge prompt. |
| Large `graph_json` payloads blow past Supabase's 8MB body limit | Medium | Measure casa-sol (test scene in `test-reports/casa-sol/`) and typical scene sizes. If >1MB, chunk or move to Storage buckets. |
| Serverless cold starts on `/scene/[id]` make "open" slow | Low | Use Next.js `revalidate: 0` + ISR; Edge runtime where possible; cache in localStorage on client. |
| `applySceneGraphToEditor` is a client-only function, can't run on SSR | Low | Keep it client; `<SceneLoader>` component does `useEffect(() => applySceneGraphToEditor(initialGraph), [])`. |
| Supabase outage breaks the whole editor | Medium | Layer Option A on top: editor falls back to last-known-good from localStorage when fetch fails. |
| RLS misconfiguration leaks scenes across users | Critical | Policy tests in `supabase/tests/`; add a dedicated "rls" CI step; `anon` client gets read-only and only `public = true` rows. |
| MCP Node process can't reach `~/.pascal` on Windows Claude Desktop install | Low | Use `envPaths('pascal')` (via `env-paths`) to resolve per-OS. |
| Slug collisions / path traversal via `save_scene({ slug: '../foo' })` | High (for A) | Strict slug regex `^[a-z0-9-]{1,64}$`; reject everything else. |
| MCP writes a scene under user A; user B opens the URL → leak | High (for B) | Only share by signed URL or explicit `public = true` flag. Default visibility is `owner-only`. |
---
## Open questions for the user
1. **Are scenes per-user or per-project?** Current `projectId="local-editor"` suggests projects exist — do MCP-created scenes live under a project, or are they free-floating until assigned?
2. **How does a stdio MCP know who the user is?** Is the intended flow "user signs into editor → editor emits a device token → user pastes into Claude Desktop config"? Or "MCP is always anonymous and scenes live in a shared staging bucket"?
3. **Offline requirements?** Must editing work with zero network (implies Option A cache on top of B), or is online-required acceptable for v1?
4. **Deployment target for the editor?** Vercel (rules out C, F) or self-hosted (all options viable)?
5. **Scene mutability after save?** Can the editor edit a scene MCP created and have those edits visible to the next MCP call? (Implies bidirectional sync, easiest via B's REST; harder via A's file racing.)
6. **Auth provider?** `env.mjs` has `BETTER_AUTH_SECRET` + `GOOGLE_CLIENT_ID` — is Better Auth the plan, or Supabase Auth? They can coexist but one is source of truth for `auth.uid()`.
7. **Versioning/history UX?** Is "every MCP call appends a revision" desired, or should only explicit saves create revisions? Affects `scene_revisions` write patterns.
8. **Multi-agent story?** If two Claude Desktops write to the same scene concurrently, which wins? (Punts on this until Yjs/E; in B, last-write-wins with optimistic concurrency is a fine v1.)
9. **How does "open in the editor" trigger?** Does MCP return a URL and the user clicks? Or does MCP invoke a deeplink (`pascal://scene/<id>` handler) that focuses an already-open browser tab?
10. **Catalog availability in MCP.** Today `pascal://catalog/items` returns `catalog_unavailable` in headless mode. If a scene references catalog items, does the editor need to re-hydrate them on open, or does MCP have to snapshot the catalog into the scene graph?
@@ -1,184 +0,0 @@
# R9 — Production Readiness Assessment
**Scope:** "MCP creates scene → user opens it in editor" workflow.
**Current state:** 13 commits on `feat/mcp-server`. MCP server ships stdio + streamable HTTP transports, 19 scene tools, vision sampling, resources/prompts. Target: Option B from R8 (server-side persistence with user-scoped scenes). Baseline: single-user editor with `localStorage` autosave, no backend, no auth, no Supabase client in the repo.
---
## 1. Readiness matrix
| Dimension | Current state | Needed for GA | Gap |
|---|---|---|---|
| **Transport security** | stdio (local) + HTTP on `0.0.0.0:<port>`, no TLS, no auth, `sessionIdGenerator` is `randomUUID()` but session unbound to user | TLS termination (ALB/Cloudflare), per-request auth, origin allowlist, DNS rebinding guard | No auth, no TLS, binds `0.0.0.0` by default (`transports/http.ts:30`) |
| **Auth (human → editor)** | None. `projectId="local-editor"` hardcoded in `apps/editor/app/page.tsx:31` | OAuth (GitHub/Google) or magic link; session cookie; JWT for API; Supabase Auth if we adopt it | Starting from zero; no user model, no login UI |
| **Auth (MCP → API)** | None. stdio spawns local process; HTTP transport accepts any caller | Per-user MCP tokens (OAuth2 device flow or PAT), token rotation, scoped capabilities | No token concept, no issuer, no revocation |
| **Persistence** | `localStorage` only (`editor/src/lib/scene.ts:379`) | Server-side DB with per-user rows, versioned rows, RLS | No DB, no API, no migration story |
| **RLS / ownership** | N/A (no DB) | Postgres RLS: `USING (auth.uid() = owner_id)` on `scenes`, `scene_versions`, `scene_assets` | Needs full data model from scratch |
| **URL validation in scenes** | `GuideNode.url`, `ScanNode.url`, `MaterialSchema.texture.url`, `ItemNode.thumbnail`/`src` are bare `z.string()` (`core/src/schema/nodes/guide.ts:7`, `scan.ts:7`, `material.ts:33`, `item.ts:81-82`) | Allowlist (our CDN + signed-URL origins only), SSRF-safe parser, `data:` caps, `blob:` rejection at save-time | Zero validation; SSRF primitive surfaces any time editor or MCP renders a scene |
| **CSP / headers** | `next.config.ts` allows images from `protocol: https, hostname: '**'` and `protocol: http, hostname: '**'` | Explicit CSP (`img-src`, `connect-src`, `media-src`, `script-src 'self'`), HSTS, `X-Frame-Options`, `Referrer-Policy` | No headers set; wildcard image hosts |
| **Rate limiting** | None on HTTP transport (`transports/http.ts`) | Token-bucket per user + per-IP; stricter bucket on mutating tools; MCP tool-call ceiling | None |
| **Quota** | None. An agent can call `create_wall` infinitely; `setScene` accepts any-size JSON | Per-user scene count cap, per-scene node count cap (e.g. 50k), per-version bytes cap (e.g. 5 MB), monthly tool-call cap | None |
| **Size caps** | None. Next `serverActions.bodySizeLimit: '100mb'` (`next.config.ts:19`) is the only ceiling | Explicit per-endpoint limits (256 KB scene patch, 5 MB full save), gzip required, reject on oversize before parse | 100 MB server-action body limit is a DoS amplifier |
| **Concurrency** | Last-write-wins implicit; no version token, no lock | Optimistic concurrency via `if-match: <version>` ETag; reject stale saves; later: CRDT (Yjs/Automerge) for true multi-agent | No detection at all; silent overwrite |
| **Versioning** | `temporal` (zundo) exists in-memory; not persisted | Every save creates `scene_versions` row; retain last N + all "named" versions; soft delete | No persistence at all |
| **Schema evolution** | `setScene.migrateNodes` hook exists in core (per `CROSS_CUTTING.md` §2) but no migration registry | Versioned schema tag on every row (`schema_version: int`), forward-migration functions, replay on load | No schema version field in scene JSON today |
| **Observability** | `console.error` only (`transports/http.ts:33`) | Structured logs (JSON), trace ID per MCP request, Sentry/similar error reporting, metric counters for tool calls | No traces, no error pipeline, no metrics |
| **Audit log** | None | Append-only `scene_events` table with user, tool, timestamp, diff-size, source (mcp/human) | None |
| **GDPR / data rights** | N/A (no user data stored server-side) | DSAR endpoint (export scenes as JSON/GLB), deletion pipeline, consent banner, processor contracts | Needs legal + product work |
| **Cost model** | Storage = 0 (localStorage on user's device) | Budget per user: ~10 MB scenes + thumbnails; CDN egress ~100 MB/mo free tier; vision sampling cost per call | Unknown; depends on choice of storage (Supabase Storage vs S3) |
| **Offline support** | Implicit — `localStorage` works offline | Service Worker + IndexedDB mirror; background sync queue; conflict resolution on reconnect | Current localStorage works but only on same device/browser |
| **Multi-agent collab** | None (single Zustand store, no broadcast) | Realtime channel (Supabase Realtime / Ably / WebSockets); op-log or CRDT; presence | Architectural rewrite |
| **Thumbnail pipeline** | Client-side only (`thumbnail-generator.tsx`) | Server-side rendering worker (headless Three or pre-rendered bake); CDN caching; signed URLs | No server path; MCP cannot currently produce a thumbnail without the editor |
| **Testing at load** | Unit + smoke tests only | Load tests (k6/artillery): 100 concurrent MCP sessions, 1000 writes/min, 95p < 500 ms | No load suite |
| **Secrets hygiene** | No secrets in repo yet | HSM/Vault, per-env keys, rotation policy, supply-chain scanning (SBOM) | Not addressed |
---
## 2. Top 10 risks ranked by severity
1. **SSRF via scene URLs (Critical)**
Any `GuideNode.url` / `ScanNode.url` / `MaterialSchema.texture.url` is a `z.string()`. If MCP writes a scene containing `http://169.254.169.254/latest/meta-data/` or `http://localhost:6379`, when a user later opens that scene the editor `<img>` / `<texture>` load will fetch it from the user's browser or from an SSR render pipeline. With wildcard `images.remotePatterns` this is already loaded client-side. Severity high because MCP is exactly the attacker-controllable input source.
2. **No auth on HTTP transport (Critical)**
`transports/http.ts` binds `0.0.0.0` and generates session IDs client-gettable. Anyone on the network (or Internet if exposed) can invoke every tool — including `apply_patch`, `delete_node`, and write through to an eventual backend. Today this is "only local," but that is a deploy-time decision; the code has no hard barrier.
3. **No user model → no meaningful RLS possible (High)**
Everything below depends on user identity. Without auth, quotas, audit trails, data deletion, concurrent-edit resolution, and cost accounting all collapse to guesswork.
4. **Unbounded scene size / tool-call rate → DoS + cost blowup (High)**
`apply_patch` accepts batched ops with no ceiling. `place_item` can be called in a loop. Combined with Next's 100 MB server-action limit, a compromised MCP can push gigabyte-scale scenes or detonate CDN bills.
5. **Last-writer-wins silent overwrite (High)**
Two agents (or an agent + a human) editing simultaneously: whoever saves last wins, no warning. With MCP autonomous workflows this is likely, not hypothetical.
6. **No schema version on persisted scenes (High)**
First breaking change to `@pascal-app/core` schemas (e.g. `SiteNode.children` fix in `CROSS_CUTTING.md` §2) will silently corrupt saved scenes. There is no `schema_version: n` today.
7. **Dev bridge leaks scene store to window (Medium)**
`apps/editor/app/page.tsx:13-15` sets `window.__pascalScene` in non-production. If `NODE_ENV` is ever mis-set, or a preview deploy ships, any XSS becomes a full scene-graph takeover. Guard is environment-string based, not build-time stripped.
8. **No CSP; wildcard image hosts (Medium)**
`next.config.ts` allows `http(s)://**`. Combined with Risk 1 this is a clean data exfiltration channel: attacker-controlled URL in scene → user's browser GETs `https://attacker.com/?cookie=...` as an image load. `document.cookie` doesn't leak, but `Referer` and timing do.
9. **No observability → breaches invisible (Medium)**
Only `console.error`. No audit log, no trace IDs. We would not detect an in-progress compromise until a user complained.
10. **Supply chain: `@modelcontextprotocol/sdk` and vision tooling (Medium)**
MCP SDK is v1.29.0 and moving fast. Vision tools call out to the host's model provider. Neither has SBOM, pinned digests, or review gate in our CI.
---
## 3. Recommended hardening order
Phased by dependency: each phase unblocks the next.
### Phase A — "don't ship HTTP transport to the open Internet" (days)
1. Default HTTP bind to `127.0.0.1`; require explicit `--bind 0.0.0.0` flag with warning.
2. Add `Origin` / `Host` header check for DNS rebinding (MCP SDK 1.29 has a guard; verify enabled).
3. Mandatory bearer token on HTTP transport; `PASCAL_MCP_TOKEN` env; reject without it.
4. Strip `window.__pascalScene` at build-time (`defineConfig` constant) rather than runtime `NODE_ENV` check.
5. Add strict CSP to `apps/editor` (`Content-Security-Policy: default-src 'self'; img-src 'self' data: https://<our-cdn>; ...`).
6. Replace `z.string()` with `z.string().url()` plus a **URL validator** on `GuideNode.url`, `ScanNode.url`, `MaterialSchema.texture.url`, `ItemNode.thumbnail`/`src`. Allowlist: `data:image/*` (≤ 256 KB), our CDN origin, and signed-URL hosts only. Reject `file:`, `blob:`, `javascript:`, private IPs, link-local, `.internal`.
### Phase B — auth + persistence skeleton (weeks 12)
7. Pick auth stack (Supabase Auth, Clerk, or self-rolled NextAuth). Supabase gives RLS + storage + realtime for free, so it's the low-friction default even if R8's Option B is a different DB.
8. Design minimal schema:
- `scenes(id, owner_id, name, current_version_id, created_at, updated_at, schema_version int)`
- `scene_versions(id, scene_id, parent_version_id, body_jsonb, byte_size, author_id, source enum('human','mcp'), created_at)`
- `scene_assets(id, scene_id, kind, sha256, cdn_url, byte_size, owner_id)`
- `mcp_tokens(id, user_id, hashed_token, scopes, last_used_at, revoked_at)`
9. RLS on all four tables: `owner_id = auth.uid()`. Never use Supabase service role from browser.
10. Server API (Next Route Handlers or tRPC): `POST /api/scenes`, `GET /api/scenes/:id`, `PUT /api/scenes/:id` (takes `if-match` ETag = version ID). All checks `auth.getUser()`.
11. MCP: add `PASCAL_API_URL` + `PASCAL_API_TOKEN` env. Every tool that mutates routes through `apiClient`. Token = per-user PAT, hashed in DB, revocable.
### Phase C — quotas, size caps, rate limiting (week 23)
12. Per-user quotas: 100 scenes, 50k nodes/scene, 5 MB/version, 10k MCP tool calls/day. Enforce at write-path.
13. Rate limiting: Upstash Ratelimit or Postgres advisory locks. 100 req/min global, 20 req/min mutating.
14. Reject requests with `content-length` > cap before reading body.
15. Add size budget to `apply_patch`: max 500 ops per call; reject otherwise.
### Phase D — concurrency + versioning (week 34)
16. Every `PUT /api/scenes/:id` requires `if-match` ETag. On mismatch return 409 with the current version for client merge.
17. Insert a `scene_versions` row on every successful save. Retain last 50; keep all "named" ones; soft-delete older.
18. Expose `GET /api/scenes/:id/versions` + `GET /api/scenes/:id/versions/:v` for history UI.
19. Add `schema_version` to persisted body (start at `1`); migration registry `coreSchemaMigrations[n]` in `@pascal-app/core`; run on load.
### Phase E — observability + audit (week 45)
20. Structured JSON logs (pino), trace IDs propagated through MCP tool calls via headers / session meta.
21. Sentry (or equivalent) for both editor and MCP server.
22. Append-only `scene_events(id, scene_id, user_id, tool, diff_size, source, ts)`.
23. Basic dashboard: writes/min, tool mix, p95 latency, error rate.
### Phase F — compliance + cost (week 56)
24. DSAR endpoint `GET /api/me/export.zip` (all scenes + versions + assets).
25. Account deletion pipeline: hard-delete within 30 days, audit record of deletion.
26. Privacy notice update (`apps/editor/app/privacy/page.tsx`) to describe MCP ingress.
27. Cost model: storage $/scene (estimate ~50 KB avg compressed JSON, 500 KB thumbnails), CDN egress, Sentry seat, Supabase tier.
### Phase G — collab (month 2+)
28. Realtime channel per scene; presence; ephemeral locks per subtree.
29. CRDT decision (Yjs with a lossless bridge to our scene graph) — or stay with OT + server-authoritative ops.
---
## 4. "Beta" vs "GA" checkpoints
### Ready for beta (closed, trusted users, ≤ 100 accounts)
- Phase A complete.
- Phase B (auth + persistence skeleton) complete.
- Phase C-lite: soft quotas + rate limiting; no hard enforcement on node count yet.
- Phase D-lite: `if-match` ETag on writes; version history retained but not yet surfaced in UI.
- Observability: Sentry + basic logs. No dashboards required.
- Privacy notice updated. No DSAR endpoint yet (manual support OK for ≤ 100 users).
- Acceptance criteria:
- Two agents hitting the same scene get a clean 409 on the loser, not silent overwrite.
- Saving a 6 MB scene returns a structured error, not a 500.
- Loading a 2-week-old scene still works after a schema change.
- Revoking an MCP token blocks that client within 60 s.
- An attacker-controlled URL in a scene does **not** cause the editor to call out to `169.254.169.254`.
### Ready for GA (open signup, cost accountable)
- All of Phase AF complete.
- Load tested: 500 concurrent MCP sessions, 2000 writes/min, p95 < 500 ms for read, < 1 s for write.
- Full audit log searchable by operator.
- DSAR + deletion pipeline with SLA (≤ 30 days).
- Written incident response runbook; on-call rotation.
- External pen test focused on MCP transport + URL sanitization (repeat of Phase 3 audit).
- CSP in `Content-Security-Policy` header (not just report-only).
- Thumbnail pipeline server-side (so a scene created by MCP can be listed in a gallery without opening the editor).
- Phase G (realtime collab) can be post-GA if we accept "single active editor per scene at a time" as a UX contract for v1.
---
## 5. Verdict
**Weeks or months to production: ~1014 weeks minimum to GA, ~45 weeks to credible private beta**, assuming one full-time engineer on the hardening work and Option B from R8 (server-side persistence) is chosen.
Rough breakdown:
- **Private beta: ~45 weeks** (Phases AD at MVP depth).
- **Public beta: ~8 weeks** (add Phase E, quota enforcement, version UI, one round of pen-test fixes).
- **GA: ~1214 weeks** (add Phase F: compliance, cost accounting, DSAR, load-test-driven tuning, external pen test).
The schedule is dominated by:
1. **Auth + persistence from scratch** — the repo has none today. Supabase would compress this to ~1 week; NextAuth + self-hosted Postgres is ~23 weeks.
2. **URL hardening on the core schemas** — a breaking change requiring a migration, though small in code size.
3. **Concurrency model** — ETag-based OCC is ~1 week; real CRDT collab is a month and probably post-GA.
**Blockers that could push this out:**
- If R8 picks an Option B that requires rewriting `@pascal-app/core` schemas (e.g. moving to a DB-native format), add 24 weeks.
- If legal requires SOC 2 or EU data residency before launch, add 23 months.
- Any real-time multi-agent requirement in v1 moves GA out by 46 weeks.
**Recommendation.** Ship Phase A (transport hardening + URL validation) in the first week independently of R8 — it's cheap, it reduces blast radius today, and it's not coupled to the persistence choice. Block any public deploy of the HTTP transport until Phase A lands.
@@ -1,154 +0,0 @@
# Research synthesis — "MCP creates scene → I open it in the editor"
> 10 parallel research agents (R1R10) investigated this workflow against the Pascal repo. This document pulls their findings into a single actionable answer.
## Direct answer to your question
**Yes, this is the right approach. And it's about 40% already built.** The Pascal Editor was designed from day one to be backend-agnostic — `onLoad(sceneId)` / `onSave(scene)` callbacks are public props. The plumbing that's missing isn't the Editor; it's the **scene-entity layer** (id, name, thumbnail, owner) and **a backend to store it**. The groundwork for that backend is already laid in env vars and privacy policy, but zero lines of backend code exist yet.
## What exists today (40%)
| Piece | Status | Evidence |
|---|---|---|
| Scene graph serialization | ✅ done | `SceneGraph` type; `export_json` MCP tool; `"Save Build"` UI button |
| Scene graph deserialization | ✅ done | `applySceneGraphToEditor()` + `setScene()`; `"Load Build"` UI button |
| Autosave pipeline (debounced, status-reported) | ✅ done | `use-auto-save.ts` with 6-state machine + `onSaveStatusChange` |
| Host persistence hooks (`onLoad`, `onSave`, `onDirty`) | ✅ done | `<Editor>` props, R2 |
| Thumbnail auto-capture | ✅ done | `onThumbnailCapture` fires ~10s after scene stable, 1920×1080 SSGI |
| Store-level project scoping | ✅ done | `projectId` prop flows through viewer + selection |
| localStorage fallback persistence | ✅ done | `pascal-editor-scene` key |
| IndexedDB for assets | ✅ done | `idb-keyval` for texture blobs |
| Single route (`/`) | ✅ done | but no dynamic segments — R4 |
## What's missing (60%)
| Piece | Effort | Owner |
|---|---|---|
| Scene entity metadata (id, name, thumbnail, owner, created_at) | S | R2 gap |
| Backend storage (Supabase `scenes` table) — env is declared, code is zero | M | R5 gap |
| Dynamic routes `/scene/[id]` and `/editor/[projectId]/[sceneId]` | S | R4 gap |
| Scene-list UI (picker, rename, delete, duplicate) | M | R3 gap |
| MCP tools for scene lifecycle (`save_scene`, `list_scenes`, `load_scene`, `delete_scene`) | S | R8 |
| Zod validation at the scene-load boundary | XS | R6 gap (pre-existing security finding) |
| Auth (Supabase auth / Better Auth — env is declared, code is zero) | M | R5, R9 gap |
| Device-pairing flow so MCP acts as the user | M | R9, R8 |
## The recommended plan — R8's phased approach
**Ship Option A this week. Commit to Option B for production. Defer D (real-time) to Q2. Skip C and E.**
### Week 1 — Option A: filesystem handoff (kills the injection hack)
```
MCP ──► ~/.pascal/scenes/<slug>.json ──► Next.js API route ──► /scene/<slug> page ──► applySceneGraphToEditor()
```
- New MCP tools: `save_scene({ slug })`, `load_scene({ slug })`, `list_scenes()`.
- New Next.js route `/scene/[slug]` that fetches `/api/scenes/[slug]` and loads via the existing `applySceneGraphToEditor` utility.
- Delete `window.__pascalScene` injection from `apps/editor/app/page.tsx`.
- **Effort: 12 days. No new deps. No breaking changes.**
### Weeks 24 — Option B: Supabase backend (the product path)
```
MCP ──► Supabase (SERVICE_ROLE) ──► scenes table
Editor /scene/[id] ──► Supabase (ANON_KEY + RLS) ──► scenes row
```
Schema:
```sql
create table scenes (
id uuid primary key default gen_random_uuid(),
project_id uuid references projects(id),
owner_id uuid references auth.users(id),
name text not null,
graph_json jsonb not null,
thumbnail_url text,
version int not null default 1,
public boolean not null default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
create table scene_revisions (
scene_id uuid references scenes(id) on delete cascade,
version int,
graph_json jsonb,
author_kind text check (author_kind in ('human','mcp','agent')),
created_at timestamptz default now(),
primary key (scene_id, version)
);
```
- MCP side: ~200 LOC. `save_scene``supabase.from('scenes').upsert({...})`.
- Editor side: `/scene/[id]/page.tsx` is a Server Component that fetches the row and passes to a client `<SceneLoader>`.
- RLS: owner reads/writes; `public = true` rows readable by anyone.
- **Effort: ~2 weeks.**
### Quarter 2 — Option D: live mode via Supabase Realtime
Supabase's `postgres_changes` channel over the `scenes` row gives you cross-agent realtime without Yjs. A human editing at the same time as an MCP agent would see each other's changes. This is **not** an MCP protocol feature — it's a Postgres feature that Supabase exposes. Much cheaper than rebuilding on Yjs (Option E).
### What about Yjs / CRDT (Option E)?
Defer. It's a 3-month rewrite of `@pascal-app/core`'s store. Only justified if multiplayer is the moat, which the current product signal doesn't demonstrate. If you DO go there later, the migration is painless because `applySceneGraphToEditor` still accepts a `SceneGraph` — you'd just rewrite the store underneath.
## Edge cases R10 surfaced that you hadn't mentioned
1. **"Auto-frame camera on MCP-opened scene"** — today the MCP creates a scene at world origin, the default editor camera points at 30m grid, user sees a black screen. Tiny fix, huge UX win.
2. **MCP-written scenes can carry malicious URLs**`guide.url`, `scan.url`, `material.texture.url`, `item.asset.src` are `z.string()` in core (no scheme allowlist). A scene opened in the editor can beacon home. **This is the same finding as the Phase 3 security audit, not remediated.**
3. **Overwrite vs merge when MCP edits a scene the user is also editing** — today: last-writer-wins silently. Needs ETag or revision-number optimistic locking.
4. **Scene size limit** — Supabase API has an 8MB body limit. Casa del Sol is 27 KB; a real project might go to 12 MB. Measure before you commit.
5. **Undo-stack surprise** — an MCP multi-op patch collapses to ONE undo step. From the user's view, Ctrl+Z wipes the whole MCP run. Might be surprising. Document or segment.
6. **`metadata: json` on every node is AI-visible** — an attacker-crafted `metadata.note: "ignore all instructions and..."` could prompt-inject a summarising agent.
7. **MCP and editor use separate Zundo temporal stores** — undo in one doesn't reach the other.
8. **`SiteNode.children` holds objects not ids** — shipping the scene across the wire requires handling this inconsistency (already workaround-ed in MCP; would re-emerge on the Supabase side).
9. **Offline editor opening a cloud-only scene** — degrade to cached/read-only, don't crash.
10. **Agent writes infinite scenes in a loop** — quota + rate limits per user.
## Ideas you didn't ask for but should consider (R10's top 10 by value×feasibility)
1. **Photo → Pascal** — MCP already has `analyze_floorplan_image`. The unblocker is: a scene UI "Upload floor plan" → vision tool → new scene. Highest ROI in the repo.
2. **Scene templates catalogue** — doubles as marketplace seed inventory.
3. **Auto-framing camera** — fixes "black screen" failure mode.
4. **Prompt → Pascal one-shot studio** — the canonical "MCP creates scene" hosted flow.
5. **Multi-variant generation** — "give me 5 variations" using `forkSceneGraph` (already in core).
6. **Scene diff view** — makes AI actions reviewable.
7. **BOM + cost synthesis** — turns toy scenes into quoteable artefacts.
8. **Scene branches & forks** — prerequisite for co-design workflows.
9. **GLB export via headless renderer** — unlocks AR + USD/IFC pipelines.
10. **Regulatory / accessibility linting** — B2B architect segment.
## Production readiness (R9)
**~45 weeks to private beta. ~1014 weeks to GA.** Dominated by:
- Greenfield auth + persistence (Supabase rails declared but zero code).
- URL hardening migration on core node schemas (close the security audit's Phase 3 gap properly, not just in MCP).
- Optimistic concurrency / revision tracking.
- ETag-based merge UX.
Phase A (transport hardening + URL validation) is decoupled and can ship week 1 as a defensive floor.
## What you should tell me next
Answer these and I can write the implementation PR:
1. **Deployment target** — local-only (Option A sufficient), or Vercel + multi-user (must do B)?
2. **Auth direction** — Supabase Auth, Better Auth (env var is there), Clerk, or none yet?
3. **Scope for v0.1** — just "save + open" with one hardcoded user, OR proper multi-tenant + sharing from day one?
4. **Scene-list UI location** — inside the Editor package (add a new panel), inside the host app (`apps/editor`), or both?
5. **What do I do with the current `feat/mcp-server` branch?** — merge as is (MCP server + test-reports), or fold this new work into the same branch, or open a new `feat/mcp-persistence` branch?
My recommendation in one line: **answer 1 = Vercel/multi-user → ship Option A as a branch-local step this week, then B over weeks 24, and merge the whole thing as `feat/mcp-cloud-scenes`**.
## Report index
- [R1 — Persistence layer](./R1-persistence.md) — localStorage-only, single key, no backend
- [R2 — `projectId` semantics & Editor API](./R2-project-id.md) — Editor is backend-agnostic via `onLoad`/`onSave`
- [R3 — Scene management UI](./R3-scene-ui.md) — 40% there; needs scene list + palette commands
- [R4 — Routing & URLs](./R4-routing.md) — zero dynamic routes; latent expectation of `/editor/<projectId>/…`
- [R5 — Backend / Supabase](./R5-backend.md) — env declared, zero code
- [R6 — File I/O pathways](./R6-file-io.md) — "Save/Load Build" work; no Zod validation on import
- [R7 — `@pascal-app/editor` API](./R7-editor-api.md) — rich callback surface; scene switcher is a 12 day host feature
- [R8 — Integration design options](./R8-mcp-integration-design.md) — A→B→D phased recommendation
- [R9 — Production readiness](./R9-production-readiness.md) — 45 weeks to beta, 1014 to GA
- [R10 — Ideas and edge cases](./R10-ideas-and-edges.md) — 300+ lines; top 10 ranked by value×feasibility
@@ -1,234 +0,0 @@
# T1 stdio MCP test report
Generated: 2026-04-18T16:04:24.979Z
## Summary
- Tools listed: **21/21** OK
- Tools exercised: **21**
- Passed: **21/21**
- Failed: **0/21**
- Total run time: **106 ms**
- Transport: stdio (`bun packages/mcp/dist/bin/pascal-mcp.js --stdio`)
## Pass/fail matrix
| # | Tool | Status | Summary |
|---|------|--------|---------|
| 1 | `get_scene` | PASS | 3 nodes, 1 roots |
| 2 | `get_node` | PASS | node type=site, id=site_71e14qucq8msx6w7 |
| 3 | `describe_node` | PASS | type=site, 1 children |
| 4 | `find_nodes` | PASS | 1 level node(s) |
| 5 | `measure` | PASS | distance=0.000m |
| 6 | `apply_patch` | PASS | applied=1, created=1 |
| 7 | `create_level` | PASS | levelId=level_fkcj2m1n3vq4xfx6 |
| 8 | `create_wall` | PASS | wallId=wall_iznvk1lp5u2zb77v |
| 9 | `place_item` | PASS | status: catalog_unavailable |
| 10 | `cut_opening` | PASS | openingId=door_72wicnv8i6c0pqru |
| 11 | `set_zone` | PASS | zoneId=zone_p1ek0k35wz93mdqo |
| 12 | `duplicate_level` | PASS | newLevelId=level_4kpvf7vxyok3l7v2, 6 nodes |
| 13 | `delete_node` | PASS | deleted 6 node(s) |
| 14 | `undo` | PASS | undone=1 |
| 15 | `redo` | PASS | redone=1 |
| 16 | `export_json` | PASS | 5678 chars JSON |
| 17 | `export_glb` | PASS | status: not_implemented |
| 18 | `validate_scene` | PASS | valid=true, errors=0 |
| 19 | `check_collisions` | PASS | 0 collision(s) |
| 20 | `analyze_floorplan_image` | PASS | expected status: sampling_unavailable |
| 21 | `analyze_room_photo` | PASS | expected status: sampling_unavailable |
## Detail per tool
### 1. `get_scene` — PASS
Summary: 3 nodes, 1 roots
```json
{"nodes":{"site_71e14qucq8msx6w7":{"object":"node","id":"site_71e14qucq8msx6w7","type":"site","parentId":null,"visible":true,"metadata":{},"polygon":{"type":"polygon","points":[[-15,-15],[15,-15],[15,15],[-15,15]]},"children":[{"object":"node","id":"building_gyseslm2yvanyqkc","type":"building","parentId":null,"visible"
```
### 2. `get_node` — PASS
Summary: node type=site, id=site_71e14qucq8msx6w7
```json
{"node":{"object":"node","id":"site_71e14qucq8msx6w7","type":"site","parentId":null,"visible":true,"metadata":{},"polygon":{"type":"polygon","points":[[-15,-15],[15,-15],[15,15],[-15,15]]},"children":[{"object":"node","id":"building_gyseslm2yvanyqkc","type":"building","parentId":null,"visible":true,"metadata":{},"child
```
### 3. `describe_node` — PASS
Summary: type=site, 1 children
```json
{"id":"site_71e14qucq8msx6w7","type":"site","parentId":null,"ancestryIds":[],"childrenIds":["building_gyseslm2yvanyqkc"],"properties":{"object":"node","id":"site_71e14qucq8msx6w7","type":"site","parentId":null,"visible":true,"metadata":{},"polygon":{"type":"polygon","points":[[-15,-15],[15,-15],[15,15],[-15,15]]},"chil
```
### 4. `find_nodes` — PASS
Summary: 1 level node(s)
```json
{"nodes":[{"object":"node","id":"level_7somiy6h3is3wqw8","type":"level","parentId":null,"visible":true,"metadata":{},"children":[],"level":0}]}
```
### 5. `measure` — PASS
Summary: distance=0.000m
```json
{"distanceMeters":0,"units":"meters"}
```
### 6. `apply_patch` — PASS
Summary: applied=1, created=1
```json
{"appliedOps":1,"deletedIds":[],"createdIds":["wall_t1patch_1776528264967"]}
```
### 7. `create_level` — PASS
Summary: levelId=level_fkcj2m1n3vq4xfx6
```json
{"levelId":"level_fkcj2m1n3vq4xfx6"}
```
### 8. `create_wall` — PASS
Summary: wallId=wall_iznvk1lp5u2zb77v
```json
{"wallId":"wall_iznvk1lp5u2zb77v"}
```
### 9. `place_item` — PASS
Summary: status: catalog_unavailable
```json
{"itemId":"item_g971o8cwvpzw0qhx","status":"catalog_unavailable"}
```
### 10. `cut_opening` — PASS
Summary: openingId=door_72wicnv8i6c0pqru
```json
{"openingId":"door_72wicnv8i6c0pqru"}
```
### 11. `set_zone` — PASS
Summary: zoneId=zone_p1ek0k35wz93mdqo
```json
{"zoneId":"zone_p1ek0k35wz93mdqo"}
```
### 12. `duplicate_level` — PASS
Summary: newLevelId=level_4kpvf7vxyok3l7v2, 6 nodes
```json
{"newLevelId":"level_4kpvf7vxyok3l7v2","newNodeIds":["level_4kpvf7vxyok3l7v2","wall_i4d5be8v8vni4m7a","wall_34mhnuwozzzxoq2h","item_l62wjnjhmvy7fo17","door_cinkqu1h3rsmva82","zone_fpykyioy7rrzq3es"]}
```
### 13. `delete_node` — PASS
Summary: deleted 6 node(s)
```json
{"deletedIds":["level_4kpvf7vxyok3l7v2","wall_i4d5be8v8vni4m7a","wall_34mhnuwozzzxoq2h","item_l62wjnjhmvy7fo17","door_cinkqu1h3rsmva82","zone_fpykyioy7rrzq3es"]}
```
### 14. `undo` — PASS
Summary: undone=1
```json
{"undone":1}
```
### 15. `redo` — PASS
Summary: redone=1
```json
{"redone":1}
```
### 16. `export_json` — PASS
Summary: 5678 chars JSON
```json
{"json":"{\n \"nodes\": {\n \"site_71e14qucq8msx6w7\": {\n \"object\": \"node\",\n \"id\": \"site_71e14qucq8msx6w7\",\n \"type\": \"site\",\n \"parentId\": null,\n \"visible\": true,\n \"metadata\": {},\n \"polygon\": {\n \"type\": \"polygon\",\n \"points\": [\n
```
### 17. `export_glb` — PASS
Summary: status: not_implemented
```json
{"status":"not_implemented","reason":"GLB export requires the Three.js renderer, which is browser-only"}
```
### 18. `validate_scene` — PASS
Summary: valid=true, errors=0
```json
{"valid":true,"errors":[]}
```
### 19. `check_collisions` — PASS
Summary: 0 collision(s)
```json
{"collisions":[]}
```
### 20. `analyze_floorplan_image` — PASS
Summary: expected status: sampling_unavailable
```json
"MCP error -32600: sampling_unavailable"
```
### 21. `analyze_room_photo` — PASS
Summary: expected status: sampling_unavailable
```json
"MCP error -32600: sampling_unavailable"
```
## Tools listed by server
```
analyze_floorplan_image
analyze_room_photo
apply_patch
check_collisions
create_level
create_wall
cut_opening
delete_node
describe_node
duplicate_level
export_glb
export_json
find_nodes
get_node
get_scene
measure
place_item
redo
set_zone
undo
validate_scene
```
@@ -1,32 +0,0 @@
[pascal-mcp] stdio server running
[t1] listTools → 21 tools (expected 21) OK
[t1] tool names: analyze_floorplan_image, analyze_room_photo, apply_patch, check_collisions, create_level, create_wall, cut_opening, delete_node, describe_node, duplicate_level, export_glb, export_json, find_nodes, get_node, get_scene, measure, place_item, redo, set_zone, undo, validate_scene
✅ get_scene (3 nodes, 1 roots)
[t1] discovered: site=site_71e14qucq8msx6w7 building=building_gyseslm2yvanyqkc level=level_7somiy6h3is3wqw8
✅ get_node (node type=site, id=site_71e14qucq8msx6w7)
✅ describe_node (type=site, 1 children)
✅ find_nodes (1 level node(s))
[t1] groundLevelId=level_7somiy6h3is3wqw8
✅ measure (distance=0.000m)
✅ apply_patch (applied=1, created=1)
✅ create_level (levelId=level_fkcj2m1n3vq4xfx6)
✅ create_wall (wallId=wall_iznvk1lp5u2zb77v)
✅ place_item (status: catalog_unavailable)
✅ cut_opening (openingId=door_72wicnv8i6c0pqru)
✅ set_zone (zoneId=zone_p1ek0k35wz93mdqo)
✅ duplicate_level (newLevelId=level_4kpvf7vxyok3l7v2, 6 nodes)
✅ delete_node (deleted 6 node(s))
✅ undo (undone=1)
✅ redo (redone=1)
✅ export_json (5678 chars JSON)
✅ export_glb (status: not_implemented)
✅ validate_scene (valid=true, errors=0)
✅ check_collisions (0 collision(s))
✅ analyze_floorplan_image (expected status: sampling_unavailable)
✅ analyze_room_photo (expected status: sampling_unavailable)
[t1] tools listed: 21/21
[t1] passed: 21/21
[t1] failed: 0/21
[t1] total time: 106ms
[t1] report written: /Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t1-stdio/REPORT.md
-620
View File
@@ -1,620 +0,0 @@
/**
* T1 stdio test runner: exercises every MCP tool against the live stdio
* transport with REAL happy-path arguments and writes a pass/fail matrix.
*
* Run with: bun packages/mcp/test-reports/t1-stdio/run.ts
*/
import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'REPORT.md')
const EXPECTED_TOOL_COUNT = 21
type RowStatus = 'pass' | 'fail'
type Row = {
name: string
status: RowStatus
summary: string
detail?: string
}
const rows: Row[] = []
function shortJson(value: unknown, max = 160): string {
let text: string
try {
text = JSON.stringify(value)
} catch {
text = String(value)
}
if (text.length <= max) return text
return `${text.slice(0, max)}`
}
function pickContentText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
const first = content[0]
if (first && typeof first === 'object' && typeof first.text === 'string') {
return first.text
}
return ''
}
async function main(): Promise<void> {
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
})
const client = new Client({ name: 'pascal-mcp-t1', version: '0.0.0' })
const t0 = Date.now()
await client.connect(transport)
// 0. listTools assertion
const listed = await client.listTools()
const toolNames = listed.tools.map((t) => t.name).sort()
const listOk = listed.tools.length === EXPECTED_TOOL_COUNT
console.log(
`[t1] listTools → ${listed.tools.length} tools (expected ${EXPECTED_TOOL_COUNT}) ${listOk ? 'OK' : 'MISMATCH'}`,
)
console.log(`[t1] tool names: ${toolNames.join(', ')}`)
// Helper to run a tool and record a row.
async function run(
name: string,
args: Record<string, unknown>,
opts: { expectStatus?: string; describe?: (r: any) => string } = {},
): Promise<any> {
try {
const result = (await client.callTool({ name, arguments: args })) as any
const text = pickContentText(result)
let parsedText: any = null
if (text) {
try {
parsedText = JSON.parse(text)
} catch {
// not all tools emit pure JSON; ignore parse failures
}
}
// Detect structured "expected" status fields.
const status =
(parsedText && typeof parsedText === 'object' && parsedText.status) ||
(result.structuredContent &&
typeof result.structuredContent === 'object' &&
(result.structuredContent as any).status) ||
null
if (result.isError) {
// If the host expects a specific status string in the error body, accept it.
if (opts.expectStatus && text.includes(opts.expectStatus)) {
rows.push({
name,
status: 'pass',
summary: `expected status: ${opts.expectStatus}`,
detail: shortJson(text, 220),
})
console.log(`${name} (expected status: ${opts.expectStatus})`)
return result
}
rows.push({
name,
status: 'fail',
summary: 'isError true',
detail: shortJson(text, 240),
})
console.log(`${name} (${shortJson(text, 160)})`)
return result
}
// Non-error path. Recognise structured `not_implemented` /
// `catalog_unavailable` as expected pass-with-status.
if (status && (status === 'not_implemented' || status === 'catalog_unavailable')) {
rows.push({
name,
status: 'pass',
summary: `status: ${status}`,
detail: shortJson(parsedText ?? result.structuredContent, 240),
})
console.log(`${name} (status: ${status})`)
return result
}
const summary = opts.describe
? opts.describe(result)
: shortJson(result.structuredContent ?? parsedText ?? text, 160)
rows.push({
name,
status: 'pass',
summary,
detail: shortJson(result.structuredContent ?? parsedText ?? text, 320),
})
console.log(`${name} (${summary})`)
return result
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Some tools throw with a structured body. Accept matching expected status.
if (opts.expectStatus && msg.includes(opts.expectStatus)) {
rows.push({
name,
status: 'pass',
summary: `expected throw: ${opts.expectStatus}`,
detail: msg,
})
console.log(`${name} (expected throw: ${opts.expectStatus})`)
return null
}
rows.push({
name,
status: 'fail',
summary: 'threw',
detail: msg,
})
console.log(`${name} (threw: ${msg})`)
return null
}
}
// ---- 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`
},
},
)
// Discover key node ids from the scene snapshot.
const sceneNodes: Record<string, any> = (sceneResult?.structuredContent as any)?.nodes ?? {}
const sceneRoots: string[] = (sceneResult?.structuredContent as any)?.rootNodeIds ?? []
const findFirst = (type: string): any | null => {
for (const n of Object.values(sceneNodes)) {
if ((n as any).type === type) return n as any
}
return null
}
const siteNode = findFirst('site') ?? (sceneRoots[0] ? sceneNodes[sceneRoots[0]] : null)
const buildingNode = findFirst('building')
const levelNode = findFirst('level')
console.log(
`[t1] discovered: site=${siteNode?.id} building=${buildingNode?.id} level=${levelNode?.id}`,
)
// ---- 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}`
},
},
)
// ---- 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`
},
},
)
// ---- 4. find_nodes -----------------------------------------------------
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
console.log(`[t1] groundLevelId=${groundLevelId}`)
// ---- 5. measure --------------------------------------------------------
// Find any two centre-bearing nodes (building + site work).
let measureFromId: string | undefined
let measureToId: string | undefined
for (const n of Object.values(sceneNodes)) {
const t = (n as any).type
if (
t === 'wall' ||
t === 'fence' ||
t === 'item' ||
t === 'door' ||
t === 'window' ||
t === 'building' ||
t === 'stair' ||
t === 'roof' ||
t === 'slab' ||
t === 'ceiling' ||
t === 'zone' ||
t === 'site'
) {
if (!measureFromId) measureFromId = (n as any).id
else if (!measureToId) {
measureToId = (n as any).id
break
}
}
}
// If only one was found, fall back to self-measurement on a polygon node.
if (measureFromId && !measureToId) measureToId = measureFromId
await run(
'measure',
{ fromId: measureFromId ?? '', toId: measureToId ?? '' },
{
describe: (r) => {
const s = r.structuredContent as any
return `distance=${s?.distanceMeters?.toFixed?.(3) ?? s?.distanceMeters}m${
s?.areaSqMeters !== undefined ? ` area=${s.areaSqMeters.toFixed?.(2)}` : ''
}`
},
},
)
// ---- 6. apply_patch — create a wall ------------------------------------
// Use a minimal valid wall payload; the bridge will Zod-parse it. The schema
// requires id/type but ItemNode/WallNode etc fill defaults. We construct the
// canonical raw object the schema would accept after parse — id is filled
// via objectId('wall')'s default when omitted.
const patchWallId = `wall_t1patch_${Date.now()}`
await run(
'apply_patch',
{
patches: [
{
op: 'create',
node: {
id: patchWallId,
type: 'wall',
children: [],
start: [0, 0],
end: [3, 0],
thickness: 0.1,
height: 2.5,
frontSide: 'unknown',
backSide: 'unknown',
},
parentId: groundLevelId,
},
],
},
{
describe: (r) => {
const s = r.structuredContent as any
return `applied=${s?.appliedOps}, created=${s?.createdIds?.length}`
},
},
)
// ---- 7. create_level ---------------------------------------------------
let createdLevelId: string | undefined
if (buildingNode?.id) {
const cl = await run(
'create_level',
{ buildingId: buildingNode.id, elevation: 1, height: 3 },
{
describe: (r) => `levelId=${(r.structuredContent as any)?.levelId}`,
},
)
createdLevelId = (cl?.structuredContent as any)?.levelId
} else {
rows.push({
name: 'create_level',
status: 'fail',
summary: 'no building in scene',
})
console.log('❌ create_level (no building in scene)')
}
// ---- 8. create_wall ----------------------------------------------------
let createdWallId: string | undefined
if (groundLevelId) {
const cw = await run(
'create_wall',
{
levelId: groundLevelId,
start: [0, 0],
end: [4, 0],
thickness: 0.12,
height: 2.6,
},
{
describe: (r) => `wallId=${(r.structuredContent as any)?.wallId}`,
},
)
createdWallId = (cw?.structuredContent as any)?.wallId
} else {
rows.push({
name: 'create_wall',
status: 'fail',
summary: 'no level',
})
console.log('❌ create_wall (no level)')
}
// ---- 9. place_item -----------------------------------------------------
// place_item requires target type wall|ceiling|site. Use the wall we just
// made; falls back to site if not available.
const placeTargetId = createdWallId ?? siteNode?.id
await run(
'place_item',
{
catalogItemId: 'test-chair',
targetNodeId: placeTargetId ?? '',
position: [1, 0, 1],
},
{
describe: (r) => {
const s = r.structuredContent as any
return `itemId=${s?.itemId}${s?.status ? ` status=${s.status}` : ''}`
},
},
)
// ---- 10. cut_opening ---------------------------------------------------
if (createdWallId) {
await run(
'cut_opening',
{
wallId: createdWallId,
type: 'door',
position: 0.5,
width: 0.9,
height: 2.1,
},
{
describe: (r) => `openingId=${(r.structuredContent as any)?.openingId}`,
},
)
} else {
rows.push({
name: 'cut_opening',
status: 'fail',
summary: 'no wall created earlier',
})
console.log('❌ cut_opening (no wall created earlier)')
}
// ---- 11. set_zone ------------------------------------------------------
if (groundLevelId) {
await run(
'set_zone',
{
levelId: groundLevelId,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
label: 'living room',
},
{
describe: (r) => `zoneId=${(r.structuredContent as any)?.zoneId}`,
},
)
} else {
rows.push({
name: 'set_zone',
status: 'fail',
summary: 'no level',
})
console.log('❌ set_zone (no level)')
}
// ---- 12. duplicate_level -----------------------------------------------
let duplicatedLevelId: string | undefined
if (groundLevelId) {
const dl = await run(
'duplicate_level',
{ levelId: groundLevelId },
{
describe: (r) => {
const s = r.structuredContent as any
return `newLevelId=${s?.newLevelId}, ${s?.newNodeIds?.length} nodes`
},
},
)
duplicatedLevelId = (dl?.structuredContent as any)?.newLevelId
} else {
rows.push({
name: 'duplicate_level',
status: 'fail',
summary: 'no level',
})
console.log('❌ duplicate_level (no level)')
}
// ---- 13. delete_node ---------------------------------------------------
if (duplicatedLevelId) {
await run(
'delete_node',
{ id: duplicatedLevelId, cascade: true },
{
describe: (r) => {
const s = r.structuredContent as any
return `deleted ${s?.deletedIds?.length} node(s)`
},
},
)
} else {
rows.push({
name: 'delete_node',
status: 'fail',
summary: 'no duplicated level to delete',
})
console.log('❌ delete_node (no duplicated level to delete)')
}
// ---- 14. undo ----------------------------------------------------------
await run(
'undo',
{},
{
describe: (r) => `undone=${(r.structuredContent as any)?.undone}`,
},
)
// ---- 15. redo ----------------------------------------------------------
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`
},
},
)
// ---- 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}`
},
},
)
// ---- 19. check_collisions ----------------------------------------------
await run(
'check_collisions',
{},
{
describe: (r) => `${(r.structuredContent as any)?.collisions?.length ?? 0} collision(s)`,
},
)
// ---- 20. analyze_floorplan_image — expected sampling_unavailable -------
await run(
'analyze_floorplan_image',
{ image: 'https://example.com/nonexistent.png' },
{ expectStatus: 'sampling_unavailable' },
)
// ---- 21. analyze_room_photo — expected sampling_unavailable ------------
await run(
'analyze_room_photo',
{ image: 'https://example.com/nonexistent.png' },
{ expectStatus: 'sampling_unavailable' },
)
const elapsedMs = Date.now() - t0
await client.close()
// Summary
const passed = rows.filter((r) => r.status === 'pass').length
const failed = rows.filter((r) => r.status === 'fail').length
const total = rows.length
console.log(`\n[t1] tools listed: ${listed.tools.length}/${EXPECTED_TOOL_COUNT}`)
console.log(`[t1] passed: ${passed}/${total}`)
console.log(`[t1] failed: ${failed}/${total}`)
console.log(`[t1] total time: ${elapsedMs}ms`)
// Write the markdown report.
const ts = new Date().toISOString()
const lines: string[] = []
lines.push('# T1 stdio MCP test report')
lines.push('')
lines.push(`Generated: ${ts}`)
lines.push('')
lines.push('## Summary')
lines.push('')
lines.push(
`- Tools listed: **${listed.tools.length}/${EXPECTED_TOOL_COUNT}** ${listOk ? 'OK' : 'MISMATCH'}`,
)
lines.push(`- Tools exercised: **${total}**`)
lines.push(`- Passed: **${passed}/${total}**`)
lines.push(`- Failed: **${failed}/${total}**`)
lines.push(`- Total run time: **${elapsedMs} ms**`)
lines.push(`- Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`)`)
lines.push('')
lines.push('## Pass/fail matrix')
lines.push('')
lines.push('| # | Tool | Status | Summary |')
lines.push('|---|------|--------|---------|')
rows.forEach((row, i) => {
const sym = row.status === 'pass' ? 'PASS' : 'FAIL'
const safeSummary = row.summary.replace(/\|/g, '\\|')
lines.push(`| ${i + 1} | \`${row.name}\` | ${sym} | ${safeSummary} |`)
})
lines.push('')
lines.push('## Detail per tool')
lines.push('')
rows.forEach((row, i) => {
lines.push(`### ${i + 1}. \`${row.name}\`${row.status.toUpperCase()}`)
lines.push('')
lines.push(`Summary: ${row.summary}`)
if (row.detail) {
lines.push('')
lines.push('```json')
lines.push(row.detail)
lines.push('```')
}
lines.push('')
})
lines.push('## Tools listed by server')
lines.push('')
lines.push('```')
lines.push(toolNames.join('\n'))
lines.push('```')
lines.push('')
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
console.log(`[t1] report written: ${REPORT_PATH}`)
if (failed > 0) {
process.exitCode = 1
}
}
main().catch((err) => {
console.error('[t1] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(2)
})
@@ -1,84 +0,0 @@
# T2 MCP HTTP transport report
Generated: 2026-04-18T16:16:28.651Z
Target: http://localhost:3917/mcp
Transport: Streamable HTTP (single-session stateful)
## Summary
- Tools exercised: 21
- Passes: 0/21
- Expected tool count (21) on first listTools: (got 0)
- Session state stable across two listTools() calls: n/a
- Session A connected: false — error: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
- Session B connected: no
- Two clients got distinct session IDs: n/a
- Session B listTools count: n/a
- Shared SceneBridge observation: n/a (could not connect)
## Latency (get_scene × 0 on session A)
| Metric | ms |
|--------|----|
| p50 | 0.0 |
| p99 | 0.0 |
| mean | 0.0 |
| min | 0.0 |
| max | 0.0 |
No latency samples were captured (could not connect).
## Pass/Fail matrix
| Tool | Status | Latency (ms) | Note |
|------|--------|--------------|------|
| get_scene | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| get_node | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| describe_node | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| find_nodes | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| measure | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| apply_patch | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| create_level | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| create_wall | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| place_item | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| cut_opening | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| set_zone | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| duplicate_level | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| delete_node | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| undo | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| redo | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| export_json | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| export_glb | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| validate_scene | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| check_collisions | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| analyze_floorplan_image | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
| analyze_room_photo | FAIL | | connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null} |
## Server state probes
Before the SDK-based test run, these HTTP probes were executed:
- POST initialize (no session) → 200: `event: message
data: {"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"pasca`
- POST tools/list (no session) → 400: `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}`
- GET /mcp (no session) → 400: `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}`
- DELETE /mcp (no session) → 400: `{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}`
## HTTP-specific quirks
- `packages/mcp/src/transports/http.ts` uses a single
`StreamableHTTPServerTransport` per process with stateful session-id
generation. The SDK's transport sets `_initialized=true` on the first
valid `initialize` POST and never clears it. Consequence: the running
server can only ever accept **one** session for its lifetime; subsequent
`initialize` requests receive HTTP 400 `{"code":-32600,"message":"Invalid Request: Server already initialized"}`.
- Because both sessions (when connect succeeds) share the same
`SceneBridge` singleton, any mutation made on one session is visible to
the other. This is expected given the server holds one bridge process-wide.
- `not_implemented`, `catalog_unavailable`, and `sampling_unavailable`
responses are treated as passes per the agreed test protocol.
## Notes
- Server was in a clean state and accepted both sessions.
-37
View File
@@ -1,37 +0,0 @@
=== T2 MCP HTTP transport smoke test ===
Target: http://localhost:3917/mcp
--- Server state probe ---
POST initialize (no session) → 200 body: event: message
data: {"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"pasca
POST tools/list (no session) → 400 body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}
GET /mcp (no session) → 400 body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}
DELETE /mcp (no session) → 400 body: {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Mcp-Session-Id header is required"},"id":null}
--- Session A connect ---
Session A connect FAILED: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] get_scene — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] get_node — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] describe_node — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] find_nodes — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] measure — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] apply_patch — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] create_level — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] create_wall — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] place_item — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] cut_opening — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] set_zone — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] duplicate_level — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] delete_node — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] undo — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] redo — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] export_json — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] export_glb — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] validate_scene — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] check_collisions — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] analyze_floorplan_image — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
[FAIL] analyze_room_photo — connect failed: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"id":null}
=== SUMMARY ===
Passes: 0/21
Server appears locked to an earlier session: false
-936
View File
@@ -1,936 +0,0 @@
/**
* T2: Exercise every MCP tool over the HTTP transport.
*
* Target: http://localhost:3917/mcp (already running via `bun packages/mcp/dist/bin/pascal-mcp.js --http --port 3917`).
*
* Emits a pass/fail matrix plus latency percentiles for get_scene,
* and reports the behaviour of two concurrent sessions sharing the
* SceneBridge singleton.
*
* IMPORTANT: `connectHttp` in `packages/mcp/src/transports/http.ts`
* instantiates a SINGLE `StreamableHTTPServerTransport` with stateful
* session-id generation. The SDK's server transport sets `_initialized=true`
* on the first valid `initialize` POST and never clears it — meaning only
* ONE session is ever accepted for the lifetime of the process. If any prior
* client initialized, new clients receive:
*
* 400 {"error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}
*
* We detect this state, report it as an HTTP-specific finding, and emit a
* best-effort report.
*/
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'
type ToolResult = {
name: string
pass: boolean
note: string
latencyMs?: number
rawError?: string
}
const results: ToolResult[] = []
function record(
name: string,
pass: boolean,
note: string,
latencyMs?: number,
rawError?: string,
): void {
results.push({ name, pass, note, latencyMs, rawError })
const tag = pass ? 'PASS' : 'FAIL'
const lat = latencyMs !== undefined ? ` (${latencyMs.toFixed(1)}ms)` : ''
console.log(`[${tag}] ${name}${lat}${note}`)
}
/** Expected structured errors that count as passes. */
const EXPECTED_STRUCTURED_ERRORS = new Set([
'not_implemented',
'catalog_unavailable',
'sampling_unavailable',
'sampling_response_unparseable',
'sampling_response_invalid',
])
function errorIsExpected(err: unknown): { expected: boolean; label: string } {
const msg = err instanceof Error ? err.message : String(err)
for (const tok of EXPECTED_STRUCTURED_ERRORS) {
if (msg.includes(tok)) return { expected: true, label: tok }
}
return { expected: false, label: msg }
}
async function callTool<T = unknown>(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<
| { ok: true; result: unknown; latencyMs: number }
| { ok: false; error: unknown; latencyMs: number }
> {
const t0 = performance.now()
try {
const res = await client.callTool({ name, arguments: args })
const latencyMs = performance.now() - t0
const maybeIsError = (res as { isError?: boolean }).isError
if (maybeIsError === true) {
const text = Array.isArray(res.content)
? res.content
.filter((c) => (c as { type?: string }).type === 'text')
.map((c) => (c as { text: string }).text)
.join('\n')
: ''
return { ok: false, error: new Error(text || 'isError=true'), latencyMs }
}
return { ok: true, result: res, latencyMs }
} catch (err) {
const latencyMs = performance.now() - t0
return { ok: false, error: err, latencyMs }
}
}
function getStructured<T>(
result:
| { ok: true; result: unknown; latencyMs: number }
| { ok: false; error: unknown; latencyMs: number },
): T | null {
if (!result.ok) return null
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
if (textBlock?.text) {
try {
return JSON.parse(textBlock.text) as T
} catch {
return null
}
}
}
return null
}
async function connectClient(
label: string,
): Promise<{ client: Client; sessionId?: string } | { error: Error }> {
const transport = new StreamableHTTPClientTransport(new URL(TARGET_URL))
const client = new Client({ name: `t2-http-${label}`, version: '0.0.1' })
try {
await client.connect(transport)
} catch (err) {
return { error: err instanceof Error ? err : new Error(String(err)) }
}
const sid = (transport as unknown as { sessionId?: string }).sessionId
return { client, sessionId: sid }
}
/** Small curl-equivalent probe used to characterise server state. */
async function probeServer(): Promise<{ probed: string; status: number; body: string }[]> {
const probes: { name: string; init: RequestInit }[] = [
{
name: 'POST initialize (no session)',
init: {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'probe-init',
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 't2-probe', version: '0.0.1' },
},
}),
},
},
{
name: 'POST tools/list (no session)',
init: {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
},
},
{
name: 'GET /mcp (no session)',
init: { method: 'GET', headers: { accept: 'text/event-stream' } },
},
{
name: 'DELETE /mcp (no session)',
init: { method: 'DELETE' },
},
]
const out: { probed: string; status: number; body: string }[] = []
for (const p of probes) {
try {
const res = await fetch(TARGET_URL, p.init)
const text = await res.text()
out.push({ probed: p.name, status: res.status, body: text.slice(0, 200) })
} catch (err) {
out.push({
probed: p.name,
status: -1,
body: err instanceof Error ? err.message : String(err),
})
}
}
return out
}
// ---- Main ----------------------------------------------------------------
async function main() {
console.log('=== T2 MCP HTTP transport smoke test ===')
console.log(`Target: ${TARGET_URL}`)
console.log('')
// ---- Server state probe ---------------------------------------------
console.log('--- Server state probe ---')
const probes = await probeServer()
for (const p of probes) {
console.log(` ${p.probed}${p.status} body: ${p.body}`)
}
console.log('')
const serverIsLocked = probes.some(
(p) =>
p.probed === 'POST initialize (no session)' &&
p.status === 400 &&
/Server already initialized/i.test(p.body),
)
// ---- Session A --------------------------------------------------------
console.log('--- Session A connect ---')
const connA = await connectClient('A')
let clientA: Client | null = null
let sidA: string | undefined
let initErrorA: string | null = null
if ('error' in connA) {
initErrorA = connA.error.message
console.error(`Session A connect FAILED: ${initErrorA}`)
} else {
clientA = connA.client
sidA = connA.sessionId
console.log(`Session A connected (sessionId: ${sidA ?? '<unknown>'})`)
}
// If we cannot connect, there is nothing left to do but report.
if (!clientA) {
const note = serverIsLocked
? 'server locked to an earlier session (StreamableHTTPServerTransport single-session stateful mode; `_initialized=true` is sticky)'
: `connect failed: ${initErrorA}`
for (const name of ALL_TOOLS) {
record(name, false, note)
}
const report = buildReport({
connectedA: false,
sidA: null,
sidB: null,
toolCountA1: 0,
toolCountA2: 0,
toolCountB: 0,
sessionStateStable: null,
distinctSessions: null,
sharedBridgeNote: 'n/a (could not connect)',
latencies: [],
serverIsLocked,
probes,
initErrorA,
})
writeFileSync(join(OUT_DIR, 'REPORT.md'), report, 'utf8')
console.log('')
console.log('=== SUMMARY ===')
console.log(`Passes: 0/${ALL_TOOLS.length}`)
console.log(`Server appears locked to an earlier session: ${serverIsLocked}`)
return
}
const toolsListA1 = await clientA.listTools()
const toolCountA1 = toolsListA1.tools.length
console.log(`Session A listTools()#1 → ${toolCountA1} tools`)
const toolsListA2 = await clientA.listTools()
const toolCountA2 = toolsListA2.tools.length
const sessionStateStable = toolCountA1 === toolCountA2
console.log(
`Session A listTools()#2 → ${toolCountA2} tools — state stable: ${sessionStateStable}`,
)
// ---- get_scene --------------------------------------------------------
const sceneRes = await callTool(clientA, 'get_scene', {})
record(
'get_scene',
sceneRes.ok,
sceneRes.ok ? 'scene returned ok' : `error: ${String(sceneRes.error)}`,
sceneRes.latencyMs,
)
const scene = getStructured<{
nodes: Record<string, { type: string; id: string; parentId: string | null }>
rootNodeIds: string[]
}>(sceneRes)
if (!scene) {
console.error('get_scene did not return usable scene; skipping downstream tool tests.')
const reason = 'cannot proceed — get_scene returned no structured content'
for (const name of ALL_TOOLS) {
if (!results.find((r) => r.name === name)) record(name, false, reason)
}
await clientA.close()
return
}
let buildingId: string | null = null
let levelId: string | null = null
for (const n of Object.values(scene.nodes)) {
if (!buildingId && n.type === 'building') buildingId = n.id
if (!levelId && n.type === 'level') levelId = n.id
}
console.log(`Discovered: building=${buildingId} level=${levelId}`)
if (!buildingId || !levelId) {
const reason = 'default scene missing building or level'
for (const name of ALL_TOOLS) {
if (!results.find((r) => r.name === name)) record(name, false, reason)
}
await clientA.close()
return
}
// ---- get_node ---------------------------------------------------------
{
const r = await callTool(clientA, 'get_node', { id: levelId })
const struct = getStructured<{ node: { id: string; type: string } }>(r)
record(
'get_node',
r.ok && struct?.node?.id === levelId,
r.ok ? `returned node ${struct?.node?.id}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- describe_node ----------------------------------------------------
{
const r = await callTool(clientA, 'describe_node', { id: levelId })
const struct = getStructured<{ id: string; description: string }>(r)
record(
'describe_node',
r.ok && struct?.id === levelId,
r.ok ? `description: "${struct?.description}"` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- find_nodes -------------------------------------------------------
{
const r = await callTool(clientA, 'find_nodes', { type: 'level' })
const struct = getStructured<{ nodes: unknown[] }>(r)
record(
'find_nodes',
r.ok && Array.isArray(struct?.nodes),
r.ok ? `found ${struct?.nodes?.length ?? 0} level nodes` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- measure ----------------------------------------------------------
{
const r = await callTool(clientA, 'measure', { fromId: levelId, toId: levelId })
const struct = getStructured<{ distanceMeters: number; units: string }>(r)
record(
'measure',
r.ok && struct?.units === 'meters',
r.ok ? `self-distance=${struct?.distanceMeters}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- create_level -----------------------------------------------------
let extraLevelId: string | null = null
{
const r = await callTool(clientA, 'create_level', {
buildingId,
elevation: 3,
height: 2.7,
label: 'T2-test-level',
})
const struct = getStructured<{ levelId: string }>(r)
extraLevelId = struct?.levelId ?? null
record(
'create_level',
r.ok && typeof struct?.levelId === 'string',
r.ok ? `created level ${struct?.levelId}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- create_wall ------------------------------------------------------
let wallId: string | null = null
{
const r = await callTool(clientA, 'create_wall', {
levelId,
start: [0, 0],
end: [3, 0],
thickness: 0.1,
height: 2.5,
})
const struct = getStructured<{ wallId: string }>(r)
wallId = struct?.wallId ?? null
record(
'create_wall',
r.ok && typeof struct?.wallId === 'string',
r.ok ? `created wall ${struct?.wallId}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- place_item -------------------------------------------------------
{
const target = wallId
if (!target) {
record('place_item', false, 'skipped — no wall id to place against')
} else {
const r = await callTool(clientA, 'place_item', {
catalogItemId: 'test-chair',
targetNodeId: target,
position: [1.5, 0, 0],
rotation: 0,
})
const struct = getStructured<{ itemId: string; status?: string }>(r)
record(
'place_item',
r.ok && typeof struct?.itemId === 'string',
r.ok
? `placed ${struct?.itemId} (status=${struct?.status ?? 'none'})`
: `error: ${String(r.error)}`,
r.latencyMs,
)
}
}
// ---- 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,
)
}
// ---- set_zone ---------------------------------------------------------
let zoneId: string | null = null
{
const r = await callTool(clientA, 'set_zone', {
levelId,
polygon: [
[0, 0],
[5, 0],
[5, 5],
[0, 5],
],
label: 'T2-zone',
properties: { owner: 't2-http' },
})
const struct = getStructured<{ zoneId: string }>(r)
zoneId = struct?.zoneId ?? null
record(
'set_zone',
r.ok && typeof struct?.zoneId === 'string',
r.ok ? `created zone ${struct?.zoneId}` : `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,
)
}
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 (!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 -------------------------------------------------------------
{
const r = await callTool(clientA, 'undo', { steps: 1 })
const struct = getStructured<{ undone: number }>(r)
record(
'undo',
r.ok && typeof struct?.undone === 'number',
r.ok ? `undone=${struct?.undone}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- redo -------------------------------------------------------------
{
const r = await callTool(clientA, 'redo', { steps: 1 })
const struct = getStructured<{ redone: number }>(r)
record(
'redo',
r.ok && typeof struct?.redone === 'number',
r.ok ? `redone=${struct?.redone}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- export_json ------------------------------------------------------
{
const r = await callTool(clientA, 'export_json', { pretty: true })
const struct = getStructured<{ json: string }>(r)
let usable = false
try {
if (struct?.json) {
JSON.parse(struct.json)
usable = true
}
} catch {
usable = false
}
record(
'export_json',
r.ok && usable,
r.ok ? `json length=${struct?.json?.length ?? 0} chars` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- export_glb -------------------------------------------------------
{
const r = await callTool(clientA, 'export_glb', {})
if (r.ok) {
const struct = getStructured<{ status: string; reason: string }>(r)
const good = struct?.status === 'not_implemented'
record(
'export_glb',
good,
good
? `structured not_implemented (expected)`
: `unexpected payload: ${JSON.stringify(struct)}`,
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'export_glb',
info.expected,
info.expected ? `structured error ${info.label} (expected)` : `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- validate_scene ---------------------------------------------------
{
const r = await callTool(clientA, 'validate_scene', {})
const struct = getStructured<{ valid: boolean; errors: unknown[] }>(r)
record(
'validate_scene',
r.ok && typeof struct?.valid === 'boolean',
r.ok
? `valid=${struct?.valid}, errors=${struct?.errors?.length ?? 0}`
: `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- check_collisions -------------------------------------------------
{
const r = await callTool(clientA, 'check_collisions', { levelId })
const struct = getStructured<{ collisions: unknown[] }>(r)
record(
'check_collisions',
r.ok && Array.isArray(struct?.collisions),
r.ok ? `collisions=${struct?.collisions?.length ?? 0}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- analyze_floorplan_image -----------------------------------------
{
const r = await callTool(clientA, 'analyze_floorplan_image', {
image: Buffer.from('not-a-real-image').toString('base64'),
scaleHint: '1 cm = 1 m',
})
if (r.ok) {
const struct = getStructured<unknown>(r)
record(
'analyze_floorplan_image',
struct !== null,
`host responded with structured payload (sampling apparently available)`,
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'analyze_floorplan_image',
info.expected,
info.expected
? `structured error ${info.label} (expected — host lacks sampling)`
: `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- analyze_room_photo ----------------------------------------------
{
const r = await callTool(clientA, 'analyze_room_photo', {
image: Buffer.from('not-a-real-image').toString('base64'),
})
if (r.ok) {
const struct = getStructured<unknown>(r)
record(
'analyze_room_photo',
struct !== null,
'host responded with structured payload',
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'analyze_room_photo',
info.expected,
info.expected ? `structured error ${info.label} (expected)` : `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- Second concurrent session (B) ------------------------------------
console.log('')
console.log('--- Session B connect (concurrent) ---')
const connB = await connectClient('B')
let sidB: string | undefined
let toolCountB = 0
let distinctSessions: boolean | null = null
let sharedBridgeNote = 'n/a'
if ('error' in connB) {
console.error(`Session B connect failed: ${connB.error.message}`)
sharedBridgeNote = `session B could not connect — server in single-session mode: ${connB.error.message.slice(0, 160)}`
} else {
const clientB = connB.client
sidB = connB.sessionId
console.log(`Session B connected (sessionId: ${sidB ?? '<unknown>'})`)
distinctSessions = sidA !== sidB
const toolsListB = await clientB.listTools()
toolCountB = toolsListB.tools.length
console.log(
`Session B listTools() → ${toolCountB} tools; sessions distinct: ${distinctSessions}`,
)
const sceneB = await callTool(clientB, 'get_scene', {})
const sceneBstruct = getStructured<{ nodes: Record<string, unknown> }>(sceneB)
if (sceneBstruct && scene) {
const nodesA = Object.keys(scene.nodes).length
const nodesB = Object.keys(sceneBstruct.nodes).length
sharedBridgeNote = `A initial snapshot: ${nodesA} nodes; B fresh snapshot: ${nodesB} nodes (both view the same SceneBridge singleton, so mutations from A are visible to B — expected)`
} else {
sharedBridgeNote = 'get_scene on session B returned no structured content'
}
await clientB.close()
}
// ---- Latency: 20 × get_scene on session A ---------------------------
console.log('')
console.log('--- Measuring latency of get_scene × 20 on session A ---')
const latencies: number[] = []
for (let i = 0; i < 20; i++) {
const r = await callTool(clientA, 'get_scene', {})
latencies.push(r.latencyMs)
}
const sorted = [...latencies].sort((a, b) => a - b)
const percentile = (p: number): number => {
const idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx] ?? 0
}
const p50 = percentile(50)
const p99 = percentile(99)
const mean = latencies.reduce((a, b) => a + b, 0) / latencies.length
const min = sorted[0] ?? 0
const max = sorted[sorted.length - 1] ?? 0
console.log(
`Latency: p50=${p50.toFixed(1)}ms, p99=${p99.toFixed(1)}ms, mean=${mean.toFixed(1)}ms, min=${min.toFixed(1)}ms, max=${max.toFixed(1)}ms`,
)
await clientA.close()
// ---- Summary + Report -----------------------------------------------
const passes = results.filter((r) => r.pass).length
const total = results.length
console.log('')
console.log('=== SUMMARY ===')
console.log(`Tools exercised: ${total}`)
console.log(`Passes: ${passes}/${total}`)
console.log(`Session state stable across two listTools: ${sessionStateStable}`)
console.log(`Two sessions got distinct IDs: ${distinctSessions}`)
console.log(`Shared SceneBridge observation: ${sharedBridgeNote}`)
console.log(`Latency p50=${p50.toFixed(1)}ms, p99=${p99.toFixed(1)}ms`)
const report = buildReport({
connectedA: true,
sidA: sidA ?? null,
sidB: sidB ?? null,
toolCountA1,
toolCountA2,
toolCountB,
sessionStateStable,
distinctSessions,
sharedBridgeNote,
latencies,
serverIsLocked,
probes,
initErrorA,
})
writeFileSync(join(OUT_DIR, 'REPORT.md'), report, 'utf8')
console.log('')
console.log(`Wrote ${join(OUT_DIR, 'REPORT.md')}`)
}
// Always-in-order list of tools so we can fill in "not tested" rows if we
// have to abort early.
const ALL_TOOLS = [
'get_scene',
'get_node',
'describe_node',
'find_nodes',
'measure',
'apply_patch',
'create_level',
'create_wall',
'place_item',
'cut_opening',
'set_zone',
'duplicate_level',
'delete_node',
'undo',
'redo',
'export_json',
'export_glb',
'validate_scene',
'check_collisions',
'analyze_floorplan_image',
'analyze_room_photo',
] as const
function buildReport(args: {
connectedA: boolean
sidA: string | null
sidB: string | null
toolCountA1: number
toolCountA2: number
toolCountB: number
sessionStateStable: boolean | null
distinctSessions: boolean | null
sharedBridgeNote: string
latencies: number[]
serverIsLocked: boolean
probes: { probed: string; status: number; body: string }[]
initErrorA: string | null
}): string {
const {
connectedA,
sidA,
sidB,
toolCountA1,
toolCountA2,
toolCountB,
sessionStateStable,
distinctSessions,
sharedBridgeNote,
latencies,
serverIsLocked,
probes,
initErrorA,
} = args
const sorted = [...latencies].sort((a, b) => a - b)
const percentile = (p: number): number => {
if (sorted.length === 0) return 0
const idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx] ?? 0
}
const p50 = percentile(50)
const p99 = percentile(99)
const mean = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0
const min = sorted[0] ?? 0
const max = sorted[sorted.length - 1] ?? 0
const passes = results.filter((r) => r.pass).length
const total = results.length
const rows = ALL_TOOLS.map((name) => {
const r = results.find((x) => x.name === name)
if (!r) return `| ${name} | NOT_RUN | | (tool was not reached) |`
const status = r.pass ? 'PASS' : 'FAIL'
const lat = r.latencyMs !== undefined ? `${r.latencyMs.toFixed(1)}` : ''
const note = r.note.replace(/\|/g, '\\|').replace(/\n/g, ' ')
return `| ${name} | ${status} | ${lat} | ${note} |`
}).join('\n')
const probeBlock = probes
.map((p) => `- ${p.probed}${p.status}: \`${p.body.replace(/`/g, '\\`').slice(0, 200)}\``)
.join('\n')
return `# T2 MCP HTTP transport report
Generated: ${new Date().toISOString()}
Target: ${TARGET_URL}
Transport: Streamable HTTP (${serverIsLocked ? 'single-session, already claimed' : 'single-session stateful'})
## Summary
- Tools exercised: ${total}
- Passes: ${passes}/${total}
- Expected tool count (21) on first listTools: ${toolCountA1 === 21 ? 'OK' : `(got ${toolCountA1})`}
- Session state stable across two listTools() calls: ${sessionStateStable ?? 'n/a'}
- Session A connected: ${connectedA}${sidA ? ` (id=${sidA})` : ''}${initErrorA ? ` — error: ${initErrorA}` : ''}
- Session B connected: ${sidB ? `yes (id=${sidB})` : 'no'}
- Two clients got distinct session IDs: ${distinctSessions ?? 'n/a'}
- Session B listTools count: ${toolCountB || 'n/a'}
- Shared SceneBridge observation: ${sharedBridgeNote}
## Latency (get_scene × ${latencies.length} on session A)
| Metric | ms |
|--------|----|
| p50 | ${p50.toFixed(1)} |
| p99 | ${p99.toFixed(1)} |
| mean | ${mean.toFixed(1)} |
| min | ${min.toFixed(1)} |
| max | ${max.toFixed(1)} |
${
latencies.length
? `Individual samples (ms): ${latencies.map((l) => l.toFixed(1)).join(', ')}`
: 'No latency samples were captured (could not connect).'
}
## Pass/Fail matrix
| Tool | Status | Latency (ms) | Note |
|------|--------|--------------|------|
${rows}
## Server state probes
Before the SDK-based test run, these HTTP probes were executed:
${probeBlock}
## HTTP-specific quirks
- \`packages/mcp/src/transports/http.ts\` uses a single
\`StreamableHTTPServerTransport\` per process with stateful session-id
generation. The SDK's transport sets \`_initialized=true\` on the first
valid \`initialize\` POST and never clears it. Consequence: the running
server can only ever accept **one** session for its lifetime; subsequent
\`initialize\` requests receive HTTP 400 \`{"code":-32600,"message":"Invalid Request: Server already initialized"}\`.
- Because both sessions (when connect succeeds) share the same
\`SceneBridge\` singleton, any mutation made on one session is visible to
the other. This is expected given the server holds one bridge process-wide.
- \`not_implemented\`, \`catalog_unavailable\`, and \`sampling_unavailable\`
responses are treated as passes per the agreed test protocol.
## Notes
${
serverIsLocked
? '- The running server was already claimed by an earlier client before this run started; we could not open a new session. See the server-state probes above for reproducers.'
: '- Server was in a clean state and accepted both sessions.'
}
`
}
main().catch((err) => {
console.error('FATAL:', err)
try {
const partial = `# T2 MCP HTTP transport report — FATAL
Fatal error during run: ${err instanceof Error ? err.stack : String(err)}
Results so far:
${results
.map(
(r) =>
`- [${r.pass ? 'PASS' : 'FAIL'}] ${r.name}${r.note}${
r.latencyMs !== undefined ? ` (${r.latencyMs.toFixed(1)}ms)` : ''
}`,
)
.join('\n')}
`
writeFileSync(join(OUT_DIR, 'REPORT.md'), partial, 'utf8')
} catch {
// ignore
}
process.exit(1)
})
@@ -1,81 +0,0 @@
# T3 Scenario Report — 2-Bedroom Apartment
Generated: 2026-04-18T16:20:27.809Z
Transport: http (shared HTTP server)
Server URL: http://localhost:3917/mcp
## Step-by-step
### Step 1: discover OK (4ms)
- Summary: building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)
- Node IDs (2): building_bfqg91ai9ijps9ej, level_wyuoxj87czq3v0re
### Step 2: perimeter walls OK (2ms)
- Summary: created 4 perimeter walls (result.createdIds=["wall_y87bsrljd2245n51","wall_sja6jpda73tlhxwb","wall_aegff27krjwgmkmi","wall_dcymglyle9ff09ti"])
- Node IDs (4): wall_y87bsrljd2245n51, wall_sja6jpda73tlhxwb, wall_aegff27krjwgmkmi, wall_dcymglyle9ff09ti
### Step 3: interior partitions OK (1ms)
- Summary: created 7 interior walls
- Node IDs (7): wall_rl83cc5tnbf4b34j, wall_qv53jm9kvl7k6slf, wall_8y52fwzco2ep7fb1, wall_hrfixeusz7zb7x63, wall_1ullk9bm6dw15i9t, wall_c4fjjswnk0mprctm, wall_p359pffjf3qs59cy
### Step 4: set zones OK (3ms)
- Summary: created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen
- Node IDs (4): zone_3fyksm10tb0dhn1e, zone_u95l1bt35jci3gvu, zone_r9ma8tvsqt9w1zey, zone_l189q61kf9ra2m8t
### Step 5: cut openings OK (6ms)
- Summary: 3 doors, 3 windows
- Node IDs (6): door_cjzja4lt8owg88wg, door_bs7bf0azevq9vd76, door_o8etwqsemfgj5mkj, window_47x40mtv2l4ca9p4, window_n09awmg5m3ct4fvn, window_xlta3f3f0cnmbti3
### Step 6: validate scene OK (1ms)
- Summary: valid=true, errors=0
### Step 7: measure furthest zones OK (3ms)
- Summary: furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m
- Node IDs (2): zone_3fyksm10tb0dhn1e, zone_u95l1bt35jci3gvu
### Step 8: export json OK (1ms)
- Summary: exported 15392 bytes -> apartment.json
### Step 9: undo 3 steps OK (2ms)
- Summary: undone=3, nodes 24 -> 21 (delta=3)
### Step 10: redo 3 steps OK (2ms)
- Summary: redone=3, nodes 21 -> 24
### Step 11: duplicate level + validate OK (2ms)
- Summary: newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0
- Node IDs (1): level_cxvltlqvgqcasiep
### Step 12: delete duplicated level OK (3ms)
- Summary: deleted 22 nodes; nodes 46 -> 24
- Node IDs (22): level_cxvltlqvgqcasiep, wall_xhn7o9bfpmcv2znr, window_0qb4bgvzp46y412o, window_cjk6a6zwsn48dj5u, wall_hgatfahp4i53s139, wall_fp25ulcwqmsim8p5, window_jv0g8uos313ljbbe, wall_mjue0xaodm8d3vzi, wall_brw2c9vg502md7a0, wall_1at54a0mfxgq2txb, door_l8k2adas2djwpcf8, wall_6m920gigvtn113af, wall_2hazwrgv922l3t6q, door_a62eecbdzpdoqnlo, wall_11h43exay41fub7f, door_06dpidftldcoaisv, wall_u7e1gp0xbir5112y, wall_jbwbwvgt9lzw51mk, zone_hkp5wsyw7aydi175, zone_rpqx5ul6bl1tpdmd ...
## Final Counts
- Total nodes: 24
- Zones: 4
- Doors: 3
- Windows: 3
- Post-step-5 node count: 24
## Validation
- Valid: true
- Errors: 0
## Transport Notes
- Used transport: **http**
- Reason: shared HTTP server
@@ -1,488 +0,0 @@
{
"nodes": {
"site_xs1r72ib2ymzpjus": {
"object": "node",
"id": "site_xs1r72ib2ymzpjus",
"type": "site",
"parentId": null,
"visible": true,
"metadata": {},
"polygon": {
"type": "polygon",
"points": [[-15, -15], [15, -15], [15, 15], [-15, 15]]
},
"children": [
{
"object": "node",
"id": "building_bfqg91ai9ijps9ej",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_wyuoxj87czq3v0re"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
}
]
},
"building_bfqg91ai9ijps9ej": {
"object": "node",
"id": "building_bfqg91ai9ijps9ej",
"type": "building",
"parentId": null,
"visible": true,
"metadata": {},
"children": ["level_wyuoxj87czq3v0re"],
"position": [0, 0, 0],
"rotation": [0, 0, 0]
},
"level_wyuoxj87czq3v0re": {
"object": "node",
"id": "level_wyuoxj87czq3v0re",
"type": "level",
"parentId": null,
"visible": true,
"metadata": {},
"children": [
"wall_y87bsrljd2245n51",
"wall_sja6jpda73tlhxwb",
"wall_aegff27krjwgmkmi",
"wall_dcymglyle9ff09ti",
"wall_rl83cc5tnbf4b34j",
"wall_qv53jm9kvl7k6slf",
"wall_8y52fwzco2ep7fb1",
"wall_hrfixeusz7zb7x63",
"wall_1ullk9bm6dw15i9t",
"wall_c4fjjswnk0mprctm",
"wall_p359pffjf3qs59cy",
"zone_3fyksm10tb0dhn1e",
"zone_u95l1bt35jci3gvu",
"zone_r9ma8tvsqt9w1zey",
"zone_l189q61kf9ra2m8t"
],
"level": 0
},
"wall_y87bsrljd2245n51": {
"object": "node",
"id": "wall_y87bsrljd2245n51",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": ["window_47x40mtv2l4ca9p4", "window_n09awmg5m3ct4fvn"],
"thickness": 0.2,
"height": 2.7,
"start": [0, 0],
"end": [10, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_sja6jpda73tlhxwb": {
"object": "node",
"id": "wall_sja6jpda73tlhxwb",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [10, 0],
"end": [10, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_aegff27krjwgmkmi": {
"object": "node",
"id": "wall_aegff27krjwgmkmi",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": ["window_xlta3f3f0cnmbti3"],
"thickness": 0.2,
"height": 2.7,
"start": [10, 8],
"end": [0, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_dcymglyle9ff09ti": {
"object": "node",
"id": "wall_dcymglyle9ff09ti",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [0, 8],
"end": [0, 0],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_rl83cc5tnbf4b34j": {
"object": "node",
"id": "wall_rl83cc5tnbf4b34j",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [0, 5],
"end": [3, 5],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_qv53jm9kvl7k6slf": {
"object": "node",
"id": "wall_qv53jm9kvl7k6slf",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": ["door_cjzja4lt8owg88wg"],
"thickness": 0.2,
"height": 2.7,
"start": [3, 5],
"end": [3, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_8y52fwzco2ep7fb1": {
"object": "node",
"id": "wall_8y52fwzco2ep7fb1",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [7, 5],
"end": [10, 5],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_hrfixeusz7zb7x63": {
"object": "node",
"id": "wall_hrfixeusz7zb7x63",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": ["door_bs7bf0azevq9vd76"],
"thickness": 0.2,
"height": 2.7,
"start": [7, 5],
"end": [7, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_1ullk9bm6dw15i9t": {
"object": "node",
"id": "wall_1ullk9bm6dw15i9t",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": ["door_o8etwqsemfgj5mkj"],
"thickness": 0.2,
"height": 2.7,
"start": [4, 6],
"end": [6, 6],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_c4fjjswnk0mprctm": {
"object": "node",
"id": "wall_c4fjjswnk0mprctm",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [4, 6],
"end": [4, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"wall_p359pffjf3qs59cy": {
"object": "node",
"id": "wall_p359pffjf3qs59cy",
"type": "wall",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"children": [],
"thickness": 0.2,
"height": 2.7,
"start": [6, 6],
"end": [6, 8],
"frontSide": "unknown",
"backSide": "unknown"
},
"zone_3fyksm10tb0dhn1e": {
"object": "node",
"id": "zone_3fyksm10tb0dhn1e",
"type": "zone",
"name": "bedroom-1",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"polygon": [[0, 5], [3, 5], [3, 8], [0, 8]],
"color": "#3b82f6"
},
"zone_u95l1bt35jci3gvu": {
"object": "node",
"id": "zone_u95l1bt35jci3gvu",
"type": "zone",
"name": "bedroom-2",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"polygon": [[7, 5], [10, 5], [10, 8], [7, 8]],
"color": "#3b82f6"
},
"zone_r9ma8tvsqt9w1zey": {
"object": "node",
"id": "zone_r9ma8tvsqt9w1zey",
"type": "zone",
"name": "bathroom",
"parentId": "level_wyuoxj87czq3v0re",
"visible": true,
"metadata": {},
"polygon": [[4, 6], [6, 6], [6, 8], [4, 8]],
"color": "#3b82f6"
},
"zone_l189q61kf9ra2m8t": {
"object": "node",
"id": "zone_l189q61kf9ra2m8t",
"type": "zone",
"name": "living-kitchen",
"parentId": "level_wyuoxj87czq3v0re",
"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]
],
"color": "#3b82f6"
},
"door_cjzja4lt8owg88wg": {
"object": "node",
"id": "door_cjzja4lt8owg88wg",
"type": "door",
"parentId": "wall_qv53jm9kvl7k6slf",
"visible": true,
"metadata": {},
"position": [0.5, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_qv53jm9kvl7k6slf",
"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
},
"door_bs7bf0azevq9vd76": {
"object": "node",
"id": "door_bs7bf0azevq9vd76",
"type": "door",
"parentId": "wall_hrfixeusz7zb7x63",
"visible": true,
"metadata": {},
"position": [0.5, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_hrfixeusz7zb7x63",
"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
},
"door_o8etwqsemfgj5mkj": {
"object": "node",
"id": "door_o8etwqsemfgj5mkj",
"type": "door",
"parentId": "wall_1ullk9bm6dw15i9t",
"visible": true,
"metadata": {},
"position": [0.5, 1.05, 0],
"rotation": [0, 0, 0],
"wallId": "wall_1ullk9bm6dw15i9t",
"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
},
"window_47x40mtv2l4ca9p4": {
"object": "node",
"id": "window_47x40mtv2l4ca9p4",
"type": "window",
"parentId": "wall_y87bsrljd2245n51",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_n09awmg5m3ct4fvn": {
"object": "node",
"id": "window_n09awmg5m3ct4fvn",
"type": "window",
"parentId": "wall_y87bsrljd2245n51",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
},
"window_xlta3f3f0cnmbti3": {
"object": "node",
"id": "window_xlta3f3f0cnmbti3",
"type": "window",
"parentId": "wall_aegff27krjwgmkmi",
"visible": true,
"metadata": {},
"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],
"columnDividerThickness": 0.03,
"rowDividerThickness": 0.03,
"sill": true,
"sillDepth": 0.08,
"sillThickness": 0.03
}
},
"rootNodeIds": ["site_xs1r72ib2ymzpjus"],
"collections": {}
}
@@ -1,165 +0,0 @@
{
"transport": {
"kind": "http",
"note": "shared HTTP server"
},
"steps": [
{
"n": 1,
"name": "discover",
"ok": true,
"durationMs": 4,
"summary": "building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)",
"nodeIds": ["building_bfqg91ai9ijps9ej", "level_wyuoxj87czq3v0re"]
},
{
"n": 2,
"name": "perimeter walls",
"ok": true,
"durationMs": 2,
"summary": "created 4 perimeter walls (result.createdIds=[\"wall_y87bsrljd2245n51\",\"wall_sja6jpda73tlhxwb\",\"wall_aegff27krjwgmkmi\",\"wall_dcymglyle9ff09ti\"])",
"nodeIds": [
"wall_y87bsrljd2245n51",
"wall_sja6jpda73tlhxwb",
"wall_aegff27krjwgmkmi",
"wall_dcymglyle9ff09ti"
]
},
{
"n": 3,
"name": "interior partitions",
"ok": true,
"durationMs": 1,
"summary": "created 7 interior walls",
"nodeIds": [
"wall_rl83cc5tnbf4b34j",
"wall_qv53jm9kvl7k6slf",
"wall_8y52fwzco2ep7fb1",
"wall_hrfixeusz7zb7x63",
"wall_1ullk9bm6dw15i9t",
"wall_c4fjjswnk0mprctm",
"wall_p359pffjf3qs59cy"
]
},
{
"n": 4,
"name": "set zones",
"ok": true,
"durationMs": 3,
"summary": "created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen",
"nodeIds": [
"zone_3fyksm10tb0dhn1e",
"zone_u95l1bt35jci3gvu",
"zone_r9ma8tvsqt9w1zey",
"zone_l189q61kf9ra2m8t"
]
},
{
"n": 5,
"name": "cut openings",
"ok": true,
"durationMs": 6,
"summary": "3 doors, 3 windows",
"nodeIds": [
"door_cjzja4lt8owg88wg",
"door_bs7bf0azevq9vd76",
"door_o8etwqsemfgj5mkj",
"window_47x40mtv2l4ca9p4",
"window_n09awmg5m3ct4fvn",
"window_xlta3f3f0cnmbti3"
]
},
{
"n": 6,
"name": "validate scene",
"ok": true,
"durationMs": 1,
"summary": "valid=true, errors=0"
},
{
"n": 7,
"name": "measure furthest zones",
"ok": true,
"durationMs": 3,
"summary": "furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m",
"nodeIds": ["zone_3fyksm10tb0dhn1e", "zone_u95l1bt35jci3gvu"]
},
{
"n": 8,
"name": "export json",
"ok": true,
"durationMs": 1,
"summary": "exported 15392 bytes -> apartment.json"
},
{
"n": 9,
"name": "undo 3 steps",
"ok": true,
"durationMs": 2,
"summary": "undone=3, nodes 24 -> 21 (delta=3)"
},
{
"n": 10,
"name": "redo 3 steps",
"ok": true,
"durationMs": 2,
"summary": "redone=3, nodes 21 -> 24"
},
{
"n": 11,
"name": "duplicate level + validate",
"ok": true,
"durationMs": 2,
"summary": "newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0",
"nodeIds": ["level_cxvltlqvgqcasiep"]
},
{
"n": 12,
"name": "delete duplicated level",
"ok": true,
"durationMs": 3,
"summary": "deleted 22 nodes; nodes 46 -> 24",
"nodeIds": [
"level_cxvltlqvgqcasiep",
"wall_xhn7o9bfpmcv2znr",
"window_0qb4bgvzp46y412o",
"window_cjk6a6zwsn48dj5u",
"wall_hgatfahp4i53s139",
"wall_fp25ulcwqmsim8p5",
"window_jv0g8uos313ljbbe",
"wall_mjue0xaodm8d3vzi",
"wall_brw2c9vg502md7a0",
"wall_1at54a0mfxgq2txb",
"door_l8k2adas2djwpcf8",
"wall_6m920gigvtn113af",
"wall_2hazwrgv922l3t6q",
"door_a62eecbdzpdoqnlo",
"wall_11h43exay41fub7f",
"door_06dpidftldcoaisv",
"wall_u7e1gp0xbir5112y",
"wall_jbwbwvgt9lzw51mk",
"zone_hkp5wsyw7aydi175",
"zone_rpqx5ul6bl1tpdmd",
"zone_kohgjo47reiluhto",
"zone_o65jm5oy5v5ej6aw"
]
}
],
"final": {
"totalNodes": 24,
"zones": 4,
"doors": 3,
"windows": 3,
"step5NodeCount": 24,
"validationSummary": {
"valid": true,
"errors": []
},
"undoObservation": {
"undone": 3,
"before": 24,
"after": 21,
"delta": 3
}
}
}
@@ -1,18 +0,0 @@
[t3] attempting HTTP transport at http://localhost:3917/mcp
[t3] HTTP transport connected
[t3] using transport: http — shared HTTP server
[t3] OK step 1 discover (4ms): building=building_bfqg91ai9ijps9ej, level=level_wyuoxj87czq3v0re (of 1 buildings, 1 levels)
[t3] using buildingId=building_bfqg91ai9ijps9ej levelId=level_wyuoxj87czq3v0re
[t3] OK step 2 perimeter walls (2ms): created 4 perimeter walls (result.createdIds=["wall_y87bsrljd2245n51","wall_sja6jpda73tlhxwb","wall_aegff27krjwgmkmi","wall_dcymglyle9ff09ti"])
[t3] OK step 3 interior partitions (1ms): created 7 interior walls
[t3] OK step 4 set zones (3ms): created 4 zones: bedroom-1, bedroom-2, bathroom, living-kitchen
[t3] OK step 5 cut openings (6ms): 3 doors, 3 windows
[t3] post-step-5 total node count: 24
[t3] OK step 6 validate scene (1ms): valid=true, errors=0
[t3] OK step 7 measure furthest zones (3ms): furthest: zone_3fyksm10tb0dhn1e <-> zone_u95l1bt35jci3gvu = 7.000m
[t3] OK step 8 export json (1ms): exported 15392 bytes -> apartment.json
[t3] OK step 9 undo 3 steps (2ms): undone=3, nodes 24 -> 21 (delta=3)
[t3] OK step 10 redo 3 steps (2ms): redone=3, nodes 21 -> 24
[t3] OK step 11 duplicate level + validate (2ms): newLevelId=level_cxvltlqvgqcasiep, cloned=22, valid=true, errors=0
[t3] OK step 12 delete duplicated level (3ms): deleted 22 nodes; nodes 46 -> 24
[t3] done
@@ -1,629 +0,0 @@
/**
* T3 Scenario: End-to-end 2-bedroom apartment build via MCP HTTP server.
*
* Primary path: connect to the shared HTTP server at http://localhost:3917/mcp
* using StreamableHTTPClientTransport (as mandated by the task).
*
* Fallback path: if the shared server is stuck (e.g. "Server already
* initialized" because a previous client is still holding the single session
* slot), fall back to an in-memory MCP server that still exercises the same
* tool surface. We still emit evidence (apartment.json, REPORT.md) and the
* bug is surfaced verbatim in the report.
*
* Run:
* bun packages/mcp/test-reports/t3-scenario/run.ts
*/
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'
type StepResult = {
n: number
name: string
ok: boolean
durationMs: number
summary: string
nodeIds?: string[]
error?: string
}
const steps: StepResult[] = []
function log(msg: string): void {
console.log(`[t3] ${msg}`)
}
async function timed<T>(
n: number,
name: string,
fn: () => Promise<{ summary: string; nodeIds?: string[]; result: T }>,
): Promise<T | null> {
const start = Date.now()
try {
const { summary, nodeIds, result } = await fn()
const durationMs = Date.now() - start
steps.push({ n, name, ok: true, durationMs, summary, nodeIds })
log(`OK step ${n} ${name} (${durationMs}ms): ${summary}`)
return result
} catch (err) {
const durationMs = Date.now() - start
const msg = err instanceof Error ? err.message : String(err)
steps.push({ n, name, ok: false, durationMs, summary: 'FAILED', error: msg })
log(`ERR step ${n} ${name} (${durationMs}ms): ${msg}`)
return null
}
}
async function callTool<T = Record<string, unknown>>(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<T> {
const res = await client.callTool({ name, arguments: args })
if (res.isError) {
const text = Array.isArray(res.content)
? res.content
.map((c) =>
typeof (c as { text?: unknown }).text === 'string' ? (c as { text: string }).text : '',
)
.join('\n')
: ''
throw new Error(`tool ${name} error: ${text || 'unknown'}`)
}
return (res.structuredContent ?? {}) as T
}
type TransportKind = 'http' | 'in-memory'
async function connectClient(): Promise<{
client: Client
kind: TransportKind
note: string
closers: Array<() => Promise<void>>
}> {
// Try HTTP first.
log(`attempting HTTP transport at ${SERVER_URL}`)
try {
const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL))
const client = new Client({ name: 't3-scenario', version: '0.1.0' })
await client.connect(transport)
// Smoke probe — a listTools gets the session working.
await client.listTools()
log(`HTTP transport connected`)
return {
client,
kind: 'http',
note: 'shared HTTP server',
closers: [async () => client.close()],
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`HTTP transport failed: ${msg}`)
log(`FALLING BACK to in-memory MCP server`)
// Lazy import so we don't pay the cost when HTTP works.
const { SceneBridge } = await import('../../src/bridge/scene-bridge')
const { createPascalMcpServer } = await import('../../src/server')
const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 't3-scenario-inmem', version: '0.1.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
return {
client,
kind: 'in-memory',
note: `fallback — HTTP server returned: ${msg}`,
closers: [async () => client.close(), async () => server.close()],
}
}
}
async function main(): Promise<void> {
mkdirSync(HERE, { recursive: true })
const conn = await connectClient()
const { client } = conn
const transportKind = conn.kind
const transportNote = conn.note
log(`using transport: ${transportKind}${transportNote}`)
// ----- Step 1: Discover -----
const discovered = await timed(1, 'discover', async () => {
const buildings = await callTool<{ nodes: Array<{ id: string; type: string; name?: string }> }>(
client,
'find_nodes',
{ type: 'building' },
)
const levels = await callTool<{
nodes: Array<{ id: string; type: string; name?: string; parentId?: string }>
}>(client, 'find_nodes', { type: 'level' })
if (!buildings.nodes.length) throw new Error('no building found')
if (!levels.nodes.length) throw new Error('no level found')
const building = buildings.nodes[0]!
const level = levels.nodes.find((l) => l.parentId === building.id) ?? levels.nodes[0]!
return {
summary: `building=${building.id}, level=${level.id} (of ${buildings.nodes.length} buildings, ${levels.nodes.length} levels)`,
nodeIds: [building.id, level.id],
result: { buildingId: building.id, levelId: level.id },
}
})
if (!discovered) {
log('cannot continue without discovered ids')
for (const c of conn.closers) await c()
return
}
const { buildingId, levelId } = discovered
log(`using buildingId=${buildingId} levelId=${levelId}`)
// Helper: generate a wall id so we can reliably recover it after apply_patch.
// Uses nanoid-like custom alphabet to match core's id generator.
const ALPHA = '0123456789abcdefghijklmnopqrstuvwxyz'
function genWallId(): string {
let s = ''
for (let i = 0; i < 16; i++) s += ALPHA[Math.floor(Math.random() * ALPHA.length)]
return `wall_${s}`
}
// ----- Step 2: Perimeter walls — 10m x 8m rectangle -----
const perimeter = await timed(2, 'perimeter walls', async () => {
const ids = [genWallId(), genWallId(), genWallId(), genWallId()]
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: [
{
op: 'create',
parentId: levelId,
node: {
id: ids[0],
type: 'wall',
start: [0, 0],
end: [10, 0],
thickness: 0.2,
height: 2.7,
},
},
{
op: 'create',
parentId: levelId,
node: {
id: ids[1],
type: 'wall',
start: [10, 0],
end: [10, 8],
thickness: 0.2,
height: 2.7,
},
},
{
op: 'create',
parentId: levelId,
node: {
id: ids[2],
type: 'wall',
start: [10, 8],
end: [0, 8],
thickness: 0.2,
height: 2.7,
},
},
{
op: 'create',
parentId: levelId,
node: {
id: ids[3],
type: 'wall',
start: [0, 8],
end: [0, 0],
thickness: 0.2,
height: 2.7,
},
},
],
})
// The apply_patch tool's createdIds field is buggy (contains undefined when
// the caller doesn't supply an id — the bridge reads p.node.id before the
// Zod default fires). We pre-supply ids so the result is deterministic.
const createdIds = res.createdIds.length && res.createdIds[0] ? res.createdIds : ids
return {
summary: `created ${createdIds.length} perimeter walls (result.createdIds=${JSON.stringify(res.createdIds)})`,
nodeIds: createdIds,
result: createdIds,
}
})
const [southId, eastId, northId, westId] = perimeter ?? []
// ----- Step 3: Interior partition walls -----
// Layout (x=0..10 west→east, z=0..8 south→north):
// Bedroom 1 — top-left 3x3 (x 0..3, z 5..8)
// Bedroom 2 — top-right 3x3 (x 7..10, z 5..8)
// Bathroom — 2x2 between them (x 4..6, z 6..8)
// Living/Kitchen — everything else
const interior = await timed(3, 'interior partitions', async () => {
const walls: Array<{
start: [number, number]
end: [number, number]
}> = [
{ start: [0, 5], end: [3, 5] }, // bed1 south
{ start: [3, 5], end: [3, 8] }, // bed1 east
{ start: [7, 5], end: [10, 5] }, // bed2 south
{ start: [7, 5], end: [7, 8] }, // bed2 west
{ start: [4, 6], end: [6, 6] }, // bath south
{ start: [4, 6], end: [4, 8] }, // bath west
{ start: [6, 6], end: [6, 8] }, // bath east
]
const res = await callTool<{
appliedOps: number
createdIds: string[]
deletedIds: string[]
}>(client, 'apply_patch', {
patches: walls.map((w) => ({
op: 'create',
parentId: levelId,
node: {
type: 'wall',
start: w.start,
end: w.end,
thickness: 0.2,
height: 2.7,
},
})),
})
return {
summary: `created ${res.createdIds.length} interior walls`,
nodeIds: res.createdIds,
result: res.createdIds,
}
})
const [bed1SouthId, bed1EastId, bed2SouthId, bed2WestId, bathSouthId, bathWestId, bathEastId] =
interior ?? []
// ----- Step 4: Set zones -----
const zones = await timed(4, 'set zones', async () => {
const zoneIds: Record<string, string> = {}
const specs = [
{
label: 'bedroom-1',
polygon: [
[0, 5],
[3, 5],
[3, 8],
[0, 8],
] as Array<[number, number]>,
},
{
label: 'bedroom-2',
polygon: [
[7, 5],
[10, 5],
[10, 8],
[7, 8],
] as Array<[number, number]>,
},
{
label: 'bathroom',
polygon: [
[4, 6],
[6, 6],
[6, 8],
[4, 8],
] as Array<[number, number]>,
},
{
label: 'living-kitchen',
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],
] as Array<[number, number]>,
},
]
for (const s of specs) {
const r = await callTool<{ zoneId: string }>(client, 'set_zone', {
levelId,
label: s.label,
polygon: s.polygon,
})
zoneIds[s.label] = r.zoneId
}
return {
summary: `created ${Object.keys(zoneIds).length} zones: ${Object.keys(zoneIds).join(', ')}`,
nodeIds: Object.values(zoneIds),
result: zoneIds,
}
})
// ----- Step 5: Cut openings -----
const openings = await timed(5, 'cut openings', async () => {
const results: Array<{ wall: string; type: string; id: string }> = []
const doors: Array<[string | undefined, string]> = [
[bed1EastId, 'bed1-door'],
[bed2WestId, 'bed2-door'],
[bathSouthId, 'bath-door'],
]
for (const [wallId, label] of doors) {
if (!wallId) {
log(`skip ${label}: no wall id`)
continue
}
const r = await callTool<{ openingId: string }>(client, 'cut_opening', {
wallId,
type: 'door',
position: 0.5,
width: 0.9,
height: 2.1,
})
results.push({ wall: wallId, type: 'door', id: r.openingId })
}
const windows: Array<[string | undefined, number, string]> = [
[southId, 0.3, 'south-win-1'],
[southId, 0.7, 'south-win-2'],
[northId, 0.5, 'north-win-1'],
]
for (const [wallId, pos, label] of windows) {
if (!wallId) {
log(`skip ${label}: no wall id`)
continue
}
const r = await callTool<{ openingId: string }>(client, 'cut_opening', {
wallId,
type: 'window',
position: pos,
width: 1.2,
height: 1.2,
})
results.push({ wall: wallId, type: 'window', id: r.openingId })
}
const doorCount = results.filter((r) => r.type === 'door').length
const winCount = results.filter((r) => r.type === 'window').length
return {
summary: `${doorCount} doors, ${winCount} windows`,
nodeIds: results.map((r) => r.id),
result: results,
}
})
const step5NodeCount = openings
? (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
: 0
log(`post-step-5 total node count: ${step5NodeCount}`)
// ----- Step 6: Validate -----
const validation = await timed(6, 'validate scene', async () => {
const r = await callTool<{
valid: boolean
errors: Array<{ nodeId: string; path: string; message: string }>
}>(client, 'validate_scene', {})
if (!r.valid && r.errors.length) {
log('VALIDATION ERRORS VERBATIM:')
for (const e of r.errors) {
log(` nodeId=${e.nodeId} path=${e.path} :: ${e.message}`)
}
}
return {
summary: `valid=${r.valid}, errors=${r.errors.length}`,
result: r,
}
})
// ----- Step 7: Measure (two furthest zone centroids) -----
await timed(7, 'measure furthest zones', async () => {
if (!zones) throw new Error('no zones created')
const zoneIds = Object.values(zones)
if (zoneIds.length < 2) throw new Error('fewer than 2 zones')
let best: { a: string; b: string; d: number } | null = null
for (let i = 0; i < zoneIds.length; i++) {
for (let j = i + 1; j < zoneIds.length; j++) {
const a = zoneIds[i]!
const b = zoneIds[j]!
const r = await callTool<{ distanceMeters: number }>(client, 'measure', {
fromId: a,
toId: b,
})
if (!best || r.distanceMeters > best.d) {
best = { a, b, d: r.distanceMeters }
}
}
}
return {
summary: `furthest: ${best?.a} <-> ${best?.b} = ${best?.d.toFixed(3)}m`,
nodeIds: best ? [best.a, best.b] : [],
result: best,
}
})
// ----- Step 8: Export JSON -----
await timed(8, 'export json', async () => {
const r = await callTool<{ json: string }>(client, 'export_json', { pretty: true })
writeFileSync(`${HERE}/apartment.json`, r.json, 'utf-8')
return {
summary: `exported ${r.json.length} bytes -> apartment.json`,
result: r.json.length,
}
})
// ----- Step 9: Undo 3 steps -----
const undoResult = await timed(9, 'undo 3 steps', async () => {
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const r = await callTool<{ undone: number }>(client, 'undo', { steps: 3 })
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const delta = before - after
return {
summary: `undone=${r.undone}, nodes ${before} -> ${after} (delta=${delta})`,
result: { undone: r.undone, before, after, delta },
}
})
// ----- Step 10: Redo 3 steps -----
await timed(10, 'redo 3 steps', async () => {
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const r = await callTool<{ redone: number }>(client, 'redo', { steps: 3 })
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
return {
summary: `redone=${r.redone}, nodes ${before} -> ${after}`,
result: { redone: r.redone, before, after },
}
})
// ----- Step 11: Duplicate level + validate -----
const dup = await timed(11, 'duplicate level + validate', async () => {
const r = await callTool<{ newLevelId: string; newNodeIds: string[] }>(
client,
'duplicate_level',
{ levelId },
)
const v = await callTool<{
valid: boolean
errors: Array<{ nodeId: string; path: string; message: string }>
}>(client, 'validate_scene', {})
if (!v.valid && v.errors.length) {
log('VALIDATION ERRORS after duplicate:')
for (const e of v.errors) {
log(` nodeId=${e.nodeId} path=${e.path} :: ${e.message}`)
}
}
return {
summary: `newLevelId=${r.newLevelId}, cloned=${r.newNodeIds.length}, valid=${v.valid}, errors=${v.errors.length}`,
nodeIds: [r.newLevelId],
result: r,
}
})
// ----- Step 12: Delete duplicated level cascade -----
await timed(12, 'delete duplicated level', async () => {
if (!dup) throw new Error('no duplicated level id')
const before = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
const r = await callTool<{ deletedIds: string[] }>(client, 'delete_node', {
id: dup.newLevelId,
cascade: true,
})
const after = (await callTool<{ nodes: unknown[] }>(client, 'find_nodes', {})).nodes.length
return {
summary: `deleted ${r.deletedIds.length} nodes; nodes ${before} -> ${after}`,
nodeIds: r.deletedIds,
result: r,
}
})
// ----- Final summary -----
const allNodes = (await callTool<{ nodes: Array<{ type: string }> }>(client, 'find_nodes', {}))
.nodes
const zoneNodes = allNodes.filter((n) => n.type === 'zone')
const doorNodes = allNodes.filter((n) => n.type === 'door')
const windowNodes = allNodes.filter((n) => n.type === 'window')
const report = {
transport: { kind: transportKind, note: transportNote },
steps,
final: {
totalNodes: allNodes.length,
zones: zoneNodes.length,
doors: doorNodes.length,
windows: windowNodes.length,
step5NodeCount,
validationSummary: validation ?? null,
undoObservation: undoResult ?? null,
},
}
writeFileSync(`${HERE}/run-summary.json`, JSON.stringify(report, null, 2), 'utf-8')
// Build REPORT.md
const lines: string[] = []
lines.push('# T3 Scenario Report — 2-Bedroom Apartment')
lines.push('')
lines.push(`Generated: ${new Date().toISOString()}`)
lines.push(`Transport: ${transportKind} (${transportNote})`)
lines.push(`Server URL: ${SERVER_URL}`)
lines.push('')
lines.push('## Step-by-step')
lines.push('')
for (const s of steps) {
lines.push(`### Step ${s.n}: ${s.name} ${s.ok ? 'OK' : 'FAIL'} (${s.durationMs}ms)`)
lines.push('')
lines.push(`- Summary: ${s.summary}`)
if (s.nodeIds?.length) {
lines.push(
`- Node IDs (${s.nodeIds.length}): ${s.nodeIds.slice(0, 20).join(', ')}${s.nodeIds.length > 20 ? ' ...' : ''}`,
)
}
if (s.error) lines.push(`- Error: \`${s.error}\``)
lines.push('')
}
lines.push('## Final Counts')
lines.push('')
lines.push(`- Total nodes: ${allNodes.length}`)
lines.push(`- Zones: ${zoneNodes.length}`)
lines.push(`- Doors: ${doorNodes.length}`)
lines.push(`- Windows: ${windowNodes.length}`)
lines.push(`- Post-step-5 node count: ${step5NodeCount}`)
lines.push('')
lines.push('## Validation')
lines.push('')
if (validation) {
lines.push(`- Valid: ${validation.valid}`)
lines.push(`- Errors: ${validation.errors.length}`)
if (!validation.valid && validation.errors.length) {
lines.push('')
lines.push('Verbatim errors:')
lines.push('')
for (const e of validation.errors) {
lines.push(`- nodeId=\`${e.nodeId}\` path=\`${e.path}\` :: ${e.message}`)
}
}
} else {
lines.push('- (validation step failed)')
}
lines.push('')
lines.push('## Transport Notes')
lines.push('')
lines.push(`- Used transport: **${transportKind}**`)
lines.push(`- Reason: ${transportNote}`)
if (transportKind === 'in-memory') {
lines.push('')
lines.push(
'The shared HTTP server at :3917 returned "Server already initialized" — a known bug where the SDK\'s `StreamableHTTPServerTransport` in stateful mode accepts only a single session across the process lifetime. Subsequent clients cannot initialize. Falling back to an in-memory MCP server that exercises the same tools end-to-end.',
)
}
lines.push('')
writeFileSync(`${HERE}/REPORT.md`, lines.join('\n'), 'utf-8')
log('done')
for (const c of conn.closers) await c()
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -1,728 +0,0 @@
# T4 — Error Contract Verification Report
Server: `http://localhost:3917/`
Run date: 2026-04-18T16:17:28.757Z
## Summary
- PASS: 24
- WARN: 0
- FAIL: 0
- Total cases: 24
Baseline node count: 3
Final node count: 3 (delta=0)
Final validation: valid=true, errors=0
## Cases
### T4-01 — `get_node` — nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "node_doesnotexist_xyz"
}
```
**Expected:** McpError InvalidParams (-32602) "Node not found" OR structured tool error
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_doesnotexist_xyz",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_doesnotexist_xyz"
}
]
}
```
### T4-02 — `describe_node` — nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "node_missing_123"
}
```
**Expected:** McpError InvalidParams (-32602) "Node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_missing_123",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_missing_123"
}
]
}
```
### T4-03 — `find_nodes` — invalid type enum "hamster"
**Verdict:** ✅ PASS
**Input:**
```json
{
"type": "hamster"
}
```
**Expected:** Zod validation error (MCP InvalidParams -32602)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool find_nodes: [\n {\n \"code\": \"invalid_value\",\n \"values\": [\n \"site\",\n \"building\",\n \"level\",\n \"wall\",\n \"fence\",\n \"zone\",\n \"slab\",\n \"ceiling\",\n \"roof\",\n \"roof-segment\",\n \"stair\",\n \"stair-segment\",\n \"item\",\n \"door\",\n \"window\",\n \"scan\",\n \"guide\"\n ],\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid option: expected one of \\\"site\\\"|\\\"building\\\"|\\\"level\\\"|\\\"wall\\\"|\\\"fence\\\"|\\\"zone\\\"|\\\"slab\\\"|\\\"ceiling\\\"|\\\"roof\\\"|\\\"roof-segment\\\"|\\\"stair\\\"|\\\"stair-segment\\\"|\\\"item\\\"|\\\"door\\\"|\\\"window\\\"|\\\"scan\\\"|\\\"guide\\\"\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool find_nodes: [\n {\n \"code\": \"invalid_value\",\n \"values\": [\n \"site\",\n \"building\",\n \"level\",\n \"wall\",\n \"fence\",\n \"zone\",\n \"slab\",\n \"ceiling\",\n \"roof\",\n \"roof-segment\",\n \"stair\",\n \"stair-segment\",\n \"item\",\n \"door\",\n \"window\",\n \"scan\",\n \"guide\"\n ],\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid option: expected one of \\\"site\\\"|\\\"building\\\"|\\\"level\\\"|\\\"wall\\\"|\\\"fence\\\"|\\\"zone\\\"|\\\"slab\\\"|\\\"ceiling\\\"|\\\"roof\\\"|\\\"roof-segment\\\"|\\\"stair\\\"|\\\"stair-segment\\\"|\\\"item\\\"|\\\"door\\\"|\\\"window\\\"|\\\"scan\\\"|\\\"guide\\\"\"\n }\n]"
}
]
}
```
### T4-04 — `measure` — nonexistent fromId
**Verdict:** ✅ PASS
**Input:**
```json
{
"fromId": "node_nosuch_f",
"toId": "site_watn4a0qt2xpgri7"
}
```
**Expected:** McpError InvalidParams "Node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node not found: node_nosuch_f",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node not found: node_nosuch_f"
}
]
}
```
### T4-05 — `apply_patch` — patches with one invalid node (missing type)
**Verdict:** ✅ PASS
**Input:**
```json
{
"patches": [
{
"op": "create",
"node": {
"foo": "bar"
},
"parentId": "level_tl2aravmn2u9afft"
}
]
}
```
**Expected:** McpError InvalidParams, all-or-nothing rollback (no partial state change)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: invalid patch: patches[0] create node failed schema: [\n {\n \"code\": \"invalid_union\",\n \"errors\": [],\n \"note\": \"No matching discriminator\",\n \"discriminator\": \"type\",\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid input\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: invalid patch: patches[0] create node failed schema: [\n {\n \"code\": \"invalid_union\",\n \"errors\": [],\n \"note\": \"No matching discriminator\",\n \"discriminator\": \"type\",\n \"path\": [\n \"type\"\n ],\n \"message\": \"Invalid input\"\n }\n]"
}
]
}
```
### T4-06 — `apply_patch` — delete nonexistent id
**Verdict:** ✅ PASS
**Input:**
```json
{
"patches": [
{
"op": "delete",
"id": "node_nonexistent_delete_xyz"
}
]
}
```
**Expected:** McpError InvalidParams, no state change
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: invalid patch: patches[0] delete id \"node_nonexistent_delete_xyz\" not found",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: invalid patch: patches[0] delete id \"node_nonexistent_delete_xyz\" not found"
}
]
}
```
### T4-07 — `create_level` — buildingId is not a building (passed a wall/level/site id)
**Verdict:** ✅ PASS
**Input:**
```json
{
"buildingId": "level_tl2aravmn2u9afft",
"elevation": 0
}
```
**Expected:** McpError InvalidParams "expected building"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node level_tl2aravmn2u9afft is a level, expected building",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node level_tl2aravmn2u9afft is a level, expected building"
}
]
}
```
### T4-08 — `create_wall` — levelId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_nosuch_999",
"start": [
0,
0
],
"end": [
5,
0
]
}
```
**Expected:** McpError InvalidParams "Level not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Level not found: level_nosuch_999",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Level not found: level_nosuch_999"
}
]
}
```
### T4-09 — `create_wall` — start not a tuple
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_tl2aravmn2u9afft",
"start": "not-a-tuple",
"end": [
5,
0
]
}
```
**Expected:** Zod validation error (MCP InvalidParams -32602)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool create_wall: [\n {\n \"expected\": \"tuple\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"start\"\n ],\n \"message\": \"Invalid input: expected tuple, received string\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool create_wall: [\n {\n \"expected\": \"tuple\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"start\"\n ],\n \"message\": \"Invalid input: expected tuple, received string\"\n }\n]"
}
]
}
```
### T4-10 — `place_item` — targetNodeId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"catalogItemId": "chair-1",
"targetNodeId": "node_nosuch_target",
"position": [
0,
0,
0
]
}
```
**Expected:** McpError InvalidParams "Target node not found"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Target node not found: node_nosuch_target",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Target node not found: node_nosuch_target"
}
]
}
```
### T4-11 — `cut_opening` — wallId is not a wall
**Verdict:** ✅ PASS
**Input:**
```json
{
"wallId": "site_watn4a0qt2xpgri7",
"type": "door",
"position": 0.5,
"width": 0.8,
"height": 2
}
```
**Expected:** McpError InvalidParams "expected wall"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected wall",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected wall"
}
]
}
```
### T4-12 — `cut_opening` — position out of [0,1]
**Verdict:** ✅ PASS
**Input:**
```json
{
"wallId": "missing_wall",
"type": "door",
"position": 2.5,
"width": 0.8,
"height": 2
}
```
**Expected:** Zod validation error (MCP InvalidParams) — position must be <= 1
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool cut_opening: [\n {\n \"origin\": \"number\",\n \"code\": \"too_big\",\n \"maximum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"position\"\n ],\n \"message\": \"Too big: expected number to be <=1\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool cut_opening: [\n {\n \"origin\": \"number\",\n \"code\": \"too_big\",\n \"maximum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"position\"\n ],\n \"message\": \"Too big: expected number to be <=1\"\n }\n]"
}
]
}
```
### T4-13 — `set_zone` — polygon with < 3 points
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_tl2aravmn2u9afft",
"polygon": [
[
0,
0
],
[
5,
0
]
],
"label": "Tiny"
}
```
**Expected:** Zod validation error (MCP InvalidParams) — polygon must have >= 3 points
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool set_zone: [\n {\n \"origin\": \"array\",\n \"code\": \"too_small\",\n \"minimum\": 3,\n \"inclusive\": true,\n \"path\": [\n \"polygon\"\n ],\n \"message\": \"Too small: expected array to have >=3 items\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool set_zone: [\n {\n \"origin\": \"array\",\n \"code\": \"too_small\",\n \"minimum\": 3,\n \"inclusive\": true,\n \"path\": [\n \"polygon\"\n ],\n \"message\": \"Too small: expected array to have >=3 items\"\n }\n]"
}
]
}
```
### T4-14 — `duplicate_level` — levelId is not a level
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "site_watn4a0qt2xpgri7"
}
```
**Expected:** McpError InvalidParams "expected level"
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected level",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected level"
}
]
}
```
### T4-15 — `delete_node` — cascade=false with children (target site site_watn4a0qt2xpgri7 children=1)
**Verdict:** ✅ PASS
**Input:**
```json
{
"id": "site_watn4a0qt2xpgri7",
"cascade": false
}
```
**Expected:** McpError InvalidRequest "node has children" (no delete)
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: node has 2 descendant(s); pass cascade: true to delete recursively",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: node has 2 descendant(s); pass cascade: true to delete recursively"
}
]
}
```
### T4-16a — `undo` — negative steps
**Verdict:** ✅ PASS
**Input:**
```json
{
"steps": -1
}
```
**Expected:** Zod validation error (MCP InvalidParams) — steps must be positive int
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool undo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool undo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]"
}
]
}
```
### T4-16b — `redo` — negative steps
**Verdict:** ✅ PASS
**Input:**
```json
{
"steps": -2
}
```
**Expected:** Zod validation error (MCP InvalidParams) — steps must be positive int
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool redo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool redo: [\n {\n \"origin\": \"number\",\n \"code\": \"too_small\",\n \"minimum\": 0,\n \"inclusive\": false,\n \"path\": [\n \"steps\"\n ],\n \"message\": \"Too small: expected number to be >0\"\n }\n]"
}
]
}
```
### T4-17 — `export_json` — pretty='yes' (string not bool)
**Verdict:** ✅ PASS
**Input:**
```json
{
"pretty": "yes"
}
```
**Expected:** Zod validation error (MCP InvalidParams) — pretty must be boolean
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32602: Input validation error: Invalid arguments for tool export_json: [\n {\n \"expected\": \"boolean\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"pretty\"\n ],\n \"message\": \"Invalid input: expected boolean, received string\"\n }\n]",
"rawContent": [
{
"type": "text",
"text": "MCP error -32602: Input validation error: Invalid arguments for tool export_json: [\n {\n \"expected\": \"boolean\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"pretty\"\n ],\n \"message\": \"Invalid input: expected boolean, received string\"\n }\n]"
}
]
}
```
### T4-18 — `check_collisions` — levelId doesn't exist
**Verdict:** ✅ PASS
**Input:**
```json
{
"levelId": "level_nosuch_zzz"
}
```
**Expected:** Empty collisions result OR graceful error
**Actual:**
```json
{
"kind": "unexpected_success",
"message": "tool returned successfully with no isError flag",
"structuredContent": {
"collisions": []
},
"rawContent": [
{
"type": "text",
"text": "{\"collisions\":[]}"
}
]
}
```
**Note:** returned empty collisions (graceful)
### T4-19 — `validate_scene` — baseline: no args
**Verdict:** ✅ PASS
**Input:**
```json
{}
```
**Expected:** Success — structured { valid, errors[] }
**Actual:**
```json
{
"kind": "unexpected_success",
"message": "tool returned successfully with no isError flag",
"structuredContent": {
"valid": true,
"errors": []
},
"rawContent": [
{
"type": "text",
"text": "{\"valid\":true,\"errors\":[]}"
}
]
}
```
**Note:** baseline passed
### T4-20a — `analyze_floorplan_image` — image: '' (empty string)
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": ""
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-20b — `analyze_floorplan_image` — image: 'not-a-url-or-base64'
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": "not-a-url-or-base64"
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-21a — `analyze_room_photo` — image: '' (empty string)
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": ""
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
### T4-21b — `analyze_room_photo` — image: 'not-a-url-or-base64'
**Verdict:** ✅ PASS
**Input:**
```json
{
"image": "not-a-url-or-base64"
}
```
**Expected:** Validation error OR sampling_unavailable
**Actual:**
```json
{
"kind": "tool_error",
"message": "MCP error -32600: sampling_unavailable",
"rawContent": [
{
"type": "text",
"text": "MCP error -32600: sampling_unavailable"
}
]
}
```
-160
View File
@@ -1,160 +0,0 @@
connected to http://localhost:3917/
baseline node count = 3
discovered: site=site_watn4a0qt2xpgri7 building=building_wqydpgpprigdcq8a level=level_tl2aravmn2u9afft wall=undefined
node-with-children=site_watn4a0qt2xpgri7 type=site children=1
[PASS] T4-01 (get_node): nonexistent id
-> tool_error msg="MCP error -32602: Node not found: node_doesnotexist_xyz"
[PASS] T4-02 (describe_node): nonexistent id
-> tool_error msg="MCP error -32602: Node not found: node_missing_123"
[PASS] T4-03 (find_nodes): invalid type enum "hamster"
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool find_nodes: [
{
"code": "invalid_value",
"values": [
"site",
"building",
"level",
"wall",
"fence",
"zone",
"slab",
"ceiling",
"roof",
"roof-segment",
"stair",
"stair-segment",
"item",
"door",
"window",
"scan",
"guide"
],
"path": [
"type"
],
"message": "Invalid option: expected one of \"site\"|\"building\"|\"level\"|\"wall\"|\"fence\"|\"zone\"|\"slab\"|\"ceiling\"|\"roof\"|\"roof-segment\"|\"stair\"|\"stair-segment\"|\"item\"|\"door\"|\"window\"|\"scan\"|\"guide\""
}
]"
[PASS] T4-04 (measure): nonexistent fromId
-> tool_error msg="MCP error -32602: Node not found: node_nosuch_f"
[PASS] T4-05 (apply_patch): patches with one invalid node (missing type)
-> tool_error msg="MCP error -32602: invalid patch: patches[0] create node failed schema: [
{
"code": "invalid_union",
"errors": [],
"note": "No matching discriminator",
"discriminator": "type",
"path": [
"type"
],
"message": "Invalid input"
}
]"
[PASS] T4-06 (apply_patch): delete nonexistent id
-> tool_error msg="MCP error -32602: invalid patch: patches[0] delete id "node_nonexistent_delete_xyz" not found"
[PASS] T4-07 (create_level): buildingId is not a building (passed a wall/level/site id)
-> tool_error msg="MCP error -32602: Node level_tl2aravmn2u9afft is a level, expected building"
[PASS] T4-08 (create_wall): levelId doesn't exist
-> tool_error msg="MCP error -32602: Level not found: level_nosuch_999"
[PASS] T4-09 (create_wall): start not a tuple
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool create_wall: [
{
"expected": "tuple",
"code": "invalid_type",
"path": [
"start"
],
"message": "Invalid input: expected tuple, received string"
}
]"
[PASS] T4-10 (place_item): targetNodeId doesn't exist
-> tool_error msg="MCP error -32602: Target node not found: node_nosuch_target"
[PASS] T4-11 (cut_opening): wallId is not a wall
-> tool_error msg="MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected wall"
[PASS] T4-12 (cut_opening): position out of [0,1]
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool cut_opening: [
{
"origin": "number",
"code": "too_big",
"maximum": 1,
"inclusive": true,
"path": [
"position"
],
"message": "Too big: expected number to be <=1"
}
]"
[PASS] T4-13 (set_zone): polygon with < 3 points
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool set_zone: [
{
"origin": "array",
"code": "too_small",
"minimum": 3,
"inclusive": true,
"path": [
"polygon"
],
"message": "Too small: expected array to have >=3 items"
}
]"
[PASS] T4-14 (duplicate_level): levelId is not a level
-> tool_error msg="MCP error -32602: Node site_watn4a0qt2xpgri7 is a site, expected level"
[PASS] T4-15 (delete_node): cascade=false with children (target site site_watn4a0qt2xpgri7 children=1)
-> tool_error msg="MCP error -32600: node has 2 descendant(s); pass cascade: true to delete recursively"
[PASS] T4-16a (undo): negative steps
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool undo: [
{
"origin": "number",
"code": "too_small",
"minimum": 0,
"inclusive": false,
"path": [
"steps"
],
"message": "Too small: expected number to be >0"
}
]"
[PASS] T4-16b (redo): negative steps
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool redo: [
{
"origin": "number",
"code": "too_small",
"minimum": 0,
"inclusive": false,
"path": [
"steps"
],
"message": "Too small: expected number to be >0"
}
]"
[PASS] T4-17 (export_json): pretty='yes' (string not bool)
-> tool_error msg="MCP error -32602: Input validation error: Invalid arguments for tool export_json: [
{
"expected": "boolean",
"code": "invalid_type",
"path": [
"pretty"
],
"message": "Invalid input: expected boolean, received string"
}
]"
[PASS] T4-18 (check_collisions): levelId doesn't exist
-> SUCCESS payload={"collisions":[]}
note: returned empty collisions (graceful)
[PASS] T4-19 (validate_scene): baseline: no args
-> SUCCESS payload={"valid":true,"errors":[]}
note: baseline passed
[PASS] T4-20a (analyze_floorplan_image): image: '' (empty string)
-> tool_error msg="MCP error -32600: sampling_unavailable"
[PASS] T4-20b (analyze_floorplan_image): image: 'not-a-url-or-base64'
-> tool_error msg="MCP error -32600: sampling_unavailable"
[PASS] T4-21a (analyze_room_photo): image: '' (empty string)
-> tool_error msg="MCP error -32600: sampling_unavailable"
[PASS] T4-21b (analyze_room_photo): image: 'not-a-url-or-base64'
-> tool_error msg="MCP error -32600: sampling_unavailable"
final node count = 3 (baseline 3)
final validation: valid=true errors=0
wrote report: /Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t4-errors/REPORT.md
summary: PASS=24 WARN=0 FAIL=0
-751
View File
@@ -1,751 +0,0 @@
/**
* T4 — Error contract verification for the MCP HTTP server.
*
* Sends intentionally invalid calls to the live server at :3917 and captures
* the structured response (code + message, or tool payload isError). Each case
* is logged with a verdict: PASS (expectation matched), WARN (acceptable but
* different shape), or FAIL (wrong behaviour / bug).
*
* Run:
* bun packages/mcp/test-reports/t4-errors/run.ts
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { McpError } from '@modelcontextprotocol/sdk/types.js'
const SERVER_URL = new URL('http://localhost:3917/')
type Verdict = 'PASS' | 'WARN' | 'FAIL'
type CaseResult = {
id: string
tool: string
description: string
input: unknown
expected: string
actual: {
kind: 'mcp_error' | 'tool_error' | 'unexpected_success' | 'client_error'
code?: number
message?: string
data?: unknown
structuredContent?: unknown
rawContent?: unknown
}
verdict: Verdict
note?: string
}
const results: CaseResult[] = []
let client: Client | null = null
/**
* Call a tool and normalise the outcome into one of three shapes:
* - mcp_error: server threw McpError (protocol-level JSON-RPC error).
* - tool_error: tool returned `{ isError: true, content: [...] }`.
* - unexpected_success: tool returned a normal payload.
* Anything else (timeout, transport crash) becomes `client_error`.
*/
async function callTool(
name: string,
args: Record<string, unknown>,
): Promise<CaseResult['actual']> {
try {
const result = await client!.callTool({ name, arguments: args })
if (result.isError) {
const rawContent = (result.content ?? []) as Array<{ type: string; text?: string }>
const textBlock = rawContent.find((b) => b.type === 'text')
return {
kind: 'tool_error',
message: textBlock?.text ?? JSON.stringify(rawContent),
rawContent: result.content,
structuredContent: result.structuredContent,
}
}
return {
kind: 'unexpected_success',
message: 'tool returned successfully with no isError flag',
structuredContent: result.structuredContent,
rawContent: result.content,
}
} catch (err) {
if (err instanceof McpError) {
return {
kind: 'mcp_error',
code: err.code,
message: err.message,
data: err.data,
}
}
return {
kind: 'client_error',
message: err instanceof Error ? err.message : String(err),
}
}
}
function record(
id: string,
tool: string,
description: string,
input: unknown,
expected: string,
actual: CaseResult['actual'],
verdict: Verdict,
note?: string,
): void {
results.push({ id, tool, description, input, expected, actual, verdict, note })
const icon = verdict === 'PASS' ? '[PASS]' : verdict === 'WARN' ? '[WARN]' : '[FAIL]'
const line = `${icon} ${id} (${tool}): ${description}`
console.log(line)
if (actual.kind === 'mcp_error') {
console.log(` -> McpError code=${actual.code} msg="${actual.message}"`)
} else if (actual.kind === 'tool_error') {
console.log(` -> tool_error msg="${actual.message}"`)
} else if (actual.kind === 'unexpected_success') {
console.log(` -> SUCCESS payload=${JSON.stringify(actual.structuredContent)}`)
} else {
console.log(` -> client_error msg="${actual.message}"`)
}
if (note) console.log(` note: ${note}`)
}
/** Verify actual matches expectation of "any structured error surface". */
function classifyRejection(
actual: CaseResult['actual'],
allowMcp = true,
allowTool = true,
): Verdict {
if (actual.kind === 'mcp_error' && allowMcp) return 'PASS'
if (actual.kind === 'tool_error' && allowTool) return 'PASS'
if (actual.kind === 'unexpected_success') return 'FAIL'
if (actual.kind === 'client_error') return 'FAIL'
return 'WARN'
}
async function main(): Promise<void> {
// --- Connect ---
const transport = new StreamableHTTPClientTransport(SERVER_URL)
client = new Client({ name: 't4-error-tester', version: '0.0.0' })
await client.connect(transport)
console.log(`connected to ${SERVER_URL.href}`)
// --- Baseline: scene snapshot ---
const scene0 = await client.callTool({ name: 'get_scene', arguments: {} })
const nodes0 = (scene0.structuredContent as { nodes?: Record<string, unknown> })?.nodes ?? {}
const nodeCount0 = Object.keys(nodes0).length
console.log(`baseline node count = ${nodeCount0}`)
// Discover real IDs for positive-side assertions (e.g. real wall, real level).
const scenePayload = scene0.structuredContent as {
nodes: Record<string, { id: string; type: string; children?: string[] }>
}
const allNodes = Object.values(scenePayload.nodes ?? {}) as Array<{
id: string
type: string
children?: string[]
}>
const findFirst = (t: string) => allNodes.find((n) => n.type === t)
const realSite = findFirst('site')
const realBuilding = findFirst('building')
const realLevel = findFirst('level')
const realWall = findFirst('wall')
// Try to locate a node with children (for delete_node cascade=false case).
let nodeWithChildren = allNodes.find((n) => (n.children?.length ?? 0) > 0)
// Fallback to site/building/level if they have children.
if (!nodeWithChildren) nodeWithChildren = realSite ?? realBuilding ?? realLevel
console.log(
`discovered: site=${realSite?.id} building=${realBuilding?.id} level=${realLevel?.id} wall=${realWall?.id}`,
)
console.log(
`node-with-children=${nodeWithChildren?.id} type=${nodeWithChildren?.type} children=${nodeWithChildren?.children?.length ?? 0}`,
)
// ==========================================================================
// TESTS
// ==========================================================================
// 1. get_node — nonexistent id
{
const input = { id: 'node_doesnotexist_xyz' }
const actual = await callTool('get_node', input)
record(
'T4-01',
'get_node',
'nonexistent id',
input,
'McpError InvalidParams (-32602) "Node not found" OR structured tool error',
actual,
classifyRejection(actual),
)
}
// 2. describe_node — nonexistent id
{
const input = { id: 'node_missing_123' }
const actual = await callTool('describe_node', input)
record(
'T4-02',
'describe_node',
'nonexistent id',
input,
'McpError InvalidParams (-32602) "Node not found"',
actual,
classifyRejection(actual),
)
}
// 3. find_nodes — invalid type enum
{
const input = { type: 'hamster' }
const actual = await callTool('find_nodes', input)
// Zod validation should fail before handler runs → MCP error.
const isZod =
actual.kind === 'mcp_error' &&
(actual.message?.toLowerCase().includes('invalid') ||
actual.message?.toLowerCase().includes('enum') ||
actual.message?.toLowerCase().includes('hamster'))
record(
'T4-03',
'find_nodes',
'invalid type enum "hamster"',
input,
'Zod validation error (MCP InvalidParams -32602)',
actual,
isZod ? 'PASS' : classifyRejection(actual),
)
}
// 4. measure — nonexistent fromId
{
const input = { fromId: 'node_nosuch_f', toId: realSite?.id ?? 'x' }
const actual = await callTool('measure', input)
record(
'T4-04',
'measure',
'nonexistent fromId',
input,
'McpError InvalidParams "Node not found"',
actual,
classifyRejection(actual),
)
}
// 5. apply_patch — patch with invalid node (missing type field)
// The schema accepts `node: z.record(z.string(), z.unknown())` so missing
// `type` slips past Zod; the bridge's core validator catches it and
// apply-patch converts that into an McpError via its try/catch.
{
const input = {
patches: [
{
op: 'create',
node: { foo: 'bar' /* no type */ },
parentId: realLevel?.id ?? 'missing',
},
],
}
const actual = await callTool('apply_patch', input)
record(
'T4-05',
'apply_patch',
'patches with one invalid node (missing type)',
input,
'McpError InvalidParams, all-or-nothing rollback (no partial state change)',
actual,
classifyRejection(actual),
)
}
// 6. apply_patch — delete nonexistent id
{
const input = {
patches: [{ op: 'delete', id: 'node_nonexistent_delete_xyz' }],
}
const actual = await callTool('apply_patch', input)
record(
'T4-06',
'apply_patch',
'delete nonexistent id',
input,
'McpError InvalidParams, no state change',
actual,
classifyRejection(actual),
)
}
// 7. create_level — buildingId that isn't a building (feed it a site/wall/level)
{
const notBuildingId = realWall?.id ?? realLevel?.id ?? realSite?.id ?? 'missing'
const input = { buildingId: notBuildingId, elevation: 0 }
const actual = await callTool('create_level', input)
record(
'T4-07',
'create_level',
'buildingId is not a building (passed a wall/level/site id)',
input,
'McpError InvalidParams "expected building"',
actual,
classifyRejection(actual),
)
}
// 8. create_wall — levelId that doesn't exist
{
const input = {
levelId: 'level_nosuch_999',
start: [0, 0],
end: [5, 0],
}
const actual = await callTool('create_wall', input)
record(
'T4-08',
'create_wall',
"levelId doesn't exist",
input,
'McpError InvalidParams "Level not found"',
actual,
classifyRejection(actual),
)
}
// 9. create_wall — start/end not tuples
{
const input = {
levelId: realLevel?.id ?? 'x',
start: 'not-a-tuple',
end: [5, 0],
}
const actual = await callTool('create_wall', input)
record(
'T4-09',
'create_wall',
'start not a tuple',
input,
'Zod validation error (MCP InvalidParams -32602)',
actual,
classifyRejection(actual),
)
}
// 10. place_item — targetNodeId doesn't exist
{
const input = {
catalogItemId: 'chair-1',
targetNodeId: 'node_nosuch_target',
position: [0, 0, 0],
}
const actual = await callTool('place_item', input)
record(
'T4-10',
'place_item',
"targetNodeId doesn't exist",
input,
'McpError InvalidParams "Target node not found"',
actual,
classifyRejection(actual),
)
}
// 11. cut_opening — wallId isn't a wall (pass a site/building/level)
{
const notWallId = realSite?.id ?? realBuilding?.id ?? realLevel?.id ?? 'missing'
const input = {
wallId: notWallId,
type: 'door',
position: 0.5,
width: 0.8,
height: 2.0,
}
const actual = await callTool('cut_opening', input)
record(
'T4-11',
'cut_opening',
'wallId is not a wall',
input,
'McpError InvalidParams "expected wall"',
actual,
classifyRejection(actual),
)
}
// 12. cut_opening — position out of [0,1]
{
const input = {
wallId: realWall?.id ?? 'missing_wall',
type: 'door',
position: 2.5,
width: 0.8,
height: 2.0,
}
const actual = await callTool('cut_opening', input)
record(
'T4-12',
'cut_opening',
'position out of [0,1]',
input,
'Zod validation error (MCP InvalidParams) — position must be <= 1',
actual,
classifyRejection(actual),
)
}
// 13. set_zone — polygon with < 3 points
{
const input = {
levelId: realLevel?.id ?? 'missing',
polygon: [
[0, 0],
[5, 0],
],
label: 'Tiny',
}
const actual = await callTool('set_zone', input)
record(
'T4-13',
'set_zone',
'polygon with < 3 points',
input,
'Zod validation error (MCP InvalidParams) — polygon must have >= 3 points',
actual,
classifyRejection(actual),
)
}
// 14. duplicate_level — levelId isn't a level (pass a wall/site/building)
{
const notLevelId = realWall?.id ?? realSite?.id ?? realBuilding?.id ?? 'missing'
const input = { levelId: notLevelId }
const actual = await callTool('duplicate_level', input)
record(
'T4-14',
'duplicate_level',
'levelId is not a level',
input,
'McpError InvalidParams "expected level"',
actual,
classifyRejection(actual),
)
}
// 15. delete_node — id with children, cascade=false (happy-path for the rejection)
// NOTE: this one mutates state if it succeeds. Since we expect it to REJECT
// when cascade=false, there should be no state change. We'll verify below
// via validate_scene.
{
const target = nodeWithChildren
if (!target) {
record(
'T4-15',
'delete_node',
'cascade=false with children',
{ id: 'no-candidate-found' },
'McpError "node has children"',
{ kind: 'client_error', message: 'no node with children available in scene' },
'WARN',
'skipped: no node with children in the default scene',
)
} else {
const input = { id: target.id, cascade: false }
const actual = await callTool('delete_node', input)
record(
'T4-15',
'delete_node',
`cascade=false with children (target ${target.type} ${target.id} children=${target.children?.length ?? 0})`,
input,
'McpError InvalidRequest "node has children" (no delete)',
actual,
classifyRejection(actual),
)
}
}
// 16. undo — negative steps
{
const input = { steps: -1 }
const actual = await callTool('undo', input)
record(
'T4-16a',
'undo',
'negative steps',
input,
'Zod validation error (MCP InvalidParams) — steps must be positive int',
actual,
classifyRejection(actual),
)
}
// 16b. redo — negative steps
{
const input = { steps: -2 }
const actual = await callTool('redo', input)
record(
'T4-16b',
'redo',
'negative steps',
input,
'Zod validation error (MCP InvalidParams) — steps must be positive int',
actual,
classifyRejection(actual),
)
}
// 17. export_json — prettify is string 'yes' instead of bool
{
const input = { pretty: 'yes' }
const actual = await callTool('export_json', input)
record(
'T4-17',
'export_json',
"pretty='yes' (string not bool)",
input,
'Zod validation error (MCP InvalidParams) — pretty must be boolean',
actual,
classifyRejection(actual),
)
}
// 18. check_collisions — levelId doesn't exist (spec: empty result OR graceful error)
{
const input = { levelId: 'level_nosuch_zzz' }
const actual = await callTool('check_collisions', input)
// Spec allows either. Success with empty collisions is the friendly path.
let verdict: Verdict = 'WARN'
let note: string | undefined
if (actual.kind === 'unexpected_success') {
const sc = actual.structuredContent as { collisions?: unknown[] } | undefined
const empty = Array.isArray(sc?.collisions) && sc.collisions.length === 0
verdict = empty ? 'PASS' : 'WARN'
note = empty ? 'returned empty collisions (graceful)' : 'returned non-empty result'
} else if (actual.kind === 'mcp_error' || actual.kind === 'tool_error') {
verdict = 'PASS'
note = 'structured error (also acceptable per spec)'
} else {
verdict = 'FAIL'
}
record(
'T4-18',
'check_collisions',
"levelId doesn't exist",
input,
'Empty collisions result OR graceful error',
actual,
verdict,
note,
)
}
// 19. validate_scene — no args → should succeed (baseline, not an error test)
{
const input = {}
const actual = await callTool('validate_scene', input)
const ok = actual.kind === 'unexpected_success'
record(
'T4-19',
'validate_scene',
'baseline: no args',
input,
'Success — structured { valid, errors[] }',
actual,
ok ? 'PASS' : 'FAIL',
ok ? 'baseline passed' : 'baseline failed',
)
}
// 20. analyze_floorplan_image — image: ''
{
const input = { image: '' }
const actual = await callTool('analyze_floorplan_image', input)
// Expected: Zod string.min rule (we have no min, so it accepts empty),
// falling through to sampling_unavailable (no client caps on HTTP).
// Either is acceptable — this is a validation/sampling-guard test.
let verdict = classifyRejection(actual)
let note: string | undefined
if (actual.kind === 'mcp_error') {
if (actual.message?.includes('sampling_unavailable')) {
note = 'sampling_unavailable (no client capabilities) — acceptable'
verdict = 'PASS'
} else {
note = 'structured error'
}
}
record(
'T4-20a',
'analyze_floorplan_image',
"image: '' (empty string)",
input,
'Validation error OR sampling_unavailable',
actual,
verdict,
note,
)
}
// 20b. analyze_floorplan_image — image: 'not-a-url-or-base64'
{
const input = { image: 'not-a-url-or-base64' }
const actual = await callTool('analyze_floorplan_image', input)
let verdict = classifyRejection(actual)
let note: string | undefined
if (actual.kind === 'mcp_error' && actual.message?.includes('sampling_unavailable')) {
note = 'sampling_unavailable (no client capabilities)'
verdict = 'PASS'
}
record(
'T4-20b',
'analyze_floorplan_image',
"image: 'not-a-url-or-base64'",
input,
'Validation error OR sampling_unavailable',
actual,
verdict,
note,
)
}
// 21. analyze_room_photo — image: ''
{
const input = { image: '' }
const actual = await callTool('analyze_room_photo', input)
let verdict = classifyRejection(actual)
let note: string | undefined
if (actual.kind === 'mcp_error' && actual.message?.includes('sampling_unavailable')) {
note = 'sampling_unavailable (no client capabilities)'
verdict = 'PASS'
}
record(
'T4-21a',
'analyze_room_photo',
"image: '' (empty string)",
input,
'Validation error OR sampling_unavailable',
actual,
verdict,
note,
)
}
// 21b. analyze_room_photo — image: 'not-a-url-or-base64'
{
const input = { image: 'not-a-url-or-base64' }
const actual = await callTool('analyze_room_photo', input)
let verdict = classifyRejection(actual)
let note: string | undefined
if (actual.kind === 'mcp_error' && actual.message?.includes('sampling_unavailable')) {
note = 'sampling_unavailable (no client capabilities)'
verdict = 'PASS'
}
record(
'T4-21b',
'analyze_room_photo',
"image: 'not-a-url-or-base64'",
input,
'Validation error OR sampling_unavailable',
actual,
verdict,
note,
)
}
// --- Post-check: scene count should be unchanged (all error paths) ---
const scene1 = await client.callTool({ name: 'get_scene', arguments: {} })
const nodes1 = (scene1.structuredContent as { nodes?: Record<string, unknown> })?.nodes ?? {}
const nodeCount1 = Object.keys(nodes1).length
console.log(`\nfinal node count = ${nodeCount1} (baseline ${nodeCount0})`)
const delta = nodeCount1 - nodeCount0
if (delta !== 0) {
console.log(`WARN: node count changed by ${delta}`)
}
const validationFinal = await client.callTool({
name: 'validate_scene',
arguments: {},
})
const vf = validationFinal.structuredContent as { valid: boolean; errors: unknown[] }
console.log(`final validation: valid=${vf.valid} errors=${vf.errors.length}`)
// --- Emit report ---
await writeReport(nodeCount0, nodeCount1, vf)
await client.close()
}
async function writeReport(
nodeCountBefore: number,
nodeCountAfter: number,
finalValidation: { valid: boolean; errors: unknown[] },
): Promise<void> {
const pass = results.filter((r) => r.verdict === 'PASS').length
const warn = results.filter((r) => r.verdict === 'WARN').length
const fail = results.filter((r) => r.verdict === 'FAIL').length
const lines: string[] = []
lines.push('# T4 — Error Contract Verification Report')
lines.push('')
lines.push(`Server: \`${SERVER_URL.href}\``)
lines.push(`Run date: ${new Date().toISOString()}`)
lines.push('')
lines.push('## Summary')
lines.push('')
lines.push(`- PASS: ${pass}`)
lines.push(`- WARN: ${warn}`)
lines.push(`- FAIL: ${fail}`)
lines.push(`- Total cases: ${results.length}`)
lines.push('')
lines.push(`Baseline node count: ${nodeCountBefore}`)
lines.push(`Final node count: ${nodeCountAfter} (delta=${nodeCountAfter - nodeCountBefore})`)
lines.push(
`Final validation: valid=${finalValidation.valid}, errors=${finalValidation.errors.length}`,
)
lines.push('')
if (fail > 0) {
lines.push('## Failures (real bugs)')
lines.push('')
for (const r of results.filter((x) => x.verdict === 'FAIL')) {
lines.push(`- **${r.id}** \`${r.tool}\`: ${r.description}`)
lines.push(` - Expected: ${r.expected}`)
lines.push(` - Actual kind: \`${r.actual.kind}\``)
if (r.actual.code !== undefined) lines.push(` - Code: \`${r.actual.code}\``)
if (r.actual.message) lines.push(` - Message: \`${r.actual.message}\``)
}
lines.push('')
}
lines.push('## Cases')
lines.push('')
for (const r of results) {
const icon = r.verdict === 'PASS' ? '✅' : r.verdict === 'WARN' ? '⚠️' : '❌'
lines.push(`### ${r.id}\`${r.tool}\`${r.description}`)
lines.push('')
lines.push(`**Verdict:** ${icon} ${r.verdict}`)
lines.push('')
lines.push('**Input:**')
lines.push('```json')
lines.push(JSON.stringify(r.input, null, 2))
lines.push('```')
lines.push('')
lines.push(`**Expected:** ${r.expected}`)
lines.push('')
lines.push('**Actual:**')
lines.push('```json')
lines.push(JSON.stringify(r.actual, null, 2))
lines.push('```')
if (r.note) {
lines.push('')
lines.push(`**Note:** ${r.note}`)
}
lines.push('')
}
const { writeFile } = await import('node:fs/promises')
const reportPath = new URL('./REPORT.md', import.meta.url)
await writeFile(reportPath, lines.join('\n'), 'utf8')
console.log(`\nwrote report: ${reportPath.pathname}`)
console.log(`summary: PASS=${pass} WARN=${warn} FAIL=${fail}`)
}
main().catch((err) => {
console.error('[t4] fatal:', err)
process.exit(1)
})
@@ -1,97 +0,0 @@
#!/usr/bin/env bash
# T5 — Next.js dev server probe.
#
# Verifies http://localhost:3002 is serving the Pascal editor cleanly without
# touching the running process.
#
# Outputs structured "KEY=value" lines so the REPORT.md can be authored from
# them, plus a longer human-readable summary at the end.
set -uo pipefail
URL_ROOT="http://localhost:3002/"
URL_HEALTH="http://localhost:3002/api/health"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
ROOT_BODY="$TMPDIR/root.html"
ROOT_HEAD="$TMPDIR/root.head"
HEALTH_BODY="$TMPDIR/health.body"
HEALTH_HEAD="$TMPDIR/health.head"
echo "## Probe: $URL_ROOT"
ROOT_STATUS=$(curl -sS --connect-timeout 5 --max-time 30 \
-o "$ROOT_BODY" -D "$ROOT_HEAD" -w '%{http_code}' "$URL_ROOT" || echo "000")
ROOT_BYTES=$(wc -c < "$ROOT_BODY" | tr -d ' ')
echo "ROOT_STATUS=$ROOT_STATUS"
echo "ROOT_BYTES=$ROOT_BYTES"
# Pascal mention: explicit "Pascal" string OR @pascal-app reference.
if grep -q -i 'Pascal' "$ROOT_BODY"; then
ROOT_HAS_PASCAL=1
else
ROOT_HAS_PASCAL=0
fi
if grep -q '@pascal-app' "$ROOT_BODY"; then
ROOT_HAS_PASCAL_APP=1
else
ROOT_HAS_PASCAL_APP=0
fi
echo "ROOT_HAS_PASCAL=$ROOT_HAS_PASCAL"
echo "ROOT_HAS_PASCAL_APP=$ROOT_HAS_PASCAL_APP"
# Count <script src="..."> tags.
SCRIPT_COUNT=$(grep -o '<script [^>]*src="[^"]*"' "$ROOT_BODY" | wc -l | tr -d ' ')
NEXT_CHUNK_COUNT=$(grep -o '<script [^>]*src="[^"]*_next[^"]*"' "$ROOT_BODY" | wc -l | tr -d ' ')
# Best-effort search for editor / viewer / core chunks (workspace wiring sanity).
EDITOR_CHUNK_COUNT=$(grep -o -E '<script [^>]*src="[^"]*(editor|viewer|core|three|gltf)[^"]*"' "$ROOT_BODY" | wc -l | tr -d ' ')
echo "SCRIPT_COUNT=$SCRIPT_COUNT"
echo "NEXT_CHUNK_COUNT=$NEXT_CHUNK_COUNT"
echo "EDITOR_VIEWER_CORE_CHUNK_COUNT=$EDITOR_CHUNK_COUNT"
# Error indicator scan.
ERR_APP_ERROR=$(grep -c 'Application error' "$ROOT_BODY" || true)
ERR_FAILED_TO=$(grep -c 'Failed to' "$ROOT_BODY" || true)
ERR_CANNOT=$(grep -c 'cannot' "$ROOT_BODY" || true)
echo "ERR_APP_ERROR=$ERR_APP_ERROR"
echo "ERR_FAILED_TO=$ERR_FAILED_TO"
echo "ERR_CANNOT=$ERR_CANNOT"
# Print first ~5 unique script sources for sanity inspection (cap output).
echo
echo "## First script src= matches (max 10)"
grep -o '<script [^>]*src="[^"]*"' "$ROOT_BODY" | sed -E 's/.*src="([^"]+)".*/\1/' | head -10 || true
# /api/health probe (best effort — endpoint may not exist).
echo
echo "## Probe: $URL_HEALTH"
HEALTH_STATUS=$(curl -sS --connect-timeout 5 --max-time 10 \
-o "$HEALTH_BODY" -D "$HEALTH_HEAD" -w '%{http_code}' "$URL_HEALTH" || echo "000")
HEALTH_BYTES=$(wc -c < "$HEALTH_BODY" | tr -d ' ')
echo "HEALTH_STATUS=$HEALTH_STATUS"
echo "HEALTH_BYTES=$HEALTH_BYTES"
# If the response is JSON, show first 200 bytes; otherwise mark as N/A.
if [ "$HEALTH_STATUS" = "200" ]; then
HEALTH_BODY_TEXT=$(head -c 200 "$HEALTH_BODY" | tr -d '\n')
echo "HEALTH_BODY_PREVIEW=$HEALTH_BODY_TEXT"
fi
# Brief title extraction.
TITLE_LINE=$(grep -o '<title>[^<]*</title>' "$ROOT_BODY" | head -1 || true)
echo
echo "ROOT_TITLE=$TITLE_LINE"
# Final aggregate verdict.
echo
echo "## Summary"
if [ "$ROOT_STATUS" = "200" ] && \
{ [ "$ROOT_HAS_PASCAL" = "1" ] || [ "$ROOT_HAS_PASCAL_APP" = "1" ]; } && \
[ "$ERR_APP_ERROR" = "0" ]; then
echo "DEV_VERDICT=PASS"
else
echo "DEV_VERDICT=FAIL"
fi
@@ -1,33 +0,0 @@
## Probe: http://localhost:3002/
ROOT_STATUS=200
ROOT_BYTES=53138
ROOT_HAS_PASCAL=0
ROOT_HAS_PASCAL_APP=0
SCRIPT_COUNT=52
NEXT_CHUNK_COUNT=52
EDITOR_VIEWER_CORE_CHUNK_COUNT=31
ERR_APP_ERROR=0
ERR_FAILED_TO=0
ERR_CANNOT=0
## First script src= matches (max 10)
/_next/static/chunks/10-e_next_dist_compiled_next-devtools_index_0c.hc5b.js
/_next/static/chunks/10-e_next_dist_compiled_react-dom_0_a2p7j._.js
/_next/static/chunks/10-e_next_dist_compiled_react-server-dom-turbopack_0~q-o27._.js
/_next/static/chunks/10-e_next_dist_compiled_0z_hko_._.js
/_next/static/chunks/10-e_next_dist_client_0.-jx~k._.js
/_next/static/chunks/10-e_next_dist_04-q5rb._.js
/_next/static/chunks/0xsp_%40swc_helpers_cjs_05abggq._.js
/_next/static/chunks/_worktrees_mcp-server_apps_editor_0rqeker._.js
/_next/static/chunks/turbopack-_worktrees_mcp-server_apps_editor_0mhsrfr._.js
/_next/static/chunks/02ss__bun_02a3.wx._.js
## Probe: http://localhost:3002/api/health
HEALTH_STATUS=200
HEALTH_BYTES=69
HEALTH_BODY_PREVIEW={"status":"ok","app":"editor","timestamp":"2026-04-18T16:17:41.398Z"}
ROOT_TITLE=
## Summary
DEV_VERDICT=FAIL
@@ -1,25 +0,0 @@
[t5] connected to http://localhost:3917/mcp
[t5] listResources count = 3
[t5] listResources names = scene-current, scene-summary, catalog-items
[t5] discovered levelId = level_9qlc3co208erq5o6
[t5] listPrompts count = 3
[t5] listPrompts names = from_brief, iterate_on_feedback, renovation_from_photos
========== T5 SUMMARY ==========
listResources count: 3
listPrompts count: 3
--- Resource results ---
PASS scene/current — application/json, nodes=3, rootNodeIds=1
PASS scene/current/summary — text/markdown, 375 bytes, preview: "# Scene summary | | - Sites: 1 Buildings: 1 Levels: 1 | - Root nodes: 1"
PASS catalog/items — application/json, status=catalog_unavailable, items.length=0
PASS constraints/{levelId} — levelId=level_9qlc3co208erq5o6, slabs=0, wallPolygons=0
Resources pass: 4/4
--- Prompt results ---
PASS from_brief — messages=1, userMsgs=1, brief-included=true, preview: "You are a Pascal 3D scene designer. You have access to the `apply_patch` tool for all scene mutations. Prefer it over in"
PASS iterate_on_feedback — messages=1, userMsgs=1, feedback-included=true, preview: "You are iterating on an existing Pascal scene based on user feedback. Given the current state (read via the `pascal://sc"
PASS renovation_from_photos — messages=7, userMsgs=7, urls(a/b/c)=true/true/true, goals-included=true
Prompts pass: 3/3
================================
@@ -1,429 +0,0 @@
import { dirname, resolve as pathResolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const HTTP_URL = new URL('http://localhost:3917/mcp')
// Fallback path: launch a fresh stdio binary if HTTP is locked. The MCP HTTP
// server runs a single shared StreamableHTTPServerTransport whose `_initialized`
// + `sessionId` are claimed by the first connecting client and never released
// when other agents hold the slot — see SDK
// `webStandardStreamableHttp.js:425` (rejects re-init in stateful mode).
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const STDIO_BIN = pathResolve(__dirname, '../../dist/bin/pascal-mcp.js')
type Outcome = { name: string; pass: boolean; detail: string }
function ok(name: string, detail: string): Outcome {
return { name, pass: true, detail }
}
function fail(name: string, detail: string): Outcome {
return { name, pass: false, detail }
}
function safeStringify(value: unknown, max = 400): string {
let out: string
try {
out = JSON.stringify(value)
} catch (err) {
out = `<unserializable: ${err instanceof Error ? err.message : String(err)}>`
}
if (out.length > max) out = `${out.slice(0, max)}...<truncated>`
return out
}
/**
* Connect with bounded retry. The MCP HTTP server is shared with T2/T3/T4,
* so we may transiently see "Server already initialized" while another agent
* holds the in-flight session. Retry with backoff for up to ~30 s.
*/
async function connectWithRetry(): Promise<{
client: Client
transport: StreamableHTTPClientTransport
}> {
const maxAttempts = 30
let lastErr: unknown = null
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const transport = new StreamableHTTPClientTransport(HTTP_URL)
const client = new Client({ name: 't5-resources-prompts', version: '0.0.0' })
try {
await client.connect(transport)
if (attempt > 1) console.log(`[t5] connected on attempt ${attempt}`)
return { client, transport }
} catch (err) {
lastErr = err
try {
await client.close()
} catch {
// ignore
}
const msg = err instanceof Error ? err.message : String(err)
console.warn(`[t5] connect attempt ${attempt} failed: ${msg.slice(0, 200)}`)
// Brief backoff with jitter — keep total under ~30 s.
await new Promise((r) => setTimeout(r, 800 + Math.floor(Math.random() * 400)))
}
}
throw lastErr instanceof Error
? lastErr
: new Error(`failed to connect after ${maxAttempts} attempts`)
}
async function main(): Promise<void> {
const { client } = await connectWithRetry()
const resourceOutcomes: Outcome[] = []
const promptOutcomes: Outcome[] = []
let listResourcesCount = 0
let listPromptsCount = 0
try {
console.log(`[t5] connected to ${HTTP_URL.href}`)
// ---------------- listResources ----------------
try {
const list = await client.listResources()
listResourcesCount = Array.isArray(list.resources) ? list.resources.length : 0
console.log(`[t5] listResources count = ${listResourcesCount}`)
console.log(
`[t5] listResources names = ${(list.resources ?? [])
.map((r) => r.name ?? r.uri)
.join(', ')}`,
)
} catch (err) {
console.error(`[t5] listResources error: ${err instanceof Error ? err.message : String(err)}`)
}
// ---------------- Resource 1: pascal://scene/current ----------------
try {
const result = await client.readResource({ uri: 'pascal://scene/current' })
const c = result.contents?.[0]
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)}`))
} else {
const text = typeof c.text === 'string' ? c.text : ''
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch (err) {
resourceOutcomes.push(
fail(
'scene/current',
`invalid json: ${err instanceof Error ? err.message : String(err)}`,
),
)
parsed = null
}
const obj = parsed as { nodes?: unknown; rootNodeIds?: unknown } | null
if (!obj) {
resourceOutcomes.push(fail('scene/current', 'empty payload'))
} else if (!obj.nodes || typeof obj.nodes !== 'object') {
resourceOutcomes.push(fail('scene/current', 'missing nodes object'))
} else if (!Array.isArray(obj.rootNodeIds)) {
resourceOutcomes.push(fail('scene/current', 'missing rootNodeIds array'))
} else {
const nodeCount = Object.keys(obj.nodes as Record<string, unknown>).length
const rootCount = (obj.rootNodeIds as unknown[]).length
resourceOutcomes.push(
ok('scene/current', `application/json, nodes=${nodeCount}, rootNodeIds=${rootCount}`),
)
}
}
} catch (err) {
resourceOutcomes.push(
fail('scene/current', `threw: ${err instanceof Error ? err.message : String(err)}`),
)
}
// ---------------- Resource 2: pascal://scene/current/summary ----------------
try {
const result = await client.readResource({ uri: 'pascal://scene/current/summary' })
const c = result.contents?.[0]
if (!c) {
resourceOutcomes.push(fail('scene/current/summary', 'no contents returned'))
} else if (c.mimeType !== 'text/markdown') {
resourceOutcomes.push(
fail('scene/current/summary', `wrong mime type: ${String(c.mimeType)}`),
)
} else {
const text = typeof c.text === 'string' ? c.text : ''
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'))
} else if (!hasZoneOrLevel) {
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(' | ')
resourceOutcomes.push(
ok(
'scene/current/summary',
`text/markdown, ${text.length} bytes, preview: "${preview.slice(0, 200)}"`,
),
)
}
}
} catch (err) {
resourceOutcomes.push(
fail('scene/current/summary', `threw: ${err instanceof Error ? err.message : String(err)}`),
)
}
// ---------------- Resource 3: pascal://catalog/items ----------------
try {
const result = await client.readResource({ uri: 'pascal://catalog/items' })
const c = result.contents?.[0]
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)}`))
} 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)}`,
),
)
} else {
resourceOutcomes.push(
ok(
'catalog/items',
`application/json, status=catalog_unavailable, items.length=${
Array.isArray(parsed.items) ? parsed.items.length : 'N/A'
}`,
),
)
}
}
} catch (err) {
resourceOutcomes.push(
fail('catalog/items', `threw: ${err instanceof Error ? err.message : String(err)}`),
)
}
// ---------------- Resource 4: pascal://constraints/{levelId} ----------------
// Discover levelId via find_nodes tool.
let discoveredLevelId: string | null = null
try {
const findResult = await client.callTool({
name: 'find_nodes',
arguments: { type: 'level' },
})
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 }
if (typeof first?.id === 'string') {
discoveredLevelId = first.id
}
}
console.log(`[t5] discovered levelId = ${String(discoveredLevelId)}`)
} catch (err) {
console.error(`[t5] find_nodes threw: ${err instanceof Error ? err.message : String(err)}`)
}
if (!discoveredLevelId) {
resourceOutcomes.push(
fail('constraints/{levelId}', 'no level node discovered via find_nodes'),
)
} else {
try {
const uri = `pascal://constraints/${discoveredLevelId}`
const result = await client.readResource({ uri })
const c = result.contents?.[0]
if (!c) {
resourceOutcomes.push(fail('constraints/{levelId}', 'no contents returned'))
} else if (c.mimeType !== 'application/json') {
resourceOutcomes.push(
fail('constraints/{levelId}', `wrong mime type: ${String(c.mimeType)}`),
)
} else {
const text = typeof c.text === 'string' ? c.text : ''
const parsed = JSON.parse(text) as {
slabs?: unknown
wallPolygons?: unknown
error?: unknown
}
if (parsed.error) {
resourceOutcomes.push(
fail('constraints/{levelId}', `error in payload: ${safeStringify(parsed.error)}`),
)
} else if (!Array.isArray(parsed.slabs)) {
resourceOutcomes.push(fail('constraints/{levelId}', 'missing slabs array'))
} else if (!Array.isArray(parsed.wallPolygons)) {
resourceOutcomes.push(fail('constraints/{levelId}', 'missing wallPolygons array'))
} else {
resourceOutcomes.push(
ok(
'constraints/{levelId}',
`levelId=${discoveredLevelId}, slabs=${parsed.slabs.length}, wallPolygons=${parsed.wallPolygons.length}`,
),
)
}
}
} catch (err) {
resourceOutcomes.push(
fail(
'constraints/{levelId}',
`threw: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
}
// ---------------- listPrompts ----------------
try {
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(', ')}`)
} catch (err) {
console.error(`[t5] listPrompts error: ${err instanceof Error ? err.message : String(err)}`)
}
// ---------------- Prompt 1: from_brief ----------------
try {
const result = await client.getPrompt({
name: 'from_brief',
arguments: {
brief: 'A small studio apartment',
constraints: 'max 40 m^2',
},
})
const messages = result.messages ?? []
const userMsgs = messages.filter((m) => m.role === 'user')
if (userMsgs.length === 0) {
promptOutcomes.push(fail('from_brief', 'no user messages returned'))
} else {
const firstText =
userMsgs[0]?.content && 'text' in userMsgs[0].content
? String(userMsgs[0].content.text)
: ''
const mentionsBrief = /studio apartment/i.test(firstText)
promptOutcomes.push(
ok(
'from_brief',
`messages=${messages.length}, userMsgs=${userMsgs.length}, brief-included=${mentionsBrief}, preview: "${firstText.slice(0, 120).replace(/\n/g, ' ')}"`,
),
)
}
} catch (err) {
promptOutcomes.push(
fail('from_brief', `threw: ${err instanceof Error ? err.message : String(err)}`),
)
}
// ---------------- Prompt 2: iterate_on_feedback ----------------
try {
const result = await client.getPrompt({
name: 'iterate_on_feedback',
arguments: { feedback: 'the kitchen is too small' },
})
const messages = result.messages ?? []
const userMsgs = messages.filter((m) => m.role === 'user')
if (userMsgs.length === 0) {
promptOutcomes.push(fail('iterate_on_feedback', 'no user messages returned'))
} else {
const firstText =
userMsgs[0]?.content && 'text' in userMsgs[0].content
? String(userMsgs[0].content.text)
: ''
const mentionsFeedback = /kitchen is too small/i.test(firstText)
promptOutcomes.push(
ok(
'iterate_on_feedback',
`messages=${messages.length}, userMsgs=${userMsgs.length}, feedback-included=${mentionsFeedback}, preview: "${firstText.slice(0, 120).replace(/\n/g, ' ')}"`,
),
)
}
} catch (err) {
promptOutcomes.push(
fail('iterate_on_feedback', `threw: ${err instanceof Error ? err.message : String(err)}`),
)
}
// ---------------- Prompt 3: renovation_from_photos ----------------
try {
const result = await client.getPrompt({
name: 'renovation_from_photos',
arguments: {
currentPhotos: 'https://example.com/a.jpg,https://example.com/b.jpg',
referencePhotos: 'https://example.com/c.jpg',
goals: 'open-plan kitchen',
},
})
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'))
} else {
// Look across all message content for the URLs we passed.
const allText = messages
.map((m) =>
m.content && typeof m.content === 'object' && 'text' in m.content
? String((m.content as { text?: unknown }).text ?? '')
: '',
)
.join('\n')
const hasAUrl = /example\.com\/a\.jpg/.test(allText)
const hasBUrl = /example\.com\/b\.jpg/.test(allText)
const hasCUrl = /example\.com\/c\.jpg/.test(allText)
const hasGoals = /open-plan kitchen/i.test(allText)
promptOutcomes.push(
ok(
'renovation_from_photos',
`messages=${messages.length}, userMsgs=${userMsgs.length}, urls(a/b/c)=${hasAUrl}/${hasBUrl}/${hasCUrl}, goals-included=${hasGoals}`,
),
)
}
} catch (err) {
promptOutcomes.push(
fail(
'renovation_from_photos',
`threw: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
} finally {
try {
await client.close()
} catch {
// Ignore close errors.
}
}
// ---------------- Print summary ----------------
console.log('\n========== T5 SUMMARY ==========')
console.log(`listResources count: ${listResourcesCount}`)
console.log(`listPrompts count: ${listPromptsCount}`)
console.log('\n--- Resource results ---')
let resPass = 0
for (const o of resourceOutcomes) {
console.log(`${o.pass ? 'PASS' : 'FAIL'} ${o.name}${o.detail}`)
if (o.pass) resPass++
}
console.log(`Resources pass: ${resPass}/${resourceOutcomes.length}`)
console.log('\n--- Prompt results ---')
let promPass = 0
for (const o of promptOutcomes) {
console.log(`${o.pass ? 'PASS' : 'FAIL'} ${o.name}${o.detail}`)
if (o.pass) promPass++
}
console.log(`Prompts pass: ${promPass}/${promptOutcomes.length}`)
console.log('================================\n')
}
main().catch((err) => {
console.error('[t5] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(1)
})
@@ -1,71 +0,0 @@
# Villa Azul — build + 10-agent verification summary
**Scene id:** `a6e7919eacbe` | **version:** 1 (bumped to 3 by V5's PATCH tests, restored to name "Villa Azul") | **56 nodes** | **44,299 bytes on disk** | **url:** http://localhost:3002/scene/a6e7919eacbe
## Build (phases 111)
| # | Phase | Result |
|---|---|---|
| 01 | Discover site/building/level | OK |
| 02 | 4 perimeter walls | OK (15×10 envelope, thickness 0.22, height 2.8) |
| 03 | 8 interior walls | OK |
| 04 | 9 interior zones | OK (master bedroom/bath, bed2, shared bath, bed3, living/dining, kitchen, entry hall, corridor) |
| 05 | 10 doors | 10/10 cut successfully |
| 06 | 12 windows | 12/12 cut successfully |
| 07 | Pool zone (8×4) + basin slab at 2.0 m | OK |
| 08 | Outdoor kitchen + driveway + back patio zones | OK (3 exterior zones) |
| 09 | 5 rail-style fences with 2 m south-entrance gap | OK |
| 10 | `validate_scene` | **valid=true, 0 errors** |
| 11 | `save_scene({ name: 'Villa Azul' })` | id=`a6e7919eacbe` v=1 |
## Node totals
| type | count |
|---|---|
| site | 1 |
| building | 1 |
| level | 1 |
| wall | 12 |
| zone | 13 |
| door | 10 |
| window | 12 |
| slab | 1 (pool basin) |
| fence | 5 |
| **total** | **56** |
## Verification matrix
| Agent | Scope | Result |
|---|---|---|
| **V1** | Zod schema per node | **56/56 PASS**, parent-child refs consistent |
| **V2** | Geometric integrity (perimeter, interior T-junctions, no overlaps, fence gap) | **7/7 PASS** |
| **V3** | Dimensions + areas | **13/13 zone areas exact**; flagged: site polygon is core's default 30×30, not the 25×20 I specified (known core default) |
| **V4** | Opening fit + overlap | **22/22 dimensional fit PASS**; flagged: 2 window pairs on south wall overlap (< 0.2 m gap); `cut_opening` tool doesn't check adjacency |
| **V5** | Editor HTTP API | **10/10 PASS** (GET/POST/PUT/PATCH/DELETE/HEAD + If-Match conflict resolution) |
| **V6** | Next.js page render | **14/14 PASS** (/scene/:id 81 KB, /scenes 20 KB with link, 404 fallback) |
| **V7** | Parentage integrity | 4/7 PASS + 3 pre-existing CROSS_CUTTING §2 flags (site→building→level parentId=null in core's default loadScene; does NOT affect our MCP-created nodes which have proper chains) |
| **V8** | Save/load round-trip | **10/10 PASS**, byte-equal stable stringify, `duplicate_level` produces 110 nodes correctly |
| **V9** | Spatial queries + resources | **12/12 PASS** (find_nodes counts, measure=19.6 m, pool elevation 2, constraints resource lists 12 walls + 1 slab) |
| **V10** | Chrome visual | HTML fallback (Chrome extension disconnected); 3 probes 200 OK, 56-node graph intact through API |
## Aggregate
**108 checks, 104 PASS, 4 flagged as findings.**
The 4 findings:
1. Site polygon default (30×30 vs my spec's 25×20) — core loadScene default, not a build bug.
2. Building + level `parentId = null` in core's default loadScene — pre-existing (CROSS_CUTTING §2); all 53 MCP-created nodes have correct parent chains.
3. `cut_opening` doesn't check adjacency with existing openings on the same wall — **real MCP tool gap**, worth a follow-up (add an `opening-collision` check).
4. Villa Azul's south wall packed 4 windows + 2 doors with 2 pairs < 0.2 m apart — build-script authoring mistake, easily fixed by spreading positions (no downstream impact, scene still validates and renders).
## Open in browser
- Villa Azul scene: http://localhost:3002/scene/a6e7919eacbe
- All scenes list: http://localhost:3002/scenes
## Files
- `build.ts` + `build-summary.json`
- `v1-schema.*` through `v10-visual.*`
Scene is fully functional, structurally valid, and ready for continued work. The tool gap (cut_opening overlap detection) is a good v0.2 item.
@@ -1,30 +0,0 @@
{
"sceneId": "a6e7919eacbe",
"version": 1,
"nodeCount": 56,
"sizeBytes": 44299,
"url": "http://localhost:3002/scene/a6e7919eacbe",
"typeCounts": {
"site": 1,
"building": 1,
"level": 1,
"wall": 12,
"zone": 13,
"door": 10,
"window": 12,
"slab": 1,
"fence": 5
},
"validation": {
"valid": true,
"errors": 0
},
"doorResults": {
"ok": 10,
"fail": 0
},
"windowResults": {
"ok": 12,
"fail": 0
}
}
@@ -1,410 +0,0 @@
/**
* Villa Azul — build the scene via MCP HTTP, save via save_scene.
* Usage:
* PASCAL_DATA_DIR=/tmp/pascal-villa bun run packages/mcp/test-reports/villa-azul/build.ts
* Assumes MCP HTTP server is listening on :3917.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const MCP_URL = 'http://localhost:3917/mcp'
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL))
const client = new Client({ name: 'villa-azul-build', version: '0.0.0' })
await client.connect(transport)
function structured<T>(result: Awaited<ReturnType<Client['callTool']>>): T {
const text = (result.content as Array<{ text: string }>)[0]!.text
return JSON.parse(text) as T
}
async function call<T>(name: string, args: Record<string, unknown> = {}): Promise<T> {
const r = await client.callTool({ name, arguments: args })
if (r.isError) {
throw new Error(`${name} failed: ${JSON.stringify(r.content).slice(0, 400)}`)
}
return structured<T>(r)
}
type Node = { id: string; type: string; [k: string]: unknown }
type Scene = { nodes: Record<string, Node>; rootNodeIds: string[] }
type Meta = {
id: string
name: string
version: number
nodeCount: number
sizeBytes: number
url: string
}
type WallId = string & { readonly _brand: 'wall' }
console.log('---- Villa Azul build ----')
// Step 1 — Discover the default site/building/level
const scene0 = await call<Scene>('get_scene')
const buildingId = Object.values(scene0.nodes).find((n) => n.type === 'building')!.id
const levelId = Object.values(scene0.nodes).find((n) => n.type === 'level')!.id
console.log(`01 discover buildingId=${buildingId} levelId=${levelId}`)
// Step 2 — Perimeter walls of the main volume (15 × 10, offset so pool sits east)
// Building occupies x ∈ [10, 5], z ∈ [5, 5]. Pool sits east of building.
const perim = [
{ label: 'south', start: [-10, 5], end: [5, 5] },
{ label: 'north', start: [-10, -5], end: [5, -5] },
{ label: 'west', start: [-10, -5], end: [-10, 5] },
{ label: 'east', start: [5, -5], end: [5, 5] },
]
const perimWallIds: Record<string, WallId> = {}
for (const { label, start, end } of perim) {
const r = await call<{ wallId: WallId }>('create_wall', {
levelId,
start,
end,
thickness: 0.22,
height: 2.8,
})
perimWallIds[label] = r.wallId
}
console.log(`02 perimeter ${Object.values(perimWallIds).join(', ')}`)
// Step 3 — Interior partitions
// Layout from west to east:
// x=-10..-7 → Master bedroom (z=-5..1)
// x=-10..-7 → Master bath (z=1..5)
// x=-7..-4 → Bedroom 2 (z=-5..-1); Bath shared (z=-1..1); Bedroom 3 (z=1..5)
// x=-4..2 → Living/dining (z=-5..2); Kitchen (z=2..5)
// x=2..5 → Entry hall (z=-5..0); Corridor (z=0..5)
// Interior partitions (start/end in level plane):
const interior = [
{ label: 'master-east', start: [-7, -5], end: [-7, 5] }, // separates master from center
{ label: 'master-bath', start: [-10, 1], end: [-7, 1] }, // splits master bedroom from master bath
{ label: 'bed2-north', start: [-7, -1], end: [-4, -1] }, // separates bed2 from bath shared
{ label: 'bed3-south', start: [-7, 1], end: [-4, 1] }, // separates bath shared from bed3
{ label: 'center-east', start: [-4, -5], end: [-4, 5] }, // separates bedrooms from living
{ label: 'kitchen-south', start: [-4, 2], end: [2, 2] }, // splits living from kitchen
{ label: 'hall-west', start: [2, -5], end: [2, 5] }, // separates hall/corridor from living
{ label: 'hall-south', start: [2, 0], end: [5, 0] }, // splits entry hall from corridor
]
const interiorWallIds: Record<string, WallId> = {}
for (const { label, start, end } of interior) {
const r = await call<{ wallId: WallId }>('create_wall', {
levelId,
start,
end,
thickness: 0.12,
height: 2.8,
})
interiorWallIds[label] = r.wallId
}
console.log(`03 interior ${Object.keys(interiorWallIds).length} walls created`)
// Step 4 — Zones
const zones = [
{
label: 'Master bedroom',
polygon: [
[-10, -5],
[-7, -5],
[-7, 1],
[-10, 1],
],
},
{
label: 'Master bath',
polygon: [
[-10, 1],
[-7, 1],
[-7, 5],
[-10, 5],
],
},
{
label: 'Bedroom 2',
polygon: [
[-7, -5],
[-4, -5],
[-4, -1],
[-7, -1],
],
},
{
label: 'Shared bath',
polygon: [
[-7, -1],
[-4, -1],
[-4, 1],
[-7, 1],
],
},
{
label: 'Bedroom 3',
polygon: [
[-7, 1],
[-4, 1],
[-4, 5],
[-7, 5],
],
},
{
label: 'Living dining',
polygon: [
[-4, -5],
[2, -5],
[2, 2],
[-4, 2],
],
},
{
label: 'Kitchen',
polygon: [
[-4, 2],
[2, 2],
[2, 5],
[-4, 5],
],
},
{
label: 'Entry hall',
polygon: [
[2, -5],
[5, -5],
[5, 0],
[2, 0],
],
},
{
label: 'Corridor',
polygon: [
[2, 0],
[5, 0],
[5, 5],
[2, 5],
],
},
]
const zoneIds: string[] = []
for (const { label, polygon } of zones) {
const r = await call<{ zoneId: string }>('set_zone', { levelId, polygon, label })
zoneIds.push(r.zoneId)
}
console.log(`04 zones ${zoneIds.length} zones`)
// Step 5 — Doors
const doors = [
{ wallId: perimWallIds.south, pos: 0.9, w: 1.0, h: 2.1, label: 'front-door' },
{ wallId: perimWallIds.north, pos: 0.75, w: 0.9, h: 2.1, label: 'kitchen-back' },
{ wallId: perimWallIds.south, pos: 0.4, w: 2.4, h: 2.2, label: 'living-patio' },
{ wallId: perimWallIds.east, pos: 0.75, w: 1.8, h: 2.2, label: 'pool-slider' },
{ wallId: interiorWallIds['master-east']!, pos: 0.25, w: 0.8, h: 2.05, label: 'master-door' },
{ wallId: interiorWallIds['master-bath']!, pos: 0.5, w: 0.7, h: 2.0, label: 'master-bath-door' },
{ wallId: interiorWallIds['center-east']!, pos: 0.12, w: 0.8, h: 2.05, label: 'bed2-door' },
{ wallId: interiorWallIds['center-east']!, pos: 0.88, w: 0.8, h: 2.05, label: 'bed3-door' },
{ wallId: interiorWallIds['bed2-north']!, pos: 0.5, w: 0.7, h: 2.0, label: 'shared-bath-door' },
{ wallId: interiorWallIds['hall-west']!, pos: 0.9, w: 0.9, h: 2.05, label: 'hall-to-living' },
]
let doorOk = 0
let doorFail = 0
for (const d of doors) {
try {
await call<{ openingId: string }>('cut_opening', {
wallId: d.wallId,
type: 'door',
position: d.pos,
width: d.w,
height: d.h,
})
doorOk++
} catch (err) {
doorFail++
console.log(` door fail ${d.label}: ${(err as Error).message.slice(0, 80)}`)
}
}
console.log(`05 doors ${doorOk}/${doors.length} ok (${doorFail} failed)`)
// Step 6 — Windows
const windows = [
{ wallId: perimWallIds.south, pos: 0.15, w: 1.4, h: 1.5, label: 'master-s-window' },
{ wallId: perimWallIds.south, pos: 0.65, w: 2.0, h: 1.5, label: 'living-s-window' },
{ wallId: perimWallIds.north, pos: 0.15, w: 1.0, h: 1.4, label: 'bed3-n-window' },
{ wallId: perimWallIds.north, pos: 0.55, w: 1.4, h: 1.4, label: 'kitchen-n-window' },
{ wallId: perimWallIds.west, pos: 0.2, w: 1.0, h: 1.4, label: 'master-w-window' },
{ wallId: perimWallIds.west, pos: 0.75, w: 0.8, h: 0.9, label: 'master-bath-w-window' },
{ wallId: perimWallIds.east, pos: 0.15, w: 1.0, h: 1.4, label: 'entry-e-window' },
{ wallId: perimWallIds.east, pos: 0.4, w: 0.9, h: 1.4, label: 'corridor-e-window' },
{ wallId: interiorWallIds['master-bath']!, pos: 0.2, w: 0.6, h: 0.6, label: 'bath-transom' },
{ wallId: perimWallIds.north, pos: 0.35, w: 0.8, h: 0.7, label: 'shared-bath-nw' },
{ wallId: perimWallIds.south, pos: 0.22, w: 1.2, h: 1.5, label: 'bed-corridor-window' },
{ wallId: perimWallIds.south, pos: 0.55, w: 1.4, h: 1.5, label: 'living-s-2' },
]
let winOk = 0
let winFail = 0
for (const w of windows) {
try {
await call<{ openingId: string }>('cut_opening', {
wallId: w.wallId,
type: 'window',
position: w.pos,
width: w.w,
height: w.h,
})
winOk++
} catch (_err) {
winFail++
}
}
console.log(`06 windows ${winOk}/${windows.length} ok (${winFail} failed)`)
// Step 7 — Pool zone (east of house) + pool basin slab
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[7, -2],
[15, -2],
[15, 2],
[7, 2],
],
label: 'Pool',
properties: { kind: 'pool', depthM: 2.0, finish: 'tile' },
})
console.log('07 pool zone created')
const slabOpId = 'slab_azul_pool'
await call<{ appliedOps: number; createdIds: string[] }>('apply_patch', {
patches: [
{
op: 'create',
node: {
object: 'node',
id: slabOpId,
type: 'slab',
parentId: null,
visible: true,
metadata: { kind: 'pool-basin', depthM: 2.0 },
polygon: [
[7, -2],
[15, -2],
[15, 2],
[7, 2],
],
holes: [],
holeMetadata: [],
elevation: -2.0,
autoFromWalls: false,
},
parentId: levelId,
},
],
})
console.log('07b pool basin slab at elevation -2.0m')
// Step 8 — Outdoor kitchen zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[7, 3],
[12, 3],
[12, 6],
[7, 6],
],
label: 'Outdoor kitchen',
properties: { kind: 'outdoor-kitchen' },
})
// Step 9 — Driveway zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[-12.5, 5.5],
[-6, 5.5],
[-6, 10],
[-12.5, 10],
],
label: 'Driveway',
properties: { kind: 'driveway', surface: 'concrete' },
})
// Step 10 — Back patio zone
await call<{ zoneId: string }>('set_zone', {
levelId,
polygon: [
[-5, 5.5],
[5, 5.5],
[5, 7.5],
[-5, 7.5],
],
label: 'Back patio',
properties: { kind: 'patio' },
})
console.log('08 exterior zones added (outdoor kitchen, driveway, back patio)')
// Step 11 — Rail-style fence around lot perimeter (25 × 20, corners ±12.5, ±10)
const fences = [
{ start: [-12.5, 10], end: [-1, 10] }, // north-west
{ start: [1, 10], end: [12.5, 10] }, // north-east (gap at entrance)
{ start: [12.5, 10], end: [12.5, -10] }, // east
{ start: [12.5, -10], end: [-12.5, -10] }, // south
{ start: [-12.5, -10], end: [-12.5, 10] }, // west
]
const fencePatches = fences.map(({ start, end }) => ({
op: 'create' as const,
node: {
type: 'fence' as const,
start,
end,
height: 1.5,
style: 'rail' as const,
thickness: 0.08,
baseHeight: 0.1,
postSpacing: 2,
postSize: 0.1,
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.01,
baseStyle: 'grounded' as const,
color: '#ffffff',
},
parentId: levelId,
}))
await call<{ appliedOps: number; createdIds: string[] }>('apply_patch', {
patches: fencePatches,
})
console.log(`09 fences ${fences.length} rail segments (gap at south entrance)`)
// Step 12 — Validate
const validate = await call<{ valid: boolean; errors: unknown[] }>('validate_scene')
console.log(`10 validate valid=${validate.valid} errors=${validate.errors.length}`)
// Step 13 — Save
const meta = await call<Meta>('save_scene', { name: 'Villa Azul' })
console.log(`11 save id=${meta.id} version=${meta.version} nodes=${meta.nodeCount}`)
console.log(` url: ${meta.url}`)
console.log(` sizeBytes: ${meta.sizeBytes}`)
// Step 14 — Emit final counts + sceneId for verifier agents
const scene = await call<Scene>('get_scene')
const typeCounts = new Map<string, number>()
for (const n of Object.values(scene.nodes)) {
typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1)
}
const summary = {
sceneId: meta.id,
version: meta.version,
nodeCount: meta.nodeCount,
sizeBytes: meta.sizeBytes,
url: `http://localhost:3002${meta.url}`,
typeCounts: Object.fromEntries(typeCounts),
validation: { valid: validate.valid, errors: validate.errors.length },
doorResults: { ok: doorOk, fail: doorFail },
windowResults: { ok: winOk, fail: winFail },
}
console.log('\n=== SUMMARY ===')
console.log(JSON.stringify(summary, null, 2))
// Write the summary to a known location so verifier agents can read it
const summaryPath = 'packages/mcp/test-reports/villa-azul/build-summary.json'
await Bun.write(summaryPath, JSON.stringify(summary, null, 2))
console.log(`\nwrote ${summaryPath}`)
await client.close()
@@ -1,71 +0,0 @@
/**
* Populate the shared scene store with all reference scenes so /scenes
* shows the full gallery: Villa Azul + 3 templates + Casa del Sol (imported
* from the Phase 8 store).
*/
import { readFileSync, existsSync } from 'node:fs'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3917/mcp'))
const client = new Client({ name: 'gallery', version: '0.0.0' })
await client.connect(transport)
function parse<T>(r: Awaited<ReturnType<Client['callTool']>>): T {
return JSON.parse((r.content as Array<{ text: string }>)[0]!.text) as T
}
async function call<T>(name: string, args: Record<string, unknown> = {}): Promise<T> {
const r = await client.callTool({ name, arguments: args })
if (r.isError) throw new Error(`${name}: ${JSON.stringify(r.content).slice(0, 300)}`)
return parse<T>(r)
}
type Meta = { id: string; name: string; nodeCount: number; url: string }
// 1. Import Casa del Sol (if it exists on disk from Phase 8)
const casaSrcPath = '/tmp/pascal-phase8/scenes/6f87c59c1535.json'
if (existsSync(casaSrcPath)) {
const raw = JSON.parse(readFileSync(casaSrcPath, 'utf8'))
const meta = await call<Meta>('save_scene', {
name: 'Casa del Sol',
includeCurrentScene: false,
graph: { nodes: raw.graph?.nodes ?? raw.nodes, rootNodeIds: raw.graph?.rootNodeIds ?? raw.rootNodeIds },
})
console.log(`casa-sol imported: ${meta.name} (${meta.nodeCount} nodes) ${meta.url}`)
} else {
console.log('casa-sol src missing; skipping')
}
// 2. Create 3 template scenes
const templates = ['empty-studio', 'two-bedroom', 'garden-house'] as const
for (const t of templates) {
// create_from_template applies to the bridge
await call('create_from_template', { id: t, name: `Template: ${t}` })
const meta = await call<Meta>('save_scene', { name: `Template: ${t}` })
console.log(`${t} saved: ${meta.name} (${meta.nodeCount} nodes) ${meta.url}`)
}
// 3. Generate 3 variants of Villa Azul
await call('load_scene', { id: 'a6e7919eacbe' })
const variants = await call<{ variants: Array<{ sceneId?: string; url?: string; description: string }> }>(
'generate_variants',
{ baseSceneId: 'a6e7919eacbe', count: 3, vary: ['wall-thickness', 'wall-height'], seed: 7, save: true },
)
console.log(`variants generated:`)
for (const v of variants.variants) {
console.log(` ${v.description}${v.url}`)
}
// 4. List final gallery
const list = await call<{ scenes: Array<{ id: string; name: string; nodeCount: number }> }>(
'list_scenes',
{},
)
console.log(`\n=== Gallery: ${list.scenes.length} scenes ===`)
for (const s of list.scenes) {
console.log(` http://localhost:3002/scene/${s.id} - ${s.name} (${s.nodeCount} nodes)`)
}
await client.close()

Some files were not shown because too many files have changed in this diff Show More