New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@pascal-app/mcp

Package Overview
Dependencies
Maintainers
2
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@pascal-app/mcp

Model Context Protocol server for Pascal 3D editor

latest
Source
npmnpm
Version
1.0.6
Version published
Weekly downloads
295
-73.01%
Maintainers
2
Weekly downloads
 
Created
Source

@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.

For the hosted Pascal MCP endpoint and copy-ready setup for Claude Code, Codex, Cursor, and OpenClaw, read Connect an AI agent. The hosted endpoint edits projects in a Pascal account; this package is the open-source, local server for custom hosts and local scene storage.

The server runs headlessly in Node.js 22.13 or newer or 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.

For a local editor and MCP that share projects automatically, install the Pascal CLI:

npx @pascal-app/cli editor
pascal mcp setup codex

pascal editor starts the editor and an authenticated MCP service together. pascal mcp connect is a stable stdio connector that discovers the dynamic loopback port, so MCP client configuration contains neither a changing port nor a secret.

Use this package directly when embedding the MCP server, supplying a custom store, or running MCP without the Pascal editor.

Install the package directly

bun add @pascal-app/mcp

@pascal-app/core is a peer dependency. The local store uses Bun SQLite under Bun and Node's built-in SQLite driver under Node.js.

Quick start

Launch the server over stdio in one line:

bunx @pascal-app/mcp
# or
npm exec --package=@pascal-app/mcp -- pascal-mcp

Load an initial scene from disk:

bunx @pascal-app/mcp --stdio --scene ./my-scene.json

Expose it over loopback HTTP:

bunx @pascal-app/mcp --http --port 8787

Binding a non-loopback host requires a bearer token:

PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
  bunx @pascal-app/mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example

Local scene storage

Scenes saved through MCP are stored in a local SQLite database:

~/.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:

# 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

Local geometry artifacts

Scripted objects keep their GLB and source in <database-path>.artifacts/<sha256> beside the local SQLite scene database, where saved scenes live (also with --scene, which only seeds the first scene); keep that directory with the database. Files are verified against their hash and written once, so saved scenes and undo history keep their original geometry. The standalone editor reads and writes the same database-side store.

Local MCP compilation is opt-in with PASCAL_SERVER_SCRIPT_COMPILE=1. It runs in a separate process without inherited environment variables, with a deadline; this is not a sandbox for untrusted code. Blender (bpy) modules run in the Blender binary PASCAL_BLENDER names (blender -b), with the same caveat; Blender geometry scripts walks through one. get_source can read saved scripts without enabling compilation.

Live editor updates

When the editor and MCP server share the same PASCAL_DATA_DIR, MCP mutations against a loaded saved scene are persisted to SQLite and recorded in a local scene_events stream. The editor page subscribes to that stream at /api/scenes/:id/events with server-sent events, so an open browser tab can apply scene graph snapshots as the agent edits the scene.

The flow is intentionally local and lightweight:

  • Open or create a scene in the editor so it is saved in the local database.
  • Load that scene through MCP with load_scene.
  • Run MCP mutation tools such as create_room, add_door, furnish_room, add_wall, place_items, or set_zone.

Each mutation version-checks the saved scene before writing. If the browser or another MCP process saved a newer version first, the MCP tool returns live_sync_version_conflict; reload the scene with load_scene before continuing.

Managed CLI client configuration

The recommended JSON configuration for Claude Desktop, Cursor, and compatible clients is:

Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

{
  "mcpServers": {
    "pascal": {
      "command": "pascal",
      "args": ["mcp", "connect"]
    }
  }
}

Claude Code

Via the CLI:

pascal mcp setup claude

Or add to .mcp.json at the repo root:

{
  "mcpServers": {
    "pascal": {
      "command": "pascal",
      "args": ["mcp", "connect"]
    }
  }
}

For package-development testing without the managed CLI, build first and point Claude Code at the built binary:

{
  "mcpServers": {
    "pascal": {
      "command": "node",
      "args": ["/absolute/path/to/editor/packages/mcp/dist/bin/pascal-mcp.js"],
      "env": {
        "PASCAL_DATA_DIR": "/Users/you/.pascal/data"
      }
    }
  }
}

Codex CLI

Via the CLI:

pascal mcp setup codex

For local workspace testing before publish:

bun run --cwd packages/mcp build
codex mcp add pascal-dev \
  --env PASCAL_DATA_DIR="$HOME/.pascal/data" \
  -- node "$PWD/packages/mcp/dist/bin/pascal-mcp.js"

This writes an entry like this to ~/.codex/config.toml:

[mcp_servers.pascal-dev]
command = "node"
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):

{
  "mcp.servers": {
    "pascal": {
      "command": "pascal",
      "args": ["mcp", "connect"]
    }
  }
}

Programmatic use

Embed the server in your own Node.js or 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.

import { createPascalMcpServer, SceneBridge } from '@pascal-app/mcp'
import { Client, InMemoryTransport } from '@modelcontextprotocol/client'

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.

Plugin tools and the person's floor

A plugin's 'operation' tools register with the same runner as the shared tools: it parses the input, applies the outcome as one history step and answers with achieved and the refusals. The annotations are the host's, never read from the plugin's contract.

import { createSceneOperations } from '@pascal-app/mcp/operations'
import { registerOperationTool } from '@pascal-app/mcp/tools/shared-tools'
import { signageAgentTools } from 'acme-signage/agent-tools'
import { signageAgentOperations } from 'acme-signage/agent-operations'

const operations = createSceneOperations({
  bridge,
  // The floor the person is viewing: a tool call that names no level reads it.
  // Leave it out on a host with no view, and the lowest storey is read.
  activeLevelId: () => myHost.viewedLevelId(),
})

for (const contract of signageAgentTools.tools) {
  if (contract.runsIn !== 'operation') continue
  registerOperationTool(server, operations, {
    contract,
    operation: signageAgentOperations[contract.name],
    annotations: { readOnlyHint: false, destructiveHint: false },
  })
}

The contract shapes, versions and trust rules are in plugin authoring.

Coordinate conventions

Pascal is a right-handed scene where X and Z form the ground plane and Y is up. Lengths are in metres; rotations are radians, stored as Euler [x, y, z] tuples.

Plan → world. Every 2-D point you pass is a level/building-local ground-plane coordinate [x, z] — this includes wall.start / wall.end and the polygon / holes arrays of slab, zone, and ceiling. With the default identity building transform, it appears in world space as:

[x, z]  →  (x, y, z)      // the 2nd component is world Z (depth), not "up"

There is no sign flip in the stored convention: tooling consumes the second component as world Z directly. The vertical y starts from the owning level's stacked height as computed by the level system from accumulated level heights, plus the element's own height; slabs additionally carry an absolute elevation.

Heads-up when you compute coordinates outside the editor. Pascal's viewports apply their own rotations on top of the world axes: the 2-D plan panel rotates its content by the user's view rotation (north-aligned = 0°, FLOORPLAN_VIEW_ROTATION_DEG baseline, north = world −Z), and the 3-D "top-down" snap preserves the camera's current azimuth, so when invoked from the iso default position, world and screen axes are offset by ~45° until you orbit to an axis-aligned view. So a layout authored as if "Y = north, viewed top-down" — common in land surveys, north-up site plans, and 2-D plotting libraries — will arrive rotated relative to its source when viewed in Pascal (and possibly further reflected, depending on which viewport and camera state you're in). The editor's own 2-D and 3-D tools are internally consistent with their stored coordinates, so this only affects geometry authored programmatically. To verify orientation before trusting externally-computed coordinates, place a scaled guide image at known anchor points and check alignment; apply whatever rotation (or reflection) your authoring side needs to match.

A worked demonstration of all of this — axis-aligned baseline, the rotated 30° example below, and a paired "page-intent vs world-result" L for the external-coordinate gotcha — lives in examples/coordinate-conventions-demo.md and examples/coordinate-conventions-demo.json. Load the JSON with bunx @pascal-app/mcp --stdio --scene examples/coordinate-conventions-demo.json.

Example — a 6 × 4 m slab rotated 30° about its first corner (coordinates rounded to 3 dp; sides ≈ 6 m / 4 m; not axis-aligned, so the mapping is actually exercised):

{
  "op": "create",
  "parentId": "<levelId>",
  "node": {
    "type": "slab",
    "elevation": 0.0,
    "polygon": [[0, 0], [5.196, 3.0], [3.196, 6.464], [-2.0, 3.464]]
  }
}

This lands flat on the ground (Y = 0), about 6 m along a heading 30° off the +X axis and 4 m along its perpendicular — i.e. occupying world (x, z) directly.

One separate gotcha: wall-attached coordinates are wall-local, not plan coordinates. Stored door/window position[0] is metres along the wall, and wall-attached rotations are wall-local too.

Tools

All tools validate their inputs and outputs with Zod. Mutation tools are captured by Zundo's temporal middleware as a single undoable step.

Arguments a tool's input schema rejects come back as a tool error whose JSON text is { error, code: "invalid_input" }, as every other refusal names its code: the arguments are the wrong shape or type, so read error, fix them and call again. It is the schema's refusal, before the tool ran; the codes a tool raises itself (apply_patch's invalid_update and invalid_parent, a room's cannot_enclose) come back as { error, code, ...details }, and apply_patch adds patchIndex and id. A level an agent writes without a height (a create in apply_patch, or an update that clears it) is given the height the editor's own load would derive from what the level holds: the top of its tallest wall or ceiling, and for an empty level the default storey height, 2.5 m. apply_patch says so in notes, one line per level filled. add_level takes an optional height and uses the same default.

Every length and angle a shared tool takes reads one notation: a bare number is in the unit the field states (metres, degrees, radians), and text carries its own unit, inches, feet and millimetres included. A roof pitch is degrees or rise over run:

{ "wallHeight": "9 ft", "overhang": "18 in", "pitch": "6:12" }

"26.6", "26.6°", "0.46 rad", "6/12", "6 in 12" and "6 on 12" are read too. Minutes and seconds are refused ('6"' is a builder's inches), and so is a "pitch" fraction like "1/4 pitch", which is rise over span.

NamePurposeKey inputOutput
get_sceneReturn the full scene graph.—{ nodes, rootNodeIds, collections }
get_nodeFetch a node by id, whole, with what the scene decides for it beside its stored fields (a wall's or ceiling's resolvedHeight and heightIsExplicit, a wall's resolvedThickness, a door's or window's resolvedOpening, a level's or floor plate's displayName), its ancestors parent first, and the scene materials its finishes use; a node its schema rejects comes back as stored, nothing derived.{ id }{ node, ancestorIds, missingChildIds?, materials? }, or refusal node_not_found
find_by_typeFind everything of one type: nodes of a kind (any core or plugin kind), items of a category, lights, and typed parts inside authored objects, windows and doors; narrowed by level, parent, zone (on its level, by plan point, holes left out) or import source id (provenance.refs[].id or legacy metadata.sourceIds, exact or prefix); without a type, every node the filters match. Each result is the node's id, name, kind, level and parent, with its metadata and provenance (one over 1 KB named in omitted).{ type?, levelId?, parentId?, zoneId?, sourceId?, sourceIdPrefix?, limit? }{ type?, count, nodes, parts, results[], truncated?, hint? }, at most limit (200) results listed; refusals unknown_type, level_not_found, node_not_found, zone_not_found, not_a_zone
list_levelsList every building's levels in floor order with their role (storey, roof-only, support), their standing against the ground line (gradeStanding, isGroundLevel) and, per building, the level at grade.—{ activeSceneId, activeLevelId, levelCount, occupiedStoryCount, supportLevelCount, roofLevelIds, groundLevels[], levels[] }
get_level_summaryCompact summary of one level: role, counts, walls with openings, zones with areas, slabs, ceilings, items, stairs, roofs, and everything else by type. Omit levelId for the lowest storey.{ levelId?, level? }{ levelId, role, counts, walls, zones, slabs, ceilings, items, stairs, roofs, other }
get_wallsWalls on a level with length, stored and resolved height, and child doors/windows.{ levelId?, level? }{ levelId, walls[] }
get_zonesRoom/zone polygons with holes, areas (holes taken out), bounds and floor choices.{ levelId?, level? }{ levelId, zones[] }
get_siteThe site a building stands on, in its level coordinates (a lot with no building yet, in site coordinates): the lot and its area with the footprint a house usually covers (30 to 45%), the buildable area inside the setbacks with its centre, area, each edge's frontage and depth and its front edge, the street side with the lot's frontage and depth, north, the ground (sloped, read flat or not read), address, parcel and zoning, whether the lot is the new-project placeholder, and the footprint's fit and yards.{ building?, site? }{ siteId, buildingId, pose, lot, placeholder, buildable, street, ground, northDeg, address, parcel, zone, footprint, others, summary }; refusals no_site, building_required, site_not_found, not_a_site, building_not_on_site
place_buildingStand a building on its site by moving the building node only (walls keep their level coordinates): its footprint, the outline about to be drawn or the walls standing, centred in the buildable area or at the yards given; a house standing keeps its rotation unless rotationDeg is given, one with no walls yet is squared to the street.{ building?, outline?, yards?: { front?, rear?, left?, right? }, rotationDeg? }{ buildingId, position, rotationDeg, moved, footprint, yards, fit, overhang, movedWithIt?, message }; refusals no_site, needs_footprint, other_building_on_site, no_street, yards_conflict, yards_infeasible, building_required
measureDistance between two nodes' world-space reference points (hosted doors, windows and items resolved through their host; every node kind); area when applicable.{ fromId, toId }{ distanceMeters, fromPoint?, toPoint?, areaSqMeters?, units: 'meters' }
view_sceneLook at the finished building, every storey and the roof, from a viewpoint the agent picks (a compass side, a side as seen from the house's street, a street-height eye, a photo's camera, square-on, from above), in the scene's own light or a photo's, and get the picture back, with the photo's crop beside it when a region is given. interior: { zoneId } stands inside a room at eye height (1.6 m by default), a step in from a corner and looking across it; interior: { levelId } looks inside one floor: the other floors and its roofs and ceilings left out, everything on it (walls, openings, furniture) cut at 0.65 of the storey's height, for the picture only. Pictures are 1920 × 1200 unless maxPx (64 to 1920) asks for a smaller longest side. The server has no renderer: the host's sceneViews renders it (the hosted app, through the user's open editor tab). Read-only.{ target?, interior?, from?, elevation?, eyeHeight?, position?, fov?, projection?, camera?, lighting?, maxPx?, photo? }image content plus { status, camera, size, tab, capturedAt }; refusals view_unavailable, no_project, which_building (a street side, no target, buildings on several sites), no_current_camera (camera: 'current': the server has no view of the person's), camera_and_viewpoint (a camera with a viewpoint of the agent's own), photo_crop_unavailable, interior_level_invalid, interior_hidden, interior_transform_unsupported, interior_target_elsewhere; on the hosted server editor_tab_required, editor_tab_hidden, editor_tab_busy (a tab open but busy or still loading, no link: wait a few seconds and call again, do not ask the user), editor_tab_timeout (a tab claimed the view and then stopped answering); what the refusal says about opening the editor follows the person's "Agents may open the editor for me" setting (ask first, allow, never)
search_assetsSearch the host's item library (the built-in list on a standalone server), several queries in one call; a query matches name, id, category or tags. A query that finds nothing gets a hint to build the item with add_object.{ queries: [{ query, category? }] }{ groups: [{ query, total, results[] }], total, hint? }; refusal no_catalog
design_briefThe request read as a brief, once for the whole request: axes (the request read axis by axis: action, size, pieces, openings, style, materials, dressing, each bounded — the person's own words as quote, the reading in a short line, pieces as a minimum or exact — or open with a design statement of at least three words; an absent axis is refused axis_missing, a quote not in the person's message in the chat axis_quote_not_in_request, an open axis without its design axis_open_without_design; over MCP an axis quote is required and not compared, there being no message), the eighth axis source (open, catalog, brand with its brand, existing), a vision for what is open, the dressing of the rooms layer by layer (beyond: textiles, light, windows, walls, floor, plants, objects, small furniture, each pieces or { none: why }; an open dressing axis owes 6 of the 8, beyond_too_thin, a bounded one owes none, and the named pieces read as a minimum do not bound it, dressing_bounded_by_minimum; handed back as dressing), the person's words that set a source other than open (sourceQuote, required: at least two words naming the brand, the catalog or what is already there; not compared with a request here) and, with an open source, technical equipment placed as it is (asIs: sanitaryware, appliances, fixed services; each piece shown by view_assets first, each with a reason; furniture is made). Placing or making furnishings follows a brief: place_items, furnish_room, place_in_room (the catalog's) and add_object, place_design (made), inside run_batch too, are refused before one with design_brief_required.search_assets and view_assets stay open. Source open opens the made pieces, places technical equipment only when named in asIs and keeps furnish_room closed (brief_source_open); catalog, brand and existing open the catalog's tools, and creation for what it lacks. A batch holding a refused call is refused whole. The brief is the session's: a scene load or clear starts another, and the next design_brief replaces it. Changes nothing in the scene.
view_assetsPictures of 1 to 6 catalog items by id, each labelled with its size, category and the names of its paintable surfaces (its model's slot_<id> materials; paint takes one as role), returned as image content. The picture and surfaces are read before the call (a hosted server fetches and resizes the thumbnail to at most 512 px; a standalone server's built-in list has no host to fetch from, so it answers sizes and says it has no picture). Opt-in: nothing changes when it is not called.{ ids: string[] (1 to 6) }{ items: [{ id, name, category, dimensions, surfaces, picture, unavailable? }], shown, note? } plus one labelled image per item shown; refusals unknown_asset, no_catalog
search_materialsSearch or browse the material library, several queries in one call: words match as the library writes them (timber is wood, render is stucco), shades and surfaces; a query without words lists a kind, a surface or the whole library by name. A search lists 12 at a time and a browse 150, a long one saying how many more (more) and the offset that lists them (next). Reads Pascal's library and the materials the host registered (the editor registers its library; this server registers none). Read-only.{ queries: [{ query?, kind?, surface?, offset? }] }{ groups: [{ query?, kind?, surface?, offset?, results: [{ ref, label, kind, surfaces, color? }], missing?, more?, next? }] }
paintGive walls, slabs, ceilings, roofs and every other part with surfaces (columns, fences, doors, windows, stairs, blocks, cabinets, shelves…) a finish in one call, as the editor's Paint does: a colour (the nearest library colour), a library: or scene: ref, a native material kept as a scene material (reused when an equal one exists), or erase. role picks the surface (a wall's exterior/interior faces, by its rooms or else its stored sides, refused when rooms stand on only part of a face, a slot by name, a roof's top/edge/wall, or its fascia, the fascia and rake boards, held by every segment; a glazed roof's glazing-frame, glazing-glass or glazing-solid, and without a role its frame and solid panels, the glass kept); a roof shows one finish per surface, so a segment's finish goes on its roof and a roof's clears its segments' own; a wall face is painted whole, over its room's wall finish, its painted parts keeping theirs (named in note).{ targets[], role?, color?, material?, materialName?, erase? } (one finish){ ok, painted[], finish, materialId?, material?, createdMaterial?, note?, achieved }; refusals target_not_found, unknown_role, side_unknown, unknown_material, materials_unavailable, not_paintable
create_story_shellCreate one level-owned story shell from a footprint: the perimeter walls. The floor plate and ceiling are derived from the enclosed rooms; createSlab / createCeiling / slabElevation are recorded as room intent. Use once per story.{ levelId, footprint, wallHeight?, wallThickness?, createSlab?, createCeiling? }{ wallIds, zoneIds, slabId, ceilingId, createdIds }
create_stairCreate a straight, L or U stair as the editor's stair tool does: from a level to the next one above (made when there is none), owning the floor openings it cuts, or to no level (toLevelId: null).(x, z) is the middle of the bottom riser, rotation in degrees (0 climbs toward +Z).{ x, z, levelId?, toLevelId?, rotation?, layout?, turn?, lowerSteps?, width?, length?, height?, steps?, railingMode?, railingStyle?, railingMaterial? }{ stairId, segmentIds, upperLevelId, createdUpperLevel, layout, stepCount, rise, length, footprint, arrival, slabHoleCut, openingIds? }; refusals roof_level, not_above, level_not_found, slab_not_found / ceiling_not_found, slab_not_on_level / ceiling_not_on_level, stair_layout, stair_layout_required, no_level_above
measure_stairRead actual risers, going at the walking line, slope, uniformity, arrival, headroom against floors/ceilings/stair bodies, and design-target diagnostics.{ stairId, available?: { width, length } }{ measurements, layouts }
fit_stairFit uniform risers or replace the chain with a straight/L/U preset with width-sized landings or quarter-turn winders in one undoable edit.{ stairId, fitRun?, targets?, layout?, turn?, width?, landingDepth?, turningStrategy?, innerGap?, walkingLineOffset?, division? }{ stairId, measurements }
create_roofCap the top storey with a roof over its footprint (its walls' outline, else its floor plates, else its rooms), one segment per rectangle, or over a rectangle of your own (width, depth, center). The roof goes on the building's roof level above the storey (reused, the empty level directly above when it is the only one, else made and named Roof) and stands on the storey's wall tops; the hand roof tool follows the same rule. roofType: "barrel" arches each segment (depth is the span, barrelRise at most half of it, barrelSegments facets from 4 to 32); roofSystem: "glazed" builds a gable, straight shed or barrel roof as panes and framing, set by glazing (see glazed roofs).{ levelId? (or level), roofType?, roofSystem? ('standard' | 'glazed'), glazing?, barrelRise?, barrelSegments?, pitch? (degrees, or rise over run: "6:12"), overhang? (the eaves' reach past the walls in plan), wallHeight?, wallThickness?, width?, depth?, center?, materialPreset?, fasciaHeight?, fasciaMaterialPreset?, name? }{ roofId, roofLevelId, createdRoofLevelId, coveredLevelId, segmentIds, roofType, achieved }; refusals storey_above, roof_exists, building_required (several buildings, no level named), storey_without_building, conflicting_level, no_levels, no_rooms, rectangle_incomplete, unknown_material (the roof's or the fascia's finish)
move_roofs_to_roof_levelMove the roofs a storey holds on its own walls to the level the roof rule gives them (the building's roof level over the top storey, made when there is none; the storey above a lower one), as a roof's panel does by hand. Each roof stands where it stood, segments, finishes and id kept. For projects built before the roof level, when verify_scene reports roof_mixed_in_storey.{ levelId }{ roofIds, roofLevelId, createdRoofLevelId, achieved }; refusals level_not_found, no_roof_on_walls
create_roomCreate a room from a polygon: one wall per edge (an edge a wall already runs along reuses it) and the room zone; its doors and windows can be declared in the same call by edge (wallIndex) and t, each placed with add_door's and add_window's rules or listed in skippedOpenings. The floor plate and ceiling are derived, never authored. outdoor: true makes a terrace: separators (the editor's Separator: a room boundary with no wall) where no wall runs, no walls of its own, no ceiling. Where every edge of the polygon already runs along a wall (nothing to build) and the polygon fits the room those walls make, the call names that room instead of stacking a second zone on it, and zoneId is that room's. If that room has a name of its own (not a number the editor gave it: any name but the editor's "Room N", whoever gave it) the call is refused (room_named_by_person, worded with the room's name): pass rename: true only when the person asked for the rename; otherwise ask them. A room is never renamed unasked, and the same name over it says there is nothing to create. A terrace (outdoor: true) drawn over a fully walled courtyard names it outdoor. The refusal is agent-only: the editor's own room tool behaves as before (a person sees and names rooms directly).{ name, polygon, levelId?, color?, wallHeight?, wallThickness?, outdoor?, rename?, doors?, windows? }{ zoneId, slabId, ceilingId, wallIds, reusedWalls, areaSqMeters, doorIds, windowIds, skippedOpenings? }; refusals cannot_enclose, outdoor_room_overlap, room_named_by_person
add_doorAdd a door to a straight wall at t (0..1 along it) with the editor's placement rules, or a passage with no leaf (openingKind: "opening"); it slides to stay on the wall and reports clamped.doorType, style and the outline (openingShape) match a reference, code builds what they cannot, and nodeId rebuilds a scripted door.{ wallId, t?, width?, height?, openingKind?, hingesSide?, swingDirection?, doorType?, style?, openingShape?, code?, nodeId?, force? }{ doorId, wallId, localX, t, clamped, achieved }; refusals wall_not_found, not_a_wall, curved_wall, wall_too_short, opening_overlap (unless force)
add_windowAdd a window as add_door places a door, centred vertically on the wall unless sillHeight (its bottom edge above the floor) is given; windowType, columns / rows, style (a Fixed window's panes; refused on another type, style_needs_fixed_window) and the outline match a reference.{ wallId, t?, width?, height?, sillHeight?, windowType?, columns?, rows?, style?, openingShape?, code?, nodeId?, force? }{ windowId, wallId, localX, t, clamped, sillHeight, achieved }; the same refusals as add_door
furnish_roomFurnish a room for its type from the host's item catalog: the main piece against the wall facing the door, the rest along a side wall. Nothing lands in a door's clear zone or on another item: a piece that does not fit is nudged, else skipped, and skipped says why.{ zoneId?, polygon?, levelId?, roomType, doorWallIndex? }{ placed, itemIds, skipped, doorWallIndex, doorsDetected }; refusals room_required, roof_level, level_not_found, no_catalog
add_corner_windowA window wrapping the corner where two walls meet: one window per wall, both running to the corner and joined, the glass fused (a fixed window) or at a thin post (the other window types, whose frames stay), at any angle from 10° to 170° (a square corner, a bay's 135°, an acute one). corner is the point the walls of levelId end at (default the floor in view: stacked storeys share their corners' points), [x, z] in the level's plan as get_walls gives their ends; width is how far the glass runs along each wall from the corner (default 1.2 m) and widths overrides it per wall id; post is 'none' (the glass fused, a fixed window's default) or 'post' (the other types' default and only choice); height defaults to 1.5 m and the window is centred on the wall unless sillHeight (its bottom edge above the floor) is given; wallIds (two) is needed only when more than two walls end at the corner; windowType and style are add_window's (a style needs a Fixed window). By hand: select a window that ends at a corner and use the window panel's Corner section (Wrap the corner, This side and Other side, Fused glass or Corner post for a fixed window, Unwrap).{ corner: [x, z], levelId? (or level), wallIds?, width?, widths?, height?, sillHeight?, post?, windowType?, style? }{ windowIds, wallIds, angle, post, achieved }; refusals no_corner (no two walls end there), corner_ambiguous (more than two do: pass wallIds), corner_angle (under 10° or over 170°: use add_window), corner_not_fused (post: 'none' on a type other than fixed), width_exceeds_wall, style_needs_fixed_window
merge_windowsMake two windows that exist one window, as the editor's Merge windows button does; nothing is recreated. On two walls that meet at a corner, each window within 0.30 m of it, they become one corner window (the add_corner_window result): each is slid to the corner keeping its width, the glass fused (a fixed window's; other types meet at a post), and both take the first window's height, sill and type, so name first the window whose look you want; the corner is refused over 170° or under 10°. On one wall, two windows with at most 0.30 m between them and nothing between become one window spanning both (overlapping or one inside the other, their union), from the lower sill to the higher head, with the first window's type and style. Use it for two windows already built that open a corner or one wide opening (verify_scene's corner_unjoined names a pair for the first); add_corner_window builds a new pair. By hand: select two windows and use Merge windows in the multi-selection panel, or place or drag a window near a corner where one waits and click or release (Alt places it plain).{ windowIds: [a, b] }{ kind: 'corner' | 'same_wall', windowIds, removedIds, achieved }; refusals window_not_found, not_a_window, same_window, window_not_on_wall, window_already_joined (unwrap it first), windows_already_joined, windows_not_adjacent, opening_between, walls_not_meeting, corner_angle, window_not_at_corner, height_exceeds_wall, opening_overlap
apply_patchBatched create/update/delete/move, validated and dry-run before commit. Batch-first is the default: send all create/update/delete ops for a build step in one atomic call (stable order, later ops may reference earlier created ids); do not loop one-op calls. A create with an id already in the scene is refused (node_exists); delete it earlier in the same patch to replace it. Updates cannot change id or type (identity_change), object or children (immutable_field), move a node under a missing or childless parent (invalid_parent), or add schema issues (invalid_update); default gutters and downspouts a delete regenerates are addressable only in a later call (regenerated_default). Refusals are tool errors with JSON text { code, patchIndex, id, message }. A level created without a height, or an update that clears it, gets the height the editor's load derives from what the level holds (an empty level: 2.5 m), one line per level in notes. A roof the patch makes or moves onto the storey whose walls carry it is seated on the level above those walls, one line per roof in notes; a new storey at or above the roof level moves it up. Refused: storey content on a roof level (roof_level), declaring a level that holds storey content the roof level (roof_level_holds_storey), floor indexes that put the roof level under a storey (roof_level_on_top).{ patches: Patch[] }{ applied: number, notes?: string[] }
add_levelAdd an empty level to a building, as the editor does: on top of its storeys, under its roof level (which moves up one), or below the lowest for a basement (every existing floor, the ground floor included, keeps its place). Omit buildingId for the scene's only building.{ buildingId?, position?, name?, height? }{ levelId, buildingId, floorIndex, height, shiftedLevelIds? }; refusals building_required, building_not_found, no_building
set_ground_levelMake a level the building's ground floor, as the level panel's "Set as ground floor": the building names it, is seated so that floor stands over grade as the building's foundation construction seats it, the top of the foundation (8 in, 0.203 m, on block: the default and the US; 0.15 m on cast concrete in Europe; height only, the plan stays), and the floor indexes are renumbered so it is 0 and the levels under it are the basements.{ levelId }{ levelId, buildingId, floorElevation, aboveGrade, foundation { region, construction, basis }, movedBuildingBy, renumbered, unchanged? }; refusals level_not_found, not_a_level, no_building, not_a_storey
add_wallAdd a wall to a level, straight or, with curveOffset, an arc. Omit levelId for the lowest storey.{ start, end, levelId?, thickness?, height?, curveOffset? }{ wallId, levelId, length }; refusals roof_level, wall_too_short, level_not_found
add_fenceAdd a fence along points on a level, one fence per side; closed runs it back to the first point (a lot's boundary). Privacy panels unless style says otherwise.{ points, closed?, height?, style?, baseStyle?, materialPreset?, levelId? }{ count, fenceIds, levelId, length, achieved }; refusals fence_too_short, roof_level, unknown_material, level_not_found
place_itemsPlace catalog items in one call, each placed or refused on its own, at level (x, z): on the level's floor, or on the host each names (targetNodeId): a wall (y, the height of its bottom, required), a ceiling, or an item standing on the floor. A floor item in a door's clearance is refused blocks_door (naming the door and a spot that clears every door), one its room cannot hold in any turn too_large_for_room.{ items: [{ assetId, x, z, y?, targetNodeId?, rotation? }], levelId? }{ levelId, items: [{ ok, itemId?, hostId?, restingOn?, code? }] }; per-item refusals asset_not_found, outside_rooms, blocks_door, too_large_for_room, height_required, item_too_tall, unsupported_host, host_not_found, host_not_on_level
place_designCreate one design (procedural item recipe, object or JSON string) that passes validate_design. Its mounting picks the host: level/slab/zone or a design surface (surfaceId), a straight wall face, or a ceiling. Create-only, one undo step, inline designs up to 24 KiB, with coded refusals.{ design, hostId, position, rotation?, side?, surfaceId?, parameters?, slots?, name?, id? }{ designId, parentId, surfaceId }
set_zoneCreate a zone/room polygon on a level; over walls that already enclose a room, it is that room, never a second zone: one still named "Room N", or already named label, takes the label; one a person named otherwise is refused.{ levelId, polygon, label, properties? }{ zoneId }
duplicate_levelCopy a level as the editor does (units whose rooms are all on it included; plan references, scans and spawns left behind), above or below, shifting the floors past it.{ levelId, position?, name?, preset? }{ newLevelId, name, floorIndex, shiftedLevelIds, copied, skipped, newNodeIds[] }
delete_nodeDelete a node with everything under it, as the editor's Delete does.{ id }{ deletedIds: [] }
clear_sceneEmpty the project on purpose, back to the default site, building and level. The only way to empty a project: a write that would leave it empty is refused (scene_wipe_blocked) by a store that guards against accidental wipes, and the session keeps the project as stored.{ reason }{ cleared: { removed }, version, graphHash }
undoStep back through temporal history.{ steps? }{ undone: number }
redoStep forward through temporal history.{ steps? }{ redone: number }
export_jsonSerialize the scene graph as JSON.{ pretty? }{ json: string }
export_glbStubbed: GLB export requires the browser renderer.—throws not_implemented
validate_designRead-only: validate a design (procedural item recipe, object or JSON string) and measure it. The authority over pascal://schema/design.{ design, parameters? }{ valid, diagnostics[], design, sweep, measurements }
verify_sceneThe one scene check: schema validity, per-level counts, roles and the spaces the walls enclose, empty levels, and typed practical issues (every node field its schema rejects as schema_invalid with its nodeId and path, a node kind no schema knows once per kind, a space with no room or with several, a room the walls do not close or with no name, storeys with no stair, openings off their wall, stairs off their slab, blocked doors, a house past its lot line or in its required yards…).—{ valid, levels[], issues: { type, message, … }[], hasIssues }
check_collisionsFind overlapping items and out-of-bounds placements.{ levelId? }{ collisions: { aId, bId, kind }[] }
analyze_floorplan_imageVision tool: extract walls, rooms, and approximate dimensions from a floorplan image.{ image, scaleHint? }{ walls, rooms, approximateDimensions, confidence }
analyze_room_photoVision 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.

A retired tool is listed no more and still answered: a call to its name returns an error with the code tool_retired, the tool that replaced it (replacement) and how to use that one. validate_scene retired into verify_scene, find_nodes into find_by_type and describe_node into get_node (RETIRED_MCP_TOOLS in @pascal-app/core/agent-tools).

// find_nodes { zoneId } → find_by_type, with or without a type
{ "name": "find_by_type", "arguments": { "type": "window", "zoneId": "zone_kitchen" } }
{ "name": "find_by_type", "arguments": { "sourceIdPrefix": "ground/exterior-01" } }
// describe_node → get_node: resolvedHeight, resolvedThickness, ancestorIds, displayName…
{ "name": "get_node", "arguments": { "id": "wall_host" } }
// validate_scene → verify_scene: schema_invalid issues by nodeId and path, room findings
{ "name": "verify_scene", "arguments": {} }

Examples

// The lot before the house: frontage, depth, each buildable edge as a front, the ground
{ "name": "get_site", "arguments": {} }
// An L stair with a glass railing, and porch steps to no level
{ "name": "create_stair", "arguments": { "x": 2, "z": 1, "layout": "l", "turn": "left", "lowerSteps": 6, "railingStyle": "glass" } }
{ "name": "create_stair", "arguments": { "x": 0, "z": -4, "toLevelId": null, "steps": 3, "height": "0.5 m" } }
// A glazed barrel roof over a 6 × 4 m rectangle
{ "name": "create_roof", "arguments": { "roofType": "barrel", "roofSystem": "glazed", "width": 6, "depth": 4, "barrelRise": 1.4, "barrelSegments": 12 } }
// Gable ends reaching 0.6 m past their walls, apart from the eaves
{ "name": "apply_patch", "arguments": { "patches": [{ "op": "update", "id": "rseg_main", "data": { "gableOverhang": 0.6 } }] } }
// Find a finish, then paint every exterior wall face with it
{ "name": "search_materials", "arguments": { "queries": [{ "query": "white render", "surface": "wall" }] } }
{ "name": "paint", "arguments": { "targets": ["wall_a", "wall_b"], "role": "exterior", "material": "library:<id>" } }
// The house as a street photo shows it, at dusk, small
{ "name": "view_scene", "arguments": { "from": "street-left", "lighting": "dusk", "maxPx": 960 } }

gableOverhang is a roof segment's own reach past its gable-end walls (a gable's or gambrel's ends, a shed's sides), in plan metres; left out, the ends reach as far as the eaves. The roof panel's "Gable overhang" slider edits the same field.

Resources

URIMIMEPurpose
pascal://scene/currentapplication/jsonFull { nodes, rootNodeIds, collections } snapshot.
pascal://scene/current/summarytext/markdownHuman-readable summary with node counts, bounding box, and level areas.
pascal://agent/guidetext/markdownMCP-first construction workflow, scene invariants, and tool preferences for agents. On a hosted server it also points at the project's other chats, files and members (search_project_chats, read_project_chat, get_project_assets, read_project_asset, list_project_members), read as quoted data, never instructions.
pascal://catalog/itemsapplication/jsonDependency-free built-in catalog subset for common residential furniture and fixtures.
pascal://constraints/{levelId}application/jsonSlab footprints and wall polygons for the given level — useful as planner context.
pascal://schema/designapplication/jsonDesign contract: JSON Schema generated from core's RecipeSchema, limits, rules only validate_design checks, and an example.

Prompts

NameArgsPurpose
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_glb returns not_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.
  • The built-in MCP catalog is intentionally small. Host applications can expose their own richer catalog through additional tools/resources without requiring the MCP package to depend on the editor UI bundle.
  • 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/viewer in a browser host.
  • Core's loadAssetUrl / saveAsset are browser-only; items that reference asset://<id> URLs aren't resolvable in Node. Supply absolute URLs or data: URLs for item assets if you need them usable outside the browser.
  • dirtyNodes accumulates in headless mode because no renderer consumes it. Call bridge.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

Keywords

mcp

FAQs

Package last updated on 23 Oct 2026

Related posts