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:
co-authored by
Claude Opus 4.7
parent
58ad89e80b
commit
570c605446
@@ -0,0 +1,61 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneBridge } from '../bridge/scene-bridge'
|
||||
|
||||
const PREAMBLE = [
|
||||
'You are a Pascal 3D scene designer.',
|
||||
'You have access to the `apply_patch` tool for all scene mutations. Prefer it over individual create_* tools so that your changes land as a single undoable step.',
|
||||
'Build incrementally. Starting from an empty scene, first create a Site, then a Building, then one or more Levels; only after that do you create walls, zones, slabs, items, and openings.',
|
||||
'Respect these invariants:',
|
||||
' - Levels live under a Building.',
|
||||
' - Walls, fences, zones, slabs, ceilings, roofs, stairs live under a Level.',
|
||||
' - Doors and windows live under a Wall (parentId = wallId).',
|
||||
' - Items live under a Wall, Ceiling, or Site.',
|
||||
'Use realistic dimensions in meters. Keep wall thickness small (0.1–0.3 m) and ceiling height 2.4–3.0 m unless the brief dictates otherwise.',
|
||||
'Respond ONLY with tool calls. Do not produce verbose narrative or prose; keep any explanations in short tool-call arguments.',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Build the user-facing prompt text for `from_brief`. Pure function for testability.
|
||||
*/
|
||||
export function buildFromBriefPrompt(args: {
|
||||
brief: string
|
||||
constraints?: string | undefined
|
||||
}): string {
|
||||
const parts: string[] = [PREAMBLE, '', '## Brief', args.brief.trim()]
|
||||
if (args.constraints && args.constraints.trim().length > 0) {
|
||||
parts.push('', '## Constraints', args.constraints.trim())
|
||||
}
|
||||
parts.push(
|
||||
'',
|
||||
'## Task',
|
||||
'Produce a plan of `apply_patch` calls that realises the brief within the stated constraints. Start from an empty site. Call the vision / query tools only if you need extra context.',
|
||||
)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export function registerFromBrief(server: McpServer, _bridge: SceneBridge): void {
|
||||
server.registerPrompt(
|
||||
'from_brief',
|
||||
{
|
||||
title: 'Generate a Pascal scene from a brief',
|
||||
description:
|
||||
'Produces a plan of apply_patch calls to create a scene from a natural-language brief.',
|
||||
argsSchema: {
|
||||
brief: z.string(),
|
||||
constraints: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ brief, constraints }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildFromBriefPrompt({ brief, constraints }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneBridge } from '../bridge/scene-bridge'
|
||||
import { registerFromBrief } from './from-brief'
|
||||
import { registerIterateOnFeedback } from './iterate-on-feedback'
|
||||
import { registerRenovationFromPhotos } from './renovation-from-photos'
|
||||
|
||||
/**
|
||||
* Registers all MCP prompts exposed by `@pascal-app/mcp`:
|
||||
* - `from_brief` — generate a scene from a natural-language brief
|
||||
* - `iterate_on_feedback` — minimal-diff patches from user feedback
|
||||
* - `renovation_from_photos` — photo-driven renovation plan via vision tools
|
||||
*/
|
||||
export function registerPrompts(server: McpServer, bridge: SceneBridge): void {
|
||||
registerFromBrief(server, bridge)
|
||||
registerIterateOnFeedback(server, bridge)
|
||||
registerRenovationFromPhotos(server, bridge)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneBridge } from '../bridge/scene-bridge'
|
||||
|
||||
const PREAMBLE = [
|
||||
'You are iterating on an existing Pascal scene based on user feedback.',
|
||||
'Given the current state (read via the `pascal://scene/current` resource) and the user feedback below, propose the MINIMUM set of `apply_patch` operations that satisfies the feedback.',
|
||||
'Rules:',
|
||||
' - Prefer updates over create+delete pairs when a field change will do.',
|
||||
' - Do not re-create nodes that already exist.',
|
||||
' - Do not touch nodes that are unrelated to the feedback.',
|
||||
' - Bundle related mutations into a single `apply_patch` call so they share one undo step.',
|
||||
' - Respond ONLY with tool calls. No prose.',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Build the user-facing prompt text for `iterate_on_feedback`.
|
||||
* Pure function for testability.
|
||||
*/
|
||||
export function buildIterateOnFeedbackPrompt(args: { feedback: string }): string {
|
||||
return [PREAMBLE, '', '## User feedback', args.feedback.trim()].join('\n')
|
||||
}
|
||||
|
||||
export function registerIterateOnFeedback(server: McpServer, _bridge: SceneBridge): void {
|
||||
server.registerPrompt(
|
||||
'iterate_on_feedback',
|
||||
{
|
||||
title: 'Iterate on a scene from user feedback',
|
||||
description:
|
||||
'Produces a minimal-diff plan of apply_patch calls in response to user feedback on the current scene.',
|
||||
argsSchema: {
|
||||
feedback: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ feedback }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: buildIterateOnFeedbackPrompt({ feedback }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// 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 useScene from '@pascal-app/core/store'
|
||||
import { SceneBridge } from '../bridge/scene-bridge'
|
||||
import { buildFromBriefPrompt, registerFromBrief } from './from-brief'
|
||||
import { buildIterateOnFeedbackPrompt, registerIterateOnFeedback } from './iterate-on-feedback'
|
||||
import { buildRenovationMessages, registerRenovationFromPhotos } from './renovation-from-photos'
|
||||
|
||||
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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resetScene(): void {
|
||||
useScene.getState().unloadScene()
|
||||
useScene.temporal.getState().clear()
|
||||
}
|
||||
|
||||
describe('from_brief', () => {
|
||||
beforeEach(() => resetScene())
|
||||
|
||||
test('includes brief in the returned user message', async () => {
|
||||
const pair = await spinUp(registerFromBrief)
|
||||
try {
|
||||
const res = await pair.client.getPrompt({
|
||||
name: 'from_brief',
|
||||
arguments: { brief: 'A 60 sqm studio with a kitchenette' },
|
||||
})
|
||||
expect(res.messages).toHaveLength(1)
|
||||
const m = res.messages[0]
|
||||
expect(m).toBeDefined()
|
||||
if (!m) return
|
||||
expect(m.role).toBe('user')
|
||||
expect(m.content.type).toBe('text')
|
||||
if (m.content.type === 'text') {
|
||||
expect(m.content.text).toContain('60 sqm studio')
|
||||
expect(m.content.text).toContain('apply_patch')
|
||||
}
|
||||
} finally {
|
||||
await pair.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('appends constraints section when provided', async () => {
|
||||
const pair = await spinUp(registerFromBrief)
|
||||
try {
|
||||
const res = await pair.client.getPrompt({
|
||||
name: 'from_brief',
|
||||
arguments: {
|
||||
brief: 'Tiny house',
|
||||
constraints: 'footprint under 40 sqm',
|
||||
},
|
||||
})
|
||||
const m = res.messages[0]
|
||||
expect(m).toBeDefined()
|
||||
if (!m) return
|
||||
if (m.content.type === 'text') {
|
||||
expect(m.content.text).toContain('## Constraints')
|
||||
expect(m.content.text).toContain('footprint under 40 sqm')
|
||||
}
|
||||
} finally {
|
||||
await pair.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('buildFromBriefPrompt omits constraints section when empty', () => {
|
||||
const text = buildFromBriefPrompt({ brief: 'Studio', constraints: '' })
|
||||
expect(text).not.toContain('## Constraints')
|
||||
expect(text).toContain('Studio')
|
||||
})
|
||||
})
|
||||
|
||||
describe('iterate_on_feedback', () => {
|
||||
beforeEach(() => resetScene())
|
||||
|
||||
test('returns single user message referencing the feedback and the scene resource', async () => {
|
||||
const pair = await spinUp(registerIterateOnFeedback)
|
||||
try {
|
||||
const res = await pair.client.getPrompt({
|
||||
name: 'iterate_on_feedback',
|
||||
arguments: { feedback: 'Move the fridge to the opposite wall' },
|
||||
})
|
||||
expect(res.messages).toHaveLength(1)
|
||||
const m = res.messages[0]
|
||||
expect(m).toBeDefined()
|
||||
if (!m) return
|
||||
expect(m.role).toBe('user')
|
||||
if (m.content.type === 'text') {
|
||||
expect(m.content.text).toContain('Move the fridge')
|
||||
expect(m.content.text).toContain('pascal://scene/current')
|
||||
expect(m.content.text).toContain('apply_patch')
|
||||
}
|
||||
} finally {
|
||||
await pair.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('buildIterateOnFeedbackPrompt emphasises minimal diff', () => {
|
||||
const text = buildIterateOnFeedbackPrompt({ feedback: 'x' })
|
||||
expect(text.toLowerCase()).toContain('minimum')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renovation_from_photos', () => {
|
||||
beforeEach(() => resetScene())
|
||||
|
||||
test('parses JSON-array photo lists and emits image/text content', async () => {
|
||||
const pair = await spinUp(registerRenovationFromPhotos)
|
||||
try {
|
||||
const longBase64 = 'A'.repeat(40) // length % 4 == 0, pure base64 chars.
|
||||
const res = await pair.client.getPrompt({
|
||||
name: 'renovation_from_photos',
|
||||
arguments: {
|
||||
currentPhotos: JSON.stringify(['https://example.com/current1.jpg', longBase64]),
|
||||
referencePhotos: JSON.stringify(['data:image/png;base64,iVBORw0K']),
|
||||
goals: 'make it look mid-century modern',
|
||||
},
|
||||
})
|
||||
expect(res.messages.length).toBeGreaterThan(1)
|
||||
|
||||
// Intro text should mention goals + counts.
|
||||
const intro = res.messages[0]
|
||||
expect(intro).toBeDefined()
|
||||
if (!intro) return
|
||||
if (intro.content.type !== 'text') throw new Error('intro not text')
|
||||
expect(intro.content.text).toContain('mid-century modern')
|
||||
expect(intro.content.text).toContain('Current photos: 2')
|
||||
expect(intro.content.text).toContain('Reference photos: 1')
|
||||
|
||||
// There should be at least one image content (from the base64) and one
|
||||
// URL text fallback (from the https URL).
|
||||
const kinds = res.messages.map((m) => m.content.type)
|
||||
expect(kinds).toContain('image')
|
||||
const textMessages = res.messages.filter((m) => m.content.type === 'text')
|
||||
const hasUrlFallback = textMessages.some(
|
||||
(m) => m.content.type === 'text' && m.content.text.startsWith('URL: https://'),
|
||||
)
|
||||
expect(hasUrlFallback).toBe(true)
|
||||
|
||||
// Final message should be a task directive.
|
||||
const last = res.messages[res.messages.length - 1]
|
||||
expect(last).toBeDefined()
|
||||
if (!last) return
|
||||
if (last.content.type === 'text') {
|
||||
expect(last.content.text).toContain('## Task')
|
||||
expect(last.content.text).toContain('apply_patch')
|
||||
}
|
||||
} finally {
|
||||
await pair.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('data-URL with explicit mimeType becomes image content', () => {
|
||||
const messages = buildRenovationMessages({
|
||||
currentPhotos: JSON.stringify(['data:image/png;base64,aGVsbG8='] as string[]),
|
||||
referencePhotos: '[]',
|
||||
goals: 'test',
|
||||
})
|
||||
const imageMsg = messages.find((m) => m.content.type === 'image')
|
||||
expect(imageMsg).toBeDefined()
|
||||
if (imageMsg && imageMsg.content.type === 'image') {
|
||||
expect(imageMsg.content.mimeType).toBe('image/png')
|
||||
expect(imageMsg.content.data).toBe('aGVsbG8=')
|
||||
}
|
||||
})
|
||||
|
||||
test('comma-separated fallback parses a list correctly', () => {
|
||||
const messages = buildRenovationMessages({
|
||||
currentPhotos: 'https://a.example/1.jpg, https://b.example/2.jpg',
|
||||
referencePhotos: '',
|
||||
goals: 'test',
|
||||
})
|
||||
const urlTextMsgs = messages.filter(
|
||||
(m) => m.content.type === 'text' && m.content.text.startsWith('URL: https://'),
|
||||
)
|
||||
expect(urlTextMsgs.length).toBe(2)
|
||||
})
|
||||
|
||||
test('empty lists produce no per-photo sections but still include task directive', () => {
|
||||
const messages = buildRenovationMessages({
|
||||
currentPhotos: '',
|
||||
referencePhotos: '',
|
||||
goals: 'nothing to do',
|
||||
})
|
||||
// 1 intro + 1 task = 2 messages.
|
||||
expect(messages.length).toBe(2)
|
||||
const last = messages[messages.length - 1]
|
||||
expect(last).toBeDefined()
|
||||
if (last && last.content.type === 'text') {
|
||||
expect(last.content.text).toContain('## Task')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user