feat(mcp): add resources and prompts

Resources: pascal://scene/current (JSON), /scene/current/summary
(markdown with per-level counts, floor areas, bbox), /catalog/items
(returns catalog_unavailable in headless mode), and the templated
pascal://constraints/{levelId} which exposes slabs + wall footprints
via @pascal-app/core/wall helpers.

Prompts: from_brief (generate scene from a natural-language brief),
iterate_on_feedback (minimal-diff patch proposals), and
renovation_from_photos (orchestrates the vision tools).

17 tests, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adrian Perez
2026-04-18 17:51:15 +02:00
co-authored by Claude Opus 4.7
parent 58ad89e80b
commit 570c605446
11 changed files with 1171 additions and 0 deletions
@@ -0,0 +1,39 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
/**
* `pascal://catalog/items` — item catalog (if the host supplies one).
*
* `@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.
*/
export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void {
server.registerResource(
'catalog-items',
'pascal://catalog/items',
{
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.',
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.',
}
return {
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(payload),
},
],
}
},
)
}
+100
View File
@@ -0,0 +1,100 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, SlabNode, WallNode } from '@pascal-app/core/schema'
import { getWallPlanFootprint } from '@pascal-app/core/wall'
import type { SceneBridge } from '../bridge/scene-bridge'
type WallFootprint = {
wallId: string
footprint: Array<[number, number]>
}
type ConstraintsPayload = {
levelId: string
slabs: SlabNode[]
wallPolygons: WallFootprint[]
}
type ConstraintsError = {
error: 'level_not_found'
levelId: string
slabs: never[]
wallPolygons: never[]
}
/**
* Empty `WallMiterData` — we don't compute junctions here. The footprint
* falls back to a simple rectangle based on start/end + thickness, which is
* correct for non-intersecting walls and an acceptable approximation for
* constraint hints.
*
* Typed via `Parameters<typeof getWallPlanFootprint>[1]` to avoid `any` and
* to stay in sync with the core signature.
*/
const EMPTY_MITER_DATA: Parameters<typeof getWallPlanFootprint>[1] = {
junctionData: new Map(),
junctions: new Map(),
}
function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload | ConstraintsError {
const level = bridge.getNode(levelId as never)
if (!level || level.type !== 'level') {
return {
error: 'level_not_found',
levelId,
slabs: [] as never[],
wallPolygons: [] as never[],
}
}
const all = bridge.findNodes({ levelId: levelId as never })
const slabs: SlabNode[] = []
const walls: WallNode[] = []
for (const n of all as AnyNode[]) {
if (n.type === 'slab') slabs.push(n as SlabNode)
else if (n.type === 'wall') walls.push(n as WallNode)
}
const wallPolygons: WallFootprint[] = []
for (const wall of walls) {
const points = getWallPlanFootprint(wall, EMPTY_MITER_DATA)
wallPolygons.push({
wallId: wall.id,
footprint: points.map((p) => [p.x, p.y] as [number, number]),
})
}
return { levelId, slabs, wallPolygons }
}
/**
* `pascal://constraints/{levelId}` — per-level geometric constraints used as
* input hints for agents: slab nodes (with polygons/holes/elevation) + each
* wall's plan-view footprint polygon.
*/
export function registerConstraints(server: McpServer, bridge: SceneBridge): void {
server.registerResource(
'constraints',
new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }),
{
title: 'Level constraints',
description:
'Per-level constraints: slab nodes and wall plan footprints. Returns {error:"level_not_found"} if the level id is unknown.',
mimeType: 'application/json',
},
async (uri, variables) => {
const rawLevelId = variables.levelId
const levelId = Array.isArray(rawLevelId) ? rawLevelId[0] : rawLevelId
const payload = buildPayload(bridge, levelId ?? '')
return {
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(payload),
},
],
}
},
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
import { registerSceneSummary } from './scene-summary'
/**
* Registers all MCP resources exposed by `@pascal-app/mcp`.
*
* Resources:
* - `pascal://scene/current` — application/json, full snapshot
* - `pascal://scene/current/summary` — text/markdown, human summary
* - `pascal://catalog/items` — application/json, host-supplied catalog
* - `pascal://constraints/{levelId}` — application/json, per-level constraints
*/
export function registerResources(server: McpServer, bridge: SceneBridge): void {
registerSceneCurrent(server, bridge)
registerSceneSummary(server, bridge)
registerCatalogItems(server, bridge)
registerConstraints(server, bridge)
}
@@ -0,0 +1,260 @@
// Side-effect import MUST come first: installs RAF polyfill before core loads.
import '../bridge/node-shims'
import { beforeEach, describe, expect, test } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
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 { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
import { registerSceneCurrent } from './scene-current'
import { registerSceneSummary } from './scene-summary'
type ClientServerPair = {
client: Client
server: McpServer
bridge: SceneBridge
close: () => Promise<void>
}
async function spinUp(
register: (server: McpServer, bridge: SceneBridge) => void,
): Promise<ClientServerPair> {
const bridge = new SceneBridge()
const server = new McpServer({ name: 'test', version: '0.0.0' })
register(server, bridge)
const client = new Client({ name: 'test-client', version: '0.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)])
return {
client,
server,
bridge,
close: async () => {
await client.close()
await server.close()
},
}
}
/** Reset the store between tests so temporal history and nodes don't leak. */
function resetScene(): void {
useScene.getState().unloadScene()
useScene.temporal.getState().clear()
}
describe('pascal://scene/current', () => {
beforeEach(() => resetScene())
test('returns the full scene JSON', async () => {
const pair = await spinUp(registerSceneCurrent)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({ uri: 'pascal://scene/current' })
expect(res.contents).toHaveLength(1)
const content = res.contents[0]
expect(content).toBeDefined()
const c = content as { uri: string; mimeType?: string; text?: string }
expect(c.mimeType).toBe('application/json')
expect(c.uri).toBe('pascal://scene/current')
const parsed = JSON.parse(c.text ?? '{}')
expect(parsed).toHaveProperty('nodes')
expect(parsed).toHaveProperty('rootNodeIds')
expect(parsed).toHaveProperty('collections')
expect(Array.isArray(parsed.rootNodeIds)).toBe(true)
expect(parsed.rootNodeIds.length).toBeGreaterThan(0)
} finally {
await pair.close()
}
})
test('reflects mutations to the store', async () => {
const pair = await spinUp(registerSceneCurrent)
try {
pair.bridge.loadDefault()
const beforeRes = await pair.client.readResource({
uri: 'pascal://scene/current',
})
const beforeText = (beforeRes.contents[0] as { text: string }).text
const before = JSON.parse(beforeText)
const beforeCount = Object.keys(before.nodes).length
// Add a zone.
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
const zone = ZoneNode.parse({
name: 'Living',
parentId: level.id,
polygon: [
[0, 0],
[3, 0],
[3, 3],
[0, 3],
],
})
pair.bridge.createNode(zone, level.id as never)
const afterRes = await pair.client.readResource({
uri: 'pascal://scene/current',
})
const afterText = (afterRes.contents[0] as { text: string }).text
const after = JSON.parse(afterText)
expect(Object.keys(after.nodes).length).toBe(beforeCount + 1)
} finally {
await pair.close()
}
})
})
describe('pascal://scene/current/summary', () => {
beforeEach(() => resetScene())
test('returns markdown with counts and bbox', async () => {
const pair = await spinUp(registerSceneSummary)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const content = res.contents[0] as { uri: string; mimeType?: string; text?: string }
expect(content.mimeType).toBe('text/markdown')
const text = content.text ?? ''
expect(text.startsWith('# Scene summary')).toBe(true)
expect(text).toContain('Sites:')
expect(text).toContain('Buildings:')
expect(text).toContain('Levels:')
expect(text).toContain('Scene bbox')
} finally {
await pair.close()
}
})
test('estimated floor area sums zone polygon areas', async () => {
const pair = await spinUp(registerSceneSummary)
try {
pair.bridge.loadDefault()
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
const zone = ZoneNode.parse({
name: 'Big',
parentId: level.id,
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
pair.bridge.createNode(zone, level.id as never)
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const text = (res.contents[0] as { text: string }).text
expect(text).toContain('12.00 m^2')
} finally {
await pair.close()
}
})
test('empty scene returns a markdown skeleton without crashing', async () => {
const pair = await spinUp(registerSceneSummary)
try {
// deliberately do NOT call loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://scene/current/summary',
})
const text = (res.contents[0] as { text: string }).text
expect(text.startsWith('# Scene summary')).toBe(true)
expect(text).toContain('Total nodes: 0')
} finally {
await pair.close()
}
})
})
describe('pascal://catalog/items', () => {
beforeEach(() => resetScene())
test('returns catalog_unavailable payload', 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(typeof parsed.note).toBe('string')
} finally {
await pair.close()
}
})
})
describe('pascal://constraints/{levelId}', () => {
beforeEach(() => resetScene())
test('returns slabs + wall footprints for a known level', async () => {
const pair = await spinUp(registerConstraints)
try {
pair.bridge.loadDefault()
const level = pair.bridge
.findNodes({ type: 'level' as never })
.find((n) => n.type === 'level')
if (!level) throw new Error('no level')
// Add a wall so wallPolygons is non-empty.
const wall = WallNode.parse({
parentId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
pair.bridge.createNode(wall, level.id as never)
const res = await pair.client.readResource({
uri: `pascal://constraints/${level.id}`,
})
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.levelId).toBe(level.id)
expect(Array.isArray(parsed.slabs)).toBe(true)
expect(Array.isArray(parsed.wallPolygons)).toBe(true)
expect(parsed.wallPolygons.length).toBe(1)
expect(parsed.wallPolygons[0].wallId).toBe(wall.id)
expect(Array.isArray(parsed.wallPolygons[0].footprint)).toBe(true)
expect(parsed.wallPolygons[0].footprint.length).toBeGreaterThan(0)
// Each footprint point should be [x, y].
for (const pt of parsed.wallPolygons[0].footprint) {
expect(pt).toHaveLength(2)
}
} finally {
await pair.close()
}
})
test('returns {error:"level_not_found"} for unknown levelId', async () => {
const pair = await spinUp(registerConstraints)
try {
pair.bridge.loadDefault()
const res = await pair.client.readResource({
uri: 'pascal://constraints/level_nope',
})
const content = res.contents[0] as { text?: string }
const parsed = JSON.parse(content.text ?? '{}')
expect(parsed.error).toBe('level_not_found')
expect(parsed.slabs).toEqual([])
expect(parsed.wallPolygons).toEqual([])
} finally {
await pair.close()
}
})
})
@@ -0,0 +1,29 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
/**
* `pascal://scene/current` — full `{ nodes, rootNodeIds, collections }` snapshot.
*
* Static URI (not a template). MIME `application/json`.
*/
export function registerSceneCurrent(server: McpServer, bridge: SceneBridge): void {
server.registerResource(
'scene-current',
'pascal://scene/current',
{
title: 'Current scene',
description:
'Complete snapshot of the live Pascal scene: nodes dict, rootNodeIds, collections.',
mimeType: 'application/json',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(bridge.exportJSON()),
},
],
}),
)
}
+216
View File
@@ -0,0 +1,216 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeType } from '@pascal-app/core/schema'
import type { SceneBridge } from '../bridge/scene-bridge'
type Poly2D = ReadonlyArray<readonly [number, number]>
/** Shoelace polygon area (absolute, square meters). */
function polygonArea(poly: Poly2D): number {
if (!Array.isArray(poly) || poly.length < 3) return 0
let sum = 0
for (let i = 0; i < poly.length; i++) {
const a = poly[i]
const b = poly[(i + 1) % poly.length]
if (!a || !b) continue
sum += a[0] * b[1] - b[0] * a[1]
}
return Math.abs(sum) / 2
}
type BBox = {
min: [number, number, number]
max: [number, number, number]
empty: boolean
}
function emptyBBox(): BBox {
return {
min: [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY],
max: [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
empty: true,
}
}
function expandBBox(bbox: BBox, x: number, y: number, z: number): void {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return
bbox.empty = false
if (x < bbox.min[0]) bbox.min[0] = x
if (y < bbox.min[1]) bbox.min[1] = y
if (z < bbox.min[2]) bbox.min[2] = z
if (x > bbox.max[0]) bbox.max[0] = x
if (y > bbox.max[1]) bbox.max[1] = y
if (z > bbox.max[2]) bbox.max[2] = z
}
/** Fold a node's world-relevant points into the running bbox. */
function foldNodeIntoBBox(node: AnyNode, bbox: BBox): void {
// Walls / fences: 2D start/end. Treat missing y as 0.
if (node.type === 'wall' || node.type === 'fence') {
const anyNode = node as { start?: [number, number]; end?: [number, number] }
if (anyNode.start) expandBBox(bbox, anyNode.start[0], 0, anyNode.start[1])
if (anyNode.end) expandBBox(bbox, anyNode.end[0], 0, anyNode.end[1])
return
}
// Zone / slab / ceiling: polygon + optional holes. Treat ground plane y=0.
if (node.type === 'zone' || node.type === 'slab' || node.type === 'ceiling') {
const poly = (node as { polygon?: Array<[number, number]> }).polygon
if (Array.isArray(poly)) {
for (const p of poly) {
if (Array.isArray(p) && p.length >= 2) expandBBox(bbox, p[0], 0, p[1])
}
}
return
}
// Positioned nodes (building/item/roof/stair/scan/guide/...):
const pos = (node as { position?: [number, number, number] }).position
if (Array.isArray(pos) && pos.length >= 3) {
expandBBox(bbox, pos[0], pos[1], pos[2])
}
}
function countByType(nodes: AnyNode[]): Record<string, number> {
const out: Record<string, number> = {}
for (const n of nodes) {
out[n.type] = (out[n.type] ?? 0) + 1
}
return out
}
/** Build the markdown summary. Pure over the SceneGraph snapshot. */
export function buildSceneSummaryMarkdown(snapshot: ReturnType<SceneBridge['exportJSON']>): string {
const { nodes, rootNodeIds } = snapshot
const allNodes = Object.values(nodes) as AnyNode[]
const sites = allNodes.filter((n) => n.type === 'site')
const buildings = allNodes.filter((n) => n.type === 'building')
const levels = allNodes.filter((n) => n.type === 'level')
const bbox = emptyBBox()
for (const n of allNodes) foldNodeIntoBBox(n, bbox)
const lines: string[] = []
lines.push('# Scene summary')
lines.push('')
lines.push(`- Sites: ${sites.length} Buildings: ${buildings.length} Levels: ${levels.length}`)
lines.push(`- Root nodes: ${rootNodeIds.length}`)
lines.push(`- Total nodes: ${allNodes.length}`)
lines.push('')
// Hierarchy table
lines.push('## Hierarchy')
lines.push('')
lines.push('| Site | Building | Level |')
lines.push('| --- | --- | --- |')
if (sites.length === 0 && buildings.length === 0 && levels.length === 0) {
lines.push('| _(empty scene)_ | | |')
} else {
for (const site of sites) {
const sName = (site as { name?: string }).name ?? site.id
const siteBuildings = allNodes.filter((n) => n.type === 'building' && n.parentId === site.id)
if (siteBuildings.length === 0) {
lines.push(`| ${sName} | _(none)_ | |`)
continue
}
for (const b of siteBuildings) {
const bName = (b as { name?: string }).name ?? b.id
const bLevels = allNodes.filter((n) => n.type === 'level' && n.parentId === b.id)
if (bLevels.length === 0) {
lines.push(`| ${sName} | ${bName} | _(none)_ |`)
continue
}
for (const l of bLevels) {
const lName = (l as { name?: string }).name ?? l.id
lines.push(`| ${sName} | ${bName} | ${lName} |`)
}
}
}
}
lines.push('')
// Per-level detail
if (levels.length > 0) {
lines.push('## Per level')
lines.push('')
for (const level of levels) {
const lName = (level as { name?: string }).name ?? level.id
// Nodes whose ancestry includes this level.
const levelNodes = allNodes.filter(
(n) => n.id !== level.id && walkToLevel(n, nodes as Record<string, AnyNode>) === level.id,
)
const counts = countByType(levelNodes)
const countKeys = Object.keys(counts).sort() as AnyNodeType[]
// Estimated floor area = sum of zone polygon areas on this level.
const zones = levelNodes.filter((n) => n.type === 'zone') as Array<
AnyNode & { polygon: Array<[number, number]> }
>
let floorAreaSq = 0
for (const z of zones) {
floorAreaSq += polygonArea(z.polygon)
}
lines.push(`### ${lName}`)
lines.push('')
if (countKeys.length === 0) {
lines.push('- _(no descendants)_')
} else {
const parts = countKeys.map((k) => `${k}=${counts[k]}`)
lines.push(`- Node counts: ${parts.join(', ')}`)
}
lines.push(`- Estimated floor area (zones): ${floorAreaSq.toFixed(2)} m^2`)
lines.push('')
}
}
// BBox
lines.push('## Scene bbox (meters)')
lines.push('')
if (bbox.empty) {
lines.push('- _(no positioned nodes)_')
} else {
lines.push(`- min: [${bbox.min.map((v) => v.toFixed(3)).join(', ')}]`)
lines.push(`- max: [${bbox.max.map((v) => v.toFixed(3)).join(', ')}]`)
}
return lines.join('\n')
}
/** Walk up parentId until we find a level; return its id or null. */
function walkToLevel(node: AnyNode, nodes: Record<string, AnyNode>): string | null {
const seen = new Set<string>()
let current: AnyNode | undefined = node
while (current && !seen.has(current.id)) {
seen.add(current.id)
if (current.type === 'level') return current.id
const pid: string | null = current.parentId
if (!pid) return null
current = nodes[pid]
}
return null
}
/**
* `pascal://scene/current/summary` — human-readable scene overview.
* MIME `text/markdown`.
*/
export function registerSceneSummary(server: McpServer, bridge: SceneBridge): void {
server.registerResource(
'scene-summary',
'pascal://scene/current/summary',
{
title: 'Scene summary (markdown)',
description:
'Markdown overview: sites/buildings/levels, per-level node counts, zone floor areas, scene bbox.',
mimeType: 'text/markdown',
},
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: 'text/markdown',
text: buildSceneSummaryMarkdown(bridge.exportJSON()),
},
],
}),
)
}