# @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": "bun 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`.
