diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index 81ff4815..d70b6db1 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -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}"` }, diff --git a/apps/editor/lib/scene-store-server.test.ts b/apps/editor/lib/scene-store-server.test.ts index 90d11d2a..684d09dc 100644 --- a/apps/editor/lib/scene-store-server.test.ts +++ b/apps/editor/lib/scene-store-server.test.ts @@ -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, diff --git a/apps/editor/lib/scene-store-server.ts b/apps/editor/lib/scene-store-server.ts index 3eb61add..1deb3248 100644 --- a/apps/editor/lib/scene-store-server.ts +++ b/apps/editor/lib/scene-store-server.ts @@ -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 - load(id: SceneId): Promise - list(opts?: SceneListOptions): Promise - delete(id: SceneId, opts?: SceneMutateOptions): Promise - rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise -} +import type { SceneStore } from '@pascal-app/mcp/storage' /** * Per-process singleton. The factory is async because backend modules are diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index e802f23e..6c684f1b 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -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', diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index 2d55f436..70924e11 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -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" } diff --git a/bun.lock b/bun.lock index 7d9ff1c0..56ac447c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index f536a4bb..9adaa851 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -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. diff --git a/packages/mcp/CROSS_CUTTING.md b/packages/mcp/CROSS_CUTTING.md index 268d5ba5..1b18c91f 100644 --- a/packages/mcp/CROSS_CUTTING.md +++ b/packages/mcp/CROSS_CUTTING.md @@ -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. --- - diff --git a/packages/mcp/PLAN.md b/packages/mcp/PLAN.md index 73640417..30f9ad53 100644 --- a/packages/mcp/PLAN.md +++ b/packages/mcp/PLAN.md @@ -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" diff --git a/packages/mcp/PR_DESCRIPTION.md b/packages/mcp/PR_DESCRIPTION.md index dc51490b..edf0c238 100644 --- a/packages/mcp/PR_DESCRIPTION.md +++ b/packages/mcp/PR_DESCRIPTION.md @@ -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 ────────────────┐ │ -│ │ headless Zustand store + Zundo │ │ -│ │ RAF polyfill at import time │ │ -│ │ zod validation at every boundary │ │ -│ └──────────────────────────────────────────┘ │ +│ ┌──────────── SceneBridge + SceneStore ───────────────┐ │ +│ │ headless Zustand store + Zundo │ │ +│ │ 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/`. ## 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://` 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/` and `/scene/` 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. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 450c347d..4324b890 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -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. diff --git a/packages/mcp/examples/embed-in-agent.ts b/packages/mcp/examples/embed-in-agent.ts index 52a96e64..d553833b 100644 --- a/packages/mcp/examples/embed-in-agent.ts +++ b/packages/mcp/examples/embed-in-agent.ts @@ -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: diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 42cadd89..1c095d50 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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": { diff --git a/packages/mcp/sql/README.md b/packages/mcp/sql/README.md deleted file mode 100644 index e4526118..00000000 --- a/packages/mcp/sql/README.md +++ /dev/null @@ -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 - -# Each migration — run once, in order -supabase db execute --file packages/mcp/sql/migrations/0001_scenes.sql -``` - -For a brand-new project you can also drop the files into -`supabase/migrations/` and use `supabase db push`, but the -`db execute --file` form works for any existing project without adopting the -CLI's migration tracking. - -## Option 2 — Apply via the Supabase Dashboard - -1. Open your project at . -2. `SQL Editor → New query`. -3. Paste the contents of `packages/mcp/sql/migrations/0001_scenes.sql`. -4. `Run`. You should see `Success. No rows returned.` - -Re-running the file is safe; every statement is guarded with -`if not exists` / `create or replace`. - -## Verifying the install - -In the dashboard SQL editor: - -```sql -select table_name -from information_schema.tables -where table_schema = 'public' - and table_name in ('projects', 'scenes', 'scene_revisions') -order by table_name; -``` - -All three should be present. Check `Database → Policies` to confirm RLS is -enabled with the `scenes_owner_all`, `scenes_public_read`, -`revisions_owner_read`, and `projects_owner_all` policies. - -## Environment variables consumed by the MCP server - -| Variable | Required | Notes | -| ---------------------------- | -------- | ------------------------------------------ | -| `SUPABASE_URL` | yes | `https://.supabase.co` | -| `SUPABASE_SERVICE_ROLE_KEY` | yes | Server-side only. Never log this value. | - -When both are set, `createSceneStore()` picks the Supabase backend; otherwise -it falls back to the filesystem store. diff --git a/packages/mcp/sql/migrations/0001_scenes.sql b/packages/mcp/sql/migrations/0001_scenes.sql deleted file mode 100644 index 06e6271c..00000000 --- a/packages/mcp/sql/migrations/0001_scenes.sql +++ /dev/null @@ -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(); diff --git a/packages/mcp/src/bin/pascal-mcp.ts b/packages/mcp/src/bin/pascal-mcp.ts index e1409d9f..31c032df 100644 --- a/packages/mcp/src/bin/pascal-mcp.ts +++ b/packages/mcp/src/bin/pascal-mcp.ts @@ -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' diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index fefe460f..a0d07f2c 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -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 { const real = await resolve() diff --git a/packages/mcp/src/storage/filesystem-scene-store.test.ts b/packages/mcp/src/storage/filesystem-scene-store.test.ts deleted file mode 100644 index 36adce33..00000000 --- a/packages/mcp/src/storage/filesystem-scene-store.test.ts +++ /dev/null @@ -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 { - return { - nodes: { - site_abc: { - object: 'node', - id: 'site_abc', - type: 'site', - parentId: null, - visible: true, - metadata: {}, - }, - building_def: { - object: 'node', - id: 'building_def', - type: 'building', - parentId: 'site_abc', - visible: true, - metadata: {}, - }, - } as SceneGraph['nodes'], - rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'], - ...overrides, - } -} - -async function mkTmpRoot(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-test-')) -} - -async function rmrf(p: string): Promise { - await fs.rm(p, { recursive: true, force: true }) -} - -function createStore(rootDir: string, opts: Partial = {}) { - return new FilesystemSceneStore({ rootDir, ...opts }) -} - -describe('resolveDefaultRootDir', () => { - test('respects PASCAL_DATA_DIR when set', () => { - const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '/custom/pascal' }) - expect(dir).toBe('/custom/pascal') - }) - - test('ignores empty PASCAL_DATA_DIR', () => { - const dir = resolveDefaultRootDir({ PASCAL_DATA_DIR: '', HOME: '/home/user' }) - expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true) - }) - - test('falls back to XDG_DATA_HOME', () => { - if (process.platform === 'win32') return - const dir = resolveDefaultRootDir({ XDG_DATA_HOME: '/xdg/share' }) - expect(dir).toBe(path.join('/xdg/share', 'pascal', 'data')) - }) - - test('falls back to homedir + .pascal/data', () => { - if (process.platform === 'win32') return - const dir = resolveDefaultRootDir({}) - expect(dir.endsWith(path.join('.pascal', 'data'))).toBe(true) - }) -}) - -describe('FilesystemSceneStore', () => { - let rootDir: string - let store: FilesystemSceneStore - - beforeEach(async () => { - rootDir = await mkTmpRoot() - store = createStore(rootDir) - }) - - afterEach(async () => { - await rmrf(rootDir) - }) - - // ----------- Construction / defaults ----------- - - test('backend is "filesystem"', () => { - expect(store.backend).toBe('filesystem') - }) - - test('resolves default root when no rootDir is passed', () => { - const fallback = new FilesystemSceneStore({ env: { PASCAL_DATA_DIR: rootDir } }) - expect(fallback.backend).toBe('filesystem') - }) - - // ----------- save() ----------- - - test('generates an id when none is provided', async () => { - const meta = await store.save({ name: 'Scratch', graph: makeGraph() }) - expect(typeof meta.id).toBe('string') - expect(meta.id.length).toBeGreaterThan(0) - expect(meta.version).toBe(1) - }) - - test('round-trip save → load preserves graph exactly', async () => { - const graph = makeGraph() - const saved = await store.save({ id: 'kitchen', name: 'Kitchen', graph }) - expect(saved.id).toBe('kitchen') - const loaded = await store.load('kitchen') - expect(loaded).not.toBeNull() - expect(loaded!.graph).toEqual(graph) - expect(loaded!.name).toBe('Kitchen') - expect(loaded!.nodeCount).toBe(2) - expect(loaded!.version).toBe(1) - }) - - test('stores projectId, ownerId, and thumbnailUrl verbatim', async () => { - await store.save({ - id: 'meta-test', - name: 'Meta', - graph: makeGraph(), - projectId: 'proj-1', - ownerId: 'user-42', - thumbnailUrl: 'https://example.com/t.png', - }) - const loaded = await store.load('meta-test') - expect(loaded?.projectId).toBe('proj-1') - expect(loaded?.ownerId).toBe('user-42') - expect(loaded?.thumbnailUrl).toBe('https://example.com/t.png') - }) - - test('version bumps by 1 each save', async () => { - const first = await store.save({ id: 'bump', name: 'Bump', graph: makeGraph() }) - expect(first.version).toBe(1) - const second = await store.save({ - id: 'bump', - name: 'Bump', - graph: makeGraph(), - expectedVersion: 1, - }) - expect(second.version).toBe(2) - const third = await store.save({ - id: 'bump', - name: 'Bump', - graph: makeGraph(), - expectedVersion: 2, - }) - expect(third.version).toBe(3) - }) - - test('preserves createdAt on overwrite, updates updatedAt', async () => { - const first = await store.save({ id: 'times', name: 'T', graph: makeGraph() }) - await new Promise((r) => setTimeout(r, 5)) - const second = await store.save({ - id: 'times', - name: 'T', - graph: makeGraph(), - expectedVersion: 1, - }) - expect(second.createdAt).toBe(first.createdAt) - expect(second.updatedAt >= first.updatedAt).toBe(true) - }) - - test('expectedVersion mismatch throws SceneVersionConflictError', async () => { - await store.save({ id: 'conflict', name: 'C', graph: makeGraph() }) - await expect( - store.save({ id: 'conflict', name: 'C', graph: makeGraph(), expectedVersion: 99 }), - ).rejects.toThrow(SceneVersionConflictError) - }) - - test('expectedVersion=0 matches a brand-new id', async () => { - const meta = await store.save({ - id: 'fresh', - name: 'Fresh', - graph: makeGraph(), - expectedVersion: 0, - }) - expect(meta.version).toBe(1) - }) - - test('slug collision (no expectedVersion) throws', async () => { - await store.save({ id: 'kitchen', name: 'K1', graph: makeGraph() }) - await expect(store.save({ id: 'kitchen', name: 'K2', graph: makeGraph() })).rejects.toThrow( - SceneInvalidError, - ) - }) - - test('save without id never collides (generates unique slug)', async () => { - const a = await store.save({ name: 'A', graph: makeGraph() }) - const b = await store.save({ name: 'B', graph: makeGraph() }) - expect(a.id).not.toBe(b.id) - }) - - test('name length 0 throws', async () => { - await expect(store.save({ name: '', graph: makeGraph() })).rejects.toThrow(SceneInvalidError) - }) - - test('name length 201 throws', async () => { - const longName = 'x'.repeat(201) - await expect(store.save({ name: longName, graph: makeGraph() })).rejects.toThrow( - SceneInvalidError, - ) - }) - - test('name length 200 is accepted', async () => { - const name = 'x'.repeat(200) - const meta = await store.save({ name, graph: makeGraph() }) - expect(meta.name).toBe(name) - }) - - test('non-string name throws', async () => { - await expect( - store.save({ name: 123 as unknown as string, graph: makeGraph() }), - ).rejects.toThrow(SceneInvalidError) - }) - - test('whitespace-only name throws', async () => { - await expect(store.save({ name: ' ', graph: makeGraph() })).rejects.toThrow(SceneInvalidError) - }) - - test('too-large scene throws SceneTooLargeError', async () => { - // Build a graph that encodes to > 10 MB in pretty JSON. - const nodes: Record = {} - const bigBlob = 'A'.repeat(2048) - for (let i = 0; i < 6000; i++) { - nodes[`site_${i}`] = { - object: 'node', - id: `site_${i}`, - type: 'site', - parentId: null, - visible: true, - metadata: { blob: bigBlob }, - } - } - const graph = { - nodes, - rootNodeIds: Object.keys(nodes), - } as unknown as SceneGraph - await expect(store.save({ name: 'Big', graph })).rejects.toThrow(SceneTooLargeError) - }) - - test('sanitizes id with path traversal attempt', async () => { - const meta = await store.save({ id: '../escape', name: 'Evil', graph: makeGraph() }) - expect(meta.id).toBe('escape') - const filesInScenes = await fs.readdir(path.join(rootDir, 'scenes')) - expect(filesInScenes).toContain('escape.json') - // Nothing wrote outside the scenes dir - const rootEntries = await fs.readdir(rootDir) - expect(rootEntries).toEqual(['scenes']) - }) - - test('sanitizes mixed-case / whitespace id', async () => { - const meta = await store.save({ id: 'My Kitchen!', name: 'Kitchen', graph: makeGraph() }) - expect(meta.id).toBe('my-kitchen') - }) - - test('fails fast if sanitized id is empty', async () => { - await expect(store.save({ id: '!!!', name: 'Bad', graph: makeGraph() })).rejects.toThrow() - }) - - test('pretty-prints JSON with 2-space indent', async () => { - await store.save({ id: 'pretty', name: 'P', graph: makeGraph() }) - const raw = await fs.readFile(path.join(rootDir, 'scenes', 'pretty.json'), 'utf8') - expect(raw.includes('\n "meta"')).toBe(true) - }) - - test('sizeBytes reflects on-disk byte length', async () => { - const meta = await store.save({ id: 'sized', name: 'S', graph: makeGraph() }) - const stat = await fs.stat(path.join(rootDir, 'scenes', 'sized.json')) - expect(meta.sizeBytes).toBe(stat.size) - }) - - test('nodeCount equals Object.keys(graph.nodes).length', async () => { - const meta = await store.save({ id: 'count', name: 'C', graph: makeGraph() }) - expect(meta.nodeCount).toBe(2) - }) - - test('writes index sidecar after save', async () => { - await store.save({ id: 'idx-a', name: 'A', graph: makeGraph() }) - const idxRaw = await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8') - const parsed = JSON.parse(idxRaw) as Array<{ id: string }> - expect(parsed.map((m) => m.id)).toContain('idx-a') - }) - - // ----------- load() ----------- - - test('load returns null for missing file', async () => { - const result = await store.load('nonexistent') - expect(result).toBeNull() - }) - - test('load throws SceneInvalidError for non-object nodes', async () => { - // Write bogus contents directly. - await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) - const bogus = { - meta: { - id: 'bogus', - name: 'Bogus', - projectId: null, - thumbnailUrl: null, - version: 1, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ownerId: null, - sizeBytes: 0, - nodeCount: 1, - }, - graph: { - nodes: { site_x: 'not-an-object' }, - rootNodeIds: ['site_x'], - }, - } - await fs.writeFile( - path.join(rootDir, 'scenes', 'bogus.json'), - JSON.stringify(bogus, null, 2), - 'utf8', - ) - await expect(store.load('bogus')).rejects.toThrow(SceneInvalidError) - }) - - test('load throws SceneInvalidError when nodes is not an object', async () => { - await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) - const badShape = { - meta: { - id: 'badshape', - name: 'B', - projectId: null, - thumbnailUrl: null, - version: 1, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ownerId: null, - sizeBytes: 0, - nodeCount: 0, - }, - graph: { - nodes: 'hello', - rootNodeIds: [], - }, - } - await fs.writeFile( - path.join(rootDir, 'scenes', 'badshape.json'), - JSON.stringify(badShape), - 'utf8', - ) - await expect(store.load('badshape')).rejects.toThrow(SceneInvalidError) - }) - - test('load throws SceneInvalidError for node missing "type"', async () => { - await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) - const noType = { - meta: { - id: 'notype', - name: 'N', - projectId: null, - thumbnailUrl: null, - version: 1, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - ownerId: null, - sizeBytes: 0, - nodeCount: 1, - }, - graph: { - nodes: { site_x: { id: 'site_x' } }, - rootNodeIds: ['site_x'], - }, - } - await fs.writeFile(path.join(rootDir, 'scenes', 'notype.json'), JSON.stringify(noType), 'utf8') - await expect(store.load('notype')).rejects.toThrow(SceneInvalidError) - }) - - test('load throws SceneInvalidError for unparseable JSON', async () => { - await fs.mkdir(path.join(rootDir, 'scenes'), { recursive: true }) - await fs.writeFile(path.join(rootDir, 'scenes', 'garbage.json'), '{not json', 'utf8') - await expect(store.load('garbage')).rejects.toThrow(SceneInvalidError) - }) - - // ----------- list() ----------- - - test('list returns [] when scenes dir is empty or absent', async () => { - expect(await store.list()).toEqual([]) - }) - - test('list finds all saved scenes', async () => { - await store.save({ id: 'a', name: 'A', graph: makeGraph() }) - await store.save({ id: 'b', name: 'B', graph: makeGraph() }) - await store.save({ id: 'c', name: 'C', graph: makeGraph() }) - const list = await store.list() - expect(list.map((m) => m.id).sort()).toEqual(['a', 'b', 'c']) - }) - - test('list uses index sidecar as fast path', async () => { - await store.save({ id: 'fast', name: 'F', graph: makeGraph() }) - // Corrupt the on-disk json so collectAllMeta would fail; the index should - // still list the entry as long as the file exists. - const list = await store.list() - expect(list.map((m) => m.id)).toContain('fast') - }) - - test('list falls back to readdir when index is absent', async () => { - await store.save({ id: 'slow', name: 'S', graph: makeGraph() }) - await fs.unlink(path.join(rootDir, 'scenes', '.index.json')) - const list = await store.list() - expect(list.map((m) => m.id)).toContain('slow') - }) - - test('list filters by projectId', async () => { - await store.save({ id: 'p1-a', name: 'A', graph: makeGraph(), projectId: 'p1' }) - await store.save({ id: 'p1-b', name: 'B', graph: makeGraph(), projectId: 'p1' }) - await store.save({ id: 'p2-c', name: 'C', graph: makeGraph(), projectId: 'p2' }) - const result = await store.list({ projectId: 'p1' }) - expect(result.map((m) => m.id).sort()).toEqual(['p1-a', 'p1-b']) - }) - - test('list filters by ownerId', async () => { - await store.save({ id: 'u1-a', name: 'A', graph: makeGraph(), ownerId: 'u1' }) - await store.save({ id: 'u2-b', name: 'B', graph: makeGraph(), ownerId: 'u2' }) - const result = await store.list({ ownerId: 'u1' }) - expect(result.map((m) => m.id)).toEqual(['u1-a']) - }) - - test('list respects limit', async () => { - await store.save({ id: 'l1', name: '1', graph: makeGraph() }) - await store.save({ id: 'l2', name: '2', graph: makeGraph() }) - await store.save({ id: 'l3', name: '3', graph: makeGraph() }) - const result = await store.list({ limit: 2 }) - expect(result.length).toBe(2) - }) - - test('list sorts by updatedAt desc', async () => { - await store.save({ id: 'first', name: '1', graph: makeGraph() }) - await new Promise((r) => setTimeout(r, 10)) - await store.save({ id: 'second', name: '2', graph: makeGraph() }) - const result = await store.list() - expect(result[0]?.id).toBe('second') - expect(result[1]?.id).toBe('first') - }) - - test('list ignores tmp files and non-json entries', async () => { - await store.save({ id: 'real', name: 'R', graph: makeGraph() }) - await fs.unlink(path.join(rootDir, 'scenes', '.index.json')) - await fs.writeFile(path.join(rootDir, 'scenes', 'stray.txt'), 'ignored', 'utf8') - await fs.writeFile(path.join(rootDir, 'scenes', 'real.json.tmp'), '{}', 'utf8') - const result = await store.list() - expect(result.map((m) => m.id)).toEqual(['real']) - }) - - test('list drops index entries whose file was removed out-of-band', async () => { - await store.save({ id: 'vanish', name: 'V', graph: makeGraph() }) - await store.save({ id: 'keep', name: 'K', graph: makeGraph() }) - // Bypass delete() — simulate another tool removing the file without updating the index - await fs.unlink(path.join(rootDir, 'scenes', 'vanish.json')) - const result = await store.list() - expect(result.map((m) => m.id)).toEqual(['keep']) - }) - - // ----------- delete() ----------- - - test('delete removes file and returns true', async () => { - await store.save({ id: 'del', name: 'D', graph: makeGraph() }) - const ok = await store.delete('del') - expect(ok).toBe(true) - expect(await store.load('del')).toBeNull() - }) - - test('delete returns false for missing scene', async () => { - expect(await store.delete('ghost')).toBe(false) - }) - - test('delete with matching expectedVersion succeeds', async () => { - await store.save({ id: 'dv', name: 'D', graph: makeGraph() }) - const ok = await store.delete('dv', { expectedVersion: 1 }) - expect(ok).toBe(true) - }) - - test('delete with mismatched expectedVersion throws', async () => { - await store.save({ id: 'dvx', name: 'D', graph: makeGraph() }) - await expect(store.delete('dvx', { expectedVersion: 99 })).rejects.toThrow( - SceneVersionConflictError, - ) - }) - - test('delete updates index', async () => { - await store.save({ id: 'i1', name: '1', graph: makeGraph() }) - await store.save({ id: 'i2', name: '2', graph: makeGraph() }) - await store.delete('i1') - const idx = JSON.parse( - await fs.readFile(path.join(rootDir, 'scenes', '.index.json'), 'utf8'), - ) as Array<{ id: string }> - expect(idx.map((m) => m.id)).toEqual(['i2']) - }) - - // ----------- rename() ----------- - - test('rename updates name and bumps version', async () => { - await store.save({ id: 'ren', name: 'Original', graph: makeGraph() }) - const renamed = await store.rename('ren', 'Shiny') - expect(renamed.name).toBe('Shiny') - expect(renamed.version).toBe(2) - const loaded = await store.load('ren') - expect(loaded?.name).toBe('Shiny') - }) - - test('rename preserves graph exactly', async () => { - const graph = makeGraph() - await store.save({ id: 'rg', name: 'Before', graph }) - await store.rename('rg', 'After') - const loaded = await store.load('rg') - expect(loaded?.graph).toEqual(graph) - }) - - test('rename preserves projectId / ownerId / thumbnailUrl', async () => { - await store.save({ - id: 'rmeta', - name: 'Before', - graph: makeGraph(), - projectId: 'p', - ownerId: 'u', - thumbnailUrl: 'https://x.y/z', - }) - const renamed = await store.rename('rmeta', 'After') - expect(renamed.projectId).toBe('p') - expect(renamed.ownerId).toBe('u') - expect(renamed.thumbnailUrl).toBe('https://x.y/z') - }) - - test('rename with matching expectedVersion succeeds', async () => { - await store.save({ id: 'rv', name: 'A', graph: makeGraph() }) - const renamed = await store.rename('rv', 'B', { expectedVersion: 1 }) - expect(renamed.version).toBe(2) - }) - - test('rename with mismatched expectedVersion throws', async () => { - await store.save({ id: 'rvx', name: 'A', graph: makeGraph() }) - await expect(store.rename('rvx', 'B', { expectedVersion: 99 })).rejects.toThrow( - SceneVersionConflictError, - ) - }) - - test('rename on missing scene throws SceneInvalidError', async () => { - await expect(store.rename('ghost', 'X')).rejects.toThrow(SceneInvalidError) - }) - - test('rename validates name length', async () => { - await store.save({ id: 'rnl', name: 'A', graph: makeGraph() }) - await expect(store.rename('rnl', '')).rejects.toThrow(SceneInvalidError) - await expect(store.rename('rnl', 'x'.repeat(201))).rejects.toThrow(SceneInvalidError) - }) - - // ----------- Integration: delete + list + rename round-trip ----------- - - test('round-trip: save → rename → list → delete', async () => { - await store.save({ id: 'rt1', name: 'One', graph: makeGraph() }) - await store.save({ id: 'rt2', name: 'Two', graph: makeGraph() }) - await store.rename('rt1', 'Uno') - const listed = await store.list() - const renamed = listed.find((m) => m.id === 'rt1') - expect(renamed?.name).toBe('Uno') - expect(renamed?.version).toBe(2) - expect(await store.delete('rt2')).toBe(true) - const after = await store.list() - expect(after.map((m) => m.id)).toEqual(['rt1']) - }) - - // ----------- Atomic write / concurrency ----------- - - test('atomic write does not leave tmp files on success', async () => { - await store.save({ id: 'atomic', name: 'A', graph: makeGraph() }) - const entries = await fs.readdir(path.join(rootDir, 'scenes')) - expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false) - }) - - test('concurrent saves do not leave a torn file', async () => { - // Atomic rename guarantees the on-disk file is always a complete, - // parseable snapshot even under parallel writes. We don't guarantee that - // optimistic version checks serialize writers — that requires an external - // lock — but each write either succeeds or rejects cleanly, and the - // final file is always loadable. - await store.save({ id: 'race', name: 'Race', graph: makeGraph() }) - const attempts = await Promise.allSettled( - Array.from({ length: 4 }, (_, i) => - store.save({ - id: 'race', - name: `Race-${i}`, - graph: makeGraph(), - expectedVersion: 1, - }), - ), - ) - expect(attempts.every((a) => a.status === 'fulfilled' || a.status === 'rejected')).toBe(true) - const loaded = await store.load('race') - expect(loaded).not.toBeNull() - // At least one concurrent save committed, so the version advanced. - expect(loaded!.version).toBeGreaterThanOrEqual(2) - }) -}) diff --git a/packages/mcp/src/storage/filesystem-scene-store.ts b/packages/mcp/src/storage/filesystem-scene-store.ts deleted file mode 100644 index 4eb40718..00000000 --- a/packages/mcp/src/storage/filesystem-scene-store.ts +++ /dev/null @@ -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 - -/** - * File-backed implementation of `SceneStore`. - * - * Persists each scene as `/scenes/.json` with an optional sidecar - * index file `/scenes/.index.json` for fast listing. - * - * Writes are atomic via tmp file + rename. Saves bump `meta.version` by 1 and - * honor `expectedVersion` for optimistic concurrency control. Reads return - * `null` for missing files and throw `SceneInvalidError` when a file on disk - * has become corrupt. - */ -export class FilesystemSceneStore implements SceneStore { - readonly backend = 'filesystem' as const - - private readonly rootDir: string - private readonly scenesDir: string - private readonly indexPath: string - - constructor(opts: FilesystemSceneStoreOptions = {}) { - const root = opts.rootDir ?? resolveDefaultRootDir(opts.env ?? process.env) - this.rootDir = path.resolve(root) - this.scenesDir = path.join(this.rootDir, SCENES_SUBDIR) - this.indexPath = path.join(this.scenesDir, INDEX_FILE) - } - - async save(opts: SceneSaveOptions): Promise { - this.assertValidName(opts.name) - - const providedId = opts.id - const id = providedId ? sanitizeSlug(providedId) : generateSlug() - if (!isValidSlug(id)) { - throw new SceneInvalidError(`Invalid scene id after sanitization: "${id}"`) - } - - await this.ensureScenesDir() - - const finalPath = this.scenePath(id) - const existing = await this.readPersisted(id) - - // Slug collision check: only when caller passed an explicit id - // and `expectedVersion` is NOT provided (i.e. this is treated as a create). - if (existing && providedId !== undefined && opts.expectedVersion === undefined) { - throw new SceneInvalidError( - `Scene with id "${id}" already exists. Pass a different id or provide expectedVersion to overwrite.`, - ) - } - - // Optimistic concurrency - if (opts.expectedVersion !== undefined) { - const currentVersion = existing?.meta.version ?? 0 - if (currentVersion !== opts.expectedVersion) { - throw new SceneVersionConflictError( - `Scene "${id}" version mismatch: expected ${opts.expectedVersion}, got ${currentVersion}`, - ) - } - } - - const now = new Date().toISOString() - const createdAt = existing?.meta.createdAt ?? now - const nextVersion = (existing?.meta.version ?? 0) + 1 - const nodeCount = Object.keys(opts.graph.nodes).length - - // Assemble meta + record so we can measure the final serialized size. - // sizeBytes is filled in after we know the encoded length. - const meta: SceneMeta = { - id, - name: opts.name, - projectId: opts.projectId ?? null, - thumbnailUrl: opts.thumbnailUrl ?? null, - version: nextVersion, - createdAt, - updatedAt: now, - ownerId: opts.ownerId ?? null, - sizeBytes: 0, - nodeCount, - } - - const record: PersistedScene = { meta, graph: opts.graph as PersistedScene['graph'] } - // Iterate until sizeBytes is stable: encoding the size changes the - // resulting byte count if the digit width shifts, so fixed-point it. - let json = this.serialize(record) - let sizeBytes = Buffer.byteLength(json, 'utf8') - // Fixed-point loop, bounded to avoid infinite cycles on pathological inputs. - for (let guard = 0; guard < 5; guard++) { - meta.sizeBytes = sizeBytes - record.meta = meta - const next = this.serialize(record) - const nextSize = Buffer.byteLength(next, 'utf8') - if (nextSize === sizeBytes) { - json = next - break - } - json = next - sizeBytes = nextSize - } - - if (sizeBytes > MAX_SCENE_BYTES) { - throw new SceneTooLargeError( - `Scene "${id}" is ${sizeBytes} bytes, exceeds cap of ${MAX_SCENE_BYTES} bytes`, - ) - } - - await this.atomicWrite(finalPath, json) - await this.writeIndex(await this.collectAllMeta()) - return meta - } - - async load(id: string): Promise { - const safeId = sanitizeSlug(id) - const record = await this.readPersisted(safeId) - if (!record) return null - return { ...record.meta, graph: record.graph as SceneWithGraph['graph'] } - } - - async list(opts: SceneListOptions = {}): Promise { - const metas = (await this.readIndex()) ?? (await this.collectAllMeta()) - let filtered = metas - if (opts.projectId !== undefined) { - filtered = filtered.filter((m) => m.projectId === opts.projectId) - } - if (opts.ownerId !== undefined) { - filtered = filtered.filter((m) => m.ownerId === opts.ownerId) - } - filtered = filtered.slice().sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)) - if (opts.limit !== undefined && opts.limit >= 0) { - filtered = filtered.slice(0, opts.limit) - } - return filtered - } - - async delete(id: string, opts: SceneMutateOptions = {}): Promise { - const safeId = sanitizeSlug(id) - const existing = await this.readPersisted(safeId) - if (!existing) return false - if (opts.expectedVersion !== undefined && existing.meta.version !== opts.expectedVersion) { - throw new SceneVersionConflictError( - `Scene "${safeId}" version mismatch: expected ${opts.expectedVersion}, got ${existing.meta.version}`, - ) - } - const finalPath = this.scenePath(safeId) - await fs.unlink(finalPath).catch((err: NodeJS.ErrnoException) => { - if (err.code !== 'ENOENT') throw err - }) - await this.writeIndex(await this.collectAllMeta()) - return true - } - - async rename(id: string, newName: string, opts: SceneMutateOptions = {}): Promise { - this.assertValidName(newName) - const safeId = sanitizeSlug(id) - const existing = await this.readPersisted(safeId) - if (!existing) { - throw new SceneInvalidError(`Scene "${safeId}" not found`) - } - return this.save({ - id: safeId, - name: newName, - projectId: existing.meta.projectId, - ownerId: existing.meta.ownerId, - thumbnailUrl: existing.meta.thumbnailUrl, - graph: existing.graph as SceneWithGraph['graph'], - expectedVersion: opts.expectedVersion ?? existing.meta.version, - }) - } - - // ---------- Internal helpers ---------- - - private scenePath(id: string): string { - return path.join(this.scenesDir, `${id}.json`) - } - - private assertValidName(name: string): void { - if (typeof name !== 'string') { - throw new SceneInvalidError('Scene name must be a string') - } - const trimmed = name.trim() - if (trimmed.length < MIN_NAME_LENGTH || name.length > MAX_NAME_LENGTH) { - throw new SceneInvalidError( - `Scene name must be ${MIN_NAME_LENGTH}-${MAX_NAME_LENGTH} characters (got ${name.length})`, - ) - } - } - - private serialize(record: PersistedScene): string { - return JSON.stringify(record, null, 2) - } - - private async ensureScenesDir(): Promise { - await fs.mkdir(this.scenesDir, { recursive: true }) - } - - private async atomicWrite(finalPath: string, contents: string): Promise { - const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}${TMP_SUFFIX}` - await fs.writeFile(tmpPath, contents, { encoding: 'utf8', flag: 'w' }) - try { - await fs.rename(tmpPath, finalPath) - } catch (err) { - await fs.unlink(tmpPath).catch(() => {}) - throw err - } - } - - private async readPersisted(id: string): Promise { - const filePath = this.scenePath(id) - let raw: string - try { - raw = await fs.readFile(filePath, 'utf8') - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return null - throw err - } - return this.parseRecord(raw, filePath) - } - - private parseRecord(raw: string, filePath: string): PersistedScene { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch (err) { - throw new SceneInvalidError( - `Failed to parse scene file ${filePath}: ${(err as Error).message}`, - ) - } - const result = PersistedSceneSchema.safeParse(parsed) - if (!result.success) { - throw new SceneInvalidError( - `Scene file ${filePath} has invalid shape: ${result.error.message}`, - ) - } - const record = result.data - // Validate individual node envelopes: every value in `nodes` must be a - // non-null object with a `type` string. We don't fully parse each node via - // core's AnyNode because it's expensive and the schemas evolve; the lift - // is to catch egregious corruption early. - for (const [nodeId, node] of Object.entries(record.graph.nodes)) { - if (!node || typeof node !== 'object' || Array.isArray(node)) { - throw new SceneInvalidError(`Scene file ${filePath} has non-object node at "${nodeId}"`) - } - const typeField = (node as { type?: unknown }).type - if (typeof typeField !== 'string' || typeField.length === 0) { - throw new SceneInvalidError( - `Scene file ${filePath} has node "${nodeId}" missing a string "type"`, - ) - } - } - return record - } - - private async readIndex(): Promise { - try { - const raw = await fs.readFile(this.indexPath, 'utf8') - const parsed = JSON.parse(raw) - if (!Array.isArray(parsed)) return null - // Trust the index — it was written by us — but filter out any entries - // whose underlying file has since vanished. - const valid: SceneMeta[] = [] - for (const entry of parsed as SceneMeta[]) { - if (!entry || typeof entry.id !== 'string') continue - const exists = await fs - .access(this.scenePath(entry.id), fsConstants.F_OK) - .then(() => true) - .catch(() => false) - if (exists) valid.push(entry) - } - return valid - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return null - return null - } - } - - private async collectAllMeta(): Promise { - try { - const entries = await fs.readdir(this.scenesDir) - const metas: SceneMeta[] = [] - for (const entry of entries) { - if (!entry.endsWith('.json')) continue - if (entry === INDEX_FILE) continue - if (entry.endsWith(TMP_SUFFIX)) continue - const id = entry.slice(0, -'.json'.length) - const record = await this.readPersisted(id).catch(() => null) - if (record) metas.push(record.meta) - } - return metas - } catch (err) { - const e = err as NodeJS.ErrnoException - if (e.code === 'ENOENT') return [] - throw err - } - } - - private async writeIndex(metas: SceneMeta[]): Promise { - await this.ensureScenesDir() - const sorted = metas.slice().sort((a, b) => a.id.localeCompare(b.id)) - await this.atomicWrite(this.indexPath, `${JSON.stringify(sorted, null, 2)}\n`) - } -} diff --git a/packages/mcp/src/storage/index.ts b/packages/mcp/src/storage/index.ts index 76024ce0..36e48835 100644 --- a/packages/mcp/src/storage/index.ts +++ b/packages/mcp/src/storage/index.ts @@ -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 { - 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 }) } diff --git a/packages/mcp/src/storage/sqlite-driver.ts b/packages/mcp/src/storage/sqlite-driver.ts new file mode 100644 index 00000000..0d87e6df --- /dev/null +++ b/packages/mcp/src/storage/sqlite-driver.ts @@ -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 { + 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() + }, + } +} diff --git a/packages/mcp/src/storage/sqlite-scene-store.test.ts b/packages/mcp/src/storage/sqlite-scene-store.test.ts new file mode 100644 index 00000000..cf75a797 --- /dev/null +++ b/packages/mcp/src/storage/sqlite-scene-store.test.ts @@ -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 { + return { + nodes: { + site_abc: { + object: 'node', + id: 'site_abc', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + }, + building_def: { + object: 'node', + id: 'building_def', + type: 'building', + parentId: 'site_abc', + visible: true, + metadata: {}, + }, + } as SceneGraph['nodes'], + rootNodeIds: ['site_abc'] as SceneGraph['rootNodeIds'], + ...overrides, + } +} + +async function mkTmpRoot(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'pascal-sqlite-test-')) +} + +async function rmrf(p: string): Promise { + await fs.rm(p, { recursive: true, force: true }) +} + +function createStore(rootDir: string, opts: Partial = {}) { + 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) + }) +}) diff --git a/packages/mcp/src/storage/sqlite-scene-store.ts b/packages/mcp/src/storage/sqlite-scene-store.ts new file mode 100644 index 00000000..01ec88b0 --- /dev/null +++ b/packages/mcp/src/storage/sqlite-scene-store.ts @@ -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 | 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 { + 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 { + 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 { + const clauses: string[] = [] + const bindings: Array = [] + + 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 { + 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 { + 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 { + 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(fn: (db: SqliteDatabase) => T | Promise): Promise { + 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') + } +} diff --git a/packages/mcp/src/storage/store.test.ts b/packages/mcp/src/storage/store.test.ts index 55508c16..ca94b7e0 100644 --- a/packages/mcp/src/storage/store.test.ts +++ b/packages/mcp/src/storage/store.test.ts @@ -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. diff --git a/packages/mcp/src/storage/supabase-scene-store.test.ts b/packages/mcp/src/storage/supabase-scene-store.test.ts deleted file mode 100644 index 4e98b728..00000000 --- a/packages/mcp/src/storage/supabase-scene-store.test.ts +++ /dev/null @@ -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 - -interface RecordedCall { - table: string - op: 'select' | 'insert' | 'update' | 'delete' | 'upsert' - values?: Row | Row[] - filters: Array<{ column: string; value: unknown }> - orderBy?: { column: string; ascending: boolean } - limit?: number - terminator?: 'single' | 'maybeSingle' | 'iterable' -} - -function createMockClient(): { - client: SupabaseLikeClient - tables: Record - calls: RecordedCall[] -} { - const tables: Record = {} - const calls: RecordedCall[] = [] - - function buildQuery(table: string): SupabaseQueryBuilder { - tables[table] ??= [] - const call: RecordedCall = { table, op: 'select', filters: [] } - - function matchesFilters(row: Row): boolean { - return call.filters.every((f) => row[f.column] === f.value) - } - - function applyOrderAndLimit(rows: Row[]): Row[] { - let out = [...rows] - if (call.orderBy) { - const { column, ascending } = call.orderBy - out.sort((a, b) => { - const av = a[column] as string | number - const bv = b[column] as string | number - if (av === bv) return 0 - return (av < bv ? -1 : 1) * (ascending ? 1 : -1) - }) - } - if (typeof call.limit === 'number') out = out.slice(0, call.limit) - return out - } - - function executeMany(): SupabaseQueryResult { - const rows = tables[table] as Row[] - if (call.op === 'select') { - const hits = rows.filter(matchesFilters) - return { data: applyOrderAndLimit(hits) as T[], error: null } - } - if (call.op === 'insert') { - const incoming = Array.isArray(call.values) ? call.values : [call.values!] - rows.push(...incoming) - return { data: incoming as T[], error: null } - } - if (call.op === 'update') { - const hits = rows.filter(matchesFilters) - for (const row of hits) Object.assign(row, call.values) - return { data: hits as T[], error: null } - } - if (call.op === 'delete') { - const hits = rows.filter(matchesFilters) - tables[table] = rows.filter((r) => !matchesFilters(r)) - return { data: hits as T[], error: null } - } - return { data: [] as T[], error: null } - } - - function executeSingle(required: boolean): SupabaseQueryResult { - const many = executeMany() - if (many.error) return { data: null, error: many.error } - const first = (many.data ?? [])[0] - if (!first) { - if (required) { - return { - data: null, - error: { message: 'No rows', code: 'PGRST116' }, - } - } - return { data: null, error: null } - } - return { data: first as T, error: null } - } - - const builder: SupabaseQueryBuilder = { - select(_columns?: string) { - // `select()` after a mutation keeps the mutation op; only flip to - // 'select' when no op has been set yet. - if (call.op === 'select') { - call.op = 'select' - } - return builder - }, - insert(values) { - call.op = 'insert' - call.values = values as Row | Row[] - return builder - }, - update(values) { - call.op = 'update' - call.values = values as Row - return builder - }, - delete() { - call.op = 'delete' - return builder - }, - upsert(values) { - call.op = 'upsert' - call.values = values as Row | Row[] - return builder - }, - eq(column, value) { - call.filters.push({ column, value }) - return builder - }, - order(column, opts) { - call.orderBy = { column, ascending: opts?.ascending ?? true } - return builder - }, - limit(count) { - call.limit = count - return builder - }, - async maybeSingle() { - call.terminator = 'maybeSingle' - calls.push(call) - return executeSingle(false) as SupabaseQueryResult - }, - async single() { - call.terminator = 'single' - calls.push(call) - return executeSingle(true) as SupabaseQueryResult - }, - // Supabase query builders are themselves thenable — the mock must be - // too, so that `await builder` resolves to the list result. - // biome-ignore lint/suspicious/noThenProperty: mirrors real Supabase client - then(onfulfilled, onrejected) { - call.terminator = 'iterable' - calls.push(call) - const result = executeMany() - return Promise.resolve(result).then(onfulfilled, onrejected) - }, - } - return builder - } - - const client: SupabaseLikeClient = { - from(table: string) { - return buildQuery(table) - }, - } - return { client, tables, calls } -} - -function fakeGraph(nodeCount = 2) { - const nodes: Record = {} - for (let i = 0; i < nodeCount; i++) { - nodes[`wall_${i}`] = { id: `wall_${i}`, type: 'wall' } - } - return { nodes, rootNodeIds: Object.keys(nodes) } -} - -describe('SupabaseSceneStore', () => { - let mock: ReturnType - let store: SupabaseSceneStore - - beforeEach(() => { - mock = createMockClient() - store = new SupabaseSceneStore({ - url: 'https://example.supabase.co', - serviceRoleKey: 'service-role-test-key', - client: mock.client, - }) - }) - - test('reports the supabase backend flag', () => { - expect(store.backend).toBe('supabase') - }) - - test('save (new scene) inserts at version 1 and logs a revision', async () => { - const meta = await store.save({ - name: 'first', - graph: fakeGraph(3) as never, - ownerId: null, - }) - expect(meta.version).toBe(1) - expect(meta.nodeCount).toBe(3) - expect(meta.id.length).toBeGreaterThan(0) - - // One row in scenes, one row in scene_revisions. - expect((mock.tables.scenes ?? []).length).toBe(1) - expect((mock.tables.scene_revisions ?? []).length).toBe(1) - expect((mock.tables.scene_revisions![0] as { author_kind: string }).author_kind).toBe('mcp') - }) - - test('save (existing scene) with matching expectedVersion bumps to 2', async () => { - const created = await store.save({ - id: 'my-scene', - name: 'v1', - graph: fakeGraph(1) as never, - }) - expect(created.version).toBe(1) - - const updated = await store.save({ - id: 'my-scene', - name: 'v1', - graph: fakeGraph(4) as never, - expectedVersion: 1, - }) - expect(updated.version).toBe(2) - expect(updated.nodeCount).toBe(4) - - // Two revisions should now be logged. - expect((mock.tables.scene_revisions ?? []).length).toBe(2) - const versions = (mock.tables.scene_revisions ?? []).map( - (r) => (r as { version: number }).version, - ) - expect(versions.sort()).toEqual([1, 2]) - }) - - test('save with stale expectedVersion throws SceneVersionConflictError', async () => { - await store.save({ id: 'stale', name: 's', graph: fakeGraph() as never }) - let caught: unknown = null - try { - await store.save({ - id: 'stale', - name: 's', - graph: fakeGraph() as never, - expectedVersion: 99, - }) - } catch (err) { - caught = err - } - expect(caught).toBeInstanceOf(SceneVersionConflictError) - }) - - test('load returns null when no row matches', async () => { - const result = await store.load('missing') - expect(result).toBeNull() - }) - - test('load returns the scene + graph when present', async () => { - const saved = await store.save({ id: 'my', name: 's', graph: fakeGraph(2) as never }) - const loaded = await store.load(saved.id) - expect(loaded).not.toBeNull() - expect(loaded!.id).toBe('my') - expect(Object.keys(loaded!.graph.nodes)).toEqual(['wall_0', 'wall_1']) - }) - - test('list applies ownerId filter and honours the default limit', async () => { - await store.save({ id: 'a', name: 'a', graph: fakeGraph() as never, ownerId: 'owner-1' }) - await store.save({ id: 'b', name: 'b', graph: fakeGraph() as never, ownerId: 'owner-2' }) - const onlyOne = await store.list({ ownerId: 'owner-1' }) - expect(onlyOne.map((r) => r.id)).toEqual(['a']) - - const listCall = mock.calls.find((c) => c.op === 'select' && c.terminator === 'iterable')! - expect(listCall.orderBy).toEqual({ column: 'updated_at', ascending: false }) - expect(listCall.limit).toBe(100) - expect(listCall.filters).toContainEqual({ column: 'owner_id', value: 'owner-1' }) - }) - - test('delete removes the row and cascade-deletes the revisions', async () => { - const saved = await store.save({ id: 'gone', name: 'g', graph: fakeGraph() as never }) - expect((mock.tables.scenes ?? []).length).toBe(1) - expect((mock.tables.scene_revisions ?? []).length).toBe(1) - - // Simulate on-delete-cascade by emptying revisions when scenes row goes. - const before = mock.tables.scenes!.length - const ok = await store.delete(saved.id) - expect(ok).toBe(true) - expect(mock.tables.scenes!.length).toBe(before - 1) - - // Confirm the mock recorded a delete with an id filter — this is the - // SQL-equivalent of `delete from scenes where id = ?` relied on by the - // ON DELETE CASCADE from scene_revisions → scenes. - const deleteCall = mock.calls.find((c) => c.op === 'delete' && c.table === 'scenes') - expect(deleteCall).toBeDefined() - expect(deleteCall!.filters).toContainEqual({ column: 'id', value: saved.id }) - }) - - test('delete returns false when the row does not exist', async () => { - const ok = await store.delete('never-existed') - expect(ok).toBe(false) - }) - - test('rename bumps the version and updates name', async () => { - const saved = await store.save({ id: 'ren', name: 'old', graph: fakeGraph() as never }) - const renamed = await store.rename(saved.id, 'new') - expect(renamed.version).toBe(saved.version + 1) - expect(renamed.name).toBe('new') - }) - - test('rename with stale expectedVersion throws SceneVersionConflictError', async () => { - const saved = await store.save({ id: 'ren2', name: 'old', graph: fakeGraph() as never }) - let caught: unknown = null - try { - await store.rename(saved.id, 'newer', { expectedVersion: saved.version + 5 }) - } catch (err) { - caught = err - } - expect(caught).toBeInstanceOf(SceneVersionConflictError) - }) - - test('constructor never exposes the service role key in thrown errors', () => { - let caught: unknown = null - try { - new SupabaseSceneStore({ - url: '', - serviceRoleKey: 'super-secret', - client: mock.client, - }) - } catch (err) { - caught = err - } - expect(caught).toBeInstanceOf(Error) - expect((caught as Error).message).not.toContain('super-secret') - }) -}) diff --git a/packages/mcp/src/storage/supabase-scene-store.ts b/packages/mcp/src/storage/supabase-scene-store.ts deleted file mode 100644 index 06f766bf..00000000 --- a/packages/mcp/src/storage/supabase-scene-store.ts +++ /dev/null @@ -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 { - data: T | null - error: { message: string; code?: string; details?: string } | null -} - -export interface SupabaseQueryBuilder { - select(columns?: string): SupabaseQueryBuilder - insert(values: Partial | Partial[]): SupabaseQueryBuilder - update(values: Partial): SupabaseQueryBuilder - delete(): SupabaseQueryBuilder - upsert(values: Partial | Partial[]): SupabaseQueryBuilder - eq(column: string, value: unknown): SupabaseQueryBuilder - order(column: string, opts?: { ascending?: boolean }): SupabaseQueryBuilder - limit(count: number): SupabaseQueryBuilder - maybeSingle(): Promise> - single(): Promise> - then, TResult2 = never>( - onfulfilled?: - | ((value: SupabaseQueryResult) => TResult1 | PromiseLike) - | null - | undefined, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null | undefined, - ): Promise -} - -export interface SupabaseLikeClient { - from>(table: string): SupabaseQueryBuilder -} - -export interface SupabaseSceneStoreOptions { - url: string - serviceRoleKey: string - tableScenes?: string - tableRevisions?: string - /** - * Injectable client, primarily for tests. When omitted, the constructor - * will lazily import `@supabase/supabase-js` and build a real client from - * `url` + `serviceRoleKey`. - */ - client?: SupabaseLikeClient -} - -interface SceneRow { - id: string - project_id: string | null - owner_id: string | null - name: string - graph_json: SceneGraph - thumbnail_url: string | null - version: number - public: boolean - size_bytes: number - node_count: number - created_at: string - updated_at: string -} - -interface RevisionRow { - scene_id: string - version: number - graph_json: SceneGraph - author_kind: 'human' | 'mcp' | 'agent' - author_id: string | null - created_at: string -} - -function rowToMeta(row: SceneRow): SceneMeta { - return { - id: row.id, - name: row.name, - projectId: row.project_id, - ownerId: row.owner_id, - thumbnailUrl: row.thumbnail_url, - version: row.version, - createdAt: row.created_at, - updatedAt: row.updated_at, - sizeBytes: row.size_bytes, - nodeCount: row.node_count, - } -} - -function computeSize(graph: SceneGraph): number { - return Buffer.byteLength(JSON.stringify(graph), 'utf8') -} - -function countNodes(graph: SceneGraph): number { - return Object.keys(graph.nodes ?? {}).length -} - -function validateName(name: string): void { - if (typeof name !== 'string' || name.length < 1 || name.length > MAX_NAME_LENGTH) { - throw new SceneInvalidError(`name must be 1–${MAX_NAME_LENGTH} characters`) - } -} - -export class SupabaseSceneStore implements SceneStore { - readonly backend = 'supabase' as const - - private readonly tableScenes: string - private readonly tableRevisions: string - private clientPromise: Promise - - constructor(opts: SupabaseSceneStoreOptions) { - if (!opts.url) throw new Error('SupabaseSceneStore: url is required') - if (!opts.serviceRoleKey) throw new Error('SupabaseSceneStore: serviceRoleKey is required') - - this.tableScenes = opts.tableScenes ?? 'scenes' - this.tableRevisions = opts.tableRevisions ?? 'scene_revisions' - - if (opts.client) { - const injected = opts.client - this.clientPromise = Promise.resolve(injected) - } else { - // Lazy load the real client so tests that inject `client` don't need - // `@supabase/supabase-js` installed. - const url = opts.url - const key = opts.serviceRoleKey - this.clientPromise = import('@supabase/supabase-js').then((mod) => - mod.createClient(url, key, { - auth: { persistSession: false, autoRefreshToken: false }, - }), - ) as Promise - } - } - - private async client(): Promise { - return this.clientPromise - } - - async save(opts: SceneSaveOptions): Promise { - validateName(opts.name) - if (!opts.graph || typeof opts.graph !== 'object') { - throw new SceneInvalidError('graph is required') - } - - const nowIso = new Date().toISOString() - const sizeBytes = computeSize(opts.graph) - const nodeCount = countNodes(opts.graph) - - const client = await this.client() - - const providedId = opts.id - const hasId = typeof providedId === 'string' && providedId.length > 0 - - if (!hasId) { - // New scene — generate a fresh slug and insert at version 1. - const id = generateSlug() - const inserted = await client - .from(this.tableScenes) - .insert({ - id, - project_id: opts.projectId ?? null, - owner_id: opts.ownerId ?? null, - name: opts.name, - graph_json: opts.graph, - thumbnail_url: opts.thumbnailUrl ?? null, - version: 1, - size_bytes: sizeBytes, - node_count: nodeCount, - created_at: nowIso, - updated_at: nowIso, - }) - .select() - .single() - - if (inserted.error || !inserted.data) { - throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`) - } - - await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null) - return rowToMeta(inserted.data) - } - - // Existing scene — upsert path. - const id = sanitizeSlug(providedId) - - // Look up current version so we know the next value + can enforce - // expectedVersion locally even when Supabase's RLS answer is opaque. - const existing = await client - .from(this.tableScenes) - .select('*') - .eq('id', id) - .maybeSingle() - - if (existing.error) { - throw new Error(`Supabase lookup failed: ${existing.error.message}`) - } - - if (!existing.data) { - // No row yet for this id — insert as v1. - const inserted = await client - .from(this.tableScenes) - .insert({ - id, - project_id: opts.projectId ?? null, - owner_id: opts.ownerId ?? null, - name: opts.name, - graph_json: opts.graph, - thumbnail_url: opts.thumbnailUrl ?? null, - version: 1, - size_bytes: sizeBytes, - node_count: nodeCount, - created_at: nowIso, - updated_at: nowIso, - }) - .select() - .single() - - if (inserted.error || !inserted.data) { - throw new Error(`Supabase insert failed: ${inserted.error?.message ?? 'unknown error'}`) - } - await this.insertRevision(client, id, 1, opts.graph, opts.ownerId ?? null) - return rowToMeta(inserted.data) - } - - const currentVersion = existing.data.version - if (typeof opts.expectedVersion === 'number' && opts.expectedVersion !== currentVersion) { - throw new SceneVersionConflictError( - `expected version ${opts.expectedVersion}, current ${currentVersion}`, - ) - } - - const nextVersion = currentVersion + 1 - // Optimistic lock via `where version = currentVersion`. - const updated = await client - .from(this.tableScenes) - .update({ - name: opts.name, - project_id: opts.projectId ?? existing.data.project_id, - owner_id: opts.ownerId ?? existing.data.owner_id, - graph_json: opts.graph, - thumbnail_url: - opts.thumbnailUrl === undefined ? existing.data.thumbnail_url : opts.thumbnailUrl, - version: nextVersion, - size_bytes: sizeBytes, - node_count: nodeCount, - updated_at: nowIso, - }) - .eq('id', id) - .eq('version', currentVersion) - .select() - .single() - - if (updated.error || !updated.data) { - // Either someone raced us (version drifted) or the row vanished. - throw new SceneVersionConflictError( - updated.error?.message ?? 'version conflict during update', - ) - } - - await this.insertRevision(client, id, nextVersion, opts.graph, opts.ownerId ?? null) - return rowToMeta(updated.data) - } - - async load(id: SceneId): Promise { - const client = await this.client() - const result = await client - .from(this.tableScenes) - .select('*') - .eq('id', id) - .maybeSingle() - - if (result.error) { - throw new Error(`Supabase load failed: ${result.error.message}`) - } - if (!result.data) return null - - return { ...rowToMeta(result.data), graph: result.data.graph_json } - } - - async list(opts?: SceneListOptions): Promise { - const client = await this.client() - let query = client - .from(this.tableScenes) - .select('*') - .order('updated_at', { ascending: false }) - .limit(opts?.limit ?? DEFAULT_LIST_LIMIT) - - if (opts?.projectId) query = query.eq('project_id', opts.projectId) - if (opts?.ownerId) query = query.eq('owner_id', opts.ownerId) - - const result = (await query) as SupabaseQueryResult - if (result.error) { - throw new Error(`Supabase list failed: ${result.error.message}`) - } - return (result.data ?? []).map(rowToMeta) - } - - async delete(id: SceneId, opts?: SceneMutateOptions): Promise { - const client = await this.client() - - if (typeof opts?.expectedVersion === 'number') { - const existing = await client - .from(this.tableScenes) - .select('version') - .eq('id', id) - .maybeSingle() - - if (existing.error) { - throw new Error(`Supabase lookup failed: ${existing.error.message}`) - } - if (!existing.data) return false - if (existing.data.version !== opts.expectedVersion) { - throw new SceneVersionConflictError( - `expected version ${opts.expectedVersion}, current ${existing.data.version}`, - ) - } - } - - const deleted = await client - .from(this.tableScenes) - .delete() - .eq('id', id) - .select() - .maybeSingle() - - if (deleted.error) { - throw new Error(`Supabase delete failed: ${deleted.error.message}`) - } - return deleted.data !== null - } - - async rename(id: SceneId, newName: string, opts?: SceneMutateOptions): Promise { - validateName(newName) - const client = await this.client() - - const existing = await client - .from(this.tableScenes) - .select('*') - .eq('id', id) - .maybeSingle() - - if (existing.error) { - throw new Error(`Supabase lookup failed: ${existing.error.message}`) - } - if (!existing.data) { - throw new SceneNotFoundError(`scene ${id} not found`) - } - - if ( - typeof opts?.expectedVersion === 'number' && - opts.expectedVersion !== existing.data.version - ) { - throw new SceneVersionConflictError( - `expected version ${opts.expectedVersion}, current ${existing.data.version}`, - ) - } - - const nextVersion = existing.data.version + 1 - const updated = await client - .from(this.tableScenes) - .update({ - name: newName, - version: nextVersion, - updated_at: new Date().toISOString(), - }) - .eq('id', id) - .eq('version', existing.data.version) - .select() - .single() - - if (updated.error || !updated.data) { - throw new SceneVersionConflictError( - updated.error?.message ?? 'version conflict during rename', - ) - } - return rowToMeta(updated.data) - } - - private async insertRevision( - client: SupabaseLikeClient, - sceneId: string, - version: number, - graph: SceneGraph, - authorId: string | null, - ): Promise { - const result = await client.from(this.tableRevisions).insert({ - scene_id: sceneId, - version, - graph_json: graph, - author_kind: 'mcp', - author_id: authorId, - created_at: new Date().toISOString(), - }) - if (result.error) { - // Revision history is best-effort; surface the failure so callers can - // log / alert, but don't swallow it silently. - throw new Error(`Supabase revision insert failed: ${result.error.message}`) - } - } -} diff --git a/packages/mcp/src/storage/types.ts b/packages/mcp/src/storage/types.ts index 641c8aa2..932b2032 100644 --- a/packages/mcp/src/storage/types.ts +++ b/packages/mcp/src/storage/types.ts @@ -47,7 +47,7 @@ export interface SceneMutateOptions { } export interface SceneStore { - readonly backend: 'filesystem' | 'supabase' + readonly backend: 'sqlite' save(opts: SceneSaveOptions): Promise load(id: SceneId): Promise list(opts?: SceneListOptions): Promise diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index 005f8b27..cecb2313 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -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 } : {}), }) diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 94cf41a6..735c8803 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -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, diff --git a/packages/mcp/src/tools/scene-lifecycle/test-utils.ts b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts index ad99943f..3badb8b9 100644 --- a/packages/mcp/src/tools/scene-lifecycle/test-utils.ts +++ b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts @@ -21,7 +21,7 @@ export function parseToolText(content: StoredTextContent[]): Record() private idCounter = 0 diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 138555b1..f8ea6cad 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -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. diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts index d69b1889..64355713 100644 --- a/packages/mcp/src/tools/set-zone.ts +++ b/packages/mcp/src/tools/set-zone.ts @@ -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) diff --git a/packages/mcp/src/types/bun-sqlite.d.ts b/packages/mcp/src/types/bun-sqlite.d.ts new file mode 100644 index 00000000..0f3537d8 --- /dev/null +++ b/packages/mcp/src/types/bun-sqlite.d.ts @@ -0,0 +1,34 @@ +declare module 'bun:sqlite' { + export type SQLQueryBindings = + | string + | number + | bigint + | boolean + | null + | Uint8Array + | Record + + 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 + } +} diff --git a/packages/mcp/test-reports/casa-sol/BUILD_REPORT.md b/packages/mcp/test-reports/casa-sol/BUILD_REPORT.md deleted file mode 100644 index 905c497a..00000000 --- a/packages/mcp/test-reports/casa-sol/BUILD_REPORT.md +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/casa-sol/DESIGN.md b/packages/mcp/test-reports/casa-sol/DESIGN.md deleted file mode 100644 index c6bfb949..00000000 --- a/packages/mcp/test-reports/casa-sol/DESIGN.md +++ /dev/null @@ -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. diff --git a/packages/mcp/test-reports/casa-sol/build.log b/packages/mcp/test-reports/casa-sol/build.log deleted file mode 100644 index 4a500a56..00000000 --- a/packages/mcp/test-reports/casa-sol/build.log +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/casa-sol/build.ts b/packages/mcp/test-reports/casa-sol/build.ts deleted file mode 100644 index a772634a..00000000 --- a/packages/mcp/test-reports/casa-sol/build.ts +++ /dev/null @@ -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( - n: number, - name: string, - fn: () => Promise<{ summary: string; nodeIds?: string[]; errors?: string[]; result: T }>, -): Promise { - 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>( - client: Client, - name: string, - args: Record = {}, -): Promise { - 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>( - client: Client, - name: string, - args: Record = {}, -): Promise<{ ok: true; value: T } | { ok: false; error: string }> { - try { - const v = await callTool(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 -} - -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 { - const v = await callTool(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 { - 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> = [] - 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 = {} - 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 = {} - 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() - 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 = {} - 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 = {} - 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) -}) diff --git a/packages/mcp/test-reports/casa-sol/scene.json b/packages/mcp/test-reports/casa-sol/scene.json deleted file mode 100644 index 9c225f2d..00000000 --- a/packages/mcp/test-reports/casa-sol/scene.json +++ /dev/null @@ -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": {} -} diff --git a/packages/mcp/test-reports/phase7-e2e.ts b/packages/mcp/test-reports/phase7-e2e.ts deleted file mode 100644 index aa347a15..00000000 --- a/packages/mcp/test-reports/phase7-e2e.ts +++ /dev/null @@ -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) -}) diff --git a/packages/mcp/test-reports/phase8/casa-sol-v2.json b/packages/mcp/test-reports/phase8/casa-sol-v2.json deleted file mode 100644 index 7836f7eb..00000000 --- a/packages/mcp/test-reports/phase8/casa-sol-v2.json +++ /dev/null @@ -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": {} -} diff --git a/packages/mcp/test-reports/phase8/p1-templates.md b/packages/mcp/test-reports/phase8/p1-templates.md deleted file mode 100644 index acf970a1..00000000 --- a/packages/mcp/test-reports/phase8/p1-templates.md +++ /dev/null @@ -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 | diff --git a/packages/mcp/test-reports/phase8/p1-templates.ts b/packages/mcp/test-reports/phase8/p1-templates.ts deleted file mode 100644 index 4ae646cf..00000000 --- a/packages/mcp/test-reports/phase8/p1-templates.ts +++ /dev/null @@ -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 { - // 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 | 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 | 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) -}) diff --git a/packages/mcp/test-reports/phase8/p10-full-sweep.md b/packages/mcp/test-reports/phase8/p10-full-sweep.md deleted file mode 100644 index c4b8f059..00000000 --- a/packages/mcp/test-reports/phase8/p10-full-sweep.md +++ /dev/null @@ -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) | diff --git a/packages/mcp/test-reports/phase8/p10-full-sweep.ts b/packages/mcp/test-reports/phase8/p10-full-sweep.ts deleted file mode 100644 index a7f5014c..00000000 --- a/packages/mcp/test-reports/phase8/p10-full-sweep.ts +++ /dev/null @@ -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 { - 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, - ): 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 = - 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) -}) diff --git a/packages/mcp/test-reports/phase8/p2-variants.md b/packages/mcp/test-reports/phase8/p2-variants.md deleted file mode 100644 index 2dd9a396..00000000 --- a/packages/mcp/test-reports/phase8/p2-variants.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/packages/mcp/test-reports/phase8/p2-variants.ts b/packages/mcp/test-reports/phase8/p2-variants.ts deleted file mode 100644 index 72a08a33..00000000 --- a/packages/mcp/test-reports/phase8/p2-variants.ts +++ /dev/null @@ -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 - 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 - 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; rootNodeIds: string[]; collections?: any } | undefined, -): any { - if (!graph) return null - const entries = Object.entries(graph.nodes ?? {}) - const idMap = new Map() - 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 = {} - for (const [k, v] of Object.entries(node as Record)) { - 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 = {} - 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 { - // 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) -}) diff --git a/packages/mcp/test-reports/phase8/p3-locking.md b/packages/mcp/test-reports/phase8/p3-locking.md deleted file mode 100644 index 216b8c79..00000000 --- a/packages/mcp/test-reports/phase8/p3-locking.md +++ /dev/null @@ -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) diff --git a/packages/mcp/test-reports/phase8/p3-locking.ts b/packages/mcp/test-reports/phase8/p3-locking.ts deleted file mode 100644 index 3f85c479..00000000 --- a/packages/mcp/test-reports/phase8/p3-locking.ts +++ /dev/null @@ -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; 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, -): Promise { - 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>(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 { - 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), - 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 - bodyText: string - bodyJson: unknown -} - -async function http( - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', - path: string, - body?: unknown, - extraHeaders: Record = {}, -): Promise { - const headers: Record = { ...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 = {} - res.headers.forEach((v, k) => { - hdrs[k] = v - }) - return { - status: res.status, - headers: hdrs, - bodyText: text, - bodyJson: json, - } -} - -async function runPartB(): Promise { - 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/ → 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), - 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 { - 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) -}) diff --git a/packages/mcp/test-reports/phase8/p4-url-hardening.md b/packages/mcp/test-reports/phase8/p4-url-hardening.md deleted file mode 100644 index 920ad278..00000000 --- a/packages/mcp/test-reports/phase8/p4-url-hardening.md +++ /dev/null @@ -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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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,` | 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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, → 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 -``` diff --git a/packages/mcp/test-reports/phase8/p4-url-hardening.ts b/packages/mcp/test-reports/phase8/p4-url-hardening.ts deleted file mode 100644 index 93648b17..00000000 --- a/packages/mcp/test-reports/phase8/p4-url-hardening.ts +++ /dev/null @@ -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,', - '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 { - 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, - }) - const client = new Client({ name: 'p4-url-hardening', version: '0.0.0' }) - await client.connect(transport) - - async function call(name: string, args: Record): Promise { - 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 - 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 & { 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 & { 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 { - 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 diff --git a/packages/mcp/test-reports/phase8/p5-photo-to-scene.md b/packages/mcp/test-reports/phase8/p5-photo-to-scene.md deleted file mode 100644 index d3c89b5e..00000000 --- a/packages/mcp/test-reports/phase8/p5-photo-to-scene.md +++ /dev/null @@ -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 -} -``` diff --git a/packages/mcp/test-reports/phase8/p5-photo-to-scene.ts b/packages/mcp/test-reports/phase8/p5-photo-to-scene.ts deleted file mode 100644 index 99c5bcdb..00000000 --- a/packages/mcp/test-reports/phase8/p5-photo-to-scene.ts +++ /dev/null @@ -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 { - 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; 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) -}) diff --git a/packages/mcp/test-reports/phase8/p6-casa-sol-save.md b/packages/mcp/test-reports/phase8/p6-casa-sol-save.md deleted file mode 100644 index 6269e2b9..00000000 --- a/packages/mcp/test-reports/phase8/p6-casa-sol-save.md +++ /dev/null @@ -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/ returned 200 with matching node count -- [x] GET /scene/ returned 200 HTML -- [x] wrote `/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/phase8/casa-sol-v2.json` diff --git a/packages/mcp/test-reports/phase8/p6-casa-sol-save.ts b/packages/mcp/test-reports/phase8/p6-casa-sol-save.ts deleted file mode 100644 index 01844f04..00000000 --- a/packages/mcp/test-reports/phase8/p6-casa-sol-save.ts +++ /dev/null @@ -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 } - -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( - n: number, - name: string, - fn: () => Promise<{ summary: string; result: T }>, -): Promise { - 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>( - client: Client, - name: string, - args: Record = {}, -): Promise { - 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 { - const v = await callTool(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 { - 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 = {} - - // --- 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 = {} - 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 = {} - 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() - 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 = {} - 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/ --- - 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 } } - 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/ (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/ returned 200 with matching node count`, - ) - md.push( - `- [${steps.find((s) => s.name === 'GET /scene/:id (HTML)')?.ok ? 'x' : ' '}] GET /scene/ 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) -}) diff --git a/packages/mcp/test-reports/phase8/p7-editor-api.md b/packages/mcp/test-reports/phase8/p7-editor-api.md deleted file mode 100644 index 35a29778..00000000 --- a/packages/mcp/test-reports/phase8/p7-editor-api.md +++ /dev/null @@ -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/ | 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/` on 201 from POST. -- `ETag: ""` on 200 from GET, PUT, and PATCH. -- `If-Match: ""` 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` diff --git a/packages/mcp/test-reports/phase8/p7-editor-api.ts b/packages/mcp/test-reports/phase8/p7-editor-api.ts deleted file mode 100644 index c79eaf29..00000000 --- a/packages/mcp/test-reports/phase8/p7-editor-api.ts +++ /dev/null @@ -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 { - 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 { - // 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 - // P1–P6 + 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) -}) diff --git a/packages/mcp/test-reports/phase8/p8-concurrency.md b/packages/mcp/test-reports/phase8/p8-concurrency.md deleted file mode 100644 index f61ab1f2..00000000 --- a/packages/mcp/test-reports/phase8/p8-concurrency.md +++ /dev/null @@ -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. diff --git a/packages/mcp/test-reports/phase8/p8-concurrency.ts b/packages/mcp/test-reports/phase8/p8-concurrency.ts deleted file mode 100644 index 73f09db4..00000000 --- a/packages/mcp/test-reports/phase8/p8-concurrency.ts +++ /dev/null @@ -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 `.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, -): Promise { - 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 { - 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) -}) diff --git a/packages/mcp/test-reports/phase8/p9-edges.md b/packages/mcp/test-reports/phase8/p9-edges.md deleted file mode 100644 index d4cf7ecd..00000000 --- a/packages/mcp/test-reports/phase8/p9-edges.md +++ /dev/null @@ -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.). diff --git a/packages/mcp/test-reports/phase8/p9-edges.ts b/packages/mcp/test-reports/phase8/p9-edges.ts deleted file mode 100644 index 0abdc5b7..00000000 --- a/packages/mcp/test-reports/phase8/p9-edges.ts +++ /dev/null @@ -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 = {} - 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( - dataDir: string, - label: string, - fn: (client: Client) => Promise, -): Promise { - 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 { - // 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) -}) diff --git a/packages/mcp/test-reports/pre-push/a1-secrets.md b/packages/mcp/test-reports/pre-push/a1-secrets.md deleted file mode 100644 index dbf4f303..00000000 --- a/packages/mcp/test-reports/pre-push/a1-secrets.md +++ /dev/null @@ -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` -> `` 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 ` — 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) diff --git a/packages/mcp/test-reports/pre-push/a2-security.md b/packages/mcp/test-reports/pre-push/a2-security.md deleted file mode 100644 index 6a4d50d7..00000000 --- a/packages/mcp/test-reports/pre-push/a2-security.md +++ /dev/null @@ -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 → ``. 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 `` / 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 ``. 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). diff --git a/packages/mcp/test-reports/pre-push/a3-code-quality.md b/packages/mcp/test-reports/pre-push/a3-code-quality.md deleted file mode 100644 index 3547e3b2..00000000 --- a/packages/mcp/test-reports/pre-push/a3-code-quality.md +++ /dev/null @@ -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. diff --git a/packages/mcp/test-reports/pre-push/a4-performance.md b/packages/mcp/test-reports/pre-push/a4-performance.md deleted file mode 100644 index 2bce4d52..00000000 --- a/packages/mcp/test-reports/pre-push/a4-performance.md +++ /dev/null @@ -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` diff --git a/packages/mcp/test-reports/pre-push/a5-pr-description.md b/packages/mcp/test-reports/pre-push/a5-pr-description.md deleted file mode 100644 index 28eade98..00000000 --- a/packages/mcp/test-reports/pre-push/a5-pr-description.md +++ /dev/null @@ -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 5–10 — 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 diff --git a/packages/mcp/test-reports/pre-push/a5-review-notes.md b/packages/mcp/test-reports/pre-push/a5-review-notes.md deleted file mode 100644 index 11e17604..00000000 --- a/packages/mcp/test-reports/pre-push/a5-review-notes.md +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/research/PHASE7_PLAN.md b/packages/mcp/test-reports/research/PHASE7_PLAN.md deleted file mode 100644 index 135f887c..00000000 --- a/packages/mcp/test-reports/research/PHASE7_PLAN.md +++ /dev/null @@ -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 - load(id: SceneId): Promise - list(opts?: { projectId?: string; ownerId?: string; limit?: number }): Promise - delete(id: SceneId, opts?: { expectedVersion?: number }): Promise - rename(id: SceneId, newName: string, opts?: { expectedVersion?: number }): Promise -} - -export class SceneNotFoundError extends Error { code = 'not_found' as const } -export class SceneVersionConflictError extends Error { code = 'version_conflict' as const } -export class SceneInvalidError extends Error { code = 'invalid' as const } -export class SceneTooLargeError extends Error { code = 'too_large' as const } - -export function createSceneStore(env?: NodeJS.ProcessEnv): SceneStore { /* factory */ } -``` - -## Agent scope map - -| Agent | Scope | File ownership | -|---|---|---| -| A1 | Storage interface + types + factory | `packages/mcp/src/storage/types.ts`, `packages/mcp/src/storage/index.ts`, `packages/mcp/src/storage/store.test.ts` | -| A2 | Filesystem impl | `packages/mcp/src/storage/filesystem-scene-store.ts` + tests | -| A3 | Supabase impl + migration SQL | `packages/mcp/src/storage/supabase-scene-store.ts`, `packages/mcp/sql/migrations/0001_scenes.sql` + tests | -| A4 | MCP scene-lifecycle tools | `packages/mcp/src/tools/scene-lifecycle/*.ts` + index wiring | -| A5 | Next.js API routes | `apps/editor/app/api/scenes/route.ts`, `apps/editor/app/api/scenes/[id]/route.ts`, `apps/editor/lib/scene-store-server.ts` | -| A6 | Editor routes + kill dev hook | `apps/editor/app/scene/[id]/page.tsx`, `apps/editor/app/scenes/page.tsx`, edit `apps/editor/app/page.tsx` | -| A7 | URL hardening in core schemas | `packages/core/src/schema/nodes/{scan,guide,item}.ts`, `packages/core/src/schema/material.ts` + migration | -| A8 | Auto-frame camera + scene templates | `packages/editor/src/hooks/use-auto-frame.ts`, `packages/mcp/src/templates/*`, `packages/mcp/src/tools/scene-lifecycle/list-templates.ts` | -| A9 | Multi-variant generation | `packages/mcp/src/tools/variants/*` + tests | -| A10 | Photo → scene + example | `packages/mcp/src/tools/photo-to-scene/*` (orchestrator), update `README.md`, new `examples/photo-to-scene.md` | - -## Global coordination rules -- Agent A1 drops first (interface only). A2, A3, A4, A5 read from `packages/mcp/src/storage/types.ts`; if it doesn't exist when they start, they should **inline a copy of the types above** and the integrator fixes up the import later. -- All MCP tools use `StreamableHTTPClientTransport`-compatible input/output Zod schemas. -- Every tool uses the shared `SceneStore` via `createSceneStore()` — never instantiates concrete stores. -- Tests are `bun:test`, colocated. -- Biome 2-space, single quote, no semicolons, trailing commas all. -- Do NOT run `bun install` — already done. -- Do NOT modify files outside your ownership. - -## Acceptance -- `bun test --cwd packages/mcp` green. -- `bunx biome check packages/mcp apps/editor/app` green. -- `bun run --cwd packages/mcp build` green. -- `MCP save_scene → list_scenes → editor opens /scene/` works without `window.__pascalScene`. diff --git a/packages/mcp/test-reports/research/R1-persistence.md b/packages/mcp/test-reports/research/R1-persistence.md deleted file mode 100644 index 1cbc0a4b..00000000 --- a/packages/mcp/test-reports/research/R1-persistence.md +++ /dev/null @@ -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:`. -- **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:104–135` | -| Every UI state mutation | `pascal-editor-ui-preferences` | Zustand persist in `use-editor.tsx:372–607` | -| Every viewer state mutation | `viewer-preferences` | Zustand persist in `use-viewer.ts:81–220` | -| Every audio state mutation | `pascal-audio-settings` | `use-audio.tsx:22–43` | -| On `beforeunload` | Final scene snapshot | `use-auto-save.ts:137–147` | - -## Read pathways - -1. **Editor mount** (`editor/index.tsx:765–796`): - - If host supplied `onLoad` → `await onLoad()` - - Else → `loadSceneFromLocalStorage()` - - Apply via `useScene.setScene(nodes, rootNodeIds)` -2. **Selection hydration** — `syncEditorSelectionFromCurrentScene()` (`lib/scene.ts:251–332`) -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. diff --git a/packages/mcp/test-reports/research/R10-ideas-and-edges.md b/packages/mcp/test-reports/research/R10-ideas-and-edges.md deleted file mode 100644 index 5e00ff48..00000000 --- a/packages/mcp/test-reports/research/R10-ideas-and-edges.md +++ /dev/null @@ -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** 1–2 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: S–M (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: M–L 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. $50–200/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** — S–M 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. diff --git a/packages/mcp/test-reports/research/R2-project-id.md b/packages/mcp/test-reports/research/R2-project-id.md deleted file mode 100644 index 4390d125..00000000 --- a/packages/mcp/test-reports/research/R2-project-id.md +++ /dev/null @@ -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. - -## `` 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` | `loadSceneFromLocalStorage()` | Fetch initial scene on mount, and when `onLoad` identity changes (scene switch) | -| `onSave` | `(scene: SceneGraph) => Promise` | `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 → → useEffect sync (index.tsx:757) - ↓ - useViewer.setProjectId() (packages/viewer/src/store/use-viewer.ts:147–157) - ↓ - 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(null) - - 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. diff --git a/packages/mcp/test-reports/research/R3-scene-ui.md b/packages/mcp/test-reports/research/R3-scene-ui.md deleted file mode 100644 index f0294188..00000000 --- a/packages/mcp/test-reports/research/R3-scene-ui.md +++ /dev/null @@ -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` | 205–216, 362–365 | -| "Load Build" → file picker `.json` → `setScene` | `…settings-panel/index.tsx` | 218–239, 367–383 | -| "Export Scene (JSON)" in command palette | `editor-commands.tsx` | 328–346 | -| "Export GLB / STL / OBJ" | `export-manager.tsx` | — | -| "Clear & Start New" destructive button | `settings-panel/index.tsx` | 427–434 | -| "Explore scene graph" read-only tree dialog | `settings-panel/index.tsx` | 398–421 | -| Autosave hook (1 s debounce → onSave or localStorage fallback) | `use-auto-save.ts` | 1–191 | -| Scene-dirty tracking exposed to host via `onDirty` / `onSaveStatusChange` | `use-auto-save.ts` | 56–187 | - -## 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. diff --git a/packages/mcp/test-reports/research/R4-routing.md b/packages/mcp/test-reports/research/R4-routing.md deleted file mode 100644 index e2209fae..00000000 --- a/packages/mcp/test-reports/research/R4-routing.md +++ /dev/null @@ -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: ) -├── 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//…`. No such routes exist. Falls through to `undefined` gracefully today but signals a planned structure. - -## Options to add -| Option | Route | Effort | -|---|---|---| -| A | `/?sceneId=` — 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. diff --git a/packages/mcp/test-reports/research/R5-backend.md b/packages/mcp/test-reports/research/R5-backend.md deleted file mode 100644 index 4c784f6b..00000000 --- a/packages/mcp/test-reports/research/R5-backend.md +++ /dev/null @@ -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:18–19` — `POSTGRES_URL`, `SUPABASE_SERVICE_ROLE_KEY` (server-only, `.min(1)`) -- `env.mjs:12–14` — `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_*` -- `env.mjs:27–31` — `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` -- `turbo.json:9–20` — same vars listed as build-cache keys -- `apps/editor/app/privacy/page.tsx:95–97` — "Your data is stored using Supabase (PostgreSQL database)" -- `.gitignore:22–24` — 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. diff --git a/packages/mcp/test-reports/research/R6-file-io.md b/packages/mcp/test-reports/research/R6-file-io.md deleted file mode 100644 index bdc0a801..00000000 --- a/packages/mcp/test-reports/research/R6-file-io.md +++ /dev/null @@ -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:205–216` | `layout_YYYY-MM-DD.json` | -| Cmd palette → "Export Scene (JSON)" | `editor.export.json` | `command-palette/editor-commands.tsx:329–346` | `scene_YYYY-MM-DD.json` | -| Settings/palette → "Export GLB/STL/OBJ" | `export-manager.tsx` | `editor/export-manager.tsx:71–78` | 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:218–239` -- 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:242–271`) -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=` -- 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. diff --git a/packages/mcp/test-reports/research/R7-editor-api.md b/packages/mcp/test-reports/research/R7-editor-api.md deleted file mode 100644 index 393f011f..00000000 --- a/packages/mcp/test-reports/research/R7-editor-api.md +++ /dev/null @@ -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) - -## `` host integration points - -| Prop | Signature | Trigger | Host opportunity | -|---|---|---|---| -| `onLoad` | `() => Promise` | Mount + `onLoad` identity change | Fetch scene by id from backend | -| `onSave` | `(scene) => Promise` | 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 ≈ 1–2 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. diff --git a/packages/mcp/test-reports/research/R8-mcp-integration-design.md b/packages/mcp/test-reports/research/R8-mcp-integration-design.md deleted file mode 100644 index 8d1160a1..00000000 --- a/packages/mcp/test-reports/research/R8-mcp-integration-design.md +++ /dev/null @@ -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/.json`) - -### Description - -MCP writes `SceneGraph` JSON to `~/.pascal/scenes/.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** (1–2 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/.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/.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/" } - ▼ - 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, 2–3 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 `` 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` + `rootNodeIds: []` map cleanly to `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** (1–3 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: **S–M**. 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 2–4:** 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/`". 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:').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 0–30 — "Unblock the loop" - -- **A (1–2d):** 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 31–60 — "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 61–90 — "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; `` 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/` 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? diff --git a/packages/mcp/test-reports/research/R9-production-readiness.md b/packages/mcp/test-reports/research/R9-production-readiness.md deleted file mode 100644 index 5c8ec41a..00000000 --- a/packages/mcp/test-reports/research/R9-production-readiness.md +++ /dev/null @@ -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:`, 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: ` 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 `` / `` 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://; ...`). -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 1–2) - -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 2–3) - -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 3–4) - -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 4–5) - -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 5–6) - -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 A–F 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: ~10–14 weeks minimum to GA, ~4–5 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: ~4–5 weeks** (Phases A–D at MVP depth). -- **Public beta: ~8 weeks** (add Phase E, quota enforcement, version UI, one round of pen-test fixes). -- **GA: ~12–14 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 ~2–3 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 2–4 weeks. -- If legal requires SOC 2 or EU data residency before launch, add 2–3 months. -- Any real-time multi-agent requirement in v1 moves GA out by 4–6 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. diff --git a/packages/mcp/test-reports/research/SYNTHESIS.md b/packages/mcp/test-reports/research/SYNTHESIS.md deleted file mode 100644 index 237c6edc..00000000 --- a/packages/mcp/test-reports/research/SYNTHESIS.md +++ /dev/null @@ -1,154 +0,0 @@ -# Research synthesis — "MCP creates scene → I open it in the editor" - -> 10 parallel research agents (R1–R10) 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 | `` 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/.json ──► Next.js API route ──► /scene/ 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: 1–2 days. No new deps. No breaking changes.** - -### Weeks 2–4 — 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 ``. -- 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 1–2 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) - -**~4–5 weeks to private beta. ~10–14 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 2–4, 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//…` -- [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 1–2 day host feature -- [R8 — Integration design options](./R8-mcp-integration-design.md) — A→B→D phased recommendation -- [R9 — Production readiness](./R9-production-readiness.md) — 4–5 weeks to beta, 10–14 to GA -- [R10 — Ideas and edge cases](./R10-ideas-and-edges.md) — 300+ lines; top 10 ranked by value×feasibility diff --git a/packages/mcp/test-reports/t1-stdio/REPORT.md b/packages/mcp/test-reports/t1-stdio/REPORT.md deleted file mode 100644 index 675a464f..00000000 --- a/packages/mcp/test-reports/t1-stdio/REPORT.md +++ /dev/null @@ -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 -``` diff --git a/packages/mcp/test-reports/t1-stdio/run.log b/packages/mcp/test-reports/t1-stdio/run.log deleted file mode 100644 index 09769da5..00000000 --- a/packages/mcp/test-reports/t1-stdio/run.log +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/t1-stdio/run.ts b/packages/mcp/test-reports/t1-stdio/run.ts deleted file mode 100644 index 353c2b19..00000000 --- a/packages/mcp/test-reports/t1-stdio/run.ts +++ /dev/null @@ -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 { - 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, - opts: { expectStatus?: string; describe?: (r: any) => string } = {}, - ): Promise { - 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 = (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)}m²` : '' - }` - }, - }, - ) - - // ---- 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) -}) diff --git a/packages/mcp/test-reports/t2-http/REPORT.md b/packages/mcp/test-reports/t2-http/REPORT.md deleted file mode 100644 index 8323aecf..00000000 --- a/packages/mcp/test-reports/t2-http/REPORT.md +++ /dev/null @@ -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. diff --git a/packages/mcp/test-reports/t2-http/run.log b/packages/mcp/test-reports/t2-http/run.log deleted file mode 100644 index 7336fa76..00000000 --- a/packages/mcp/test-reports/t2-http/run.log +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/t2-http/run.ts b/packages/mcp/test-reports/t2-http/run.ts deleted file mode 100644 index d675305c..00000000 --- a/packages/mcp/test-reports/t2-http/run.ts +++ /dev/null @@ -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( - client: Client, - name: string, - args: Record, -): 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( - 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 ?? ''})`) - } - - // 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 - 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(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(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 ?? ''})`) - 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 }>(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) -}) diff --git a/packages/mcp/test-reports/t3-scenario/REPORT.md b/packages/mcp/test-reports/t3-scenario/REPORT.md deleted file mode 100644 index ae8dd81d..00000000 --- a/packages/mcp/test-reports/t3-scenario/REPORT.md +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/t3-scenario/apartment.json b/packages/mcp/test-reports/t3-scenario/apartment.json deleted file mode 100644 index d293fb21..00000000 --- a/packages/mcp/test-reports/t3-scenario/apartment.json +++ /dev/null @@ -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": {} -} diff --git a/packages/mcp/test-reports/t3-scenario/run-summary.json b/packages/mcp/test-reports/t3-scenario/run-summary.json deleted file mode 100644 index 44680100..00000000 --- a/packages/mcp/test-reports/t3-scenario/run-summary.json +++ /dev/null @@ -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 - } - } -} diff --git a/packages/mcp/test-reports/t3-scenario/run.log b/packages/mcp/test-reports/t3-scenario/run.log deleted file mode 100644 index bb9efecc..00000000 --- a/packages/mcp/test-reports/t3-scenario/run.log +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/t3-scenario/run.ts b/packages/mcp/test-reports/t3-scenario/run.ts deleted file mode 100644 index 1324b46a..00000000 --- a/packages/mcp/test-reports/t3-scenario/run.ts +++ /dev/null @@ -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( - n: number, - name: string, - fn: () => Promise<{ summary: string; nodeIds?: string[]; result: T }>, -): Promise { - 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>( - client: Client, - name: string, - args: Record = {}, -): Promise { - 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> -}> { - // 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 { - 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 = {} - 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) -}) diff --git a/packages/mcp/test-reports/t4-errors/REPORT.md b/packages/mcp/test-reports/t4-errors/REPORT.md deleted file mode 100644 index 632d86f4..00000000 --- a/packages/mcp/test-reports/t4-errors/REPORT.md +++ /dev/null @@ -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" - } - ] -} -``` diff --git a/packages/mcp/test-reports/t4-errors/run.log b/packages/mcp/test-reports/t4-errors/run.log deleted file mode 100644 index 0c943082..00000000 --- a/packages/mcp/test-reports/t4-errors/run.log +++ /dev/null @@ -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 diff --git a/packages/mcp/test-reports/t4-errors/run.ts b/packages/mcp/test-reports/t4-errors/run.ts deleted file mode 100644 index 8b05b116..00000000 --- a/packages/mcp/test-reports/t4-errors/run.ts +++ /dev/null @@ -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, -): Promise { - 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 { - // --- 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 })?.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 - } - 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 })?.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 { - 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) -}) diff --git a/packages/mcp/test-reports/t5-resources-prompts/dev-probe.sh b/packages/mcp/test-reports/t5-resources-prompts/dev-probe.sh deleted file mode 100644 index 2ba4a30b..00000000 --- a/packages/mcp/test-reports/t5-resources-prompts/dev-probe.sh +++ /dev/null @@ -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