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
@@ -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`.
|
||||
Reference in New Issue
Block a user