diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml index 3e455284..c11fed1e 100644 --- a/.github/workflows/mcp-ci.yml +++ b/.github/workflows/mcp-ci.yml @@ -6,12 +6,20 @@ on: paths: - 'packages/mcp/**' - 'packages/core/**' + - 'apps/editor/app/api/scenes/**' + - 'apps/editor/lib/scene-*' + - 'apps/editor/package.json' - 'bun.lock' + - '.github/workflows/mcp-ci.yml' pull_request: paths: - 'packages/mcp/**' - 'packages/core/**' + - 'apps/editor/app/api/scenes/**' + - 'apps/editor/lib/scene-*' + - 'apps/editor/package.json' - 'bun.lock' + - '.github/workflows/mcp-ci.yml' jobs: ci: @@ -37,5 +45,8 @@ jobs: - name: Test mcp run: bun test --cwd packages/mcp + - name: Test editor scene API + run: bun test apps/editor/lib/scene-store-server.test.ts apps/editor/lib/scene-api-security.test.ts + - name: Biome check - run: bunx biome check packages/mcp + run: bunx biome check packages/mcp apps/editor/lib/scene-store-server.ts apps/editor/lib/scene-api-security.ts apps/editor/app/api/scenes diff --git a/apps/editor/app/api/scenes/[id]/events/route.ts b/apps/editor/app/api/scenes/[id]/events/route.ts index 575ac6d6..2167a12b 100644 --- a/apps/editor/app/api/scenes/[id]/events/route.ts +++ b/apps/editor/app/api/scenes/[id]/events/route.ts @@ -1,5 +1,10 @@ -import { NextResponse } from 'next/server' -import { getSceneStore } from '@/lib/scene-store-server' +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, + withSceneApiHeaders, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -10,17 +15,24 @@ const POLL_MS = 250 const HEARTBEAT_MS = 15_000 const MAX_EVENTS_PER_POLL = 50 -export async function GET(request: Request, { params }: RouteParams) { - const { id } = await params - const store = await getSceneStore() +export function OPTIONS(request: Request) { + return sceneApiPreflight(request) +} - if (!store.listSceneEvents) { - return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 }) +export async function GET(request: Request, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + + const { id } = await params + const operations = await getSceneOperations() + + if (!operations.canListSceneEvents) { + return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 }) } - const scene = await store.load(id) + const scene = await operations.loadStoredScene(id) if (!scene) { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } const url = new URL(request.url) @@ -61,7 +73,7 @@ export async function GET(request: Request, { params }: RouteParams) { const poll = async () => { if (closed) return try { - const events = await store.listSceneEvents!(id, { + const events = await operations.listSceneEvents(id, { afterEventId: cursor, limit: MAX_EVENTS_PER_POLL, }) @@ -90,12 +102,15 @@ export async function GET(request: Request, { params }: RouteParams) { }, }) - return new Response(stream, { - headers: { - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'Content-Type': 'text/event-stream; charset=utf-8', - 'X-Accel-Buffering': 'no', - }, - }) + return withSceneApiHeaders( + request, + new Response(stream, { + headers: { + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'Content-Type': 'text/event-stream; charset=utf-8', + 'X-Accel-Buffering': 'no', + }, + }), + ) } diff --git a/apps/editor/app/api/scenes/[id]/route.ts b/apps/editor/app/api/scenes/[id]/route.ts index d70b6db1..3c93f020 100644 --- a/apps/editor/app/api/scenes/[id]/route.ts +++ b/apps/editor/app/api/scenes/[id]/route.ts @@ -1,7 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { apiGraphSchema } from '@/lib/graph-schema' -import { getSceneStore } from '@/lib/scene-store-server' +import { + guardSceneApiRequest, + sceneApiJson, + sceneApiPreflight, + withSceneApiHeaders, +} from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -19,30 +25,41 @@ const patchSceneSchema = z.object({ expectedVersion: z.number().int().nonnegative().optional(), }) -export async function GET(_request: NextRequest, { params }: RouteParams) { +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const { id } = await params - const store = await getSceneStore() + const operations = await getSceneOperations() try { - const scene = await store.load(id) + const scene = await operations.loadStoredScene(id) if (!scene) { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } - return NextResponse.json(scene, { + return sceneApiJson(request, scene, { headers: { ETag: `"${scene.version}"` }, }) } catch (error) { - return handleStoreError(error) + return handleStoreError(request, error) } } export async function PUT(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const { id } = await params let body: unknown try { body = await request.json() } catch { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: 'body must be valid JSON' }, { status: 400 }, ) @@ -50,7 +67,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { const parsed = putSceneSchema.safeParse(body) if (!parsed.success) { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: parsed.error.issues }, { status: 400 }, ) @@ -59,13 +77,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { const ifMatch = parseIfMatch(request.headers.get('If-Match')) const expectedVersion = ifMatch ?? parsed.data.expectedVersion - const store = await getSceneStore() + const operations = await getSceneOperations() try { - const existing = await store.load(id) + const existing = await operations.loadStoredScene(id) if (!existing) { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } - const meta = await store.save({ + const meta = await operations.saveScene({ id, name: parsed.data.name ?? existing.name, projectId: existing.projectId, @@ -75,38 +93,45 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl, expectedVersion: expectedVersion ?? existing.version, }) - return NextResponse.json(meta, { + return sceneApiJson(request, meta, { headers: { ETag: `"${meta.version}"` }, }) } catch (error) { - return handleStoreError(error, { includeCurrentVersionFor: id }) + return handleStoreError(request, error, { includeCurrentVersionFor: id }) } } export async function DELETE(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const { id } = await params const ifMatch = parseIfMatch(request.headers.get('If-Match')) - const store = await getSceneStore() + const operations = await getSceneOperations() try { - const removed = await store.delete(id, { expectedVersion: ifMatch }) + const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch }) if (!removed) { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } - return new NextResponse(null, { status: 204 }) + return withSceneApiHeaders(request, new NextResponse(null, { status: 204 })) } catch (error) { - return handleStoreError(error, { includeCurrentVersionFor: id }) + return handleStoreError(request, error, { includeCurrentVersionFor: id }) } } export async function PATCH(request: NextRequest, { params }: RouteParams) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const { id } = await params let body: unknown try { body = await request.json() } catch { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: 'body must be valid JSON' }, { status: 400 }, ) @@ -114,7 +139,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const parsed = patchSceneSchema.safeParse(body) if (!parsed.success) { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: parsed.error.issues }, { status: 400 }, ) @@ -123,14 +149,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { const ifMatch = parseIfMatch(request.headers.get('If-Match')) const expectedVersion = ifMatch ?? parsed.data.expectedVersion - const store = await getSceneStore() + const operations = await getSceneOperations() try { - const meta = await store.rename(id, parsed.data.name, { expectedVersion }) - return NextResponse.json(meta, { + const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion }) + return sceneApiJson(request, meta, { headers: { ETag: `"${meta.version}"` }, }) } catch (error) { - return handleStoreError(error, { includeCurrentVersionFor: id }) + return handleStoreError(request, error, { includeCurrentVersionFor: id }) } } @@ -152,6 +178,7 @@ function parseIfMatch(raw: string | null): number | undefined { } async function handleStoreError( + request: NextRequest, error: unknown, opts: { includeCurrentVersionFor?: string } = {}, ): Promise { @@ -160,14 +187,15 @@ async function handleStoreError( let currentVersion: number | undefined if (opts.includeCurrentVersionFor) { try { - const store = await getSceneStore() - const current = await store.load(opts.includeCurrentVersionFor) + const operations = await getSceneOperations() + const current = await operations.loadStoredScene(opts.includeCurrentVersionFor) currentVersion = current?.version } catch { // Best-effort; skip reporting currentVersion on secondary failure. } } - return NextResponse.json( + return sceneApiJson( + request, currentVersion === undefined ? { error: 'version_conflict' } : { error: 'version_conflict', currentVersion }, @@ -175,14 +203,14 @@ async function handleStoreError( ) } if (code === 'not_found') { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } if (code === 'too_large') { - return NextResponse.json({ error: 'too_large' }, { status: 413 }) + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) } if (code === 'invalid') { - return NextResponse.json({ error: 'invalid' }, { status: 400 }) + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) } const message = error instanceof Error ? error.message : 'unexpected_error' - return NextResponse.json({ error: 'internal_error', message }, { status: 500 }) + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) } diff --git a/apps/editor/app/api/scenes/route.ts b/apps/editor/app/api/scenes/route.ts index 3053aa95..01ffc3ba 100644 --- a/apps/editor/app/api/scenes/route.ts +++ b/apps/editor/app/api/scenes/route.ts @@ -1,7 +1,8 @@ -import { type NextRequest, NextResponse } from 'next/server' +import type { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { apiGraphSchema } from '@/lib/graph-schema' -import { getSceneStore } from '@/lib/scene-store-server' +import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security' +import { getSceneOperations } from '@/lib/scene-store-server' export const dynamic = 'force-dynamic' @@ -18,33 +19,45 @@ const listQuerySchema = z.object({ limit: z.coerce.number().int().positive().max(500).optional(), }) +export function OPTIONS(request: NextRequest) { + return sceneApiPreflight(request) +} + export async function GET(request: NextRequest) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + const url = new URL(request.url) const parsed = listQuerySchema.safeParse({ projectId: url.searchParams.get('projectId') ?? undefined, limit: url.searchParams.get('limit') ?? undefined, }) if (!parsed.success) { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: parsed.error.issues }, { status: 400 }, ) } - const store = await getSceneStore() - const scenes = await store.list({ + const operations = await getSceneOperations() + const scenes = await operations.listScenes({ projectId: parsed.data.projectId, limit: parsed.data.limit, }) - return NextResponse.json({ scenes }) + return sceneApiJson(request, { scenes }) } export async function POST(request: NextRequest) { + const guard = guardSceneApiRequest(request) + if (guard) return guard + let body: unknown try { body = await request.json() } catch { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: 'body must be valid JSON' }, { status: 400 }, ) @@ -52,44 +65,45 @@ export async function POST(request: NextRequest) { const parsed = createSceneSchema.safeParse(body) if (!parsed.success) { - return NextResponse.json( + return sceneApiJson( + request, { error: 'invalid_request', details: parsed.error.issues }, { status: 400 }, ) } - const store = await getSceneStore() + const operations = await getSceneOperations() try { - const meta = await store.save({ + const meta = await operations.saveScene({ id: parsed.data.id, name: parsed.data.name, projectId: parsed.data.projectId ?? null, graph: parsed.data.graph as never, thumbnailUrl: parsed.data.thumbnailUrl ?? null, }) - return NextResponse.json(meta, { + return sceneApiJson(request, meta, { status: 201, headers: { Location: `/scene/${meta.id}` }, }) } catch (error) { - return handleStoreError(error) + return handleStoreError(request, error) } } -function handleStoreError(error: unknown): NextResponse { +function handleStoreError(request: NextRequest, error: unknown): NextResponse { const code = (error as { code?: string })?.code if (code === 'version_conflict') { - return NextResponse.json({ error: 'version_conflict' }, { status: 409 }) + return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 }) } if (code === 'not_found') { - return NextResponse.json({ error: 'not_found' }, { status: 404 }) + return sceneApiJson(request, { error: 'not_found' }, { status: 404 }) } if (code === 'too_large') { - return NextResponse.json({ error: 'too_large' }, { status: 413 }) + return sceneApiJson(request, { error: 'too_large' }, { status: 413 }) } if (code === 'invalid') { - return NextResponse.json({ error: 'invalid' }, { status: 400 }) + return sceneApiJson(request, { error: 'invalid' }, { status: 400 }) } const message = error instanceof Error ? error.message : 'unexpected_error' - return NextResponse.json({ error: 'internal_error', message }, { status: 500 }) + return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 }) } diff --git a/apps/editor/lib/scene-api-security.test.ts b/apps/editor/lib/scene-api-security.test.ts new file mode 100644 index 00000000..6494b5dd --- /dev/null +++ b/apps/editor/lib/scene-api-security.test.ts @@ -0,0 +1,64 @@ +import { afterEach, expect, test } from 'bun:test' +import { guardSceneApiRequest, sceneApiPreflight } from './scene-api-security' + +const OLD_ENV = { ...process.env } + +afterEach(() => { + restoreEnv('PASCAL_SCENE_API_TOKEN') + restoreEnv('PASCAL_SCENE_API_ORIGINS') + restoreEnv('PASCAL_SCENE_API_RATE_LIMIT') +}) + +function restoreEnv(key: keyof NodeJS.ProcessEnv): void { + if (OLD_ENV[key] === undefined) delete process.env[key] + else process.env[key] = OLD_ENV[key] +} + +test('allows loopback scene API requests without a token', () => { + delete process.env.PASCAL_SCENE_API_TOKEN + const request = new Request('http://127.0.0.1:3000/api/scenes', { + headers: { host: '127.0.0.1:3000' }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + +test('requires a token for non-loopback scene API requests', async () => { + delete process.env.PASCAL_SCENE_API_TOKEN + const request = new Request('https://editor.example/api/scenes', { + headers: { host: 'editor.example' }, + }) + + const response = guardSceneApiRequest(request) + + expect(response?.status).toBe(503) + expect(await response?.json()).toEqual({ error: 'scene_api_token_required' }) +}) + +test('accepts bearer token auth when configured', () => { + process.env.PASCAL_SCENE_API_TOKEN = 'secret' + const request = new Request('https://editor.example/api/scenes', { + headers: { + authorization: 'Bearer secret', + host: 'editor.example', + }, + }) + + expect(guardSceneApiRequest(request)).toBeNull() +}) + +test('applies configured CORS origins for preflight', () => { + process.env.PASCAL_SCENE_API_ORIGINS = 'https://app.example' + const request = new Request('https://editor.example/api/scenes', { + method: 'OPTIONS', + headers: { + host: 'editor.example', + origin: 'https://app.example', + }, + }) + + const response = sceneApiPreflight(request) + + expect(response.status).toBe(204) + expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example') +}) diff --git a/apps/editor/lib/scene-api-security.ts b/apps/editor/lib/scene-api-security.ts new file mode 100644 index 00000000..2dd3819a --- /dev/null +++ b/apps/editor/lib/scene-api-security.ts @@ -0,0 +1,180 @@ +import { timingSafeEqual } from 'node:crypto' +import { NextResponse } from 'next/server' + +const DEFAULT_RATE_LIMIT_PER_MINUTE = 120 +const WINDOW_MS = 60_000 +const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS' +const ALLOWED_HEADERS = 'authorization, content-type, if-match, last-event-id, x-pascal-scene-token' + +type RateBucket = { + resetAt: number + count: number +} + +const rateBuckets = new Map() + +export function sceneApiPreflight(request: Request): NextResponse { + const guard = guardSceneApiRequest(request, { skipRateLimit: true, skipAuth: true }) + if (guard) return guard + return withSceneApiHeaders(request, new NextResponse(null, { status: 204 })) +} + +export function guardSceneApiRequest( + request: Request, + opts: { skipRateLimit?: boolean; skipAuth?: boolean } = {}, +): NextResponse | null { + const originError = validateOrigin(request) + if (originError) return originError + + if (!opts.skipAuth) { + const authError = validateAuth(request) + if (authError) return authError + } + + if (!opts.skipRateLimit) { + const rateError = validateRateLimit(request) + if (rateError) return rateError + } + + return null +} + +export function sceneApiJson(request: Request, body: unknown, init?: ResponseInit): NextResponse { + return withSceneApiHeaders(request, NextResponse.json(body, init)) +} + +export function withSceneApiHeaders(request: Request, response: T): T { + const origin = request.headers.get('origin') + if (origin && isOriginAllowed(request, origin)) { + response.headers.set('Access-Control-Allow-Origin', origin) + response.headers.append('Vary', 'Origin') + } + response.headers.set('Access-Control-Allow-Methods', ALLOWED_METHODS) + response.headers.set('Access-Control-Allow-Headers', ALLOWED_HEADERS) + response.headers.set('Cache-Control', response.headers.get('Cache-Control') ?? 'no-store') + response.headers.set('X-Content-Type-Options', 'nosniff') + return response +} + +function validateOrigin(request: Request): NextResponse | null { + const origin = request.headers.get('origin') + if (!origin || isOriginAllowed(request, origin)) return null + return sceneApiJson(request, { error: 'origin_not_allowed' }, { status: 403 }) +} + +function validateAuth(request: Request): NextResponse | null { + const token = process.env.PASCAL_SCENE_API_TOKEN + if (!token) { + if (isLoopbackRequest(request)) return null + return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 }) + } + + const supplied = bearerToken(request) ?? request.headers.get('x-pascal-scene-token') + if (supplied && safeEqual(supplied, token)) return null + return sceneApiJson(request, { error: 'unauthorized' }, { status: 401 }) +} + +function validateRateLimit(request: Request): NextResponse | null { + const limit = rateLimitPerMinute() + if (limit <= 0) return null + + const now = Date.now() + const key = clientIp(request) + const bucket = rateBuckets.get(key) + if (!bucket || bucket.resetAt <= now) { + rateBuckets.set(key, { count: 1, resetAt: now + WINDOW_MS }) + return null + } + + bucket.count++ + if (bucket.count <= limit) return null + + const retryAfter = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)) + const response = sceneApiJson(request, { error: 'rate_limited' }, { status: 429 }) + response.headers.set('Retry-After', String(retryAfter)) + return response +} + +function bearerToken(request: Request): string | null { + const header = request.headers.get('authorization') + if (!header) return null + const match = header.match(/^Bearer\s+(.+)$/i) + return match?.[1] ?? null +} + +function safeEqual(a: string, b: string): boolean { + const aBuffer = Buffer.from(a) + const bBuffer = Buffer.from(b) + if (aBuffer.length !== bBuffer.length) return false + return timingSafeEqual(aBuffer, bBuffer) +} + +function rateLimitPerMinute(): number { + const raw = process.env.PASCAL_SCENE_API_RATE_LIMIT + if (!raw) return DEFAULT_RATE_LIMIT_PER_MINUTE + const n = Number.parseInt(raw, 10) + return Number.isFinite(n) ? n : DEFAULT_RATE_LIMIT_PER_MINUTE +} + +function clientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + if (forwarded) return forwarded + return request.headers.get('x-real-ip') ?? 'unknown' +} + +function isOriginAllowed(request: Request, origin: string): boolean { + if (isSameOrigin(request, origin)) return true + const parsed = parseUrl(origin) + if (!parsed) return false + if (isLoopbackHostname(parsed.hostname)) return true + return configuredOrigins().has(normalizeOrigin(parsed)) +} + +function configuredOrigins(): Set { + const raw = process.env.PASCAL_SCENE_API_ORIGINS + if (!raw) return new Set() + return new Set( + raw + .split(',') + .map((part) => parseUrl(part.trim())) + .filter((url): url is URL => url !== null) + .map(normalizeOrigin), + ) +} + +function isSameOrigin(request: Request, origin: string): boolean { + const parsedOrigin = parseUrl(origin) + if (!parsedOrigin) return false + const requestUrl = new URL(request.url) + return normalizeOrigin(parsedOrigin) === normalizeOrigin(requestUrl) +} + +function isLoopbackRequest(request: Request): boolean { + const host = request.headers.get('host') ?? new URL(request.url).host + return isLoopbackHostname(stripPort(host)) +} + +function isLoopbackHostname(hostname: string): boolean { + const h = hostname.toLowerCase() + return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1' +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value) + } catch { + return null + } +} + +function normalizeOrigin(url: URL): string { + return `${url.protocol}//${url.host}`.toLowerCase() +} + +function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']') + return end === -1 ? host : host.slice(1, end) + } + return host.split(':')[0] ?? host +} diff --git a/apps/editor/lib/scene-store-server.test.ts b/apps/editor/lib/scene-store-server.test.ts index 684d09dc..cf0d28ef 100644 --- a/apps/editor/lib/scene-store-server.test.ts +++ b/apps/editor/lib/scene-store-server.test.ts @@ -2,6 +2,12 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' describe('getSceneStore', () => { beforeEach(() => { + mock.module('@pascal-app/mcp/operations', () => ({ + createSceneOperations: ({ store }: { store: unknown }) => ({ + __store: store, + hasStore: true, + }), + })) mock.module('@pascal-app/mcp/storage', () => { let callCount = 0 return { @@ -55,4 +61,14 @@ describe('getSceneStore', () => { expect(first).not.toBe(second) }) + + test('getSceneOperations wraps the cached store', async () => { + const mod = await import('./scene-store-server') + mod.__resetSceneStoreForTests() + + const store = await mod.getSceneStore() + const operations = await mod.getSceneOperations() + + expect((operations as unknown as { __store: unknown }).__store).toBe(store) + }) }) diff --git a/apps/editor/lib/scene-store-server.ts b/apps/editor/lib/scene-store-server.ts index 1deb3248..796381f0 100644 --- a/apps/editor/lib/scene-store-server.ts +++ b/apps/editor/lib/scene-store-server.ts @@ -1,8 +1,4 @@ -// TODO: auth — every call in this module currently runs unauthenticated. -// v0.1 is scoped to local-first use on a developer machine. A hosted editor -// should pass request-scoped user context through this factory before exposing -// these routes publicly. - +import type { SceneOperations } from '@pascal-app/mcp/operations' import type { SceneStore } from '@pascal-app/mcp/storage' /** @@ -10,18 +6,32 @@ import type { SceneStore } from '@pascal-app/mcp/storage' * dynamically imported — we cache the in-flight promise so concurrent calls * during a cold start share a single instantiation. */ -let cached: Promise | null = null +let cachedStore: Promise | null = null +let cachedOperations: Promise | null = null export function getSceneStore(): Promise { - if (!cached) { - cached = (async () => { + if (!cachedStore) { + cachedStore = (async () => { const mod = (await import('@pascal-app/mcp/storage')) as { createSceneStore: (env?: NodeJS.ProcessEnv) => Promise } return mod.createSceneStore(process.env) })() } - return cached + return cachedStore +} + +export function getSceneOperations(): Promise { + if (!cachedOperations) { + cachedOperations = (async () => { + const store = await getSceneStore() + const mod = (await import('@pascal-app/mcp/operations')) as { + createSceneOperations: (options: { store: SceneStore }) => SceneOperations + } + return mod.createSceneOperations({ store }) + })() + } + return cachedOperations } /** @@ -29,5 +39,6 @@ export function getSceneStore(): Promise { * callers. */ export function __resetSceneStoreForTests(): void { - cached = null + cachedStore = null + cachedOperations = null } diff --git a/apps/editor/public/dev/casa-sol.json b/apps/editor/public/dev/casa-sol.json deleted file mode 100644 index 975f1789..00000000 --- a/apps/editor/public/dev/casa-sol.json +++ /dev/null @@ -1,1305 +0,0 @@ -{ - "nodes": { - "site_3ss5ro12ozrgtdbf": { - "object": "node", - "id": "site_3ss5ro12ozrgtdbf", - "type": "site", - "parentId": null, - "visible": true, - "metadata": {}, - "polygon": { - "type": "polygon", - "points": [ - [ - -15, - -15 - ], - [ - 15, - -15 - ], - [ - 15, - 15 - ], - [ - -15, - 15 - ] - ] - }, - "children": [ - { - "object": "node", - "id": "building_hhprs7o5kz0q2qo7", - "type": "building", - "parentId": null, - "visible": true, - "metadata": {}, - "children": [ - "level_9ded0vdlfag09tdt" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] - } - ] - }, - "building_hhprs7o5kz0q2qo7": { - "object": "node", - "id": "building_hhprs7o5kz0q2qo7", - "type": "building", - "parentId": null, - "visible": true, - "metadata": {}, - "children": [ - "level_9ded0vdlfag09tdt" - ], - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] - }, - "level_9ded0vdlfag09tdt": { - "object": "node", - "id": "level_9ded0vdlfag09tdt", - "type": "level", - "parentId": null, - "visible": true, - "metadata": {}, - "children": [ - "wall_vc5l8mk2b3j3ukvq", - "wall_9jaybb53j2r5j5en", - "wall_a16b1eirqz5p9f98", - "wall_yiwfcyw716g5ql5y", - "wall_9vmyxyv2kea0t2vn", - "wall_53gr0rtp1ssmxvz2", - "wall_yzuosc4u4z0a9jqo", - "wall_eeogjzcwm711gzam", - "wall_3mznfrklw8ar7jrz", - "zone_b51zjgfr0cgc7ncc", - "zone_x409m2lnw1jpmi8m", - "zone_and0s1ux5v8rexj6", - "zone_n8w3oyzr3c4ovcbu", - "zone_c1wa4cr91h215zzk", - "zone_e8e6qqmx85tockrm", - "zone_ebbidjjln9doosy5", - "zone_av8rfpjgcpmpx4pb", - "slab_tqyxze2hshzm1it3", - "fence_hnhjl6vicj3fs234", - "fence_me6wvw6rf93y2um4", - "fence_af22csiukvc7gjyf", - "fence_lp6d7gkrado9z0cc", - "fence_a09o9w183o453oqh", - "zone_gtpdocou2nceicp9" - ], - "level": 0 - }, - "wall_vc5l8mk2b3j3ukvq": { - "object": "node", - "id": "wall_vc5l8mk2b3j3ukvq", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "door_v19tfw6dbg60pjka", - "door_57wgnpcft27n51ui", - "window_6lkp36vh0kpe8vsl", - "window_8klttdg0qbxw0v6h" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -8, - 4 - ], - "end": [ - 4, - 4 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_9jaybb53j2r5j5en": { - "object": "node", - "id": "wall_9jaybb53j2r5j5en", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "door_lv8wmjjs6em9ayqg", - "window_qy4pp3lwyn5cq2u1" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -8, - -4 - ], - "end": [ - 4, - -4 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_a16b1eirqz5p9f98": { - "object": "node", - "id": "wall_a16b1eirqz5p9f98", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "window_r9mc8jkp0dem9s8u" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -8, - -4 - ], - "end": [ - -8, - 4 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_yiwfcyw716g5ql5y": { - "object": "node", - "id": "wall_yiwfcyw716g5ql5y", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "window_bbmwfj9hjxfz6y7c", - "window_19otr8qt84hke69s" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - 4, - -4 - ], - "end": [ - 4, - 4 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_9vmyxyv2kea0t2vn": { - "object": "node", - "id": "wall_9vmyxyv2kea0t2vn", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [], - "thickness": 0.2, - "height": 2.7, - "start": [ - -1, - 0 - ], - "end": [ - -1, - 4 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_53gr0rtp1ssmxvz2": { - "object": "node", - "id": "wall_53gr0rtp1ssmxvz2", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "door_g1ffn08lohho4vf0" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -1, - -4 - ], - "end": [ - -1, - 0 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_yzuosc4u4z0a9jqo": { - "object": "node", - "id": "wall_yzuosc4u4z0a9jqo", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "door_zuyuxee4afd5ae0o" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -4, - -4 - ], - "end": [ - -4, - 0 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_eeogjzcwm711gzam": { - "object": "node", - "id": "wall_eeogjzcwm711gzam", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [], - "thickness": 0.2, - "height": 2.7, - "start": [ - -4, - -1 - ], - "end": [ - -1, - -1 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "wall_3mznfrklw8ar7jrz": { - "object": "node", - "id": "wall_3mznfrklw8ar7jrz", - "type": "wall", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "children": [ - "door_n4kgjkvq18a87rh0" - ], - "thickness": 0.2, - "height": 2.7, - "start": [ - -4, - -2 - ], - "end": [ - -1, - -2 - ], - "frontSide": "unknown", - "backSide": "unknown" - }, - "zone_b51zjgfr0cgc7ncc": { - "object": "node", - "id": "zone_b51zjgfr0cgc7ncc", - "type": "zone", - "name": "living-dining", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -8, - 0 - ], - [ - -1, - 0 - ], - [ - -1, - 4 - ], - [ - -8, - 4 - ] - ], - "color": "#3b82f6" - }, - "zone_x409m2lnw1jpmi8m": { - "object": "node", - "id": "zone_x409m2lnw1jpmi8m", - "type": "zone", - "name": "kitchen", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -1, - 0 - ], - [ - 4, - 0 - ], - [ - 4, - 4 - ], - [ - -1, - 4 - ] - ], - "color": "#3b82f6" - }, - "zone_and0s1ux5v8rexj6": { - "object": "node", - "id": "zone_and0s1ux5v8rexj6", - "type": "zone", - "name": "bedroom-2", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -8, - -4 - ], - [ - -4, - -4 - ], - [ - -4, - 0 - ], - [ - -8, - 0 - ] - ], - "color": "#3b82f6" - }, - "zone_n8w3oyzr3c4ovcbu": { - "object": "node", - "id": "zone_n8w3oyzr3c4ovcbu", - "type": "zone", - "name": "hallway", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -4, - -2 - ], - [ - -1, - -2 - ], - [ - -1, - -1 - ], - [ - -4, - -1 - ] - ], - "color": "#3b82f6" - }, - "zone_c1wa4cr91h215zzk": { - "object": "node", - "id": "zone_c1wa4cr91h215zzk", - "type": "zone", - "name": "bathroom-2", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -4, - -4 - ], - [ - -1, - -4 - ], - [ - -1, - -2 - ], - [ - -4, - -2 - ] - ], - "color": "#3b82f6" - }, - "zone_e8e6qqmx85tockrm": { - "object": "node", - "id": "zone_e8e6qqmx85tockrm", - "type": "zone", - "name": "bathroom-1", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -4, - -1 - ], - [ - -1, - -1 - ], - [ - -1, - 0 - ], - [ - -4, - 0 - ] - ], - "color": "#3b82f6" - }, - "zone_ebbidjjln9doosy5": { - "object": "node", - "id": "zone_ebbidjjln9doosy5", - "type": "zone", - "name": "master-bedroom", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - -1, - -4 - ], - [ - 4, - -4 - ], - [ - 4, - 0 - ], - [ - -1, - 0 - ] - ], - "color": "#3b82f6" - }, - "door_v19tfw6dbg60pjka": { - "object": "node", - "id": "door_v19tfw6dbg60pjka", - "type": "door", - "parentId": "wall_vc5l8mk2b3j3ukvq", - "visible": true, - "metadata": {}, - "position": [ - 0.2, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_vc5l8mk2b3j3ukvq", - "width": 0.9, - "height": 2.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "door_57wgnpcft27n51ui": { - "object": "node", - "id": "door_57wgnpcft27n51ui", - "type": "door", - "parentId": "wall_vc5l8mk2b3j3ukvq", - "visible": true, - "metadata": {}, - "position": [ - 0.75, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_vc5l8mk2b3j3ukvq", - "width": 2.2, - "height": 2.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "door_lv8wmjjs6em9ayqg": { - "object": "node", - "id": "door_lv8wmjjs6em9ayqg", - "type": "door", - "parentId": "wall_9jaybb53j2r5j5en", - "visible": true, - "metadata": {}, - "position": [ - 0.65, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_9jaybb53j2r5j5en", - "width": 0.9, - "height": 2.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "door_g1ffn08lohho4vf0": { - "object": "node", - "id": "door_g1ffn08lohho4vf0", - "type": "door", - "parentId": "wall_53gr0rtp1ssmxvz2", - "visible": true, - "metadata": {}, - "position": [ - 0.3, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_53gr0rtp1ssmxvz2", - "width": 0.8, - "height": 2.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "door_zuyuxee4afd5ae0o": { - "object": "node", - "id": "door_zuyuxee4afd5ae0o", - "type": "door", - "parentId": "wall_yzuosc4u4z0a9jqo", - "visible": true, - "metadata": {}, - "position": [ - 0.5, - 1.05, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_yzuosc4u4z0a9jqo", - "width": 0.8, - "height": 2.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "door_n4kgjkvq18a87rh0": { - "object": "node", - "id": "door_n4kgjkvq18a87rh0", - "type": "door", - "parentId": "wall_3mznfrklw8ar7jrz", - "visible": true, - "metadata": {}, - "position": [ - 0.5, - 1, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_3mznfrklw8ar7jrz", - "width": 0.7, - "height": 2, - "frameThickness": 0.05, - "frameDepth": 0.07, - "threshold": true, - "thresholdHeight": 0.02, - "hingesSide": "left", - "swingDirection": "inward", - "segments": [ - { - "type": "panel", - "heightRatio": 0.4, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - }, - { - "type": "panel", - "heightRatio": 0.6, - "columnRatios": [ - 1 - ], - "dividerThickness": 0.03, - "panelDepth": 0.01, - "panelInset": 0.04 - } - ], - "handle": true, - "handleHeight": 1.05, - "handleSide": "right", - "contentPadding": [ - 0.04, - 0.04 - ], - "doorCloser": false, - "panicBar": false, - "panicBarHeight": 1 - }, - "window_6lkp36vh0kpe8vsl": { - "object": "node", - "id": "window_6lkp36vh0kpe8vsl", - "type": "window", - "parentId": "wall_vc5l8mk2b3j3ukvq", - "visible": true, - "metadata": {}, - "position": [ - 0.3, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_vc5l8mk2b3j3ukvq", - "width": 2, - "height": 1.4, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "window_8klttdg0qbxw0v6h": { - "object": "node", - "id": "window_8klttdg0qbxw0v6h", - "type": "window", - "parentId": "wall_vc5l8mk2b3j3ukvq", - "visible": true, - "metadata": {}, - "position": [ - 0.45, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_vc5l8mk2b3j3ukvq", - "width": 1.4, - "height": 1.4, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "window_r9mc8jkp0dem9s8u": { - "object": "node", - "id": "window_r9mc8jkp0dem9s8u", - "type": "window", - "parentId": "wall_a16b1eirqz5p9f98", - "visible": true, - "metadata": {}, - "position": [ - 0.25, - 0.55, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_a16b1eirqz5p9f98", - "width": 1, - "height": 1.1, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "window_bbmwfj9hjxfz6y7c": { - "object": "node", - "id": "window_bbmwfj9hjxfz6y7c", - "type": "window", - "parentId": "wall_yiwfcyw716g5ql5y", - "visible": true, - "metadata": {}, - "position": [ - 0.25, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_yiwfcyw716g5ql5y", - "width": 1.4, - "height": 1.4, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "window_19otr8qt84hke69s": { - "object": "node", - "id": "window_19otr8qt84hke69s", - "type": "window", - "parentId": "wall_yiwfcyw716g5ql5y", - "visible": true, - "metadata": {}, - "position": [ - 0.75, - 0.7, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_yiwfcyw716g5ql5y", - "width": 1.4, - "height": 1.4, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "window_qy4pp3lwyn5cq2u1": { - "object": "node", - "id": "window_qy4pp3lwyn5cq2u1", - "type": "window", - "parentId": "wall_9jaybb53j2r5j5en", - "visible": true, - "metadata": {}, - "position": [ - 0.3, - 0.3, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "wallId": "wall_9jaybb53j2r5j5en", - "width": 0.8, - "height": 0.6, - "frameThickness": 0.05, - "frameDepth": 0.07, - "columnRatios": [ - 1 - ], - "rowRatios": [ - 1 - ], - "columnDividerThickness": 0.03, - "rowDividerThickness": 0.03, - "sill": true, - "sillDepth": 0.08, - "sillThickness": 0.03 - }, - "zone_av8rfpjgcpmpx4pb": { - "object": "node", - "id": "zone_av8rfpjgcpmpx4pb", - "type": "zone", - "name": "pool", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": { - "kind": "pool", - "depthM": 1.8, - "finish": "tile" - }, - "polygon": [ - [ - 5, - -1.5 - ], - [ - 10, - -1.5 - ], - [ - 10, - 1.5 - ], - [ - 5, - 1.5 - ] - ], - "color": "#3b82f6" - }, - "slab_tqyxze2hshzm1it3": { - "object": "node", - "id": "slab_tqyxze2hshzm1it3", - "type": "slab", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "polygon": [ - [ - 5, - -1.5 - ], - [ - 10, - -1.5 - ], - [ - 10, - 1.5 - ], - [ - 5, - 1.5 - ] - ], - "holes": [], - "holeMetadata": [], - "elevation": -1.8, - "autoFromWalls": false - }, - "fence_hnhjl6vicj3fs234": { - "object": "node", - "id": "fence_hnhjl6vicj3fs234", - "type": "fence", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "start": [ - -10, - 7.5 - ], - "end": [ - -1, - 7.5 - ], - "height": 1.8, - "thickness": 0.08, - "baseHeight": 0.22, - "postSpacing": 2, - "postSize": 0.1, - "topRailHeight": 0.04, - "groundClearance": 0, - "edgeInset": 0.015, - "baseStyle": "grounded", - "color": "#ffffff", - "style": "privacy" - }, - "fence_me6wvw6rf93y2um4": { - "object": "node", - "id": "fence_me6wvw6rf93y2um4", - "type": "fence", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "start": [ - 1, - 7.5 - ], - "end": [ - 10, - 7.5 - ], - "height": 1.8, - "thickness": 0.08, - "baseHeight": 0.22, - "postSpacing": 2, - "postSize": 0.1, - "topRailHeight": 0.04, - "groundClearance": 0, - "edgeInset": 0.015, - "baseStyle": "grounded", - "color": "#ffffff", - "style": "privacy" - }, - "fence_af22csiukvc7gjyf": { - "object": "node", - "id": "fence_af22csiukvc7gjyf", - "type": "fence", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "start": [ - 10, - 7.5 - ], - "end": [ - 10, - -7.5 - ], - "height": 1.8, - "thickness": 0.08, - "baseHeight": 0.22, - "postSpacing": 2, - "postSize": 0.1, - "topRailHeight": 0.04, - "groundClearance": 0, - "edgeInset": 0.015, - "baseStyle": "grounded", - "color": "#ffffff", - "style": "privacy" - }, - "fence_lp6d7gkrado9z0cc": { - "object": "node", - "id": "fence_lp6d7gkrado9z0cc", - "type": "fence", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "start": [ - 10, - -7.5 - ], - "end": [ - -10, - -7.5 - ], - "height": 1.8, - "thickness": 0.08, - "baseHeight": 0.22, - "postSpacing": 2, - "postSize": 0.1, - "topRailHeight": 0.04, - "groundClearance": 0, - "edgeInset": 0.015, - "baseStyle": "grounded", - "color": "#ffffff", - "style": "privacy" - }, - "fence_a09o9w183o453oqh": { - "object": "node", - "id": "fence_a09o9w183o453oqh", - "type": "fence", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": {}, - "start": [ - -10, - -7.5 - ], - "end": [ - -10, - 7.5 - ], - "height": 1.8, - "thickness": 0.08, - "baseHeight": 0.22, - "postSpacing": 2, - "postSize": 0.1, - "topRailHeight": 0.04, - "groundClearance": 0, - "edgeInset": 0.015, - "baseStyle": "grounded", - "color": "#ffffff", - "style": "privacy" - }, - "zone_gtpdocou2nceicp9": { - "object": "node", - "id": "zone_gtpdocou2nceicp9", - "type": "zone", - "name": "garden", - "parentId": "level_9ded0vdlfag09tdt", - "visible": true, - "metadata": { - "kind": "garden" - }, - "polygon": [ - [ - -10, - -7.5 - ], - [ - 10, - -7.5 - ], - [ - 10, - 7.5 - ], - [ - -10, - 7.5 - ] - ], - "color": "#3b82f6" - } - }, - "rootNodeIds": [ - "site_3ss5ro12ozrgtdbf" - ], - "collections": {} -} \ No newline at end of file diff --git a/packages/mcp/CROSS_CUTTING.md b/packages/mcp/CROSS_CUTTING.md index 1b18c91f..fbe1f62a 100644 --- a/packages/mcp/CROSS_CUTTING.md +++ b/packages/mcp/CROSS_CUTTING.md @@ -6,8 +6,6 @@ Integrator review required. Each entry documents: - **Impact** on existing consumers - **Reversibility** ---- - ## 1. `packages/core/package.json` — added subpath exports ### What @@ -79,15 +77,15 @@ Align `SiteNode.children` to `z.array(z.string())` + migration in `setScene.migr ### What -Adds a CI workflow that runs on pushes to `main` and on pull requests touching `packages/mcp/`, `packages/core/`, or `bun.lock`. The job installs deps with Bun, builds `@pascal-app/core` then `@pascal-app/mcp`, runs `bun test` in the mcp package, and runs `bunx biome check packages/mcp`. +Adds a CI workflow that runs on pushes to `main` and on pull requests touching `packages/mcp/`, `packages/core/`, the editor scene API surface, `.github/workflows/mcp-ci.yml`, or `bun.lock`. The job installs deps with Bun, builds `@pascal-app/core` then `@pascal-app/mcp`, runs `bun test` in the mcp package, runs focused editor scene API tests, and runs Biome over the MCP package plus the editor scene API files. ### Why -The existing `.github/workflows/release.yml` is `workflow_dispatch`-only (manual releases for `core` / `viewer`). There was no automated pre-merge check for MCP builds/tests. A new workflow is needed so that PRs touching mcp/core are verified before merge. +The existing `.github/workflows/release.yml` is `workflow_dispatch`-only (manual releases for `core` / `viewer`). There was no automated pre-merge check for MCP builds/tests. A new workflow is still needed so that PRs touching mcp/core are verified before merge, and it now covers the editor scene API because those routes consume the same MCP operations layer. Full `apps/editor` typecheck was evaluated but is not part of this workflow because it currently fails on unrelated `packages/editor` type errors. ### Impact -None on existing workflows; purely additive. The new workflow only triggers for paths under `packages/mcp/`, `packages/core/`, or `bun.lock`, so unrelated PRs remain unaffected. `release.yml` is untouched. +None on existing workflows; purely additive. The workflow only triggers for MCP/core/editor scene API paths, the workflow file itself, or `bun.lock`, so unrelated PRs remain unaffected. `release.yml` is untouched. ### Reversibility @@ -95,15 +93,15 @@ Delete `.github/workflows/mcp-ci.yml`. --- -## 4. `packages/mcp/package.json` — added `./storage` subpath export +## 4. `packages/mcp/package.json` — added `./storage` and `./operations` subpath exports ### What -Added a `./storage` entry to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}`. The existing `"."` entry is unchanged. +Added `./storage` and `./operations` entries to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}` and `dist/operations/index.{js,d.ts}`. The existing `"."` entry is unchanged. ### Why -The Next.js editor (`apps/editor`) needs access to `createSceneStore()` + the `SceneStore` types/errors in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs the storage adapter. The subpath export lets `apps/editor` do `import type { SceneStore } from '@pascal-app/mcp/storage'` and dynamically import `createSceneStore` without re-declaring the storage contract. +The Next.js editor (`apps/editor`) needs access to `createSceneStore()`, `SceneStore` types/errors, and the shared `SceneOperations` service layer in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs storage/operations. The subpath exports let `apps/editor` dynamically import storage and operations without re-declaring either contract. The concrete backend is now `SqliteSceneStore`, backed by built-in SQLite drivers (`bun:sqlite` for the MCP CLI and `node:sqlite` for the Next.js editor server). It writes to `~/.pascal/data/pascal.db` by default and also supports `PASCAL_DATA_DIR`, `PASCAL_DB_PATH`, and `PASCAL_MAX_SCENE_BYTES`. @@ -113,12 +111,12 @@ Zero on existing consumers. Purely additive. The `.` entry continues to export ` ### Reversibility -Remove the `./storage` entry from `exports` and update `apps/editor` to use a different factory. No data or behavior changes — pure module-graph shaping. +Remove the `./storage`/`./operations` entries from `exports` and update `apps/editor` to use a different factory. No data or behavior changes — pure module-graph shaping. ### Related - `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency so the subpath resolves. -- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume this subpath. +- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume these subpaths. - `packages/mcp/src/storage/sqlite-scene-store.ts` is the only production storage backend. --- @@ -200,3 +198,35 @@ Delete `packages/core/src/schema/asset-url.ts` and revert the five imports in scene-bridge test update is self-contained. --- + +## 6. `apps/editor` / `packages/editor` scene-loading support + +### What + +The PR still touches the editor app and editor package, but the remaining files +are tied to the MCP scene workflow: + +- `apps/editor/app/api/scenes/**`, `apps/editor/components/save-button.tsx`, + and `apps/editor/components/scene-loader.tsx` expose saved MCP scenes in the + web editor. +- `packages/editor/src/hooks/use-auto-frame.ts` plus + `packages/editor/src/lib/scene-bounds.ts` frame the camera after a stored scene + is loaded, avoiding an apparently empty viewport when MCP loads a scene away + from the default camera pose. +- The large demo fixture `apps/editor/public/dev/casa-sol.json` was removed from + this PR to keep the diff focused. + +### Why + +The MCP package can save scenes without these editor changes, but the PR goal is +to let contributors open and continue scenes saved by MCP. The API/UI pieces and +auto-frame hook are the minimum editor-side bridge for that workflow. They do +not change `@pascal-app/viewer` exports. + +### Reversibility + +If maintainers want a narrower MCP-only PR, revert the editor app pages/routes +and the auto-frame helper files, then keep only `@pascal-app/mcp`, the required +`@pascal-app/core` subpath/schema changes, and `bun.lock`. + +--- diff --git a/packages/mcp/PR_DESCRIPTION.md b/packages/mcp/PR_DESCRIPTION.md index edf0c238..1735d3b7 100644 --- a/packages/mcp/PR_DESCRIPTION.md +++ b/packages/mcp/PR_DESCRIPTION.md @@ -78,9 +78,9 @@ Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/edito │ └────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ -│ ┌──────────── SceneBridge + SceneStore ───────────────┐ │ -│ │ headless Zustand store + Zundo │ │ -│ │ local SQLite storage at ~/.pascal/data/pascal.db │ │ +│ ┌─────────────── SceneOperations ─────────────────────┐ │ +│ │ shared MCP / REST operation boundary │ │ +│ │ wraps SceneBridge + local SQLite SceneStore │ │ │ │ Zod validation at every boundary │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ @@ -147,15 +147,15 @@ and `save_scene`; the scene is openable at `/scene/`. 4. **`loadAssetUrl`/`saveAsset` are browser-only.** Items with `asset://` URLs can't be resolved in Node. Supply absolute URLs or `data:` URIs if you need them usable outside the browser. 5. **`SiteNode.children` inconsistency.** Site's children hold full node objects while every other container holds ID strings (see `CROSS_CUTTING.md` §2). MCP works around this by traversing via the flat `nodes` dict. Upstream alignment proposed as a follow-up. 6. **Catalog unavailable in headless mode.** `pascal://catalog/items` and `place_item`'s catalog resolution fall back to a placeholder asset payload until the core exposes a Node-consumable catalog. -7. **Local-only auth boundary.** The HTTP transport and editor scene API are intended for local development in this PR. Do not expose them on a public network without an auth layer. +7. **HTTP/API exposure is guarded.** MCP HTTP binds to `127.0.0.1` by default and requires `PASCAL_MCP_HTTP_TOKEN`/`--auth-token` before binding non-loopback hosts. The editor scene API allows tokenless loopback development, but non-loopback requests require `PASCAL_SCENE_API_TOKEN`; both paths include CORS handling and in-memory rate limiting. ## Cross-cutting changes Documented in [`packages/mcp/CROSS_CUTTING.md`](./CROSS_CUTTING.md): 1. **`packages/core/package.json` — additive subpath exports.** Adds `./schema`, `./store`, `./material-library`, `./spatial-grid`, `./wall`. Needed because the main entry re-exports browser-only systems; subpath entries let Node consumers skip them. Zero impact on existing consumers (`apps/editor`, `@pascal-app/viewer` still use the main entry). -2. **`.github/workflows/mcp-ci.yml` — new CI.** Runs on PRs touching mcp/core; installs with Bun 1.3.0, builds, tests, biome-checks. -3. **`apps/editor` scene routes.** Adds local scene API routes and pages that read from the same SQLite `SceneStore` as MCP. +2. **`.github/workflows/mcp-ci.yml` — new CI.** Kept because the repo otherwise only has manual release CI. It runs on PRs touching MCP/core/editor scene API code; installs with Bun 1.3.0, builds MCP, runs MCP tests, runs focused editor scene API tests, and biome-checks the touched surface. +3. **`apps/editor` scene routes.** Adds scene API routes and pages that read from the same SQLite-backed `SceneOperations` layer as MCP. 4. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2. ## Checklist diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 48dece7f..d37c475d 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -33,12 +33,19 @@ Load an initial scene from disk: pascal-mcp --stdio --scene ./my-scene.json ``` -Expose it as HTTP for remote hosts: +Expose it over loopback HTTP: ```bash pascal-mcp --http --port 8787 ``` +Binding a non-loopback host requires a bearer token: + +```bash +PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \ + pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example +``` + ## Local scene storage Scenes saved through MCP are stored in a local SQLite database: diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1c095d50..366d8e5c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -15,6 +15,11 @@ "types": "./dist/storage/index.d.ts", "import": "./dist/storage/index.js", "default": "./dist/storage/index.js" + }, + "./operations": { + "types": "./dist/operations/index.d.ts", + "import": "./dist/operations/index.js", + "default": "./dist/operations/index.js" } }, "bin": { diff --git a/packages/mcp/src/bin/pascal-mcp.ts b/packages/mcp/src/bin/pascal-mcp.ts index 31c032df..76118715 100644 --- a/packages/mcp/src/bin/pascal-mcp.ts +++ b/packages/mcp/src/bin/pascal-mcp.ts @@ -19,6 +19,9 @@ OPTIONS: --stdio Use stdio transport (default) --http Use Streamable HTTP transport --port HTTP port (default 3917) + --host HTTP bind host (default 127.0.0.1) + --auth-token Bearer token required for HTTP calls + --cors-origin Repeatable allowed HTTP CORS origin --scene Initial scene JSON to load --version Print version --help Print this help @@ -30,6 +33,9 @@ async function main(): Promise { stdio: { type: 'boolean', default: false }, http: { type: 'boolean', default: false }, port: { type: 'string', default: '3917' }, + host: { type: 'string', default: '127.0.0.1' }, + 'auth-token': { type: 'string' }, + 'cors-origin': { type: 'string', multiple: true, default: [] }, scene: { type: 'string' }, help: { type: 'boolean', default: false }, version: { type: 'boolean', default: false }, @@ -61,8 +67,12 @@ async function main(): Promise { if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) { throw new Error(`invalid --port value: ${values.port}`) } - const handle = await connectHttp(server, portNum) - console.error(`[pascal-mcp] HTTP server listening on :${handle.port}`) + const handle = await connectHttp(server, portNum, { + host: values.host, + authToken: values['auth-token'], + allowedOrigins: values['cors-origin'], + }) + console.error(`[pascal-mcp] HTTP server listening on ${handle.host}:${handle.port}`) const shutdown = async () => { try { await handle.close() diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 1c0b5afc..c51591ca 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,4 +1,5 @@ export { SceneBridge } from './bridge/scene-bridge' +export { createSceneOperations, type SceneOperations } from './operations' export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server' export const version = '0.1.0' diff --git a/packages/mcp/src/operations/index.ts b/packages/mcp/src/operations/index.ts new file mode 100644 index 00000000..b71fb497 --- /dev/null +++ b/packages/mcp/src/operations/index.ts @@ -0,0 +1,5 @@ +export { + type CreateSceneOperationsOptions, + createSceneOperations, + type SceneOperations, +} from './scene-operations' diff --git a/packages/mcp/src/operations/scene-operations.ts b/packages/mcp/src/operations/scene-operations.ts new file mode 100644 index 00000000..c3fad830 --- /dev/null +++ b/packages/mcp/src/operations/scene-operations.ts @@ -0,0 +1,273 @@ +import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' +import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema' +import type { ActiveSceneMeta, Patch, SceneBridge, ValidationResult } from '../bridge/scene-bridge' +import type { + SceneEvent, + SceneEventAppendOptions, + SceneEventListOptions, + SceneListOptions, + SceneMeta, + SceneMutateOptions, + SceneSaveOptions, + SceneStore, + SceneWithGraph, +} from '../storage/types' + +export type CreateSceneOperationsOptions = { + bridge?: SceneBridge + store?: SceneStore +} + +export interface SceneOperations { + readonly hasBridge: boolean + readonly hasStore: boolean + readonly hasSceneEvents: boolean + readonly canAppendSceneEvents: boolean + readonly canListSceneEvents: boolean + readonly storeBackend: SceneStore['backend'] | null + + setActiveScene(meta: ActiveSceneMeta): void + getActiveScene(): ActiveSceneMeta | null + clearActiveScene(): void + loadDefault(): void + setScene(nodes: Record, rootNodeIds: AnyNodeId[]): void + exportJSON(): SceneGraph & { collections: Record } + exportSceneGraph(): SceneGraph + loadJSON(json: string | SceneGraph): void + getNode(id: AnyNodeId): AnyNode | null + getNodes(): Record + getRootNodeIds(): AnyNodeId[] + getChildren(parentId: AnyNodeId): AnyNode[] + getAncestry(id: AnyNodeId): AnyNode[] + findNodes(filter: { + type?: AnyNodeType + parentId?: AnyNodeId | null + levelId?: AnyNodeId + }): AnyNode[] + resolveLevelId(id: AnyNodeId): AnyNodeId | null + createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId + updateNode(id: AnyNodeId, data: Partial): void + deleteNode(id: AnyNodeId, cascade?: boolean): string[] + applyPatch(patches: Patch[]): { + appliedOps: number + deletedIds: AnyNodeId[] + createdIds: AnyNodeId[] + } + undo(steps?: number): number + redo(steps?: number): number + validateScene(): ValidationResult + flushDirty(): string[] + getHistory(): { pastCount: number; futureCount: number } + clearHistory(): void + + saveScene(options: SceneSaveOptions): Promise + loadStoredScene(id: string): Promise + listScenes(options?: SceneListOptions): Promise + deleteStoredScene(id: string, options?: SceneMutateOptions): Promise + renameStoredScene(id: string, newName: string, options?: SceneMutateOptions): Promise + appendSceneEvent(options: SceneEventAppendOptions): Promise + listSceneEvents(id: string, options?: SceneEventListOptions): Promise +} + +export function createSceneOperations(options: CreateSceneOperationsOptions): SceneOperations { + return new SceneOperationsFacade(options) +} + +class SceneOperationsFacade implements SceneOperations { + readonly #bridge?: SceneBridge + readonly #store?: SceneStore + + constructor(options: CreateSceneOperationsOptions) { + this.#bridge = options.bridge + this.#store = options.store + } + + get hasBridge(): boolean { + return this.#bridge !== undefined + } + + get hasStore(): boolean { + return this.#store !== undefined + } + + get hasSceneEvents(): boolean { + return this.canAppendSceneEvents && this.canListSceneEvents + } + + get canAppendSceneEvents(): boolean { + return typeof this.#store?.appendSceneEvent === 'function' + } + + get canListSceneEvents(): boolean { + return typeof this.#store?.listSceneEvents === 'function' + } + + get storeBackend(): SceneStore['backend'] | null { + return this.#store?.backend ?? null + } + + setActiveScene(meta: ActiveSceneMeta): void { + this.requireBridge().setActiveScene(meta) + } + + getActiveScene(): ActiveSceneMeta | null { + return this.requireBridge().getActiveScene() + } + + clearActiveScene(): void { + this.requireBridge().clearActiveScene() + } + + loadDefault(): void { + this.requireBridge().loadDefault() + } + + setScene(nodes: Record, rootNodeIds: AnyNodeId[]): void { + this.requireBridge().setScene(nodes, rootNodeIds) + } + + exportJSON(): SceneGraph & { collections: Record } { + return this.requireBridge().exportJSON() + } + + exportSceneGraph(): SceneGraph { + const exported = this.exportJSON() + return { + nodes: exported.nodes, + rootNodeIds: exported.rootNodeIds, + collections: exported.collections as SceneGraph['collections'], + } + } + + loadJSON(json: string | SceneGraph): void { + this.requireBridge().loadJSON(json) + } + + getNode(id: AnyNodeId): AnyNode | null { + return this.requireBridge().getNode(id) + } + + getNodes(): Record { + return this.requireBridge().getNodes() + } + + getRootNodeIds(): AnyNodeId[] { + return this.requireBridge().getRootNodeIds() + } + + getChildren(parentId: AnyNodeId): AnyNode[] { + return this.requireBridge().getChildren(parentId) + } + + getAncestry(id: AnyNodeId): AnyNode[] { + return this.requireBridge().getAncestry(id) + } + + findNodes(filter: { + type?: AnyNodeType + parentId?: AnyNodeId | null + levelId?: AnyNodeId + }): AnyNode[] { + return this.requireBridge().findNodes(filter) + } + + resolveLevelId(id: AnyNodeId): AnyNodeId | null { + return this.requireBridge().resolveLevelId(id) + } + + createNode(node: AnyNode, parentId?: AnyNodeId): AnyNodeId { + return this.requireBridge().createNode(node, parentId) + } + + updateNode(id: AnyNodeId, data: Partial): void { + this.requireBridge().updateNode(id, data) + } + + deleteNode(id: AnyNodeId, cascade?: boolean): string[] { + return this.requireBridge().deleteNode(id, cascade) + } + + applyPatch(patches: Patch[]): { + appliedOps: number + deletedIds: AnyNodeId[] + createdIds: AnyNodeId[] + } { + return this.requireBridge().applyPatch(patches) + } + + undo(steps?: number): number { + return this.requireBridge().undo(steps) + } + + redo(steps?: number): number { + return this.requireBridge().redo(steps) + } + + validateScene(): ValidationResult { + return this.requireBridge().validateScene() + } + + flushDirty(): string[] { + return this.requireBridge().flushDirty() + } + + getHistory(): { pastCount: number; futureCount: number } { + return this.requireBridge().getHistory() + } + + clearHistory(): void { + this.requireBridge().clearHistory() + } + + async saveScene(options: SceneSaveOptions): Promise { + return this.requireStore().save(options) + } + + async loadStoredScene(id: string): Promise { + return this.requireStore().load(id) + } + + async listScenes(options?: SceneListOptions): Promise { + return this.requireStore().list(options) + } + + async deleteStoredScene(id: string, options?: SceneMutateOptions): Promise { + return this.requireStore().delete(id, options) + } + + async renameStoredScene( + id: string, + newName: string, + options?: SceneMutateOptions, + ): Promise { + return this.requireStore().rename(id, newName, options) + } + + async appendSceneEvent(options: SceneEventAppendOptions): Promise { + const append = this.requireStore().appendSceneEvent + if (!append) return null + return append(options) + } + + async listSceneEvents(id: string, options?: SceneEventListOptions): Promise { + const list = this.requireStore().listSceneEvents + if (!list) { + throw new Error('scene_events_unavailable') + } + return list(id, options) + } + + private requireBridge(): SceneBridge { + if (!this.#bridge) { + throw new Error('scene_bridge_unavailable') + } + return this.#bridge + } + + private requireStore(): SceneStore { + if (!this.#store) { + throw new Error('scene_store_unavailable') + } + return this.#store + } +} diff --git a/packages/mcp/src/prompts/from-brief.ts b/packages/mcp/src/prompts/from-brief.ts index 73f7abaa..4a04143d 100644 --- a/packages/mcp/src/prompts/from-brief.ts +++ b/packages/mcp/src/prompts/from-brief.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ @@ -37,7 +37,7 @@ export function buildFromBriefPrompt(args: { return parts.join('\n') } -export function registerFromBrief(server: McpServer, _bridge: SceneBridge): void { +export function registerFromBrief(server: McpServer, _bridge: SceneOperations): void { server.registerPrompt( 'from_brief', { diff --git a/packages/mcp/src/prompts/index.ts b/packages/mcp/src/prompts/index.ts index e65d2d70..4f484fbc 100644 --- a/packages/mcp/src/prompts/index.ts +++ b/packages/mcp/src/prompts/index.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { registerFromBrief } from './from-brief' import { registerIterateOnFeedback } from './iterate-on-feedback' import { registerRenovationFromPhotos } from './renovation-from-photos' @@ -10,8 +10,8 @@ import { registerRenovationFromPhotos } from './renovation-from-photos' * - `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) +export function registerPrompts(server: McpServer, operations: SceneOperations): void { + registerFromBrief(server, operations) + registerIterateOnFeedback(server, operations) + registerRenovationFromPhotos(server, operations) } diff --git a/packages/mcp/src/prompts/iterate-on-feedback.ts b/packages/mcp/src/prompts/iterate-on-feedback.ts index 80de7192..5a465b62 100644 --- a/packages/mcp/src/prompts/iterate-on-feedback.ts +++ b/packages/mcp/src/prompts/iterate-on-feedback.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' const PREAMBLE = [ @@ -25,7 +25,7 @@ export function buildIterateOnFeedbackPrompt(args: { feedback: string }): string return [PREAMBLE, '', '## User feedback', args.feedback.trim()].join('\n') } -export function registerIterateOnFeedback(server: McpServer, _bridge: SceneBridge): void { +export function registerIterateOnFeedback(server: McpServer, _bridge: SceneOperations): void { server.registerPrompt( 'iterate_on_feedback', { diff --git a/packages/mcp/src/prompts/renovation-from-photos.ts b/packages/mcp/src/prompts/renovation-from-photos.ts index b177ccd5..c80fa512 100644 --- a/packages/mcp/src/prompts/renovation-from-photos.ts +++ b/packages/mcp/src/prompts/renovation-from-photos.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' const PREAMBLE = [ 'You are renovating an existing room based on photos of the current space and reference photos of the target aesthetic.', @@ -138,7 +138,7 @@ export function buildRenovationMessages(args: { return messages } -export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneBridge): void { +export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneOperations): void { server.registerPrompt( 'renovation_from_photos', { diff --git a/packages/mcp/src/resources/agent-guide.ts b/packages/mcp/src/resources/agent-guide.ts index 5705b9ab..6200e9f2 100644 --- a/packages/mcp/src/resources/agent-guide.ts +++ b/packages/mcp/src/resources/agent-guide.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' export const AGENT_GUIDE = [ '# Pascal MCP agent guide', @@ -46,7 +46,7 @@ export const AGENT_GUIDE = [ '- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.', ].join('\n') -export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void { +export function registerAgentGuide(server: McpServer, _bridge: SceneOperations): void { server.registerResource( 'agent-guide', 'pascal://agent/guide', diff --git a/packages/mcp/src/resources/catalog-items.ts b/packages/mcp/src/resources/catalog-items.ts index ba697c36..86929322 100644 --- a/packages/mcp/src/resources/catalog-items.ts +++ b/packages/mcp/src/resources/catalog-items.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog' /** @@ -8,7 +8,7 @@ import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog' * The editor UI owns the full catalog. MCP intentionally keeps a dependency-free * subset so headless agents can still place realistic furniture and fixtures. */ -export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void { +export function registerCatalogItems(server: McpServer, _bridge: SceneOperations): void { server.registerResource( 'catalog-items', 'pascal://catalog/items', diff --git a/packages/mcp/src/resources/constraints.ts b/packages/mcp/src/resources/constraints.ts index fa586b35..c4047c93 100644 --- a/packages/mcp/src/resources/constraints.ts +++ b/packages/mcp/src/resources/constraints.ts @@ -2,7 +2,7 @@ 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' +import type { SceneOperations } from '../operations' type WallFootprint = { wallId: string @@ -36,7 +36,10 @@ const EMPTY_MITER_DATA: Parameters[1] = { junctions: new Map(), } -function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload | ConstraintsError { +function buildPayload( + bridge: SceneOperations, + levelId: string, +): ConstraintsPayload | ConstraintsError { const level = bridge.getNode(levelId as never) if (!level || level.type !== 'level') { return { @@ -72,7 +75,7 @@ function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload * 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 { +export function registerConstraints(server: McpServer, bridge: SceneOperations): void { server.registerResource( 'constraints', new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }), diff --git a/packages/mcp/src/resources/index.ts b/packages/mcp/src/resources/index.ts index ac2436a9..31c5fc0f 100644 --- a/packages/mcp/src/resources/index.ts +++ b/packages/mcp/src/resources/index.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { registerAgentGuide } from './agent-guide' import { registerCatalogItems } from './catalog-items' import { registerConstraints } from './constraints' @@ -16,10 +16,10 @@ import { registerSceneSummary } from './scene-summary' * - `pascal://constraints/{levelId}` — application/json, per-level constraints * - `pascal://agent/guide` — text/markdown, MCP-first construction guide */ -export function registerResources(server: McpServer, bridge: SceneBridge): void { - registerAgentGuide(server, bridge) - registerSceneCurrent(server, bridge) - registerSceneSummary(server, bridge) - registerCatalogItems(server, bridge) - registerConstraints(server, bridge) +export function registerResources(server: McpServer, operations: SceneOperations): void { + registerAgentGuide(server, operations) + registerSceneCurrent(server, operations) + registerSceneSummary(server, operations) + registerCatalogItems(server, operations) + registerConstraints(server, operations) } diff --git a/packages/mcp/src/resources/scene-current.ts b/packages/mcp/src/resources/scene-current.ts index 85be0208..0ef3f5a8 100644 --- a/packages/mcp/src/resources/scene-current.ts +++ b/packages/mcp/src/resources/scene-current.ts @@ -1,12 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' /** * `pascal://scene/current` — full `{ nodes, rootNodeIds, collections }` snapshot. * * Static URI (not a template). MIME `application/json`. */ -export function registerSceneCurrent(server: McpServer, bridge: SceneBridge): void { +export function registerSceneCurrent(server: McpServer, bridge: SceneOperations): void { server.registerResource( 'scene-current', 'pascal://scene/current', diff --git a/packages/mcp/src/resources/scene-summary.ts b/packages/mcp/src/resources/scene-summary.ts index b3adc646..9b4bbe4a 100644 --- a/packages/mcp/src/resources/scene-summary.ts +++ b/packages/mcp/src/resources/scene-summary.ts @@ -1,6 +1,6 @@ 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' +import type { SceneOperations } from '../operations' type Poly2D = ReadonlyArray @@ -77,7 +77,9 @@ function countByType(nodes: AnyNode[]): Record { } /** Build the markdown summary. Pure over the SceneGraph snapshot. */ -export function buildSceneSummaryMarkdown(snapshot: ReturnType): string { +export function buildSceneSummaryMarkdown( + snapshot: ReturnType, +): string { const { nodes, rootNodeIds } = snapshot const allNodes = Object.values(nodes) as AnyNode[] @@ -193,7 +195,7 @@ function walkToLevel(node: AnyNode, nodes: Record): string | nu * `pascal://scene/current/summary` — human-readable scene overview. * MIME `text/markdown`. */ -export function registerSceneSummary(server: McpServer, bridge: SceneBridge): void { +export function registerSceneSummary(server: McpServer, bridge: SceneOperations): void { server.registerResource( 'scene-summary', 'pascal://scene/current/summary', diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 49b3b3d0..889d01e1 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,5 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneBridge } from './bridge/scene-bridge' +import { createSceneOperations, type SceneOperations } from './operations' import { registerPrompts } from './prompts' import { registerResources } from './resources' import { createSceneStore } from './storage' @@ -19,6 +20,7 @@ import { registerVisionTools } from './tools/vision' export type CreatePascalMcpServerOptions = { bridge: SceneBridge + operations?: SceneOperations /** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */ store?: SceneStore name?: string @@ -31,10 +33,11 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe version: opts.version ?? '0.1.0', }) const store = opts.store ?? createLazySceneStore() - registerTools(server, opts.bridge, store) - registerVisionTools(server, opts.bridge) - registerResources(server, opts.bridge) - registerPrompts(server, opts.bridge) + const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store }) + registerTools(server, operations) + registerVisionTools(server, operations) + registerResources(server, operations) + registerPrompts(server, operations) return server } diff --git a/packages/mcp/src/tools/apply-patch.ts b/packages/mcp/src/tools/apply-patch.ts index 2b61f677..18f1b708 100644 --- a/packages/mcp/src/tools/apply-patch.ts +++ b/packages/mcp/src/tools/apply-patch.ts @@ -1,8 +1,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { Patch as BridgePatch } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { PatchSchema } from './schemas' @@ -17,11 +17,7 @@ export const applyPatchOutput = { createdIds: z.array(z.string()), } -export function registerApplyPatch( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerApplyPatch(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'apply_patch', { @@ -56,7 +52,7 @@ export function registerApplyPatch( try { const result = bridge.applyPatch(bridgePatches) - await publishLiveSceneSnapshot(bridge, store, 'apply_patch') + await publishLiveSceneSnapshot(bridge, 'apply_patch') const payload = { appliedOps: result.appliedOps, deletedIds: result.deletedIds as unknown as string[], diff --git a/packages/mcp/src/tools/check-collisions.ts b/packages/mcp/src/tools/check-collisions.ts index c1b5da4f..f29a8043 100644 --- a/packages/mcp/src/tools/check-collisions.ts +++ b/packages/mcp/src/tools/check-collisions.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema' import { getScaledDimensions } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { NodeIdSchema } from './schemas' export const checkCollisionsInput = { @@ -38,7 +38,7 @@ function aabbOverlap(a: AABB, b: AABB): boolean { return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ } -export function registerCheckCollisions(server: McpServer, bridge: SceneBridge): void { +export function registerCheckCollisions(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'check_collisions', { diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index 48ad04a9..e4dac3bf 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -4,8 +4,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { LevelNode } from '@pascal-app/core/schema' import { SceneBridge } from '../bridge/scene-bridge' -import { registerSceneQueryTools } from './scene-query' import { registerConstructionTools } from './construction-tools' +import { registerSceneQueryTools } from './scene-query' describe('construction tools', () => { let client: Client diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index fbb30a36..f8ec878a 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -11,8 +11,7 @@ import { WallNode, } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' @@ -107,17 +106,15 @@ function textResult>(payload: T) { } } -function assertNode(bridge: SceneBridge, id: string, type: AnyNode['type']): AnyNode { +function assertNode(bridge: SceneOperations, id: string, type: AnyNode['type']): AnyNode { const node = bridge.getNode(id as AnyNodeId) if (!node) throw new Error(`${type} not found: ${id}`) if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`) return node } -function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId { - const building = bridge - .getAncestry(levelId as AnyNodeId) - .find((node) => node.type === 'building') +function getBuildingIdForLevel(bridge: SceneOperations, levelId: string): AnyNodeId { + const building = bridge.getAncestry(levelId as AnyNodeId).find((node) => node.type === 'building') if (!building) { throw new Error(`Building ancestor not found for level: ${levelId}`) } @@ -134,7 +131,11 @@ function isRoofLevel(level: AnyNode): boolean { ) } -function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLevel: AnyNode): number { +function nextLevelIndex( + bridge: SceneOperations, + buildingId: AnyNodeId, + referenceLevel: AnyNode, +): number { const existing = bridge .getChildren(buildingId) .filter((node): node is AnyNode & { type: 'level' } => node.type === 'level') @@ -144,14 +145,14 @@ function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLev return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate } -function nodesOnLevel(bridge: SceneBridge, levelId: string): AnyNode[] { +function nodesOnLevel(bridge: SceneOperations, levelId: string): AnyNode[] { return Object.values(bridge.getNodes()).filter( (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, ) } function firstNodeOnLevel( - bridge: SceneBridge, + bridge: SceneOperations, levelId: string, type: 'slab' | 'ceiling', ): AnyNode | null { @@ -204,11 +205,7 @@ function withHole( } as Partial } -export function registerConstructionTools( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerConstructionTools(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'create_story_shell', { @@ -285,7 +282,7 @@ export function registerConstructionTools( } const result = bridge.applyPatch(patches) - await publishLiveSceneSnapshot(bridge, store, 'create_story_shell') + await publishLiveSceneSnapshot(bridge, 'create_story_shell') return textResult({ levelId, wallIds, @@ -379,7 +376,7 @@ export function registerConstructionTools( { op: 'create', node: roof, parentId: targetRoofLevelId }, { op: 'create', node: segment, parentId: roof.id as AnyNodeId }, ]) - await publishLiveSceneSnapshot(bridge, store, 'create_roof') + await publishLiveSceneSnapshot(bridge, 'create_roof') return textResult({ referenceLevelId: levelId, roofLevelId: targetRoofLevelId, @@ -500,7 +497,7 @@ export function registerConstructionTools( } bridge.applyPatch(patches) - await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels') + await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels') return textResult({ stairId: stair.id, stairSegmentId: segment.id, diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index 1b099054..ade635b3 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' @@ -19,11 +18,7 @@ export const createLevelOutput = { levelId: z.string(), } -export function registerCreateLevel( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'create_level', { @@ -57,7 +52,7 @@ export function registerCreateLevel( }) const id = bridge.createNode(levelNode, buildingId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'create_level') + await publishLiveSceneSnapshot(bridge, 'create_level') const payload = { levelId: id as string } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/create-wall.test.ts b/packages/mcp/src/tools/create-wall.test.ts index 568af01b..4d8c49ef 100644 --- a/packages/mcp/src/tools/create-wall.test.ts +++ b/packages/mcp/src/tools/create-wall.test.ts @@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { SceneBridge } from '../bridge/scene-bridge' +import { createSceneOperations } from '../operations' import type { SceneMeta, SceneStore } from '../storage/types' import { registerCreateWall } from './create-wall' @@ -97,8 +98,9 @@ describe('create_wall', () => { } const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' }) const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' }) - bridge.setActiveScene(savedMeta) - registerCreateWall(liveServer, bridge, store) + const operations = createSceneOperations({ bridge, store }) + operations.setActiveScene(savedMeta) + registerCreateWall(liveServer, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)]) diff --git a/packages/mcp/src/tools/create-wall.ts b/packages/mcp/src/tools/create-wall.ts index dbea1240..66d6ee5e 100644 --- a/packages/mcp/src/tools/create-wall.ts +++ b/packages/mcp/src/tools/create-wall.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { WallNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema } from './schemas' @@ -20,11 +19,7 @@ export const createWallOutput = { wallId: z.string(), } -export function registerCreateWall( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerCreateWall(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'create_wall', { @@ -64,7 +59,7 @@ export function registerCreateWall( ...(height !== undefined ? { height } : {}), }) const id = bridge.createNode(wall, levelId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'create_wall') + await publishLiveSceneSnapshot(bridge, 'create_wall') const payload = { wallId: id as string } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/cut-opening.ts b/packages/mcp/src/tools/cut-opening.ts index 22f0cf02..86c0cc6b 100644 --- a/packages/mcp/src/tools/cut-opening.ts +++ b/packages/mcp/src/tools/cut-opening.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { DoorNode, WindowNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { wallLength, wallLocalXFromT } from './geometry' import { publishLiveSceneSnapshot } from './live-sync' @@ -21,11 +20,7 @@ export const cutOpeningOutput = { openingId: z.string(), } -export function registerCutOpening( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerCutOpening(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'cut_opening', { @@ -73,7 +68,7 @@ export function registerCutOpening( position: [base.position[0], 0.9 + height / 2, 0], }) const id = bridge.createNode(opening, wallId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'cut_opening') + await publishLiveSceneSnapshot(bridge, 'cut_opening') const payload = { openingId: id as string } return { diff --git a/packages/mcp/src/tools/delete-node.ts b/packages/mcp/src/tools/delete-node.ts index ab7a9991..85d60756 100644 --- a/packages/mcp/src/tools/delete-node.ts +++ b/packages/mcp/src/tools/delete-node.ts @@ -1,8 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' @@ -16,11 +15,7 @@ export const deleteNodeOutput = { deletedIds: z.array(z.string()), } -export function registerDeleteNode( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerDeleteNode(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'delete_node', { @@ -37,7 +32,7 @@ export function registerDeleteNode( } try { const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false) - await publishLiveSceneSnapshot(bridge, store, 'delete_node') + await publishLiveSceneSnapshot(bridge, 'delete_node') const payload = { deletedIds: removed } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts index 319f386d..8be93177 100644 --- a/packages/mcp/src/tools/describe-node.ts +++ b/packages/mcp/src/tools/describe-node.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { NodeIdSchema } from './schemas' @@ -59,7 +59,7 @@ function describe(node: AnyNode): string { } } -export function registerDescribeNode(server: McpServer, bridge: SceneBridge): void { +export function registerDescribeNode(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'describe_node', { diff --git a/packages/mcp/src/tools/duplicate-level.ts b/packages/mcp/src/tools/duplicate-level.ts index 11015909..5c5075aa 100644 --- a/packages/mcp/src/tools/duplicate-level.ts +++ b/packages/mcp/src/tools/duplicate-level.ts @@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { Patch as BridgePatch } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema } from './schemas' @@ -17,11 +17,7 @@ export const duplicateLevelOutput = { newNodeIds: z.array(z.string()), } -export function registerDuplicateLevel( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerDuplicateLevel(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'duplicate_level', { @@ -63,7 +59,7 @@ export function registerDuplicateLevel( }) const result = bridge.applyPatch(patches) - await publishLiveSceneSnapshot(bridge, store, 'duplicate_level') + await publishLiveSceneSnapshot(bridge, 'duplicate_level') const payload = { newLevelId: newLevelId as string, diff --git a/packages/mcp/src/tools/export-glb.ts b/packages/mcp/src/tools/export-glb.ts index bce005c6..97a3bc6f 100644 --- a/packages/mcp/src/tools/export-glb.ts +++ b/packages/mcp/src/tools/export-glb.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' export const exportGlbInput = {} @@ -9,7 +9,7 @@ export const exportGlbOutput = { reason: z.string(), } -export function registerExportGlb(server: McpServer, _bridge: SceneBridge): void { +export function registerExportGlb(server: McpServer, _bridge: SceneOperations): void { server.registerTool( 'export_glb', { diff --git a/packages/mcp/src/tools/export-json.ts b/packages/mcp/src/tools/export-json.ts index 8fd8026e..d2993447 100644 --- a/packages/mcp/src/tools/export-json.ts +++ b/packages/mcp/src/tools/export-json.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' export const exportJsonInput = { pretty: z.boolean().optional(), @@ -10,7 +10,7 @@ export const exportJsonOutput = { json: z.string(), } -export function registerExportJson(server: McpServer, bridge: SceneBridge): void { +export function registerExportJson(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'export_json', { diff --git a/packages/mcp/src/tools/find-nodes.ts b/packages/mcp/src/tools/find-nodes.ts index fa20e8e6..5c0d84b9 100644 --- a/packages/mcp/src/tools/find-nodes.ts +++ b/packages/mcp/src/tools/find-nodes.ts @@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema' import { pointInPolygon } from '@pascal-app/core/spatial-grid' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { NodeIdSchema } from './schemas' const ALL_NODE_TYPES = [ @@ -68,7 +68,7 @@ function getPointForZoneFilter(node: AnyNode): [number, number] | null { return null } -export function registerFindNodes(server: McpServer, bridge: SceneBridge): void { +export function registerFindNodes(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'find_nodes', { diff --git a/packages/mcp/src/tools/get-node.ts b/packages/mcp/src/tools/get-node.ts index cdd9af59..48102c84 100644 --- a/packages/mcp/src/tools/get-node.ts +++ b/packages/mcp/src/tools/get-node.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { NodeIdSchema } from './schemas' @@ -13,7 +13,7 @@ export const getNodeOutput = { node: z.record(z.string(), z.unknown()), } -export function registerGetNode(server: McpServer, bridge: SceneBridge): void { +export function registerGetNode(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'get_node', { diff --git a/packages/mcp/src/tools/get-scene.ts b/packages/mcp/src/tools/get-scene.ts index c6dcd7c5..77f6a856 100644 --- a/packages/mcp/src/tools/get-scene.ts +++ b/packages/mcp/src/tools/get-scene.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' export const getSceneInput = {} @@ -10,7 +10,7 @@ export const getSceneOutput = { collections: z.record(z.string(), z.unknown()).optional(), } -export function registerGetScene(server: McpServer, bridge: SceneBridge): void { +export function registerGetScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'get_scene', { diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index a04d3ea3..9ffd4a0b 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,6 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { registerApplyPatch } from './apply-patch' import { registerCheckCollisions } from './check-collisions' import { registerConstructionTools } from './construction-tools' @@ -34,35 +33,35 @@ import { registerVariantTools } from './variants' * separately via `registerVisionTools` (Agent E). * * Scene-lifecycle tools (save/load/list/delete/rename scene) are registered - * when a `store` is provided; callers that pass `undefined` skip them. + * when persistence operations are available. */ -export function registerTools(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { - registerGetScene(server, bridge) - registerGetNode(server, bridge) - registerDescribeNode(server, bridge) - registerFindNodes(server, bridge) - registerSceneQueryTools(server, bridge) - registerMeasure(server, bridge) - registerConstructionTools(server, bridge, store) - registerRoomTools(server, bridge, store) - registerApplyPatch(server, bridge, store) - registerCreateLevel(server, bridge, store) - registerCreateWall(server, bridge, store) - registerPlaceItem(server, bridge, store) - registerCutOpening(server, bridge, store) - registerSetZone(server, bridge, store) - registerDuplicateLevel(server, bridge, store) - registerDeleteNode(server, bridge, store) - registerUndo(server, bridge, store) - registerRedo(server, bridge, store) - registerExportJson(server, bridge) - registerExportGlb(server, bridge) - registerValidateScene(server, bridge) - registerCheckCollisions(server, bridge) - registerTemplateTools(server, bridge, store) - if (store) { - registerSceneLifecycleTools(server, bridge, store) - registerVariantTools(server, bridge, store) - registerPhotoToSceneTool(server, bridge, store) +export function registerTools(server: McpServer, operations: SceneOperations): void { + registerGetScene(server, operations) + registerGetNode(server, operations) + registerDescribeNode(server, operations) + registerFindNodes(server, operations) + registerSceneQueryTools(server, operations) + registerMeasure(server, operations) + registerConstructionTools(server, operations) + registerRoomTools(server, operations) + registerApplyPatch(server, operations) + registerCreateLevel(server, operations) + registerCreateWall(server, operations) + registerPlaceItem(server, operations) + registerCutOpening(server, operations) + registerSetZone(server, operations) + registerDuplicateLevel(server, operations) + registerDeleteNode(server, operations) + registerUndo(server, operations) + registerRedo(server, operations) + registerExportJson(server, operations) + registerExportGlb(server, operations) + registerValidateScene(server, operations) + registerCheckCollisions(server, operations) + registerTemplateTools(server, operations) + if (operations.hasStore) { + registerSceneLifecycleTools(server, operations) + registerVariantTools(server, operations) + registerPhotoToSceneTool(server, operations) } } diff --git a/packages/mcp/src/tools/live-sync.ts b/packages/mcp/src/tools/live-sync.ts index 8574dc1b..5c441eba 100644 --- a/packages/mcp/src/tools/live-sync.ts +++ b/packages/mcp/src/tools/live-sync.ts @@ -1,13 +1,13 @@ import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings' -import type { SceneBridge } from '../bridge/scene-bridge' -import { type SceneStore, SceneVersionConflictError } from '../storage/types' +import type { SceneOperations } from '../operations' +import { SceneVersionConflictError } from '../storage/types' import { ErrorCode, throwMcpError } from './errors' -export function syncDerivedStairOpenings(bridge: SceneBridge): number { - const updates = syncAutoStairOpenings(bridge.getNodes()) +export function syncDerivedStairOpenings(operations: SceneOperations): number { + const updates = syncAutoStairOpenings(operations.getNodes()) if (updates.length === 0) return 0 - bridge.applyPatch( + operations.applyPatch( updates.map((update) => ({ op: 'update' as const, id: update.id, @@ -23,24 +23,18 @@ export function syncDerivedStairOpenings(bridge: SceneBridge): number { * bound to a saved scene. */ export async function publishLiveSceneSnapshot( - bridge: SceneBridge, - store: SceneStore | undefined, + operations: SceneOperations, kind: string, ): Promise { - syncDerivedStairOpenings(bridge) + syncDerivedStairOpenings(operations) - const active = bridge.getActiveScene() - if (!active || !store?.appendSceneEvent) return + const active = operations.getActiveScene() + if (!active || !operations.canAppendSceneEvents) return - const exported = bridge.exportJSON() - const graph: SceneGraph = { - nodes: exported.nodes, - rootNodeIds: exported.rootNodeIds, - collections: exported.collections as SceneGraph['collections'], - } + const graph = operations.exportSceneGraph() try { - const meta = await store.save({ + const meta = await operations.saveScene({ id: active.id, name: active.name, projectId: active.projectId, @@ -49,8 +43,8 @@ export async function publishLiveSceneSnapshot( graph, expectedVersion: active.version, }) - bridge.setActiveScene(meta) - await store.appendSceneEvent({ + operations.setActiveScene(meta) + await operations.appendSceneEvent({ sceneId: meta.id, version: meta.version, kind, @@ -69,12 +63,12 @@ export async function publishLiveSceneSnapshot( } export async function appendLiveSceneEvent( - store: SceneStore, + operations: SceneOperations, sceneId: string, version: number, kind: string, graph: SceneGraph, ): Promise { - if (!store.appendSceneEvent) return - await store.appendSceneEvent({ sceneId, version, kind, graph }) + if (!operations.canAppendSceneEvents) return + await operations.appendSceneEvent({ sceneId, version, kind, graph }) } diff --git a/packages/mcp/src/tools/measure.ts b/packages/mcp/src/tools/measure.ts index f6668bea..ee4aac4d 100644 --- a/packages/mcp/src/tools/measure.ts +++ b/packages/mcp/src/tools/measure.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { NodeIdSchema } from './schemas' @@ -84,7 +84,7 @@ function shoelaceArea(polygon: Array<[number, number]>): number { return Math.abs(sum) / 2 } -export function registerMeasure(server: McpServer, bridge: SceneBridge): void { +export function registerMeasure(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'measure', { diff --git a/packages/mcp/src/tools/photo-to-scene/index.ts b/packages/mcp/src/tools/photo-to-scene/index.ts index c01ac5a6..3b0bbee6 100644 --- a/packages/mcp/src/tools/photo-to-scene/index.ts +++ b/packages/mcp/src/tools/photo-to-scene/index.ts @@ -1,20 +1,15 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { registerPhotoToScene } from './photo-to-scene' /** * Register the `photo_to_scene` orchestrator tool. Chains the vision * (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph - * synthesis → optional `SceneStore.save` → `bridge.setScene` so callers get a - * navigable Pascal scene from a single photo upload. + * synthesis → optional scene save → bridge setScene so callers get a navigable + * Pascal scene from a single photo upload. */ -export function registerPhotoToSceneTool( - server: McpServer, - bridge: SceneBridge, - store: SceneStore, -): void { - registerPhotoToScene(server, bridge, store) +export function registerPhotoToSceneTool(server: McpServer, bridge: SceneOperations): void { + registerPhotoToScene(server, bridge) } export { diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts index 5a006f08..e2ca4347 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.test.ts @@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { SceneBridge } from '../../bridge/scene-bridge' +import { createSceneOperations } from '../../operations' import { InMemorySceneStore } from '../scene-lifecycle/test-utils' import { registerPhotoToScene } from './photo-to-scene' @@ -22,8 +23,9 @@ async function makeWiredPair(opts: { withSampling: boolean; samplingHandler?: Ha const bridge = new SceneBridge() bridge.setScene({}, []) const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerPhotoToScene(server, bridge, store) + registerPhotoToScene(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index beda468d..16f775ce 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -11,8 +11,7 @@ import { ZoneNode, } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { appendLiveSceneEvent } from '../live-sync' /** @@ -341,11 +340,7 @@ function buildSceneGraphFromVision( } } -export function registerPhotoToScene( - server: McpServer, - bridge: SceneBridge, - store: SceneStore, -): void { +export function registerPhotoToScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'photo_to_scene', { @@ -375,12 +370,12 @@ export function registerPhotoToScene( // 4. Save or return inline. if (save) { - const meta = await store.save({ + const meta = await bridge.saveScene({ name, graph, }) bridge.setActiveScene(meta) - await appendLiveSceneEvent(store, meta.id, meta.version, 'photo_to_scene', graph) + await appendLiveSceneEvent(bridge, meta.id, meta.version, 'photo_to_scene', graph) const payload: { sceneId: string url: string diff --git a/packages/mcp/src/tools/place-item.ts b/packages/mcp/src/tools/place-item.ts index 053fe614..8df7eee1 100644 --- a/packages/mcp/src/tools/place-item.ts +++ b/packages/mcp/src/tools/place-item.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { findCatalogItem } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' import { projectWorldPointToWallLocalX, wallLength } from './geometry' @@ -22,11 +21,7 @@ export const placeItemOutput = { status: z.string().optional(), } -export function registerPlaceItem( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerPlaceItem(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'place_item', { @@ -102,7 +97,7 @@ export function registerPlaceItem( ...wallExtras, }) const id = bridge.createNode(item, parentId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'place_item') + await publishLiveSceneSnapshot(bridge, 'place_item') const payload = { itemId: id as string, status: catalogAsset ? 'ok' : 'catalog_unavailable', diff --git a/packages/mcp/src/tools/redo.ts b/packages/mcp/src/tools/redo.ts index a97aa3b6..b45ffa46 100644 --- a/packages/mcp/src/tools/redo.ts +++ b/packages/mcp/src/tools/redo.ts @@ -1,7 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' export const redoInput = { @@ -12,7 +11,7 @@ export const redoOutput = { redone: z.number(), } -export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { +export function registerRedo(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'redo', { @@ -24,7 +23,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge, store?: Sce }, async ({ steps }) => { const redone = bridge.redo(steps ?? 1) - if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo') + if (redone > 0) await publishLiveSceneSnapshot(bridge, 'redo') const payload = { redone } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/room-tools.ts b/packages/mcp/src/tools/room-tools.ts index 5309c23a..1b50a13c 100644 --- a/packages/mcp/src/tools/room-tools.ts +++ b/packages/mcp/src/tools/room-tools.ts @@ -10,8 +10,7 @@ import { ZoneNode, } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { findCatalogItem, searchCatalogItems } from './asset-catalog' import { ErrorCode, throwMcpError } from './errors' import { @@ -118,7 +117,7 @@ function textResult>(payload: T) { } } -function assertLevel(bridge: SceneBridge, levelId: string): AnyNode { +function assertLevel(bridge: SceneOperations, levelId: string): AnyNode { const level = bridge.getNode(levelId as AnyNodeId) if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`) if (level.type !== 'level') { @@ -138,7 +137,7 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode { return level } -function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wall' } { +function assertWall(bridge: SceneOperations, wallId: string): AnyNode & { type: 'wall' } { const wall = bridge.getNode(wallId as AnyNodeId) if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`) if (wall.type !== 'wall') { @@ -148,7 +147,7 @@ function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wal } function inferRoomGeometry( - bridge: SceneBridge, + bridge: SceneOperations, levelId: string | undefined, polygon: Vec2[] | undefined, zoneId: string | undefined, @@ -385,11 +384,7 @@ export function registerSearchAssets(server: McpServer): void { ) } -export function registerCreateRoom( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerCreateRoom(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'create_room', { @@ -431,7 +426,7 @@ export function registerCreateRoom( parentId: levelId as AnyNodeId, })), ]) - await publishLiveSceneSnapshot(bridge, store, 'create_room') + await publishLiveSceneSnapshot(bridge, 'create_room') return textResult({ zoneId: zone.id, @@ -444,7 +439,7 @@ export function registerCreateRoom( ) } -export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { +export function registerAddDoor(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'add_door', { @@ -475,17 +470,13 @@ export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: ...(swingDirection ? { swingDirection } : {}), }) const id = bridge.createNode(door, wallId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'add_door') + await publishLiveSceneSnapshot(bridge, 'add_door') return textResult({ doorId: id, localX }) }, ) } -export function registerAddWindow( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerAddWindow(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'add_window', { @@ -514,17 +505,13 @@ export function registerAddWindow( height, }) const id = bridge.createNode(windowNode, wallId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'add_window') + await publishLiveSceneSnapshot(bridge, 'add_window') return textResult({ windowId: id, localX, sillHeight }) }, ) } -export function registerFurnishRoom( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'furnish_room', { @@ -582,7 +569,7 @@ export function registerFurnishRoom( parentId: room.levelId as AnyNodeId, })), ) - await publishLiveSceneSnapshot(bridge, store, 'furnish_room') + await publishLiveSceneSnapshot(bridge, 'furnish_room') } return textResult({ @@ -594,14 +581,10 @@ export function registerFurnishRoom( ) } -export function registerRoomTools( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerRoomTools(server: McpServer, bridge: SceneOperations): void { registerSearchAssets(server) - registerCreateRoom(server, bridge, store) - registerAddDoor(server, bridge, store) - registerAddWindow(server, bridge, store) - registerFurnishRoom(server, bridge, store) + registerCreateRoom(server, bridge) + registerAddDoor(server, bridge) + registerAddWindow(server, bridge) + registerFurnishRoom(server, bridge) } diff --git a/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts index 724abb02..f56b788f 100644 --- a/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/delete-scene.test.ts @@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { registerDeleteScene } from './delete-scene' -import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' +import { + createTestSceneOperations, + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from './test-utils' const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } @@ -14,8 +19,9 @@ describe('delete_scene', () => { beforeEach(async () => { store = new InMemorySceneStore() + const { operations } = createTestSceneOperations({ store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerDeleteScene(server, store) + registerDeleteScene(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts b/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts index 3b0fa0a9..5f563bb1 100644 --- a/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/delete-scene.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types' +import type { SceneOperations } from '../../operations' +import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types' import { ErrorCode, throwMcpError } from '../errors' export const deleteSceneInput = { @@ -12,7 +13,7 @@ export const deleteSceneOutput = { deleted: z.boolean(), } -export function registerDeleteScene(server: McpServer, store: SceneStore): void { +export function registerDeleteScene(server: McpServer, operations: SceneOperations): void { server.registerTool( 'delete_scene', { @@ -24,7 +25,7 @@ export function registerDeleteScene(server: McpServer, store: SceneStore): void }, async ({ id, expectedVersion }) => { try { - const deleted = await store.delete(id, { + const deleted = await operations.deleteStoredScene(id, { ...(expectedVersion !== undefined ? { expectedVersion } : {}), }) const payload = { deleted } diff --git a/packages/mcp/src/tools/scene-lifecycle/index.ts b/packages/mcp/src/tools/scene-lifecycle/index.ts index d5325f8d..56ee03c0 100644 --- a/packages/mcp/src/tools/scene-lifecycle/index.ts +++ b/packages/mcp/src/tools/scene-lifecycle/index.ts @@ -1,6 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { registerDeleteScene } from './delete-scene' import { registerListScenes } from './list-scenes' import { registerLoadScene } from './load-scene' @@ -10,19 +9,15 @@ import { registerSaveScene } from './save-scene' /** * Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`, * `list_scenes`, `delete_scene`, `rename_scene`) against the given server. - * All tools operate against the supplied `SceneStore` so tests can inject an - * in-memory implementation. + * All tools operate against shared scene operations so MCP, REST, and future CLI + * entry points share the same storage boundary. */ -export function registerSceneLifecycleTools( - server: McpServer, - bridge: SceneBridge, - store: SceneStore, -): void { - registerSaveScene(server, bridge, store) - registerLoadScene(server, bridge, store) - registerListScenes(server, store) - registerDeleteScene(server, store) - registerRenameScene(server, store) +export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void { + registerSaveScene(server, operations) + registerLoadScene(server, operations) + registerListScenes(server, operations) + registerDeleteScene(server, operations) + registerRenameScene(server, operations) } export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene' diff --git a/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts b/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts index 3350c0e3..3198cea0 100644 --- a/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/list-scenes.test.ts @@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { registerListScenes } from './list-scenes' -import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' +import { + createTestSceneOperations, + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from './test-utils' const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } @@ -14,8 +19,9 @@ describe('list_scenes', () => { beforeEach(async () => { store = new InMemorySceneStore() + const { operations } = createTestSceneOperations({ store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerListScenes(server, store) + registerListScenes(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts b/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts index 6db28474..a9fca695 100644 --- a/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts +++ b/packages/mcp/src/tools/scene-lifecycle/list-scenes.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { ErrorCode, throwMcpError } from '../errors' const DEFAULT_LIMIT = 100 @@ -27,7 +27,7 @@ export const listScenesOutput = { ), } -export function registerListScenes(server: McpServer, store: SceneStore): void { +export function registerListScenes(server: McpServer, operations: SceneOperations): void { server.registerTool( 'list_scenes', { @@ -39,7 +39,7 @@ export function registerListScenes(server: McpServer, store: SceneStore): void { }, async ({ projectId, limit }) => { try { - const scenes = await store.list({ + const scenes = await operations.listScenes({ ...(projectId !== undefined ? { projectId } : {}), limit: limit ?? DEFAULT_LIMIT, }) diff --git a/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts index 2af4f360..d4250f30 100644 --- a/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/load-scene.test.ts @@ -5,7 +5,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { SceneBridge } from '../../bridge/scene-bridge' import { registerLoadScene } from './load-scene' -import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' +import { + createTestSceneOperations, + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from './test-utils' describe('load_scene', () => { let client: Client @@ -17,8 +22,9 @@ describe('load_scene', () => { bridge.setScene({}, []) bridge.loadDefault() store = new InMemorySceneStore() + const { operations } = createTestSceneOperations({ bridge, store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerLoadScene(server, bridge, store) + registerLoadScene(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/scene-lifecycle/load-scene.ts b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts index 0333bc91..497336d6 100644 --- a/packages/mcp/src/tools/scene-lifecycle/load-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/load-scene.ts @@ -1,7 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { ErrorCode, throwMcpError } from '../errors' export const loadSceneInput = { @@ -21,7 +20,7 @@ export const loadSceneOutput = { nodeCount: z.number(), } -export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { +export function registerLoadScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'load_scene', { @@ -32,7 +31,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: outputSchema: loadSceneOutput, }, async ({ id }) => { - const result = await store.load(id) + const result = await bridge.loadStoredScene(id) if (!result) { throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id }) } diff --git a/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts index 22206980..a74ac76b 100644 --- a/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/rename-scene.test.ts @@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { registerRenameScene } from './rename-scene' -import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' +import { + createTestSceneOperations, + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from './test-utils' const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] } @@ -14,8 +19,9 @@ describe('rename_scene', () => { beforeEach(async () => { store = new InMemorySceneStore() + const { operations } = createTestSceneOperations({ store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerRenameScene(server, store) + registerRenameScene(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts b/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts index 315e233f..998b6596 100644 --- a/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/rename-scene.ts @@ -1,6 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types' +import type { SceneOperations } from '../../operations' +import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types' import { ErrorCode, throwMcpError } from '../errors' export const renameSceneInput = { @@ -22,7 +23,7 @@ export const renameSceneOutput = { nodeCount: z.number(), } -export function registerRenameScene(server: McpServer, store: SceneStore): void { +export function registerRenameScene(server: McpServer, operations: SceneOperations): void { server.registerTool( 'rename_scene', { @@ -34,7 +35,7 @@ export function registerRenameScene(server: McpServer, store: SceneStore): void }, async ({ id, newName, expectedVersion }) => { try { - const meta = await store.rename(id, newName, { + const meta = await operations.renameStoredScene(id, newName, { ...(expectedVersion !== undefined ? { expectedVersion } : {}), }) const payload = { diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts index 2b88b635..77300201 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.test.ts @@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { SceneBridge } from '../../bridge/scene-bridge' import { registerSaveScene } from './save-scene' -import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils' +import { + createTestSceneOperations, + InMemorySceneStore, + parseToolText, + type StoredTextContent, +} from './test-utils' describe('save_scene', () => { let client: Client @@ -16,8 +21,9 @@ describe('save_scene', () => { bridge.setScene({}, []) bridge.loadDefault() store = new InMemorySceneStore() + const { operations } = createTestSceneOperations({ bridge, store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerSaveScene(server, bridge, store) + registerSaveScene(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts index 345778ff..32886351 100644 --- a/packages/mcp/src/tools/scene-lifecycle/save-scene.ts +++ b/packages/mcp/src/tools/scene-lifecycle/save-scene.ts @@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { AnyNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' -import { type SceneStore, SceneVersionConflictError } from '../../storage/types' +import type { SceneOperations } from '../../operations' +import { SceneVersionConflictError } from '../../storage/types' import { ErrorCode, throwMcpError } from '../errors' import { appendLiveSceneEvent } from '../live-sync' @@ -39,7 +39,7 @@ export const saveSceneOutput = { url: z.string(), } -export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { +export function registerSaveScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'save_scene', { @@ -56,12 +56,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: if (!validation.valid) { throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors }) } - const exported = bridge.exportJSON() - sceneGraph = { - nodes: exported.nodes, - rootNodeIds: exported.rootNodeIds, - collections: exported.collections as SceneGraph['collections'], - } + sceneGraph = bridge.exportSceneGraph() } else { if (!graph) { throwMcpError( @@ -96,7 +91,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: } try { - const meta = await store.save({ + const meta = await bridge.saveScene({ ...(id !== undefined ? { id } : {}), name, ...(projectId !== undefined ? { projectId } : {}), @@ -104,7 +99,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: ...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}), ...(expectedVersion !== undefined ? { expectedVersion } : {}), }) - await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph) + await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph) if (includeCurrentScene) { bridge.setActiveScene(meta) } diff --git a/packages/mcp/src/tools/scene-lifecycle/test-utils.ts b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts index 3badb8b9..6877cfb0 100644 --- a/packages/mcp/src/tools/scene-lifecycle/test-utils.ts +++ b/packages/mcp/src/tools/scene-lifecycle/test-utils.ts @@ -1,3 +1,5 @@ +import { SceneBridge } from '../../bridge/scene-bridge' +import { createSceneOperations, type SceneOperations } from '../../operations' import { type SceneListOptions, type SceneMeta, @@ -15,6 +17,20 @@ export function parseToolText(content: StoredTextContent[]): Record } +export function createTestSceneOperations(options?: { + bridge?: SceneBridge + store?: InMemorySceneStore +}): { + bridge: SceneBridge + store: InMemorySceneStore + operations: SceneOperations +} { + const bridge = options?.bridge ?? new SceneBridge() + const store = options?.store ?? new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) + return { bridge, store, operations } +} + /** * In-memory `SceneStore` for tests. Backed by a plain `Map` keyed by id. * Implements the full interface including optimistic concurrency via diff --git a/packages/mcp/src/tools/scene-query.test.ts b/packages/mcp/src/tools/scene-query.test.ts index 6b6bd380..f84365cc 100644 --- a/packages/mcp/src/tools/scene-query.test.ts +++ b/packages/mcp/src/tools/scene-query.test.ts @@ -159,7 +159,9 @@ describe('scene query tools', () => { ) expect(listPayload.occupiedStoryCount).toBe(2) expect(listPayload.roofLevelIds).toEqual([roofLevel.id]) - expect(listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id)).toMatchObject({ + expect( + listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id), + ).toMatchObject({ role: 'roof', isSupportLevel: true, referenceLevelId: upper.id, diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index e20592a7..75f9aea9 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' import { distance2D, pointInPolygon, @@ -88,7 +88,7 @@ function textResult>(payload: T) { } } -function getLevels(bridge: SceneBridge): AnyNode[] { +function getLevels(bridge: SceneOperations): AnyNode[] { return bridge.findNodes({ type: 'level' }).sort((a, b) => { const aa = a.type === 'level' ? a.level : 0 const bb = b.type === 'level' ? b.level : 0 @@ -96,13 +96,16 @@ function getLevels(bridge: SceneBridge): AnyNode[] { }) } -function getDefaultLevelId(bridge: SceneBridge, requested?: string | undefined): AnyNodeId | null { +function getDefaultLevelId( + bridge: SceneOperations, + requested?: string | undefined, +): AnyNodeId | null { if (requested) return requested as AnyNodeId const level = getLevels(bridge)[0] return (level?.id as AnyNodeId | undefined) ?? null } -function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] { +function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] { return Object.values(bridge.getNodes()).filter( (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, ) @@ -140,7 +143,7 @@ function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole { return 'occupied' } -function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) { +function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) { return bridge .getChildren(wallId) .filter((child) => child.type === 'door' || child.type === 'window') @@ -153,7 +156,7 @@ function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) { })) } -function wallSummary(bridge: SceneBridge, wall: AnyNode) { +function wallSummary(bridge: SceneOperations, wall: AnyNode) { if (wall.type !== 'wall') return null const length = distance2D(wall.start, wall.end) return { @@ -280,7 +283,10 @@ function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransfor return transforms } -function stairFootprintPolygons(bridge: SceneBridge, stair: AnyNode & { type: 'stair' }): Vec2[][] { +function stairFootprintPolygons( + bridge: SceneOperations, + stair: AnyNode & { type: 'stair' }, +): Vec2[][] { if (stair.stairType === 'curved' || stair.stairType === 'spiral') { const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) return [ @@ -345,7 +351,7 @@ function getLevelNumber( } function targetLevelIdsForStair( - bridge: SceneBridge, + bridge: SceneOperations, stair: AnyNode & { type: 'stair' }, ): AnyNodeId[] { const nodes = bridge.getNodes() @@ -388,7 +394,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean { }) } -function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) { +function levelSummary(bridge: SceneOperations, levelId: AnyNodeId) { const level = bridge.getNode(levelId) if (!level || level.type !== 'level') { throw new Error(`Level not found: ${levelId}`) @@ -454,7 +460,7 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) { } } -export function registerListLevels(server: McpServer, bridge: SceneBridge): void { +export function registerListLevels(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'list_levels', { @@ -497,7 +503,7 @@ export function registerListLevels(server: McpServer, bridge: SceneBridge): void ) } -export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): void { +export function registerGetLevelSummary(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'get_level_summary', { @@ -515,7 +521,7 @@ export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): ) } -export function registerGetWalls(server: McpServer, bridge: SceneBridge): void { +export function registerGetWalls(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'get_walls', { @@ -536,7 +542,7 @@ export function registerGetWalls(server: McpServer, bridge: SceneBridge): void { ) } -export function registerGetZones(server: McpServer, bridge: SceneBridge): void { +export function registerGetZones(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'get_zones', { @@ -557,7 +563,7 @@ export function registerGetZones(server: McpServer, bridge: SceneBridge): void { ) } -export function registerVerifyScene(server: McpServer, bridge: SceneBridge): void { +export function registerVerifyScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'verify_scene', { @@ -817,7 +823,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi ) } -export function registerSceneQueryTools(server: McpServer, bridge: SceneBridge): void { +export function registerSceneQueryTools(server: McpServer, bridge: SceneOperations): void { registerListLevels(server, bridge) registerGetLevelSummary(server, bridge) registerGetWalls(server, bridge) diff --git a/packages/mcp/src/tools/set-zone.ts b/packages/mcp/src/tools/set-zone.ts index f0661c84..ab70db6c 100644 --- a/packages/mcp/src/tools/set-zone.ts +++ b/packages/mcp/src/tools/set-zone.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { AnyNodeId } from '@pascal-app/core/schema' import { ZoneNode } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' import { publishLiveSceneSnapshot } from './live-sync' import { NodeIdSchema, Vec2Schema } from './schemas' @@ -19,7 +18,7 @@ export const setZoneOutput = { zoneId: z.string(), } -export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { +export function registerSetZone(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'set_zone', { @@ -58,7 +57,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: metadata: properties ?? {}, }) const id = bridge.createNode(zone, levelId as AnyNodeId) - await publishLiveSceneSnapshot(bridge, store, 'set_zone') + await publishLiveSceneSnapshot(bridge, 'set_zone') const payload = { zoneId: id as string } return { diff --git a/packages/mcp/src/tools/templates/create-from-template.ts b/packages/mcp/src/tools/templates/create-from-template.ts index f844e666..ca492ddd 100644 --- a/packages/mcp/src/tools/templates/create-from-template.ts +++ b/packages/mcp/src/tools/templates/create-from-template.ts @@ -2,9 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates' import { ErrorCode, throwMcpError } from '../errors' import { appendLiveSceneEvent } from '../live-sync' @@ -22,9 +21,10 @@ export const createFromTemplateInput = { .optional() .describe('Optional display name for the saved scene. Defaults to the template name.'), /** - * When a `SceneStore` is wired into the MCP server, set this flag to `true` - * to immediately save the instantiated template and return its `SceneMeta`. - * When `false` (default) the template is applied to the bridge only. + * When persistence operations are wired into the MCP server, set this flag to + * `true` to immediately save the instantiated template and return its + * `SceneMeta`. When `false` (default) the template is applied to the bridge + * only. */ save: z.boolean().default(false), projectId: z.string().optional(), @@ -54,17 +54,13 @@ export const createFromTemplateOutput = { /** * `create_from_template` — instantiate a seed template into the bridge, and - * optionally persist it via the attached `SceneStore`. + * optionally persist it via the attached scene operations. * * The source template is cloned with fresh ids (`cloneSceneGraph`) so the * deterministic placeholders (`site_empty`, `wall_n`, …) don't collide * across repeated calls or with other scenes. */ -export function registerCreateFromTemplate( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerCreateFromTemplate(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'create_from_template', { @@ -112,7 +108,7 @@ export function registerCreateFromTemplate( } } - if (!store) { + if (!bridge.hasStore) { // Graceful no-store mode: report that save was skipped rather than // erroring — this makes the tool usable in headless bridge-only // deployments (tests, smoke scripts) without crashing. @@ -125,13 +121,13 @@ export function registerCreateFromTemplate( } try { - const meta = await store.save({ + const meta = await bridge.saveScene({ name: name ?? entry.name, ...(projectId !== undefined ? { projectId } : {}), graph: { nodes, rootNodeIds }, }) bridge.setActiveScene(meta) - await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', { + await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', { nodes, rootNodeIds, }) diff --git a/packages/mcp/src/tools/templates/index.ts b/packages/mcp/src/tools/templates/index.ts index dd4f660a..8c138927 100644 --- a/packages/mcp/src/tools/templates/index.ts +++ b/packages/mcp/src/tools/templates/index.ts @@ -1,6 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { registerCreateFromTemplate } from './create-from-template' import { registerListTemplates } from './list-templates' @@ -8,17 +7,12 @@ import { registerListTemplates } from './list-templates' * Register the template MCP tools (`list_templates`, `create_from_template`) * against the given server. * - * `store` is optional: when omitted, `create_from_template` still applies the - * template to the bridge but skips the save step. This makes the tool safe - * to wire into headless bridge-only deployments. + * When persistence operations are unavailable, `create_from_template` still + * applies the template to the bridge but skips the save step. */ -export function registerTemplateTools( - server: McpServer, - bridge: SceneBridge, - store?: SceneStore, -): void { +export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void { registerListTemplates(server) - registerCreateFromTemplate(server, bridge, store) + registerCreateFromTemplate(server, bridge) } export { diff --git a/packages/mcp/src/tools/templates/templates.test.ts b/packages/mcp/src/tools/templates/templates.test.ts index 9d93253f..a69f435a 100644 --- a/packages/mcp/src/tools/templates/templates.test.ts +++ b/packages/mcp/src/tools/templates/templates.test.ts @@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { SceneBridge } from '../../bridge/scene-bridge' +import { createSceneOperations } from '../../operations' import { InMemorySceneStore, parseToolText, @@ -59,8 +60,9 @@ describe('create_from_template', () => { bridge = new SceneBridge() bridge.setScene({}, []) store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerCreateFromTemplate(server, bridge, store) + registerCreateFromTemplate(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) @@ -138,9 +140,10 @@ describe('create_from_template without a store', () => { beforeEach(async () => { bridge = new SceneBridge() bridge.setScene({}, []) + const operations = createSceneOperations({ bridge }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - // No store passed → save should be gracefully skipped. - registerCreateFromTemplate(server, bridge) + // No store in operations → save should be gracefully skipped. + registerCreateFromTemplate(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/undo.ts b/packages/mcp/src/tools/undo.ts index 37005021..9175bd6a 100644 --- a/packages/mcp/src/tools/undo.ts +++ b/packages/mcp/src/tools/undo.ts @@ -1,7 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' -import type { SceneStore } from '../storage/types' +import type { SceneOperations } from '../operations' import { publishLiveSceneSnapshot } from './live-sync' export const undoInput = { @@ -12,7 +11,7 @@ export const undoOutput = { undone: z.number(), } -export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { +export function registerUndo(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'undo', { @@ -24,7 +23,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge, store?: Sce }, async ({ steps }) => { const undone = bridge.undo(steps ?? 1) - if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo') + if (undone > 0) await publishLiveSceneSnapshot(bridge, 'undo') const payload = { undone } return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }], diff --git a/packages/mcp/src/tools/validate-scene.ts b/packages/mcp/src/tools/validate-scene.ts index 1a095aa2..19ae3f77 100644 --- a/packages/mcp/src/tools/validate-scene.ts +++ b/packages/mcp/src/tools/validate-scene.ts @@ -1,6 +1,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { z } from 'zod' -import type { SceneBridge } from '../bridge/scene-bridge' +import type { SceneOperations } from '../operations' export const validateSceneInput = {} @@ -15,7 +15,7 @@ export const validateSceneOutput = { ), } -export function registerValidateScene(server: McpServer, bridge: SceneBridge): void { +export function registerValidateScene(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'validate_scene', { diff --git a/packages/mcp/src/tools/variants/generate-variants.test.ts b/packages/mcp/src/tools/variants/generate-variants.test.ts index 0d9f5f86..d9e29753 100644 --- a/packages/mcp/src/tools/variants/generate-variants.test.ts +++ b/packages/mcp/src/tools/variants/generate-variants.test.ts @@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' import { SceneBridge } from '../../bridge/scene-bridge' +import { createSceneOperations } from '../../operations' import { InMemorySceneStore, parseToolText, @@ -56,8 +57,9 @@ async function setup(): Promise<{ bridge.setScene({}, []) bridge.loadDefault() const store = new InMemorySceneStore() + const operations = createSceneOperations({ bridge, store }) const server = new McpServer({ name: 'test', version: '0.0.0' }) - registerGenerateVariants(server, bridge, store) + registerGenerateVariants(server, operations) const [srvT, cliT] = InMemoryTransport.createLinkedPair() const client = new Client({ name: 'test-client', version: '0.0.0' }) await Promise.all([server.connect(srvT), client.connect(cliT)]) diff --git a/packages/mcp/src/tools/variants/generate-variants.ts b/packages/mcp/src/tools/variants/generate-variants.ts index 60a50078..0ed82ed0 100644 --- a/packages/mcp/src/tools/variants/generate-variants.ts +++ b/packages/mcp/src/tools/variants/generate-variants.ts @@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph' import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { ErrorCode, throwMcpError } from '../errors' import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations' @@ -93,17 +92,13 @@ function countInvalidNodes(graph: SceneGraph): number { return invalid } -export function registerGenerateVariants( - server: McpServer, - bridge: SceneBridge, - store: SceneStore, -): void { +export function registerGenerateVariants(server: McpServer, bridge: SceneOperations): void { server.registerTool( 'generate_variants', { title: 'Generate variants', description: - 'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via the SceneStore and returned with an id + URL; otherwise the graph is returned inline.', + 'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via scene operations and returned with an id + URL; otherwise the graph is returned inline.', inputSchema: generateVariantsInput, outputSchema: generateVariantsOutput, }, @@ -112,19 +107,14 @@ export function registerGenerateVariants( let base: SceneGraph let baseName = 'scene' if (baseSceneId) { - const loaded = await store.load(baseSceneId) + const loaded = await bridge.loadStoredScene(baseSceneId) if (!loaded) { throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId }) } base = loaded.graph baseName = loaded.name } else { - const exported = bridge.exportJSON() - base = { - nodes: exported.nodes, - rootNodeIds: exported.rootNodeIds, - collections: exported.collections as SceneGraph['collections'], - } + base = bridge.exportSceneGraph() } // 2. Seed the RNG. Default seed is a time-ish number so runs vary, but @@ -167,7 +157,7 @@ export function registerGenerateVariants( if (save) { try { - const meta = await store.save({ + const meta = await bridge.saveScene({ name: `${baseName}-variant-${i + 1}`, graph: forked, }) diff --git a/packages/mcp/src/tools/variants/index.ts b/packages/mcp/src/tools/variants/index.ts index 4075cecb..6075e320 100644 --- a/packages/mcp/src/tools/variants/index.ts +++ b/packages/mcp/src/tools/variants/index.ts @@ -1,19 +1,12 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../../bridge/scene-bridge' -import type { SceneStore } from '../../storage/types' +import type { SceneOperations } from '../../operations' import { registerGenerateVariants } from './generate-variants' /** - * Register the variant-generation MCP tools against the given server. Uses the - * supplied `SceneStore` both to load a `baseSceneId` (when provided) and to - * persist variants when `save=true`. + * Register the variant-generation MCP tools against shared scene operations. */ -export function registerVariantTools( - server: McpServer, - bridge: SceneBridge, - store: SceneStore, -): void { - registerGenerateVariants(server, bridge, store) +export function registerVariantTools(server: McpServer, bridge: SceneOperations): void { + registerGenerateVariants(server, bridge) } export { diff --git a/packages/mcp/src/tools/vision/analyze-floorplan-image.ts b/packages/mcp/src/tools/vision/analyze-floorplan-image.ts index 98c7d9f7..d462a1f6 100644 --- a/packages/mcp/src/tools/vision/analyze-floorplan-image.ts +++ b/packages/mcp/src/tools/vision/analyze-floorplan-image.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneOperations } from '../../operations' /** * Input shape for `analyze_floorplan_image`. @@ -118,7 +118,7 @@ function extractText( return texts.join('\n').trim() } -export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneBridge): void { +export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneOperations): void { server.registerTool( 'analyze_floorplan_image', { diff --git a/packages/mcp/src/tools/vision/analyze-room-photo.ts b/packages/mcp/src/tools/vision/analyze-room-photo.ts index a22f2280..bc290c67 100644 --- a/packages/mcp/src/tools/vision/analyze-room-photo.ts +++ b/packages/mcp/src/tools/vision/analyze-room-photo.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneOperations } from '../../operations' /** * Input shape for `analyze_room_photo`. @@ -99,7 +99,7 @@ function extractText( return texts.join('\n').trim() } -export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneBridge): void { +export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneOperations): void { server.registerTool( 'analyze_room_photo', { diff --git a/packages/mcp/src/tools/vision/index.ts b/packages/mcp/src/tools/vision/index.ts index a81c46c0..bc4a88a6 100644 --- a/packages/mcp/src/tools/vision/index.ts +++ b/packages/mcp/src/tools/vision/index.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { SceneBridge } from '../../bridge/scene-bridge' +import type { SceneOperations } from '../../operations' import { registerAnalyzeFloorplanImage } from './analyze-floorplan-image' import { registerAnalyzeRoomPhoto } from './analyze-room-photo' @@ -9,9 +9,9 @@ import { registerAnalyzeRoomPhoto } from './analyze-room-photo' * not advertise `sampling` support, calling either tool returns * `sampling_unavailable`. */ -export function registerVisionTools(server: McpServer, bridge: SceneBridge): void { - registerAnalyzeFloorplanImage(server, bridge) - registerAnalyzeRoomPhoto(server, bridge) +export function registerVisionTools(server: McpServer, operations: SceneOperations): void { + registerAnalyzeFloorplanImage(server, operations) + registerAnalyzeRoomPhoto(server, operations) } export { diff --git a/packages/mcp/src/transports/http.test.ts b/packages/mcp/src/transports/http.test.ts index 69be6eb7..782ac8bf 100644 --- a/packages/mcp/src/transports/http.test.ts +++ b/packages/mcp/src/transports/http.test.ts @@ -62,3 +62,39 @@ test('connectHttp close() stops the server', async () => { } expect(didConnect).toBe(false) }) + +test('connectHttp requires auth when binding a non-loopback host', async () => { + await expect(connectHttp(server, 0, { host: '0.0.0.0' })).rejects.toThrow( + /requires PASCAL_MCP_HTTP_TOKEN/, + ) +}) + +test('connectHttp rejects unauthenticated requests when a token is configured', async () => { + handle = await connectHttp(server, 0, { authToken: 'secret' }) + + const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }) + + expect(response.status).toBe(401) +}) + +test('connectHttp handles allowed CORS preflight', async () => { + handle = await connectHttp(server, 0, { + authToken: 'secret', + allowedOrigins: ['https://app.example'], + }) + + const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, { + method: 'OPTIONS', + headers: { + origin: 'https://app.example', + 'access-control-request-method': 'POST', + }, + }) + + expect(response.status).toBe(204) + expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example') +}) diff --git a/packages/mcp/src/transports/http.ts b/packages/mcp/src/transports/http.ts index 88b7b124..a54dc92f 100644 --- a/packages/mcp/src/transports/http.ts +++ b/packages/mcp/src/transports/http.ts @@ -1,15 +1,38 @@ -import { randomUUID } from 'node:crypto' -import { createServer } from 'node:http' +import { randomUUID, timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +const DEFAULT_HOST = '127.0.0.1' +const DEFAULT_RATE_LIMIT_PER_MINUTE = 120 +const WINDOW_MS = 60_000 +const ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS' +const ALLOWED_HEADERS = + 'authorization, content-type, mcp-session-id, mcp-protocol-version, x-pascal-mcp-token' + export type HttpTransportHandle = { + /** Host interface the server is listening on. */ + host: string /** Port the server is actually listening on (useful when caller passed 0). */ port: number /** Gracefully close the HTTP server and the MCP transport. */ close(): Promise } +export type HttpTransportOptions = { + /** + * Network interface to bind. Defaults to loopback. Binding to a non-loopback + * interface requires an auth token. + */ + host?: string + /** Bearer token for HTTP MCP calls. Defaults to PASCAL_MCP_HTTP_TOKEN. */ + authToken?: string + /** Exact CORS origins allowed to call this transport. Loopback origins are allowed. */ + allowedOrigins?: string[] + /** Per-client request cap per minute. Set <= 0 to disable. */ + rateLimitPerMinute?: number +} + /** * Attach an `McpServer` to a Streamable HTTP transport bound to a local port. * @@ -17,17 +40,36 @@ export type HttpTransportHandle = { * `IncomingMessage`/`ServerResponse` directly via `handleRequest(req, res)`. * A new session ID is generated per connection (stateful mode). * - * Listens on `0.0.0.0:` (pass `0` for an ephemeral port in tests). The + * Listens on `127.0.0.1:` (pass `0` for an ephemeral port in tests). The * returned handle exposes the actual bound port and a `close()` that stops - * the underlying Node HTTP server. + * the underlying Node HTTP server. To bind a public interface, pass `host` and + * configure an auth token. */ -export async function connectHttp(server: McpServer, port: number): Promise { +export async function connectHttp( + server: McpServer, + port: number, + options: HttpTransportOptions = {}, +): Promise { + const host = options.host ?? DEFAULT_HOST + const authToken = options.authToken ?? process.env.PASCAL_MCP_HTTP_TOKEN + if (!isLoopbackHost(host) && !authToken) { + throw new Error( + 'HTTP transport on a non-loopback host requires PASCAL_MCP_HTTP_TOKEN or authToken', + ) + } + const guard = createHttpGuard({ + authToken, + allowedOrigins: options.allowedOrigins ?? envAllowedOrigins(), + rateLimitPerMinute: options.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE, + }) + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }) await server.connect(transport) const httpServer = createServer((req, res) => { + if (!guard(req, res)) return transport.handleRequest(req, res).catch((err) => { // Log to stderr; never touch stdout (stdio transport uses it). console.error('[pascal-mcp] http transport error', err) @@ -52,13 +94,14 @@ export async function connectHttp(server: McpServer, port: number): Promise { await new Promise((resolve, reject) => { @@ -71,3 +114,143 @@ export async function connectHttp(server: McpServer, port: number): Promise boolean { + const buckets = new Map() + const allowedOrigins = new Set( + options.allowedOrigins + .map(normalizeOrigin) + .filter((origin): origin is string => origin !== null), + ) + + return (req, res) => { + const origin = req.headers.origin + if (origin && !isOriginAllowed(origin, req.headers.host, allowedOrigins)) { + sendJson(res, 403, { error: 'origin_not_allowed' }) + return false + } + + applyCors(req, res, allowedOrigins) + + if (req.method === 'OPTIONS') { + res.writeHead(204).end() + return false + } + + const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '/' + if (pathname !== '/mcp') { + sendJson(res, 404, { error: 'not_found' }) + return false + } + + if (options.authToken) { + const supplied = bearerToken(req) ?? headerValue(req.headers['x-pascal-mcp-token']) + if (!supplied || !safeEqual(supplied, options.authToken)) { + sendJson(res, 401, { error: 'unauthorized' }) + return false + } + } + + if (options.rateLimitPerMinute > 0) { + const now = Date.now() + const key = req.socket.remoteAddress ?? 'unknown' + const bucket = buckets.get(key) + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + WINDOW_MS }) + } else { + bucket.count++ + if (bucket.count > options.rateLimitPerMinute) { + res.setHeader('Retry-After', Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))) + sendJson(res, 429, { error: 'rate_limited' }) + return false + } + } + } + + return true + } +} + +function applyCors(req: IncomingMessage, res: ServerResponse, allowedOrigins: Set): void { + const origin = req.headers.origin + if (origin && isOriginAllowed(origin, req.headers.host, allowedOrigins)) { + res.setHeader('Access-Control-Allow-Origin', origin) + res.setHeader('Vary', 'Origin') + } + res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS) + res.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS) + res.setHeader('X-Content-Type-Options', 'nosniff') +} + +function isOriginAllowed( + origin: string, + requestHost: string | undefined, + allowedOrigins: Set, +): boolean { + const normalized = normalizeOrigin(origin) + if (!normalized) return false + const parsed = new URL(normalized) + if (isLoopbackHost(parsed.hostname)) return true + if (requestHost && normalized === normalizeOrigin(`http://${requestHost}`)) return true + if (requestHost && normalized === normalizeOrigin(`https://${requestHost}`)) return true + return allowedOrigins.has(normalized) +} + +function bearerToken(req: IncomingMessage): string | null { + const header = headerValue(req.headers.authorization) + if (!header) return null + const match = header.match(/^Bearer\s+(.+)$/i) + return match?.[1] ?? null +} + +function headerValue(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) return value[0] ?? null + return value ?? null +} + +function sendJson(res: ServerResponse, status: number, payload: unknown): void { + if (!res.hasHeader('Content-Type')) { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + } + res.writeHead(status).end(JSON.stringify(payload)) +} + +function safeEqual(a: string, b: string): boolean { + const aBuffer = Buffer.from(a) + const bBuffer = Buffer.from(b) + if (aBuffer.length !== bBuffer.length) return false + return timingSafeEqual(aBuffer, bBuffer) +} + +function envAllowedOrigins(): string[] { + return (process.env.PASCAL_MCP_HTTP_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) +} + +function normalizeOrigin(origin: string): string | null { + try { + const url = new URL(origin) + return `${url.protocol}//${url.host}`.toLowerCase() + } catch { + return null + } +} + +function isLoopbackHost(host: string): boolean { + const h = stripPort(host).toLowerCase() + return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1' +} + +function stripPort(host: string): string { + if (host.startsWith('[')) { + const end = host.indexOf(']') + return end === -1 ? host : host.slice(1, end) + } + return host.split(':')[0] ?? host +}