12 KiB
feat(mcp): add @pascal-app/mcp — Model Context Protocol server
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, 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.
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
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 scripts, background services, or IDE plugins.
What's in the box
Tool inventory
| Tool | Purpose |
|---|---|
get_scene |
Return full scene JSON |
get_node |
Fetch one node by ID |
describe_node |
Human summary: ancestry, children, properties |
find_nodes |
Filter by type / parentId / levelId / zoneId |
measure |
Distance between two nodes; area if zone |
apply_patch |
Atomic multi-op (create / update / delete). All-or-nothing |
create_level |
Create a level under a building |
create_wall |
Create a wall on a level with 2D endpoints |
place_item |
Place an item on a wall / ceiling / site |
cut_opening |
Cut a door or window into a wall at t ∈ [0,1] |
set_zone |
Create a zone polygon on a level |
duplicate_level |
Deep-clone a level subtree with new IDs |
delete_node |
Delete a node (with optional cascade) |
undo / redo |
Drive Zundo temporal store |
export_json |
Serialize scene to JSON (pretty or compact) |
export_glb |
Stub (not_implemented — renderer required) |
validate_scene |
Zod-validate every node |
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
| URI | MIME | Purpose |
|---|---|---|
pascal://scene/current |
application/json |
Full scene |
pascal://scene/current/summary |
text/markdown |
Counts, areas, bbox |
pascal://catalog/items |
application/json |
Item catalog (unavailable headless) |
pascal://constraints/{levelId} |
application/json |
Slabs + wall footprints |
Prompts
| Prompt | Args |
|---|---|
from_brief |
brief, constraints? |
iterate_on_feedback |
feedback |
renovation_from_photos |
currentPhotos, referencePhotos, goals |
Architecture
┌─── MCP host (Claude Desktop / Code / Cursor / custom) ───┐
│ ▲ │
│ stdio │ HTTP │
│ ▼ │
│ ┌──────── packages/mcp/src/bin/pascal-mcp.ts ────────┐ │
│ │ (Bun CLI, loads node-shims first) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──── createPascalMcpServer({ bridge }) ────┐ │
│ │ registerTools() │ │
│ │ registerVisionTools() │ │
│ │ registerResources() │ │
│ │ registerPrompts() │ │
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────── 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) │
└──────────────────────────────────────────────────────────┘
How to test locally
# From the repo root
bun install
bun run --cwd packages/core build
bun run --cwd packages/mcp build
# 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)
bun run --cwd packages/mcp smoke
# Biome lint
bunx biome check packages/mcp
# Turbo build
bunx turbo build --filter=@pascal-app/mcp
Try it with Claude Desktop, Claude Code, or Codex
Add to ~/Library/Application Support/Claude/claude_desktop_config.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"
}
}
}
}
For Codex CLI:
codex mcp add pascal-dev \
--env PASCAL_DATA_DIR="$HOME/.pascal/data" \
-- bun "$PWD/packages/mcp/dist/bin/pascal-mcp.js"
Run the editor with the same PASCAL_DATA_DIR, then ask the MCP host to create
and save_scene; the scene is openable at /scene/<id>.
Known limitations
- GLB export is not implemented. Three.js is browser-only; headless GLB export would require a significant additional effort.
export_glbreturns a structured{ status: 'not_implemented' }response. - Vision tools require host sampling support.
analyze_floorplan_image/analyze_room_photodefer the vision work to the host via MCP sampling. Hosts without sampling capability get a structuredsampling_unavailableerror. No vision model is bundled. - Headless mode doesn't regenerate geometry. Wall mitering, slab triangulation, CSG cutouts, etc. run only in the browser renderer. MCP clients can manipulate node data freely, but derived geometry (mitered wall corners, cut-out walls with door/window holes) is recomputed only when a browser loads the scene via
@pascal-app/viewer. loadAssetUrl/saveAssetare browser-only. Items withasset://<id>URLs can't be resolved in Node. Supply absolute URLs ordata:URIs if you need them usable outside the browser.SiteNode.childreninconsistency. Site's children hold full node objects while every other container holds ID strings (seeCROSS_CUTTING.md§2). MCP works around this by traversing via the flatnodesdict. Upstream alignment proposed as a follow-up.- Catalog unavailable in headless mode.
pascal://catalog/itemsandplace_item's catalog resolution fall back to a placeholder asset payload until the core exposes a Node-consumable catalog. - 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
Documented in packages/mcp/CROSS_CUTTING.md:
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/viewerstill use the main entry)..github/workflows/mcp-ci.yml— new CI. Runs on PRs touching mcp/core; installs with Bun 1.3.0, builds, tests, biome-checks.apps/editorscene routes. Adds local scene API routes and pages that read from the same SQLiteSceneStoreas MCP.- (Observation, not fixed)
SiteNode.childreninconsistency. Detailed in CROSS_CUTTING §2.
Checklist
- ✅
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— 248/248 tests pass across 40 files (965 expects) - ✅
bun run --cwd packages/mcp smoke— spawns stdio server, registers 30 tools, exercisesget_scene/create_level/validate_scene/undoend-to-end - ✅
bun test apps/editor/lib/scene-store-server.test.ts— editor store singleton test passes - ✅ Editor smoke —
/api/scenes/<id>and/scene/<id>return 200 for a scene saved through MCP using the shared SQLite DB - ✅ Local Codex MCP probe with
gpt-5.5— saved a template scene throughpascal-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 Supabase dependency, SQL migrations, or committed test-report artifacts
- ✅
packages/corechanges are additive subpath exports plus URL-schema hardening - ✅ Bun CLI; RAF polyfill loads before any core import
- ✅ Strict TypeScript (no
anywithout reason; no@ts-expect-error); Zod at every boundary - ✅ Every mutation goes through the Zustand store (undo-safe via Zundo)
Commit series
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
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.childrento IDs-only (withsetScenemigration) — 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/coresoplace_itemcan 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.jsstep so fresh installs get an executable bin without a manual chmod. - Consider a separate
@pascal-app/systemspackage so@pascal-app/corecan go data-only (breaking change, larger refactor).