10 parallel research agents (R1-R10) investigated whether MCP can save scenes as saveable entities that the user later opens in the editor — rather than injecting via a dev-only window.__pascalScene hook. Key findings: - Editor is already backend-agnostic via onLoad/onSave callbacks (R2,R7) - Current persistence is localStorage-only, single key (R1) - Zero dynamic routes and no backend code yet — env declared (R4,R5) - File import exists as "Load Build" but lacks Zod validation (R6) - MCP-written scenes load cleanly into the editor today (R6, Casa del Sol test already proved this) - Best path: filesystem-handoff this week, Supabase in weeks 2-4, Supabase Realtime for live mode in Q2. Skip Yjs for now. (R8) - 4-5 weeks to private beta, 10-14 to GA (R9) - 10 high-value ideas ranked; "photo -> scene" is the unblocker (R10) SYNTHESIS.md pulls all 10 reports together with the recommended implementation plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
25 KiB
R8 — MCP ↔ Editor Integration Design
Vision. "Call MCP → scene is saved → I open the scene in the editor, no injection."
Today apps/editor/app/page.tsx runs a dev-only window.__pascalScene = useScene hack so an MCP running in the same browser can setScene(). That has to die. MCP lives in Node (its own SceneBridge over useScene), editor lives in the browser (its own instance of useScene). They never share memory. So "saving" means serializing a SceneGraph to a shared medium the editor can read.
Below are five concrete options, ranked, with a recommendation.
Option A — Filesystem handoff (~/.pascal/scenes/<slug>.json)
Description
MCP writes SceneGraph JSON to ~/.pascal/scenes/<slug>.json via a new save_scene tool. Next.js API route GET /api/scenes/[slug] reads the file from disk using node:fs. /scene/[slug] page fetches and calls applySceneGraphToEditor() on mount.
Architecture
Claude Desktop / Cursor
│ stdio
▼
pascal-mcp (Node)
│
SceneBridge.exportJSON()
│
▼
~/.pascal/scenes/kitchen-v3.json ◄── shared disk
▲
│ fs.readFile (server-side)
Next.js API route /api/scenes/[slug]
▲
│ fetch()
/scene/[slug] page ──► applySceneGraphToEditor()
Pros
- Zero new infra, zero network between MCP and editor
- Works offline; trivial to debug (
cat ~/.pascal/scenes/foo.json) - No auth, no RLS, no API contracts beyond "JSON on disk"
- Editor's existing
applySceneGraphToEditor()already accepts aSceneGraph; reusingpackages/mcp/src/bridge/scene-bridge.tsexportJSON()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_scenestools inpackages/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
- Add
save_scene({ slug })andlist_scenes()tools that write to~/.pascal/scenes/<slug>.json(Nodefs/promises, slug sanitization,XDG_DATA_HOMEfallback) - Add
load_scene({ slug })tool that callsbridge.loadJSON(readFileSync(...)) apps/editor/app/api/scenes/[slug]/route.ts— GET reads~/.pascal/scenes/<slug>.jsonwith path-traversal guard, returns JSONapps/editor/app/scene/[slug]/page.tsx—use client, fetches/api/scenes/[slug]on mount, callsapplySceneGraphToEditor()- Delete the
window.__pascalSceneinjection hack fromapps/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 upserts via the service role; editor's /scene/[id] page SSR-fetches the row with anon key and RLS. Existing env.mjs already declares POSTGRES_URL, SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, BETTER_AUTH_SECRET, GOOGLE_CLIENT_ID. The rails are laid.
Architecture
Claude / agent
│
▼
pascal-mcp (Node) ─────► Supabase REST (service role)
│ INSERT scenes { id, owner_id, graph_json, updated_at }
│
│ returns { sceneId, url: "https://.../scene/<id>" }
▼
User pastes / clicks URL
│
▼
Next.js editor (Vercel)
│ SSR with anon key + RLS
▼
/scene/[id]/page.tsx ──► applySceneGraphToEditor()
│
│ on save-in-editor
▼
PATCH scenes (owner_id == auth.uid)
Tables:
scenes (id uuid pk, owner_id uuid fk auth.users, slug text, title text,
graph_json jsonb, updated_at timestamptz, created_at timestamptz)
scene_revisions (id uuid pk, scene_id fk, author_id fk, graph_json jsonb,
created_at timestamptz, author_kind text) -- "mcp" | "editor"
RLS: owner_id = auth.uid() for select/update; public-read if public = true.
Pros
- One-machine and cross-device works the same way
- Multi-user, sharing via URL, versioning via
scene_revisions— all free with Postgres - Auth already half-wired:
BETTER_AUTH_SECRET+GOOGLE_CLIENT_IDin env.mjs suggest Better Auth planned - Editor can deploy to Vercel unchanged
graph_jsonis ajsonbcolumn — 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-jsdep inpackages/mcp(dependency) andapps/editor(already probably pulling it or easy add) - Supabase project or local
supabaseCLI for dev - Migration SQL for
scenes+scene_revisions - Small change to
packages/mcp— env bootstrap, service-role key from~/.pascal/config.jsonorPASCAL_SUPABASE_URL/PASCAL_SUPABASE_KEYenv - 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
- Create
supabase/migrations/001_scenes.sqlwithscenesandscene_revisionstables + RLS policies (owner read/write, public-slug read) - Add
packages/mcp/src/adapters/supabase.tsusing@supabase/supabase-js, driven byPASCAL_SUPABASE_URL+PASCAL_SUPABASE_USER_TOKENenv (not service-role). Wrap insave_scene/load_scene/list_scenestools - Add
apps/editor/lib/supabase.tsserver client; addapps/editor/app/scene/[id]/page.tsx(server component) that fetches and passesgraph_jsonto a client<SceneLoader>that runsapplySceneGraphToEditor() - 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 toscene_revisions - Add
/settings/mcproute in editor that mints a device token and shows the JSON block the user pastes intoclaude_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.createServerwith a side route is ~30 lines - Can introspect the scene via the same bridge MCP tools use → perfect consistency
Cons
SceneBridgeis 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.tswith non-MCP REST routes (or a secondhttp.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
- Split
connectHttpintoconnectMcpHttp(existing MCP route) andconnectApiHttp(new REST). Share the sameSceneBridgeinstance - Add
GET /api/scenes/:id,POST /api/scenes,GET /api/scenesbacked by an on-disk map~/.pascal/scenes/*.json(Option A persistence reused) - Add CORS allowlist for
http://localhost:3002(editor dev port) - In the editor,
apps/editor/app/scene/[id]/page.tsxcallsfetch('http://localhost:3917/api/scenes/[id]')client-side - Wire a CLI flag
pascal-mcp --serve --port 3917 --data-dir ~/.pascal/scenesthat 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/currentwould need to be a subscribable resource. Our server doesn't currently implementnotifications/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)
SceneBridgestill 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/clientinapps/editor(new dep in browser bundle — SDK is Node-first, bundle size TBD)- Add resource subscription support to
packages/mcp/src/server.tsandscene-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
SceneBridgesession (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
- Add
server.sendResourceUpdated('pascal://scene/current')hooks insideSceneBridgemutation methods - Implement
resources/subscribehandler inpackages/mcp/src/server.tstracking per-transport subscriptions - Add browser MCP client to
apps/editor/lib/mcp-client.ts; wire auth token header apps/editor/app/scene/live/page.tsx— connects, callsget_scene, subscribes, re-applies on each notification- Add a toggle in
/settings/mcpfor "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
SceneGraphneeds a full rewrite to become a CRDT-friendly structure.nodes: Record<id, AnyNode>+rootNodeIds: []map cleanly toY.Map<Y.Map>andY.Array, but everyAnyNode.parse()and every Zustand mutation inpackages/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/coreat its heart
Dependencies
yjs,y-indexeddb, optionallyy-websocket,y-protocols- Major rewrite of
packages/core/src/store— bridging Zustand ⇄ Yjs - New MCP adapter
packages/mcp/src/bridge/yjs-bridge.tsmirroringSceneBridgebut 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
- Prototype a Yjs binding for
packages/core/src/store/use-scene.tsbehind a feature flag; keep the plain Zustand path default - Build a minimal Y.Doc ⇄
SceneGraphserializer and prove round-trip parity against existing scenes - Write
packages/mcp/src/bridge/yjs-bridge.tsthat wraps Y.Doc in the same interface asSceneBridge - Run a same-machine POC: MCP writes to Y.Doc → y-indexeddb → editor tab observes via
Y.Map.observe - 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 (
revisionstable) - 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:
-
Week 1: Option A (filesystem). Two days of work, ships the "no injection" promise for your own machine. Kills the
window.__pascalScenehack. 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. -
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/<id>". The MCP side is ~200 lines:save_scene/load_scene/list_scenestools callingsupabase.from('scenes').upsert({...}). -
Quarter 2: Option D live mode as a pro feature. Layer real-time on top of B via Supabase Realtime (
supabase.channel('scene:<id>').on('postgres_changes', ...)), not via MCP resource subscriptions — avoids the browser-as-MCP-client rabbit hole. -
Option E (CRDT) is only right if you commit to Figma-grade multiplayer. It's a 3-month project across
@pascal-app/coreand shouldn't be started until you have product-market signal that multiplayer is the moat. -
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]. Deletewindow.__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_scenefrom Claude Desktop every day. Bugs get filed.
Day 31–60 — "Productionize"
- Auth bootstrap:
/settings/mcpdevice-pairing flow. Mints a scoped Supabase user JWT (not service-role) for MCP. - Editor → Supabase save: debounced on-change writes + "Save" button. Append
scene_revisionsrows withauthor_kind: 'editor'. - History UI: simple diff viewer across
scene_revisions. - Sharing:
public = trueflag → 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_changeson the current scene and applies remote updates. Gives near-realtime collab without Yjs effort. - Presence: Supabase Realtime presence for "agent is editing".
- Figma-style undo across actors: scoped per-user temporal history (use
clearSceneHistory()when switching scenes; keep Zundo per tab). - Decide on E: if usage shows genuine concurrent-agent-plus-human friction, spike Yjs in Q2. Otherwise defer.
Risks + mitigations
| Risk | Severity | Mitigation |
|---|---|---|
| MCP needs a user identity to write to Supabase with RLS | High | Device-pairing UI in editor that mints a short-lived JWT → long-lived machine token. Never ship the service-role key. |
| Concurrent MCP + editor writes clobber each other | Medium | Optimistic concurrency: include updated_at in UPDATE WHERE clause; if mismatch, append a revision instead of overwriting and surface a merge prompt. |
Large graph_json payloads blow past Supabase's 8MB body limit |
Medium | Measure casa-sol (test scene in test-reports/casa-sol/) and typical scene sizes. If >1MB, chunk or move to Storage buckets. |
Serverless cold starts on /scene/[id] make "open" slow |
Low | Use Next.js revalidate: 0 + ISR; Edge runtime where possible; cache in localStorage on client. |
applySceneGraphToEditor is a client-only function, can't run on SSR |
Low | Keep it client; <SceneLoader> component does useEffect(() => applySceneGraphToEditor(initialGraph), []). |
| Supabase outage breaks the whole editor | Medium | Layer Option A on top: editor falls back to last-known-good from localStorage when fetch fails. |
| RLS misconfiguration leaks scenes across users | Critical | Policy tests in supabase/tests/; add a dedicated "rls" CI step; anon client gets read-only and only public = true rows. |
MCP Node process can't reach ~/.pascal on Windows Claude Desktop install |
Low | Use envPaths('pascal') (via env-paths) to resolve per-OS. |
Slug collisions / path traversal via save_scene({ slug: '../foo' }) |
High (for A) | Strict slug regex ^[a-z0-9-]{1,64}$; reject everything else. |
| MCP writes a scene under user A; user B opens the URL → leak | High (for B) | Only share by signed URL or explicit public = true flag. Default visibility is owner-only. |
Open questions for the user
-
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? -
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"?
-
Offline requirements? Must editing work with zero network (implies Option A cache on top of B), or is online-required acceptable for v1?
-
Deployment target for the editor? Vercel (rules out C, F) or self-hosted (all options viable)?
-
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.)
-
Auth provider?
env.mjshasBETTER_AUTH_SECRET+GOOGLE_CLIENT_ID— is Better Auth the plan, or Supabase Auth? They can coexist but one is source of truth forauth.uid(). -
Versioning/history UX? Is "every MCP call appends a revision" desired, or should only explicit saves create revisions? Affects
scene_revisionswrite patterns. -
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.)
-
How does "open in the editor" trigger? Does MCP return a URL and the user clicks? Or does MCP invoke a deeplink (
pascal://scene/<id>handler) that focuses an already-open browser tab? -
Catalog availability in MCP. Today
pascal://catalog/itemsreturnscatalog_unavailablein 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?