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 }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user