feat(mcp): scaffold package and confirm headless bridge viability
- Add packages/mcp/ with package.json, tsconfig, PLAN.md, CROSS_CUTTING.md - Extend @pascal-app/core exports map with subpaths (./schema, ./store, ./material-library, ./spatial-grid, ./wall) so Node consumers can skip the graphics-bound systems re-export chain. - Add scripts/spike.ts that proves useScene + temporal work in Node with a requestAnimationFrame polyfill. Spike passes end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
097886b246
commit
2ae932f384
@@ -15,6 +15,31 @@
|
||||
"types": "./dist/utils/clone-scene-graph.d.ts",
|
||||
"import": "./dist/utils/clone-scene-graph.js",
|
||||
"default": "./dist/utils/clone-scene-graph.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./dist/schema/index.d.ts",
|
||||
"import": "./dist/schema/index.js",
|
||||
"default": "./dist/schema/index.js"
|
||||
},
|
||||
"./store": {
|
||||
"types": "./dist/store/use-scene.d.ts",
|
||||
"import": "./dist/store/use-scene.js",
|
||||
"default": "./dist/store/use-scene.js"
|
||||
},
|
||||
"./material-library": {
|
||||
"types": "./dist/material-library.d.ts",
|
||||
"import": "./dist/material-library.js",
|
||||
"default": "./dist/material-library.js"
|
||||
},
|
||||
"./spatial-grid": {
|
||||
"types": "./dist/hooks/spatial-grid/spatial-grid-manager.d.ts",
|
||||
"import": "./dist/hooks/spatial-grid/spatial-grid-manager.js",
|
||||
"default": "./dist/hooks/spatial-grid/spatial-grid-manager.js"
|
||||
},
|
||||
"./wall": {
|
||||
"types": "./dist/systems/wall/wall-footprint.d.ts",
|
||||
"import": "./dist/systems/wall/wall-footprint.js",
|
||||
"default": "./dist/systems/wall/wall-footprint.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Cross-cutting changes touching packages outside `@pascal-app/mcp`
|
||||
|
||||
Integrator review required. Each entry documents:
|
||||
- **What** was changed
|
||||
- **Why** (what blocked MCP without it)
|
||||
- **Impact** on existing consumers
|
||||
- **Reversibility**
|
||||
|
||||
---
|
||||
|
||||
## 1. `packages/core/package.json` — added subpath exports
|
||||
|
||||
### What
|
||||
|
||||
Added these subpath entries to the `"exports"` map of `@pascal-app/core`:
|
||||
|
||||
- `./schema` → `./dist/schema/index.js`
|
||||
- `./store` → `./dist/store/use-scene.js`
|
||||
- `./material-library` → `./dist/material-library.js`
|
||||
- `./spatial-grid` → `./dist/hooks/spatial-grid/spatial-grid-manager.js`
|
||||
- `./wall` → `./dist/systems/wall/wall-footprint.js`
|
||||
|
||||
The existing `"."` and `"./clone-scene-graph"` entries are unchanged.
|
||||
|
||||
### Why
|
||||
|
||||
The main entry (`.`) re-exports every `System*` (`WallSystem`, `SlabSystem`, `CeilingSystem`, `RoofSystem`, `ItemSystem`, `StairSystem`, `DoorSystem`, `WindowSystem`, `FenceSystem`) which side-effect-imports `three`, `three-mesh-bvh`, and `three-bvh-csg`. In Node (no browser), `three-mesh-bvh`'s CJS UMD build fails to resolve its `three.*` globals at module-load time, so merely `import { WallNode } from '@pascal-app/core'` crashes before any user code runs.
|
||||
|
||||
By adding subpath exports that point at modules which don't transitively pull graphics code, the MCP server package (and any future Node consumer) can import just the Zod schemas and the Zustand store without dragging in `three` and its GPU-bound dependencies.
|
||||
|
||||
### Impact
|
||||
|
||||
**Zero** on existing consumers. This is purely additive. `apps/editor` and `@pascal-app/viewer` continue to import from the main entry and get the full surface — they currently don't use these subpaths and don't need to. No types, runtime behavior, or bundle composition is affected.
|
||||
|
||||
### Reversibility
|
||||
|
||||
Remove the 5 new entries from `exports` and the change is undone. `@pascal-app/mcp` would then have to ship its own shim or the core team would need to split `@pascal-app/core` into a "core-data" package and a "core-systems" package — a larger refactor.
|
||||
|
||||
### Suggested follow-up (upstream)
|
||||
|
||||
Long-term, consider moving `systems/` into a separate package `@pascal-app/systems` so that `@pascal-app/core` stays data-only. That's a breaking change and out of scope for this PR; the subpath exports are the non-breaking interim fix.
|
||||
|
||||
---
|
||||
|
||||
## 2. `SiteNode.children` inconsistency (observed, not fixed)
|
||||
|
||||
### What
|
||||
|
||||
`packages/core/src/schema/nodes/site.ts:36-38` declares:
|
||||
|
||||
```ts
|
||||
children: z.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))
|
||||
.default([BuildingNode.parse({})])
|
||||
```
|
||||
|
||||
`SiteNode.children` therefore holds **full node objects**. Every other container node (`building`, `level`, `wall`, `ceiling`, `roof`, `stair`) stores `string[]` (IDs) in `children`.
|
||||
|
||||
### Why this is a problem
|
||||
|
||||
- Data duplication: the building exists both in `nodes[building.id]` and embedded inside `site.children[0]`. Updates to the building in the dict don't propagate to the embedded copy.
|
||||
- Traversal asymmetry: "get children of a container" needs `site`-specific branching.
|
||||
- `duplicate_level`, `find_nodes({ parentId })`, and scene-serialisation round-trips all need a special case for site.
|
||||
|
||||
### Why we didn't fix it
|
||||
|
||||
Changing the schema is a breaking change to serialised scene data and would require a migration pass inside `setScene`. Out of scope for a non-breaking MCP addition.
|
||||
|
||||
### Workaround (inside MCP)
|
||||
|
||||
MCP tools resolve node children through the flat `nodes` dict by scanning for nodes whose `parentId` matches. This is correct regardless of which representation the schema chose.
|
||||
|
||||
### Suggested follow-up (upstream)
|
||||
|
||||
Align `SiteNode.children` to `z.array(z.string())` + migration in `setScene.migrateNodes` that extracts embedded building/item objects into the flat dict and replaces them with IDs.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# @pascal-app/mcp — Implementation Plan
|
||||
|
||||
> This document is the contract for the 8-agent parallel build. All subagents MUST read it before writing code. Deviations require an entry in `CROSS_CUTTING.md`.
|
||||
|
||||
## 0. Ground truth discovered in Phase 0
|
||||
|
||||
- **Monorepo layout.** Turborepo + Bun. Root `package.json` already lists `packages/*` in `workspaces`. Our new package sits at `packages/mcp/`.
|
||||
- **Build tooling.** TypeScript 5.9.3, `tsc --build` per package, outputs to `dist/`. Biome 2.4.x for lint/format (root `biome.jsonc`).
|
||||
- **AGENTS.md does not exist.** `CLAUDE.md` is a symlink pointing to a non-existent `AGENTS.md`. The conventions referenced in the task prompt are therefore derived from `README.md`, `CONTRIBUTING.md`, and the actual code.
|
||||
- **`@pascal-app/core` v0.5.1** — already built and consumed by `@pascal-app/viewer` with `workspace:*` via `peerDependencies`. It exports the full Zod schema surface, the `useScene` Zustand store with `temporal` (Zundo) wrapper, systems, hooks, lib utilities, events, and `clone-scene-graph`.
|
||||
- **MCP SDK** — `@modelcontextprotocol/sdk@1.29.0` (latest stable). Subpath exports include `./server/mcp.js`, `./server/stdio.js`, `./server/streamableHttp.js`, `./client/*`, `./types.js`.
|
||||
|
||||
## 0.5 Bridge spike result (CONFIRMED)
|
||||
|
||||
Ran `scripts/spike.ts` end-to-end. ✅ All checks pass:
|
||||
- `useScene.loadScene()` creates default Site → Building → Level (3 nodes)
|
||||
- `createNode(wall, levelId)` adds wall to `nodes` dict and to `level.children`
|
||||
- `updateNode(wallId, { thickness, height })` merges update, then RAF polyfill fires `markDirty`
|
||||
- `temporal.undo()` reverts update, and a second `undo()` removes the wall
|
||||
- `temporal.redo(2)` restores both steps
|
||||
- `deleteNode(wallId)` removes wall and cleans parent's children array
|
||||
- `unloadScene()` → `setScene(snapshot...)` round-trip preserves node count
|
||||
|
||||
Node compatibility requires:
|
||||
1. **RAF polyfill** loaded before any core import (see §1 below).
|
||||
2. **Subpath imports**, not the main entry. See §0.6 below.
|
||||
|
||||
## 0.6 Import contract (CRITICAL — every subagent must use these)
|
||||
|
||||
Do **NOT** `import X from '@pascal-app/core'`. The main entry re-exports Three.js systems and fails at load-time in Node.
|
||||
|
||||
Use these subpaths (added to core's `exports` map — see `CROSS_CUTTING.md`):
|
||||
|
||||
```ts
|
||||
// Zod schemas (safe in Node)
|
||||
import {
|
||||
AnyNode,
|
||||
BuildingNode, CeilingNode, DoorNode, FenceNode, GuideNode, ItemNode,
|
||||
LevelNode, RoofNode, RoofSegmentNode, ScanNode, SiteNode, SlabNode,
|
||||
StairNode, StairSegmentNode, WallNode, WindowNode, ZoneNode,
|
||||
type AnyNodeId, type AnyNodeType,
|
||||
} from '@pascal-app/core/schema'
|
||||
|
||||
// Zustand store (default export)
|
||||
import useScene from '@pascal-app/core/store'
|
||||
// NOTE: useScene is the DEFAULT export from this subpath
|
||||
|
||||
// Clone helpers
|
||||
import {
|
||||
cloneLevelSubtree, cloneSceneGraph, forkSceneGraph,
|
||||
type SceneGraph,
|
||||
} from '@pascal-app/core/clone-scene-graph'
|
||||
|
||||
// Material catalog (safe in Node — no three imports)
|
||||
import {
|
||||
MATERIAL_CATALOG, getCatalogMaterialById, getMaterialsForTarget,
|
||||
} from '@pascal-app/core/material-library'
|
||||
|
||||
// Spatial utilities (pure functions — safe in Node)
|
||||
import { pointInPolygon, spatialGridManager } from '@pascal-app/core/spatial-grid'
|
||||
|
||||
// Wall helpers (pure functions)
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS,
|
||||
getWallPlanFootprint, getWallThickness,
|
||||
} from '@pascal-app/core/wall'
|
||||
```
|
||||
|
||||
`useScene` is the default export of `@pascal-app/core/store`. Use `useScene.getState()` / `useScene.temporal.getState()` as usual.
|
||||
|
||||
## 0.7 SiteNode.children quirk
|
||||
|
||||
`SiteNode.children` is declared as `z.array(z.discriminatedUnion('type', [BuildingNode, ItemNode]))` — it holds **objects**, not IDs. Every other container node (`building`, `level`, `wall`, `ceiling`, `roof`, `stair`) stores `string[]` of child IDs.
|
||||
|
||||
Implications for tools:
|
||||
- Parent-child traversal through `site` cannot use the generic "children is ID[]" pattern.
|
||||
- Always resolve children through the flat `nodes` dict via `parentId` scan when you need to enumerate descendants of a site.
|
||||
- `describe_node` / `find_nodes` / `duplicate_level` must special-case site.
|
||||
|
||||
(This is upstream-worthy simplification; filed in `CROSS_CUTTING.md` section 2 as a suggested refactor but not taken in this PR.)
|
||||
|
||||
## 1. Node-compatibility: the critical adapter
|
||||
|
||||
The core store was written for the browser. Node support requires **one polyfill** at MCP package boot (before `import useScene`):
|
||||
|
||||
```ts
|
||||
// packages/mcp/src/bridge/node-shims.ts
|
||||
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
|
||||
;(globalThis as any).requestAnimationFrame = (cb: (t: number) => void): number => {
|
||||
return setTimeout(() => cb(performance.now()), 0) as unknown as number
|
||||
}
|
||||
;(globalThis as any).cancelAnimationFrame = (id: number) => {
|
||||
clearTimeout(id as unknown as NodeJS.Timeout)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `packages/core/src/store/actions/node-actions.ts:330` calls `requestAnimationFrame` inside `updateNodesAction`. `packages/core/src/store/use-scene.ts:462` calls `requestAnimationFrame` inside the temporal subscribe callback that marks affected nodes dirty after undo/redo. Both are load-reachable — the subscribe callback registers at module import time.
|
||||
|
||||
`crypto.randomUUID` is available globally in Node 18+, no shim needed. `URL.createObjectURL` is only called in `loadAssetUrl` which we do NOT call in MCP (no browser assets). `idb-keyval` is imported at the top of `asset-storage.ts` but only executes functions when `saveAsset`/`loadAssetUrl` are called; we never import that module in MCP code.
|
||||
|
||||
**Persist middleware:** core does NOT apply `zustand/middleware/persist`. Persistence happens in `apps/editor`, not in core. So the store is already Node-clean apart from RAF.
|
||||
|
||||
## 2. Node types (17 total)
|
||||
|
||||
Every one of these has a Zod schema in `packages/core/src/schema/nodes/` and participates in `AnyNode` (discriminated union on `type`):
|
||||
|
||||
| Type literal | Schema export | Parent expected | Container? | Notes |
|
||||
|-----------------|------------------|------------------|------------|-------|
|
||||
| `site` | `SiteNode` | — (root) | children via typed array | polygon (2D) |
|
||||
| `building` | `BuildingNode` | `site` | children: level IDs | position/rotation |
|
||||
| `level` | `LevelNode` | `building` | children: mixed IDs | level (int) |
|
||||
| `wall` | `WallNode` | `level` | children: item/door/window IDs | 2D `start`/`end` |
|
||||
| `fence` | `FenceNode` | `level` | — | 2D `start`/`end` |
|
||||
| `zone` | `ZoneNode` | `level` | — | polygon (2D) |
|
||||
| `slab` | `SlabNode` | `level` | — | polygon + holes |
|
||||
| `ceiling` | `CeilingNode` | `level` | children: item IDs | polygon + holes |
|
||||
| `roof` | `RoofNode` | `level` | children: roof-segment IDs | position/rotation |
|
||||
| `roof-segment` | `RoofSegmentNode`| `roof` | — | `roofType` enum |
|
||||
| `stair` | `StairNode` | `level` | children: stair-segment IDs | from/toLevelId |
|
||||
| `stair-segment` | `StairSegmentNode`| `stair` | — | flight/landing |
|
||||
| `item` | `ItemNode` | `wall` / `ceiling` / `site` | children: item IDs | `asset` payload |
|
||||
| `door` | `DoorNode` | `wall` | — | segments/panels |
|
||||
| `window` | `WindowNode` | `wall` | — | columns/rows |
|
||||
| `scan` | `ScanNode` | `level` | — | external GLB url |
|
||||
| `guide` | `GuideNode` | `level` | — | 2D guide image url |
|
||||
|
||||
The full union is `AnyNode` at `packages/core/src/schema/types.ts:20`. `AnyNodeType` and `AnyNodeId` are also exported there. **Subagents MUST reuse these — never redefine.**
|
||||
|
||||
## 3. Store API — the only mutation surface
|
||||
|
||||
From `packages/core/src/store/use-scene.ts:160-201`. All calls via `useScene.getState()`:
|
||||
|
||||
```ts
|
||||
useScene.getState().createNode(node: AnyNode, parentId?: AnyNodeId): void
|
||||
useScene.getState().createNodes(ops: { node: AnyNode; parentId?: AnyNodeId }[]): void
|
||||
useScene.getState().updateNode(id: AnyNodeId, data: Partial<AnyNode>): void
|
||||
useScene.getState().updateNodes(updates: { id: AnyNodeId; data: Partial<AnyNode> }[]): void
|
||||
useScene.getState().deleteNode(id: AnyNodeId): void
|
||||
useScene.getState().deleteNodes(ids: AnyNodeId[]): void
|
||||
useScene.getState().setScene(nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void
|
||||
useScene.getState().loadScene(): void // initializes empty default Site → Building → Level
|
||||
useScene.getState().clearScene(): void // unloadScene + loadScene
|
||||
useScene.getState().unloadScene(): void // truly empties state
|
||||
useScene.getState().markDirty(id: AnyNodeId): void
|
||||
useScene.getState().clearDirty(id: AnyNodeId): void
|
||||
useScene.getState().setReadOnly(readOnly: boolean): void
|
||||
```
|
||||
|
||||
Undo/redo (Zundo temporal wrapper):
|
||||
|
||||
```ts
|
||||
useScene.temporal.getState().undo(steps?: number): void
|
||||
useScene.temporal.getState().redo(steps?: number): void
|
||||
useScene.temporal.getState().clear(): void
|
||||
useScene.temporal.getState().pastStates // readonly
|
||||
useScene.temporal.getState().futureStates // readonly
|
||||
```
|
||||
|
||||
Plus `import { clearSceneHistory } from '@pascal-app/core'`.
|
||||
|
||||
**Dirty bookkeeping in headless mode.** Because no renderer is consuming `dirtyNodes`, the set accumulates. For MCP correctness we don't care — dirty tracking is a renderer concern. We will expose a `flushDirty()` helper in the bridge that simply empties the set after a mutation batch for observability.
|
||||
|
||||
## 4. MCP package layout
|
||||
|
||||
```
|
||||
packages/mcp/
|
||||
├── PLAN.md (this file)
|
||||
├── PR_DESCRIPTION.md (Phase 3 deliverable)
|
||||
├── CROSS_CUTTING.md (any proposed upstream changes)
|
||||
├── README.md
|
||||
├── CHANGELOG.md
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── index.ts # programmatic API re-exports
|
||||
│ ├── server.ts # createPascalMcpServer() factory
|
||||
│ ├── bridge/
|
||||
│ │ ├── node-shims.ts # RAF polyfill (load FIRST)
|
||||
│ │ ├── scene-bridge.ts # SceneBridge class
|
||||
│ │ └── scene-bridge.test.ts
|
||||
│ ├── tools/
|
||||
│ │ ├── index.ts # registerTools(server, bridge)
|
||||
│ │ ├── schemas.ts # shared patch schemas
|
||||
│ │ ├── errors.ts # structured MCP error helpers
|
||||
│ │ ├── get-scene.ts
|
||||
│ │ ├── get-node.ts
|
||||
│ │ ├── describe-node.ts
|
||||
│ │ ├── find-nodes.ts
|
||||
│ │ ├── measure.ts
|
||||
│ │ ├── apply-patch.ts
|
||||
│ │ ├── create-level.ts
|
||||
│ │ ├── create-wall.ts
|
||||
│ │ ├── place-item.ts
|
||||
│ │ ├── cut-opening.ts
|
||||
│ │ ├── set-zone.ts
|
||||
│ │ ├── duplicate-level.ts
|
||||
│ │ ├── delete-node.ts
|
||||
│ │ ├── undo.ts
|
||||
│ │ ├── redo.ts
|
||||
│ │ ├── export-json.ts
|
||||
│ │ ├── export-glb.ts # stub: not_implemented
|
||||
│ │ ├── validate-scene.ts
|
||||
│ │ ├── check-collisions.ts
|
||||
│ │ ├── analyze-floorplan-image.ts
|
||||
│ │ ├── analyze-room-photo.ts
|
||||
│ │ └── *.test.ts (one per tool)
|
||||
│ ├── resources/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── scene-current.ts
|
||||
│ │ ├── scene-summary.ts
|
||||
│ │ ├── catalog-items.ts
|
||||
│ │ ├── constraints.ts
|
||||
│ │ └── resources.test.ts
|
||||
│ ├── prompts/
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── from-brief.ts
|
||||
│ │ ├── iterate-on-feedback.ts
|
||||
│ │ ├── renovation-from-photos.ts
|
||||
│ │ └── prompts.test.ts
|
||||
│ ├── transports/
|
||||
│ │ ├── stdio.ts
|
||||
│ │ └── http.ts
|
||||
│ └── bin/
|
||||
│ └── pascal-mcp.ts # CLI entry; shebang #!/usr/bin/env node
|
||||
├── scripts/
|
||||
│ └── smoke.ts # end-to-end client test
|
||||
├── examples/
|
||||
│ ├── generate-apartment.md
|
||||
│ ├── renovate-from-photos.md
|
||||
│ └── embed-in-agent.ts
|
||||
└── dist/ # generated
|
||||
```
|
||||
|
||||
## 5. Tool inventory (exact contracts)
|
||||
|
||||
All tools declared with Zod input AND output schemas. Handlers return `{ content: [{ type: 'text', text: JSON.stringify(validatedOutput) }], structuredContent?: output, isError?: boolean }` per MCP SDK 1.x spec. Error handlers throw `McpError` with `ErrorCode.InvalidParams` / `InvalidRequest` / `InternalError`.
|
||||
|
||||
### Read-only
|
||||
|
||||
1. **`get_scene`** — `() => { nodes, rootNodeIds, collections }`
|
||||
2. **`get_node`** — `{ id }` → the node or throws `InvalidParams` "node not found"
|
||||
3. **`describe_node`** — `{ id }` → `{ id, type, parentId, ancestry[], childrenCount, properties, description }`
|
||||
4. **`find_nodes`** — `{ type?, parentId?, zoneId?, levelId? }` → `{ nodes: AnyNode[] }`. `zoneId` filter returns nodes whose position falls inside the zone polygon; `levelId` filter resolves via ancestry using `resolveLevelId`.
|
||||
5. **`measure`** — `{ fromId, toId }` → `{ distanceMeters, areaSqMeters?, units: 'meters' }`
|
||||
|
||||
### Mutations (undo-safe)
|
||||
|
||||
6. **`apply_patch`** — `{ patches: Patch[] }` where `Patch = Create | Update | Delete | Move`. Validates all with Zod, dry-runs first, then batch-applies via `createNodes` / `updateNodes` / `deleteNodes`. Zundo captures this as a single temporal step because of Zustand set batching inside each `*Nodes` call.
|
||||
7. **`create_level`** — `{ buildingId, elevation, height, label? }` → `{ levelId }`. Uses `LevelNode.parse({...})` then `createNode`.
|
||||
8. **`create_wall`** — `{ levelId, start, end, thickness?, height? }` → `{ wallId }`. Uses `WallNode.parse({...})` with defaults from `DEFAULT_WALL_HEIGHT` / `DEFAULT_WALL_THICKNESS` if omitted.
|
||||
9. **`place_item`** — `{ catalogItemId, targetNodeId, position, rotation? }` → `{ itemId }` or `{ error: 'invalid_placement', reason }`. Pre-validation:
|
||||
- If target is a slab/ceiling: call pure `spatialGridManager.canPlaceOnFloor(...)` equivalent (we inline the pure logic from `hooks/spatial-grid/spatial-grid-manager.ts` rather than using React-bound spatial-grid-sync).
|
||||
- If target is a wall: compute `wallT` from position along wall centerline; validate via `canPlaceOnWall`.
|
||||
- Resolve `catalogItemId` → asset payload. Catalog may be unavailable in headless mode — return structured `{ status: 'catalog_unavailable' }` error if so.
|
||||
10. **`cut_opening`** — `{ wallId, type: 'door' | 'window', position: 0..1, width, height }` → `{ openingId }`. Creates a `DoorNode` or `WindowNode` with `wallId` set; position maps to wallT.
|
||||
11. **`set_zone`** — `{ levelId, polygon, label, properties? }` → `{ zoneId }`. Creates `ZoneNode` via `ZoneNode.parse`.
|
||||
12. **`duplicate_level`** — `{ levelId }` → `{ newLevelId, newNodeIds[] }`. Uses `cloneLevelSubtree(levelId, { nodes, rootNodeIds })` from `@pascal-app/core/clone-scene-graph`, then bulk-inserts the cloned nodes via `createNodes`.
|
||||
13. **`delete_node`** — `{ id, cascade?: boolean }` → `{ deletedIds: [] }`. If `cascade` is false and node has children, throw `InvalidRequest` "node has children; pass cascade: true to delete recursively". If `cascade` is true, just call `deleteNode(id)` (core's deleteNodesAction already cascades via descendant collection).
|
||||
|
||||
### Undo/redo
|
||||
|
||||
14. **`undo`** — `{ steps? }` → `{ undone: number }`
|
||||
15. **`redo`** — `{ steps? }` → `{ redone: number }`
|
||||
|
||||
### Export
|
||||
|
||||
16. **`export_json`** — `{ pretty?: boolean }` → `{ json: string }`
|
||||
17. **`export_glb`** — `{}` → throws `InternalError` with `{ status: 'not_implemented', reason: 'GLB export requires the Three.js renderer, which is browser-only' }`
|
||||
|
||||
### Validation
|
||||
|
||||
18. **`validate_scene`** — `{}` → `{ valid: boolean, errors: { nodeId, path, message }[] }`. Runs `AnyNode.safeParse(node)` on every node; additionally verifies parent-child integrity.
|
||||
19. **`check_collisions`** — `{ levelId? }` → `{ collisions: { aId, bId, kind }[] }`. Uses pure spatial-grid helpers.
|
||||
|
||||
### Vision (MCP sampling)
|
||||
|
||||
20. **`analyze_floorplan_image`** — `{ image: string (base64 or https URL), scaleHint?: string }` → `{ walls, rooms, approximateDimensions, confidence }`. Constructs a `CreateMessageRequest` via `server.server.createMessage({ ... })` (MCP sampling). Response JSON validated against the output schema. On absent sampling capability, throws `InvalidRequest` `{ status: 'sampling_unavailable' }`.
|
||||
21. **`analyze_room_photo`** — `{ image }` → `{ approximateDimensions, identifiedFixtures, identifiedWindows }`. Same pattern.
|
||||
|
||||
## 6. Resources (4)
|
||||
|
||||
- `pascal://scene/current` — `application/json`, full `{ nodes, rootNodeIds, collections }`
|
||||
- `pascal://scene/current/summary` — `text/markdown`, human summary with counts + bbox + areas
|
||||
- `pascal://catalog/items` — `application/json`, item catalog if available; else `{ status: 'catalog_unavailable', items: [] }`
|
||||
- `pascal://constraints/{levelId}` — `application/json`, slab footprints + wall polygons for that level
|
||||
|
||||
Register via `server.registerResource(...)` with `readResource` handlers.
|
||||
|
||||
## 7. Prompts (3)
|
||||
|
||||
- `from_brief` — args `{ brief: string, constraints?: string }`. Returns messages that instruct the agent to call `apply_patch` incrementally starting from an empty site.
|
||||
- `iterate_on_feedback` — args `{ feedback: string }`. Minimal-diff instructions.
|
||||
- `renovation_from_photos` — args `{ currentPhotos: string[], referencePhotos: string[], goals: string }`. Tells the agent to call the vision tools first, then propose patches.
|
||||
|
||||
## 8. Transports
|
||||
|
||||
- **stdio** (default) — `StdioServerTransport` from `@modelcontextprotocol/sdk/server/stdio.js`.
|
||||
- **HTTP** — `StreamableHTTPServerTransport` from `@modelcontextprotocol/sdk/server/streamableHttp.js`, bound to a `node:http` server on `--port`.
|
||||
|
||||
CLI `pascal-mcp` flags:
|
||||
- `--stdio` (default) — stdio transport
|
||||
- `--http --port <n>` — HTTP transport
|
||||
- `--scene <path>` — load initial scene from JSON file via `setScene`
|
||||
- `--help`, `--version`
|
||||
|
||||
## 9. package.json contract
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "Model Context Protocol server for Pascal 3D editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js" },
|
||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --build --watch",
|
||||
"start": "node dist/bin/pascal-mcp.js",
|
||||
"test": "bun test",
|
||||
"smoke": "bun run scripts/smoke.ts",
|
||||
"prepublishOnly": "bun run build && bun test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `@pascal-app/core` is a **peer dependency**, but Bun workspaces auto-resolve it via `workspaces` in the root. In practice we'll also list it under `devDependencies` with `workspace:*` so `bun install` hoists it.
|
||||
|
||||
## 10. tsconfig.json contract
|
||||
|
||||
Extends `@pascal/typescript-config/base.json` (NOT react-library — no DOM).
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"extends": "@pascal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"],
|
||||
"references": [{ "path": "../core" }]
|
||||
}
|
||||
```
|
||||
|
||||
Separate tsconfig excludes do not apply to tests (bun runs them directly from TS).
|
||||
|
||||
## 11. turbo.json — no changes required
|
||||
|
||||
The existing `turbo.json` globs `packages/*` implicitly via Bun workspaces and pipeline tasks are generic (`build`, `lint`, `check-types`, `dev`). MCP picks up for free.
|
||||
|
||||
## 12. File-ownership map for 8 parallel subagents
|
||||
|
||||
**Rule: an agent may ONLY write files listed in their column. If they need a file outside their column, they must emit a `CROSS_CUTTING.md` entry instead.**
|
||||
|
||||
| Path | Agent |
|
||||
|-------------------------------------------|-------|
|
||||
| `packages/mcp/package.json` | A |
|
||||
| `packages/mcp/tsconfig.json` | A |
|
||||
| `packages/mcp/README.md` | G |
|
||||
| `packages/mcp/CHANGELOG.md` | G |
|
||||
| `packages/mcp/src/index.ts` | A |
|
||||
| `packages/mcp/src/server.ts` | C (registers tools; D extends with resources/prompts)* |
|
||||
| `packages/mcp/src/bridge/**` | B |
|
||||
| `packages/mcp/src/tools/**` (except vision) | C |
|
||||
| `packages/mcp/src/tools/analyze-*.ts` | E |
|
||||
| `packages/mcp/src/resources/**` | D |
|
||||
| `packages/mcp/src/prompts/**` | D |
|
||||
| `packages/mcp/src/transports/**` | F |
|
||||
| `packages/mcp/src/bin/**` | F |
|
||||
| `packages/mcp/scripts/smoke.ts` | F |
|
||||
| `packages/mcp/examples/**` | G |
|
||||
| Root `turbo.json` / CI workflows | H (only if strictly needed) |
|
||||
| `packages/mcp/biome.jsonc` (if any) | H |
|
||||
|
||||
*Server.ts coordination: **Agent A writes a minimal `server.ts` stub exporting `createPascalMcpServer(bridge)` that returns an empty `McpServer`**. Agents C, D, E each export `register<Tools|Resources|Prompts|VisionTools>(server, bridge)` functions from their subtrees. Integration (me) wires them up in the final `server.ts` during Phase 2.
|
||||
|
||||
## 13. Known limitations (Phase 3 will surface these)
|
||||
|
||||
- `export_glb` returns `not_implemented`. GLB export depends on Three.js renderer output — not reachable headlessly without a large additional effort.
|
||||
- Vision tools require MCP host sampling support. Claude Desktop supports this; some MCP clients don't.
|
||||
- Systems run only via React hooks; headless mode doesn't regenerate geometry. Wall mitering, slab triangulation, CSG cutouts, etc. remain unexecuted in the MCP process — but their inputs (node data) are still fully manipulable. Consumers that need derived geometry call `@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. MCP consumers should supply absolute URLs or `data:` URLs for item assets if they need them usable outside the browser.
|
||||
- `dirtyNodes` accumulates in headless mode. Consumers who care can call `bridge.flushDirty()`.
|
||||
|
||||
## 14. Zod strategy
|
||||
|
||||
- Import `z` from `zod`, matching core's `"zod": "^4.3.5"`.
|
||||
- Input schemas: declared per-tool. Prefer positional tuples for `[x, z]`/`[x, y, z]` to match core.
|
||||
- Output schemas: declared per-tool; used to validate the handler's return before sending to MCP.
|
||||
- `AnyNode` / `SiteNode` / `WallNode` etc. imported from `@pascal-app/core`. Do not redeclare.
|
||||
- For `apply_patch` inputs we use **partial schemas** (`AnyNode.partial()` isn't directly supported for discriminated unions; we declare a per-type update schema that accepts a subset of fields keyed by the type literal).
|
||||
|
||||
## 15. Test strategy
|
||||
|
||||
- `bun test` with colocated `*.test.ts` files.
|
||||
- `@modelcontextprotocol/sdk` ships a test-friendly in-memory pair: `import { Client } from '@modelcontextprotocol/sdk/client/index.js'` + `InMemoryTransport`. Use these for handler-level tests.
|
||||
- Smoke test (Agent F): spawn the stdio binary as a child process, connect from a real MCP client, assert `get_scene`, `create_level`, `create_wall`, `validate_scene`, `undo` round-trip.
|
||||
- Target: ≥80% line coverage on MCP-owned files. Bridge at ≥95%.
|
||||
|
||||
## 16. Conventional commits (one per agent scope)
|
||||
|
||||
- `feat(mcp): scaffold @pascal-app/mcp package` — Agent A
|
||||
- `feat(mcp): add headless scene bridge` — Agent B
|
||||
- `feat(mcp): implement scene query and mutation tools` — Agent C
|
||||
- `feat(mcp): add resources and prompts` — Agent D
|
||||
- `feat(mcp): add multimodal vision tools via sampling` — Agent E
|
||||
- `feat(mcp): add stdio + HTTP transports and CLI` — Agent F
|
||||
- `docs(mcp): add README, examples, and changelog` — Agent G
|
||||
- `chore(mcp): wire biome, tests, and CI` — Agent H
|
||||
|
||||
Integration commits land under `feat(mcp): wire server + integration` and `feat(mcp): v0.1.0 ready`.
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@pascal-app/mcp",
|
||||
"version": "0.1.0",
|
||||
"description": "Model Context Protocol server for Pascal 3D editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"pascal-mcp": "./dist/bin/pascal-mcp.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"CHANGELOG.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --build --watch",
|
||||
"start": "node dist/bin/pascal-mcp.js",
|
||||
"test": "bun test",
|
||||
"smoke": "bun run scripts/smoke.ts",
|
||||
"prepublishOnly": "bun run build && bun test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "workspace:*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/node": "^25.5.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"pascal",
|
||||
"3d",
|
||||
"building",
|
||||
"editor",
|
||||
"ai"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pascalorg/editor.git",
|
||||
"directory": "packages/mcp"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/pascalorg/editor/tree/main/packages/mcp#readme",
|
||||
"bugs": "https://github.com/pascalorg/editor/issues"
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Phase 0.5 bridge spike — prove that @pascal-app/core's useScene works in Node
|
||||
* after a RAF polyfill. Run with: bun run packages/mcp/scripts/spike.ts
|
||||
*/
|
||||
|
||||
// Polyfill BEFORE importing core.
|
||||
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
|
||||
;(globalThis as any).requestAnimationFrame = (cb: (t: number) => void): number => {
|
||||
return setTimeout(() => cb(performance.now()), 0) as unknown as number
|
||||
}
|
||||
;(globalThis as any).cancelAnimationFrame = (id: number) => {
|
||||
clearTimeout(id as unknown as NodeJS.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
import useScene from '@pascal-app/core/store'
|
||||
import { WallNode } from '@pascal-app/core/schema'
|
||||
|
||||
function assert(cond: unknown, msg: string): asserts cond {
|
||||
if (!cond) throw new Error(`ASSERT FAILED: ${msg}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('---- Phase 0.5 bridge spike ----')
|
||||
|
||||
// 1. Load default scene
|
||||
useScene.getState().loadScene()
|
||||
const state1 = useScene.getState()
|
||||
assert(state1.rootNodeIds.length === 1, 'expected 1 root')
|
||||
// NOTE: SiteNode.children holds node objects (not IDs); everything else uses ID arrays.
|
||||
// Resolve building/level via the flat nodes dict, filtering by type.
|
||||
const allNodes = Object.values(state1.nodes)
|
||||
const building = allNodes.find((n) => n.type === 'building')
|
||||
const level = allNodes.find((n) => n.type === 'level')
|
||||
assert(building, 'expected building node in dict')
|
||||
assert(level, 'expected level node in dict')
|
||||
const levelId = level.id
|
||||
console.log('OK 1: default scene loaded —', Object.keys(state1.nodes).length, 'nodes')
|
||||
|
||||
// 2. Clear temporal history so we measure our own undo steps
|
||||
useScene.temporal.getState().clear()
|
||||
|
||||
// 3. Create a wall via WallNode.parse
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
})
|
||||
useScene.getState().createNode(wall, levelId as any)
|
||||
const state2 = useScene.getState()
|
||||
assert(wall.id in state2.nodes, 'wall not created')
|
||||
const levelAfter = state2.nodes[levelId]!
|
||||
assert(
|
||||
'children' in levelAfter &&
|
||||
Array.isArray(levelAfter.children) &&
|
||||
levelAfter.children.includes(wall.id),
|
||||
'wall not linked as level child',
|
||||
)
|
||||
console.log('OK 2: created wall', wall.id)
|
||||
|
||||
// 4. Wait for any RAF-queued dirty markings (from updateNodesAction polyfill)
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
|
||||
// 5. Update wall
|
||||
useScene.getState().updateNode(wall.id, { thickness: 0.25, height: 3.0 })
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const state3 = useScene.getState()
|
||||
const w3 = state3.nodes[wall.id] as any
|
||||
assert(w3.thickness === 0.25, 'thickness not updated')
|
||||
assert(w3.height === 3.0, 'height not updated')
|
||||
console.log('OK 3: updated wall thickness + height')
|
||||
|
||||
// 6. Undo (thickness/height revert)
|
||||
useScene.temporal.getState().undo()
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const state4 = useScene.getState()
|
||||
const w4 = state4.nodes[wall.id] as any
|
||||
console.log(' after undo: thickness =', w4?.thickness, 'height =', w4?.height)
|
||||
assert(w4, 'wall still exists after 1 undo (update was undone)')
|
||||
// default thickness/height come from schema defaults, not required to be exact values; just prove they changed back
|
||||
assert(w4.thickness !== 0.25 || w4.height !== 3.0, 'undo did not revert update')
|
||||
console.log('OK 4: undo reverted update')
|
||||
|
||||
// 7. Undo again (wall creation reverted)
|
||||
useScene.temporal.getState().undo()
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const state5 = useScene.getState()
|
||||
assert(!(wall.id in state5.nodes), 'wall was not removed by second undo')
|
||||
console.log('OK 5: undo removed the wall')
|
||||
|
||||
// 8. Redo twice (wall comes back with the updated props)
|
||||
useScene.temporal.getState().redo(2)
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const state6 = useScene.getState()
|
||||
const w6 = state6.nodes[wall.id] as any
|
||||
assert(w6, 'redo did not restore wall')
|
||||
assert(w6.thickness === 0.25 && w6.height === 3.0, 'redo did not restore updated props')
|
||||
console.log('OK 6: redo restored wall + update')
|
||||
|
||||
// 9. Delete wall
|
||||
useScene.getState().deleteNode(wall.id)
|
||||
const state7 = useScene.getState()
|
||||
assert(!(wall.id in state7.nodes), 'wall was not deleted')
|
||||
console.log('OK 7: delete removed wall')
|
||||
|
||||
// 10. setScene round-trip
|
||||
const snapshot = {
|
||||
nodes: { ...state7.nodes },
|
||||
rootNodeIds: [...state7.rootNodeIds],
|
||||
}
|
||||
useScene.getState().unloadScene()
|
||||
useScene.getState().setScene(snapshot.nodes, snapshot.rootNodeIds)
|
||||
const state8 = useScene.getState()
|
||||
assert(
|
||||
Object.keys(state8.nodes).length === Object.keys(snapshot.nodes).length,
|
||||
'setScene node-count mismatch',
|
||||
)
|
||||
console.log('OK 8: setScene round-trip preserved node count')
|
||||
|
||||
console.log('\n✅ BRIDGE SPIKE PASSED — headless useScene is viable with RAF polyfill\n')
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('\n❌ SPIKE FAILED:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "@pascal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"incremental": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "scripts"],
|
||||
"references": [{ "path": "../core" }]
|
||||
}
|
||||
Reference in New Issue
Block a user