@pascal-app/mcp
v1.0.6
Published
Model Context Protocol server for Pascal 3D editor
Readme
@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.
Recommended local setup
For a local editor and MCP that share projects automatically, install the Pascal CLI:
npx @pascal-app/cli editor
pascal mcp setup codexpascal 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-mcpLoad an initial scene from disk:
bunx @pascal-app/mcp --stdio --scene ./my-scene.jsonExpose it over loopback HTTP:
bunx @pascal-app/mcp --http --port 8787Binding 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.exampleLocal scene storage
Scenes saved through MCP are stored in a local SQLite database:
~/.pascal/data/pascal.dbSet 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.jsLocal 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, orset_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 claudeOr 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 codexFor 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.
| Name | Purpose | Key input | Output |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| get_scene | Return the full scene graph. | — | { nodes, rootNodeIds, collections } |
| get_node | Fetch 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_type | Find 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_levels | List 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_summary | Compact 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_walls | Walls on a level with length, stored and resolved height, and child doors/windows. | { levelId?, level? } | { levelId, walls[] } |
| get_zones | Room/zone polygons with holes, areas (holes taken out), bounds and floor choices. | { levelId?, level? } | { levelId, zones[] } |
| get_site | The 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_building | Stand 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 |
| measure | Distance 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_scene | Look 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_assets | Search 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_brief | The 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_assets | Pictures 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
