Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:
1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
was treated as opaque (`z.record(z.string(), z.unknown())`) and
written to the store without re-running AnyNode.safeParse.
2. `POST /api/scenes { graph }` in the editor API — same issue; the
Zod `graphSchema` accepted anything object-shaped.
Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
node and run `AnyNode.safeParse`; collect issues and throw
`McpError(InvalidParams, 'graph_invalid', { errors })` on any
failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
`z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
that runs `AnyNode.safeParse` on every node. Invalid → 400 with
detailed issue paths.
Tests:
- Added `save_scene` regression test for the P4 attack
(item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
schema-compliant site node id (the prior `id: 'root'` now fails
the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.
Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).
Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
(expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)
Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
the P8 concurrency bugs (low priority: single-writer MCP is the
typical case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pascal-app/mcp
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.
Install
bun add @pascal-app/mcp # or: npm i @pascal-app/mcp
@pascal-app/core is a peer dependency; Bun workspaces resolve it automatically.
Quick start
Launch the server over stdio in one line:
bunx pascal-mcp # or: npx pascal-mcp
Load an initial scene from disk:
pascal-mcp --stdio --scene ./my-scene.json
Expose it as HTTP for remote hosts:
pascal-mcp --http --port 8787
Claude Desktop config
Edit ~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
}
}
}
If bunx isn't on your PATH, substitute npx or point command at the
absolute path of the pascal-mcp binary inside your project.
Claude Code config
Via the CLI:
claude mcp add pascal bunx pascal-mcp
Or add to .mcp.json at the repo root:
{
"mcpServers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
}
}
}
Cursor config
In Cursor settings (settings.json):
{
"mcp.servers": {
"pascal": {
"command": "bunx",
"args": ["pascal-mcp"]
}
}
}
Programmatic use
Embed the server in your own Node 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.
import { createPascalMcpServer, SceneBridge } from '@pascal-app/mcp'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
const bridge = new SceneBridge()
bridge.loadDefault()
const server = createPascalMcpServer({ bridge })
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'my-agent', version: '0.1.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
const tools = await client.listTools()
console.log('available tools:', tools.tools.map((t) => t.name))
const scene = await client.callTool({ name: 'get_scene', arguments: {} })
console.log(scene)
See examples/embed-in-agent.ts for a
compilable version.
Tools
All tools validate their inputs and outputs with Zod. Mutation tools are captured by Zundo's temporal middleware as a single undoable step.
| Name | Purpose | Key input | Output |
|---|---|---|---|
get_scene |
Return the full scene graph. | — | { nodes, rootNodeIds, collections } |
get_node |
Fetch a node by id. | { id } |
the node, or InvalidParams if not found |
describe_node |
Node summary with ancestry, children count and properties. | { id } |
{ id, type, parentId, ancestry[], childrenCount, properties, description } |
find_nodes |
Filter nodes by type / parent / zone / level. | { type?, parentId?, zoneId?, levelId? } |
{ nodes: AnyNode[] } |
measure |
Distance between two nodes; area when applicable. | { fromId, toId } |
{ distanceMeters, areaSqMeters?, units: 'meters' } |
apply_patch |
Batched create/update/delete/move, validated and dry-run before commit. | { patches: Patch[] } |
{ applied: number } |
create_level |
Add a new level to a building. | { buildingId, elevation, height, label? } |
{ levelId } |
create_wall |
Add a wall to a level. | { levelId, start, end, thickness?, height? } |
{ wallId } |
place_item |
Place a catalog item on a slab, ceiling, or wall with placement validation. | { catalogItemId, targetNodeId, position, rotation? } |
{ itemId } or { error: 'invalid_placement', reason } |
cut_opening |
Cut a door or window opening into a wall. | { wallId, type: 'door' | 'window', position, width, height } |
{ openingId } |
set_zone |
Create a zone/room polygon on a level. | { levelId, polygon, label, properties? } |
{ zoneId } |
duplicate_level |
Clone a level and all of its descendants. | { levelId } |
{ newLevelId, newNodeIds[] } |
delete_node |
Delete a node; cascades when cascade: true. |
{ id, cascade? } |
{ deletedIds: [] } |
undo |
Step back through temporal history. | { steps? } |
{ undone: number } |
redo |
Step forward through temporal history. | { steps? } |
{ redone: number } |
export_json |
Serialize the scene graph as JSON. | { pretty? } |
{ json: string } |
export_glb |
Stubbed: GLB export requires the browser renderer. | — | throws not_implemented |
validate_scene |
Zod-validate every node and parent-child integrity. | — | { valid, errors: { nodeId, path, message }[] } |
check_collisions |
Find overlapping items and out-of-bounds placements. | { levelId? } |
{ collisions: { aId, bId, kind }[] } |
analyze_floorplan_image |
Vision tool: extract walls, rooms, and approximate dimensions from a floorplan image. | { image, scaleHint? } |
{ walls, rooms, approximateDimensions, confidence } |
analyze_room_photo |
Vision tool: extract approximate dimensions and fixtures from a room photo. | { image } |
{ approximateDimensions, identifiedFixtures, identifiedWindows } |
The vision tools require the MCP host to support the sampling capability
(createMessage). Hosts that don't will see a structured
sampling_unavailable error.
Resources
| URI | MIME | Purpose |
|---|---|---|
pascal://scene/current |
application/json |
Full { nodes, rootNodeIds, collections } snapshot. |
pascal://scene/current/summary |
text/markdown |
Human-readable summary with node counts, bounding box, and level areas. |
pascal://catalog/items |
application/json |
Item catalog; returns { status: 'catalog_unavailable', items: [] } in headless mode if no catalog is provided. |
pascal://constraints/{levelId} |
application/json |
Slab footprints and wall polygons for the given level — useful as planner context. |
Prompts
| Name | Args | Purpose |
|---|---|---|
from_brief |
{ brief: string, constraints?: string } |
Guided workflow for turning a prose brief (e.g. "2-bed apartment in 80 m²") into an incremental sequence of apply_patch calls starting from an empty site. |
iterate_on_feedback |
{ feedback: string } |
Minimal-diff instructions: examine the current scene, then propose the smallest patch set that satisfies the feedback. |
renovation_from_photos |
{ currentPhotos: string[], referencePhotos: string[], goals: string } |
Chains the vision tools with the scene mutation tools to produce a renovation plan grounded in photos. |
Limitations
export_glbreturnsnot_implemented. GLB export depends on the Three.js renderer and isn't reachable headlessly without a large additional effort.- Vision tools require MCP host sampling support. Claude Desktop supports this; some MCP clients don't.
- Systems (wall mitering, slab triangulation, CSG cutouts, roof / stair
generation) run inside React hooks in the editor. Headless mode doesn't
regenerate derived geometry — but all node data remains fully manipulable.
Consumers that need rendered geometry run
@pascal-app/viewerin a browser host. - Core's
loadAssetUrl/saveAssetare browser-only; items that referenceasset://<id>URLs aren't resolvable in Node. Supply absolute URLs ordata:URLs for item assets if you need them usable outside the browser. dirtyNodesaccumulates in headless mode because no renderer consumes it. Callbridge.flushDirty()if observability matters to your consumer.
Development
bun install
bun run --cwd packages/mcp build
bun test
Smoke-test the stdio binary end-to-end:
bun run --cwd packages/mcp smoke
License
MIT