Files
editor/packages/mcp/src/resources/constraints.ts
T
Adrian PerezandClaude Opus 4.7 570c605446 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>
2026-04-18 17:51:15 +02:00

101 lines
3.0 KiB
TypeScript

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),
},
],
}
},
)
}