feat(mcp): add guided construction workflows

This commit is contained in:
Aymeric Rabot
2026-04-27 14:31:04 -04:00
parent b3d1f663f6
commit 3d5c87a651
48 changed files with 3877 additions and 115 deletions
+67
View File
@@ -0,0 +1,67 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
export const AGENT_GUIDE = [
'# Pascal MCP agent guide',
'',
'Use this guide before inspecting application source code. The MCP surface is intended to expose the construction contract an agent needs for normal scene editing.',
'',
'## Fast visible-progress workflow',
'',
'1. Query `pascal://scene/current/summary` or `list_levels` to orient yourself.',
'2. Create visible massing first: `create_level` as needed, then `create_story_shell` once per story.',
'3. Add room semantics next: zones/rooms, interior walls, slabs, and ceilings. Prefer `create_room` for simple rooms and `apply_patch` only for exact multi-room partitions.',
'4. Add circulation and envelope details: `create_stair_between_levels`, then `add_door` and `add_window`.',
'5. Add `create_roof`, furniture with `furnish_room`/`place_item`, and exterior features such as fences, patios, driveways, lawns, and garden zones.',
'6. Run `validate_scene` and `verify_scene`; fix issues before handing off.',
'',
'This sequence lets users see a recognizable building quickly instead of waiting for one large hidden planning pass.',
'',
'## Construction rules',
'',
'- Levels live under a Building.',
'- Walls, fences, zones, slabs, ceilings, roofs, and stairs live under a Level.',
'- Doors and windows live under their Wall. Use `add_door`/`add_window`; their `t` or `position` is 0..1 along the wall.',
'- Floor items live under a Level; wall/ceiling-attached items live under their target Wall or Ceiling.',
'- For multi-story buildings, create separate level-owned exterior walls for each story. Do not make first-story walls taller to represent upper-story bearing walls.',
'- Use `create_story_shell` once per floor/story to avoid cross-level wall ownership mistakes.',
'- Use `create_stair_between_levels` for stairs. It creates a straight stair and one rectangular manual slab/ceiling opening while disabling automatic stair-opening mode, avoiding duplicate or irregular holes.',
'- Roofs are containers with roof segments and should be isolated on a dedicated roof level for solo/exploded level views. Use `create_roof`; by default it creates a roof level above the reference occupied level. Do not attach roofs directly to the top occupied floor unless explicitly requested.',
'- Use `pascal://constraints/{levelId}` when you need existing slab holes or wall footprints for precise placement.',
'',
'## Scene model facts exposed here so agents do not need repo inspection',
'',
'- X/Z are floor-plan axes and Y is vertical; dimensions are meters.',
'- A story wall height is normally 2.4-3.0m; wall thickness is normally 0.1-0.3m.',
'- Slab and ceiling holes are polygon arrays. Manual stair openings should have `holeMetadata` with source `manual` and a single rectangular polygon.',
'- Dedicated roof levels use metadata role `roof` and normally contain the roof only; the top occupied level keeps its own walls, rooms, slabs, and ceiling.',
'- Saved site children can contain embedded building objects for compatibility, but tools handle parent/child bookkeeping. Prefer tools over raw graph surgery for common construction.',
'- `validate_scene` checks schema correctness. `verify_scene` checks practical layout issues such as empty levels, missing rooms/floors/doors, bad openings, stair obstructions, and suspicious multi-story wall heights.',
'',
'## Tool preference',
'',
'- Prefer semantic tools first: `create_story_shell`, `create_room`, `add_door`, `add_window`, `create_stair_between_levels`, `create_roof`, `furnish_room`, `place_item`.',
'- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.',
].join('\n')
export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void {
server.registerResource(
'agent-guide',
'pascal://agent/guide',
{
title: 'Agent construction guide',
description:
'MCP-first construction workflow, scene invariants, and tool preferences so agents do not need to inspect the Pascal codebase.',
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: AGENT_GUIDE,
},
],
}),
)
}
+8 -9
View File
@@ -1,13 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog'
/**
* `pascal://catalog/items` — item catalog (if the host supplies one).
* `pascal://catalog/items` — small built-in item catalog for standalone MCP.
*
* `@pascal-app/core` does NOT expose a runtime item catalog — that is the host
* app's responsibility. In headless / standalone MCP mode we therefore return
* a stable, machine-readable "unavailable" payload so agents can detect this
* and fall back to free-form item creation.
* The editor UI owns the full catalog. MCP intentionally keeps a dependency-free
* subset so headless agents can still place realistic furniture and fixtures.
*/
export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void {
server.registerResource(
@@ -16,14 +15,14 @@ export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): v
{
title: 'Item catalog',
description:
'Catalog of placeable items. Not available in core; the host app is expected to override this resource when it has a catalog.',
'Dependency-free catalog subset of placeable items available in standalone MCP mode.',
mimeType: 'application/json',
},
async (uri) => {
const payload = {
status: 'catalog_unavailable' as const,
items: [] as never[],
note: '@pascal-app/core does not ship a runtime item catalog; the host app is expected to provide one by overriding this resource.',
status: 'ok' as const,
items: MCP_CATALOG_ITEMS,
note: 'Standalone MCP catalog subset; host applications can still expose a larger catalog separately.',
}
return {
contents: [
+3
View File
@@ -1,5 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
@@ -13,8 +14,10 @@ import { registerSceneSummary } from './scene-summary'
* - `pascal://scene/current/summary` — text/markdown, human summary
* - `pascal://catalog/items` — application/json, host-supplied catalog
* - `pascal://constraints/{levelId}` — application/json, per-level constraints
* - `pascal://agent/guide` — text/markdown, MCP-first construction guide
*/
export function registerResources(server: McpServer, bridge: SceneBridge): void {
registerAgentGuide(server, bridge)
registerSceneCurrent(server, bridge)
registerSceneSummary(server, bridge)
registerCatalogItems(server, bridge)
+26 -3
View File
@@ -8,6 +8,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { WallNode, ZoneNode } from '@pascal-app/core/schema'
import useScene from '@pascal-app/core/store'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
@@ -183,15 +184,16 @@ describe('pascal://scene/current/summary', () => {
describe('pascal://catalog/items', () => {
beforeEach(() => resetScene())
test('returns catalog_unavailable payload', async () => {
test('returns built-in catalog subset', async () => {
const pair = await spinUp(registerCatalogItems)
try {
const res = await pair.client.readResource({ uri: 'pascal://catalog/items' })
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('application/json')
const parsed = JSON.parse(content.text ?? '{}')
expect(parsed.status).toBe('catalog_unavailable')
expect(parsed.items).toEqual([])
expect(parsed.status).toBe('ok')
expect(parsed.items.length).toBeGreaterThan(0)
expect(parsed.items.map((item: { id: string }) => item.id)).toContain('sofa')
expect(typeof parsed.note).toBe('string')
} finally {
await pair.close()
@@ -199,6 +201,27 @@ describe('pascal://catalog/items', () => {
})
})
describe('pascal://agent/guide', () => {
beforeEach(() => resetScene())
test('returns MCP-first construction guidance', async () => {
const pair = await spinUp(registerAgentGuide)
try {
const res = await pair.client.readResource({ uri: 'pascal://agent/guide' })
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('text/markdown')
const text = content.text ?? ''
expect(text).toContain('create_story_shell')
expect(text).toContain('create_stair_between_levels')
expect(text).toContain('dedicated roof level')
expect(text).toContain('Do not make first-story walls taller')
expect(text).toContain('Run `validate_scene` and `verify_scene`')
} finally {
await pair.close()
}
})
})
describe('pascal://constraints/{levelId}', () => {
beforeEach(() => resetScene())