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,160 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
const PREAMBLE = [
'You are renovating an existing room based on photos of the current space and reference photos of the target aesthetic.',
'',
'Follow this procedure:',
' 1. Call `analyze_floorplan_image` and/or `analyze_room_photo` on EACH current photo to extract walls, rooms, fixtures, and approximate dimensions. Do the same for reference photos.',
' 2. Compare the current-state analyses to the reference-state analyses. Identify concrete deltas (materials, fixtures, layout changes) that align with the renovation goals.',
' 3. Emit a single `apply_patch` call containing the minimum set of patches needed to converge the current scene toward the goals.',
'',
'Rules:',
' - Do not invent dimensions. Pull them from the analysis tool results.',
' - Do not modify nodes that are unrelated to the goals.',
' - Respond ONLY with tool calls. No prose.',
].join('\n')
function isDataUrl(s: string): boolean {
return s.startsWith('data:')
}
function isHttpUrl(s: string): boolean {
return s.startsWith('http://') || s.startsWith('https://')
}
/**
* Rough base64 detector: length multiple of 4, only base64 chars, at least 32 chars long.
* Deliberately conservative — when in doubt we fall back to text `URL: ...`.
*/
function looksLikeBase64(s: string): boolean {
if (s.length < 32) return false
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
type PromptContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
/** Extract a base64 payload from a data-URL, or return the raw string. */
function toImageContent(source: string): PromptContent {
if (isDataUrl(source)) {
const match = /^data:([^;,]+)?(?:;base64)?,(.*)$/.exec(source)
if (match) {
const mimeType = match[1] && match[1].length > 0 ? match[1] : 'image/jpeg'
const data = match[2] ?? ''
return { type: 'image', data, mimeType }
}
return { type: 'text', text: `URL: ${source}` }
}
if (isHttpUrl(source)) {
return { type: 'text', text: `URL: ${source}` }
}
if (looksLikeBase64(source)) {
return { type: 'image', data: source, mimeType: 'image/jpeg' }
}
return { type: 'text', text: `URL: ${source}` }
}
/** Parse the stringified list argument (JSON array or comma-separated fallback). */
function parsePhotoList(raw: string | string[] | undefined): string[] {
if (Array.isArray(raw)) {
return raw.map((s) => String(s)).filter((s) => s.length > 0)
}
const str = (raw ?? '').trim()
if (str.length === 0) return []
if (str.startsWith('[')) {
try {
const parsed = JSON.parse(str)
if (Array.isArray(parsed)) {
return parsed.map((s) => String(s)).filter((s) => s.length > 0)
}
} catch {
/* fall through to comma-split */
}
}
return str
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
}
/**
* Build the full messages array. Pure function for testability.
*/
export function buildRenovationMessages(args: {
currentPhotos: string[] | string | undefined
referencePhotos: string[] | string | undefined
goals: string
}): Array<{
role: 'user'
content: PromptContent
}> {
const current = parsePhotoList(args.currentPhotos)
const reference = parsePhotoList(args.referencePhotos)
const intro = [
PREAMBLE,
'',
'## Goals',
args.goals.trim(),
'',
'## Inputs',
`Current photos: ${current.length} item(s)`,
`Reference photos: ${reference.length} item(s)`,
].join('\n')
const messages: Array<{ role: 'user'; content: PromptContent }> = [
{ role: 'user', content: { type: 'text', text: intro } },
]
if (current.length > 0) {
messages.push({
role: 'user',
content: { type: 'text', text: '## Current photos' },
})
for (const src of current) {
messages.push({ role: 'user', content: toImageContent(src) })
}
}
if (reference.length > 0) {
messages.push({
role: 'user',
content: { type: 'text', text: '## Reference photos' },
})
for (const src of reference) {
messages.push({ role: 'user', content: toImageContent(src) })
}
}
messages.push({
role: 'user',
content: {
type: 'text',
text: '## Task\nProduce `apply_patch` operations that drive the current scene toward the goals, using only dimensions and fixtures you derived from the analysis tools.',
},
})
return messages
}
export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneBridge): void {
server.registerPrompt(
'renovation_from_photos',
{
title: 'Plan a renovation from photos',
description:
'Plan a minimal-patch renovation given current-state photos, reference-state photos, and free-form goals.',
argsSchema: {
// MCP prompt arguments are stringly-typed; accept a JSON array or a
// comma-separated list of base64 payloads / data URLs / http(s) URLs.
currentPhotos: z.string(),
referencePhotos: z.string(),
goals: z.string(),
},
},
async ({ currentPhotos, referencePhotos, goals }) => ({
messages: buildRenovationMessages({ currentPhotos, referencePhotos, goals }),
}),
)
}