diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts new file mode 100644 index 00000000..f409757b --- /dev/null +++ b/packages/mcp/src/prompts/from-brief.ts @@ -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 }), + }, + }, + ], + }), + ) +} diff --git a/packages/mcp/src/prompts/index.ts b/packages/mcp/src/prompts/index.ts new file mode 100644 index 00000000..e65d2d70 --- /dev/null +++ b/packages/mcp/src/prompts/index.ts @@ -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) +} diff --git a/packages/mcp/src/prompts/iterate-on-feedback.ts b/packages/mcp/src/prompts/iterate-on-feedback.ts new file mode 100644 index 00000000..ecf52d04 --- /dev/null +++ b/packages/mcp/src/prompts/iterate-on-feedback.ts @@ -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 }), + }, + }, + ], + }), + ) +} diff --git a/packages/mcp/src/prompts/prompts.test.ts b/packages/mcp/src/prompts/prompts.test.ts new file mode 100644 index 00000000..31d65a55 --- /dev/null +++ b/packages/mcp/src/prompts/prompts.test.ts @@ -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 +} + +async function spinUp( + register: (server: McpServer, bridge: SceneBridge) => void, +): Promise { + 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') + } + }) +}) diff --git a/packages/mcp/src/prompts/renovation-from-photos.ts b/packages/mcp/src/prompts/renovation-from-photos.ts new file mode 100644 index 00000000..b177ccd5 --- /dev/null +++ b/packages/mcp/src/prompts/renovation-from-photos.ts @@ -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 }), + }), + ) +} diff --git a/packages/mcp/src/resources/catalog-items.ts b/packages/mcp/src/resources/catalog-items.ts new file mode 100644 index 00000000..6b7da031 --- /dev/null +++ b/packages/mcp/src/resources/catalog-items.ts @@ -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), + }, + ], + } + }, + ) +} diff --git a/packages/mcp/src/resources/constraints.ts b/packages/mcp/src/resources/constraints.ts new file mode 100644 index 00000000..fa586b35 --- /dev/null +++ b/packages/mcp/src/resources/constraints.ts @@ -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[1]` to avoid `any` and + * to stay in sync with the core signature. + */ +const EMPTY_MITER_DATA: Parameters[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), + }, + ], + } + }, + ) +} diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts new file mode 100644 index 00000000..583c5082 --- /dev/null +++ b/packages/mcp/src/resources/index.ts @@ -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) +} diff --git a/packages/mcp/src/resources/resources.test.ts b/packages/mcp/src/resources/resources.test.ts new file mode 100644 index 00000000..c2277572 --- /dev/null +++ b/packages/mcp/src/resources/resources.test.ts @@ -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 +} + +async function spinUp( + register: (server: McpServer, bridge: SceneBridge) => void, +): Promise { + 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() + } + }) +}) diff --git a/packages/mcp/src/resources/scene-current.ts b/packages/mcp/src/resources/scene-current.ts new file mode 100644 index 00000000..85be0208 --- /dev/null +++ b/packages/mcp/src/resources/scene-current.ts @@ -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()), + }, + ], + }), + ) +} diff --git a/packages/mcp/src/resources/scene-summary.ts b/packages/mcp/src/resources/scene-summary.ts new file mode 100644 index 00000000..b3adc646 --- /dev/null +++ b/packages/mcp/src/resources/scene-summary.ts @@ -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 + +/** 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 { + const out: Record = {} + 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): 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) === 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 | null { + const seen = new Set() + 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()), + }, + ], + }), + ) +}