Files
editor/packages/mcp/test-reports/pre-push/a5-pr-description.md
T
Adrian PerezandClaude Opus 4.7 6ace4bf7c8 docs(mcp): add Phase 10 pre-push audit reports (5 agents)
Five parallel audit agents reviewed the branch before open-sourcing
the PR to pascalorg/editor:

- a1-secrets.md: SAFE TO PUSH. Scanned 176 files / 40,768 diff lines.
  Zero secrets, tokens, API keys, PEM blocks, JWTs, or cookies.
  Only MEDIUM finding: absolute /Users/adrian paths in test-report
  scripts (cosmetic, not security).

- a2-security.md: FOUND 2 HIGH-severity issues, both FIXED in
  commit 8757de0:
  * PUT /api/scenes/[id] still had the loose graphSchema that POST
    got fixed in Phase 8 P4. Shared schema extracted to
    apps/editor/lib/graph-schema.ts so both routes re-validate.
  * photo_to_scene + analyze_floorplan_image + analyze_room_photo
    all did raw fetch(url) on user-supplied URLs - a textbook SSRF
    to 169.254.169.254 cloud metadata. Added safe-fetch.ts with
    private-IP / link-local / .local-hostname denylists, manual
    redirect revalidation, size cap, timeout, env-allowlist.

- a3-code-quality.md: READY FOR REVIEW. Zero production `any`, all
  tools Zod-validated in+out, uniform error handling,
  conventional-commits. Two non-blocking follow-ups: client editor
  components (SceneLoader, SaveButton) have no tests; document
  check_collisions n^2 scaling.

- a4-performance.md: SHIP WITH NOTES. MCP dist 904 KB, Supabase
  lazy-imported (zero editor bundle impact), v0.1 hot paths
  sub-200ms. Flagged: FilesystemSceneStore.index.json O(n) per
  write (fine <1k scenes), concurrency races (documented in P8),
  client render at 5k nodes unverified.

- a5-pr-description.md: polished final PR description that
  corrected stale test counts (294 not 142), disclosed all 5
  cross-cutting surfaces, named the known failures honestly,
  split the checklist, expanded the scope to the real Phase 7
  deliverables.

Overall verdict: READY TO PUSH after the A2 fixes landed. No
blockers remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:00:02 +02:00

18 KiB

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" 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

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.sqlscenes 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. Known gaps remain at the save_scene / POST /api/scenes boundary (see Security notes).

apps/editor — persistence routes + scene page (CROSS_CUTTING §4)

  • apps/editor/app/api/scenes/route.tsGET /api/scenes (list), POST /api/scenes (create)
  • apps/editor/app/api/scenes/[id]/route.tsGET, 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

# From repo root
bun install
bun run --cwd packages/core build
bun run --cwd packages/mcp build

# Unit + integration tests (294 tests, 40 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 294/294 pass across 40 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; 36 fail at save_scene/POST boundary (tracked gap)
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 with includeCurrentScene: true validates via the bridge before persisting
  • PASCAL_ALLOWED_ASSET_ORIGINS env var for per-origin https: narrowing
  • 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):

  • save_scene with includeCurrentScene: false and POST /api/scenes do not re-parse per-node AnyNode — a crafted graph can bypass AssetUrl at those boundaries (Phase 8 P4: 36 FAILs)
  • item.asset.thumbnail still bare z.string()
  • No auth layer on HTTP transport or editor API routes

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
  • Add AnyNode re-parse to save_scene(includeCurrentScene: false) and POST /api/scenes
  • 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

  • 294/294 bun test --cwd packages/mcp pass
  • bunx biome check packages/mcp — 0 errors (73 files)
  • bun run --cwd packages/mcp build — tsc clean
  • bunx turbo build --filter=@pascal-app/mcp — 2/2 tasks successful
  • End-to-end smoke test passes (bun run --cwd packages/mcp smoke)
  • Phase 8 full sweep: 37/37 PASS (packages/mcp/test-reports/phase8/p10-full-sweep.md)
  • Villa Azul: 108/108 verification checks (packages/mcp/test-reports/villa-azul/SUMMARY.md)
  • Casa del Sol built end-to-end (packages/mcp/test-reports/casa-sol/BUILD_REPORT.md)
  • Secrets audit clean (packages/mcp/test-reports/pre-push/a1-secrets.md)
  • No modifications to @pascal-app/viewer
  • packages/core changes are additive only (subpath exports + AssetUrl validator)
  • Node 18+ compatible; RAF polyfill loads before any core import
  • All mutations go through Zustand store (undo-safe via Zundo)
  • Cross-cutting changes documented in packages/mcp/CROSS_CUTTING.md
  • save_scene / POST /api/scenes per-node URL validation (tracked follow-up)
  • Same-id concurrent write race (tracked follow-up)
  • Auth layer on HTTP transport (tracked follow-up)

Commit series (9 commits on feat/mcp-server)

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

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