fix(mcp): add shared operations and secure scene APIs

This commit is contained in:
Aymeric Rabot
2026-04-27 17:10:25 -04:00
parent 058ee747c9
commit 8dfd1a9429
81 changed files with 1332 additions and 1786 deletions
+12 -1
View File
@@ -6,12 +6,20 @@ on:
paths: paths:
- 'packages/mcp/**' - 'packages/mcp/**'
- 'packages/core/**' - 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock' - 'bun.lock'
- '.github/workflows/mcp-ci.yml'
pull_request: pull_request:
paths: paths:
- 'packages/mcp/**' - 'packages/mcp/**'
- 'packages/core/**' - 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock' - 'bun.lock'
- '.github/workflows/mcp-ci.yml'
jobs: jobs:
ci: ci:
@@ -37,5 +45,8 @@ jobs:
- name: Test mcp - name: Test mcp
run: bun test --cwd packages/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 - 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
+27 -12
View File
@@ -1,5 +1,10 @@
import { NextResponse } from 'next/server' import {
import { getSceneStore } from '@/lib/scene-store-server' guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
@@ -10,17 +15,24 @@ const POLL_MS = 250
const HEARTBEAT_MS = 15_000 const HEARTBEAT_MS = 15_000
const MAX_EVENTS_PER_POLL = 50 const MAX_EVENTS_PER_POLL = 50
export async function GET(request: Request, { params }: RouteParams) { export function OPTIONS(request: Request) {
const { id } = await params return sceneApiPreflight(request)
const store = await getSceneStore() }
if (!store.listSceneEvents) { export async function GET(request: Request, { params }: RouteParams) {
return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 }) 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) { if (!scene) {
return NextResponse.json({ error: 'not_found' }, { status: 404 }) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
} }
const url = new URL(request.url) const url = new URL(request.url)
@@ -61,7 +73,7 @@ export async function GET(request: Request, { params }: RouteParams) {
const poll = async () => { const poll = async () => {
if (closed) return if (closed) return
try { try {
const events = await store.listSceneEvents!(id, { const events = await operations.listSceneEvents(id, {
afterEventId: cursor, afterEventId: cursor,
limit: MAX_EVENTS_PER_POLL, limit: MAX_EVENTS_PER_POLL,
}) })
@@ -90,12 +102,15 @@ export async function GET(request: Request, { params }: RouteParams) {
}, },
}) })
return new Response(stream, { return withSceneApiHeaders(
request,
new Response(stream, {
headers: { headers: {
'Cache-Control': 'no-cache, no-transform', 'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive', Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8', 'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no', 'X-Accel-Buffering': 'no',
}, },
}) }),
)
} }
+61 -33
View File
@@ -1,7 +1,13 @@
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema' 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' export const dynamic = 'force-dynamic'
@@ -19,30 +25,41 @@ const patchSceneSchema = z.object({
expectedVersion: z.number().int().nonnegative().optional(), 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 { id } = await params
const store = await getSceneStore() const operations = await getSceneOperations()
try { try {
const scene = await store.load(id) const scene = await operations.loadStoredScene(id)
if (!scene) { 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}"` }, headers: { ETag: `"${scene.version}"` },
}) })
} catch (error) { } catch (error) {
return handleStoreError(error) return handleStoreError(request, error)
} }
} }
export async function PUT(request: NextRequest, { params }: RouteParams) { export async function PUT(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params const { id } = await params
let body: unknown let body: unknown
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' }, { error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 }, { status: 400 },
) )
@@ -50,7 +67,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
const parsed = putSceneSchema.safeParse(body) const parsed = putSceneSchema.safeParse(body)
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues }, { error: 'invalid_request', details: parsed.error.issues },
{ status: 400 }, { status: 400 },
) )
@@ -59,13 +77,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
const ifMatch = parseIfMatch(request.headers.get('If-Match')) const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const store = await getSceneStore() const operations = await getSceneOperations()
try { try {
const existing = await store.load(id) const existing = await operations.loadStoredScene(id)
if (!existing) { 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, id,
name: parsed.data.name ?? existing.name, name: parsed.data.name ?? existing.name,
projectId: existing.projectId, projectId: existing.projectId,
@@ -75,38 +93,45 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl, parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
expectedVersion: expectedVersion ?? existing.version, expectedVersion: expectedVersion ?? existing.version,
}) })
return NextResponse.json(meta, { return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` }, headers: { ETag: `"${meta.version}"` },
}) })
} catch (error) { } catch (error) {
return handleStoreError(error, { includeCurrentVersionFor: id }) return handleStoreError(request, error, { includeCurrentVersionFor: id })
} }
} }
export async function DELETE(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params const { id } = await params
const ifMatch = parseIfMatch(request.headers.get('If-Match')) const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const store = await getSceneStore() const operations = await getSceneOperations()
try { try {
const removed = await store.delete(id, { expectedVersion: ifMatch }) const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) { 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) { } catch (error) {
return handleStoreError(error, { includeCurrentVersionFor: id }) return handleStoreError(request, error, { includeCurrentVersionFor: id })
} }
} }
export async function PATCH(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params const { id } = await params
let body: unknown let body: unknown
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' }, { error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 }, { status: 400 },
) )
@@ -114,7 +139,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const parsed = patchSceneSchema.safeParse(body) const parsed = patchSceneSchema.safeParse(body)
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues }, { error: 'invalid_request', details: parsed.error.issues },
{ status: 400 }, { status: 400 },
) )
@@ -123,14 +149,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const ifMatch = parseIfMatch(request.headers.get('If-Match')) const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const store = await getSceneStore() const operations = await getSceneOperations()
try { try {
const meta = await store.rename(id, parsed.data.name, { expectedVersion }) const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return NextResponse.json(meta, { return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` }, headers: { ETag: `"${meta.version}"` },
}) })
} catch (error) { } 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( async function handleStoreError(
request: NextRequest,
error: unknown, error: unknown,
opts: { includeCurrentVersionFor?: string } = {}, opts: { includeCurrentVersionFor?: string } = {},
): Promise<NextResponse> { ): Promise<NextResponse> {
@@ -160,14 +187,15 @@ async function handleStoreError(
let currentVersion: number | undefined let currentVersion: number | undefined
if (opts.includeCurrentVersionFor) { if (opts.includeCurrentVersionFor) {
try { try {
const store = await getSceneStore() const operations = await getSceneOperations()
const current = await store.load(opts.includeCurrentVersionFor) const current = await operations.loadStoredScene(opts.includeCurrentVersionFor)
currentVersion = current?.version currentVersion = current?.version
} catch { } catch {
// Best-effort; skip reporting currentVersion on secondary failure. // Best-effort; skip reporting currentVersion on secondary failure.
} }
} }
return NextResponse.json( return sceneApiJson(
request,
currentVersion === undefined currentVersion === undefined
? { error: 'version_conflict' } ? { error: 'version_conflict' }
: { error: 'version_conflict', currentVersion }, : { error: 'version_conflict', currentVersion },
@@ -175,14 +203,14 @@ async function handleStoreError(
) )
} }
if (code === 'not_found') { if (code === 'not_found') {
return NextResponse.json({ error: 'not_found' }, { status: 404 }) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
} }
if (code === 'too_large') { if (code === 'too_large') {
return NextResponse.json({ error: 'too_large' }, { status: 413 }) return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
} }
if (code === 'invalid') { 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' 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 })
} }
+32 -18
View File
@@ -1,7 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server' import type { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema' 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' export const dynamic = 'force-dynamic'
@@ -18,33 +19,45 @@ const listQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).optional(), limit: z.coerce.number().int().positive().max(500).optional(),
}) })
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const url = new URL(request.url) const url = new URL(request.url)
const parsed = listQuerySchema.safeParse({ const parsed = listQuerySchema.safeParse({
projectId: url.searchParams.get('projectId') ?? undefined, projectId: url.searchParams.get('projectId') ?? undefined,
limit: url.searchParams.get('limit') ?? undefined, limit: url.searchParams.get('limit') ?? undefined,
}) })
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues }, { error: 'invalid_request', details: parsed.error.issues },
{ status: 400 }, { status: 400 },
) )
} }
const store = await getSceneStore() const operations = await getSceneOperations()
const scenes = await store.list({ const scenes = await operations.listScenes({
projectId: parsed.data.projectId, projectId: parsed.data.projectId,
limit: parsed.data.limit, limit: parsed.data.limit,
}) })
return NextResponse.json({ scenes }) return sceneApiJson(request, { scenes })
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
let body: unknown let body: unknown
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' }, { error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 }, { status: 400 },
) )
@@ -52,44 +65,45 @@ export async function POST(request: NextRequest) {
const parsed = createSceneSchema.safeParse(body) const parsed = createSceneSchema.safeParse(body)
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues }, { error: 'invalid_request', details: parsed.error.issues },
{ status: 400 }, { status: 400 },
) )
} }
const store = await getSceneStore() const operations = await getSceneOperations()
try { try {
const meta = await store.save({ const meta = await operations.saveScene({
id: parsed.data.id, id: parsed.data.id,
name: parsed.data.name, name: parsed.data.name,
projectId: parsed.data.projectId ?? null, projectId: parsed.data.projectId ?? null,
graph: parsed.data.graph as never, graph: parsed.data.graph as never,
thumbnailUrl: parsed.data.thumbnailUrl ?? null, thumbnailUrl: parsed.data.thumbnailUrl ?? null,
}) })
return NextResponse.json(meta, { return sceneApiJson(request, meta, {
status: 201, status: 201,
headers: { Location: `/scene/${meta.id}` }, headers: { Location: `/scene/${meta.id}` },
}) })
} catch (error) { } 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 const code = (error as { code?: string })?.code
if (code === 'version_conflict') { if (code === 'version_conflict') {
return NextResponse.json({ error: 'version_conflict' }, { status: 409 }) return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
} }
if (code === 'not_found') { if (code === 'not_found') {
return NextResponse.json({ error: 'not_found' }, { status: 404 }) return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
} }
if (code === 'too_large') { if (code === 'too_large') {
return NextResponse.json({ error: 'too_large' }, { status: 413 }) return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
} }
if (code === 'invalid') { 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' 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 })
} }
@@ -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')
})
+180
View File
@@ -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<string, RateBucket>()
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<T extends Response>(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<string> {
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
}
@@ -2,6 +2,12 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test'
describe('getSceneStore', () => { describe('getSceneStore', () => {
beforeEach(() => { beforeEach(() => {
mock.module('@pascal-app/mcp/operations', () => ({
createSceneOperations: ({ store }: { store: unknown }) => ({
__store: store,
hasStore: true,
}),
}))
mock.module('@pascal-app/mcp/storage', () => { mock.module('@pascal-app/mcp/storage', () => {
let callCount = 0 let callCount = 0
return { return {
@@ -55,4 +61,14 @@ describe('getSceneStore', () => {
expect(first).not.toBe(second) 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)
})
}) })
+21 -10
View File
@@ -1,8 +1,4 @@
// TODO: auth — every call in this module currently runs unauthenticated. import type { SceneOperations } from '@pascal-app/mcp/operations'
// 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 { SceneStore } from '@pascal-app/mcp/storage' 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 * dynamically imported — we cache the in-flight promise so concurrent calls
* during a cold start share a single instantiation. * during a cold start share a single instantiation.
*/ */
let cached: Promise<SceneStore> | null = null let cachedStore: Promise<SceneStore> | null = null
let cachedOperations: Promise<SceneOperations> | null = null
export function getSceneStore(): Promise<SceneStore> { export function getSceneStore(): Promise<SceneStore> {
if (!cached) { if (!cachedStore) {
cached = (async () => { cachedStore = (async () => {
const mod = (await import('@pascal-app/mcp/storage')) as { const mod = (await import('@pascal-app/mcp/storage')) as {
createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore> createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore>
} }
return mod.createSceneStore(process.env) return mod.createSceneStore(process.env)
})() })()
} }
return cached return cachedStore
}
export function getSceneOperations(): Promise<SceneOperations> {
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<SceneStore> {
* callers. * callers.
*/ */
export function __resetSceneStoreForTests(): void { export function __resetSceneStoreForTests(): void {
cached = null cachedStore = null
cachedOperations = null
} }
File diff suppressed because it is too large Load Diff
+40 -10
View File
@@ -6,8 +6,6 @@ Integrator review required. Each entry documents:
- **Impact** on existing consumers - **Impact** on existing consumers
- **Reversibility** - **Reversibility**
---
## 1. `packages/core/package.json` — added subpath exports ## 1. `packages/core/package.json` — added subpath exports
### What ### What
@@ -79,15 +77,15 @@ Align `SiteNode.children` to `z.array(z.string())` + migration in `setScene.migr
### What ### 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 ### 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 ### 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 ### 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 ### 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 ### 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`. 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 ### 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 ### Related
- `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency so the subpath resolves. - `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. - `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. 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`.
---
+6 -6
View File
@@ -78,9 +78,9 @@ Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/edito
│ └────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌──────────── SceneBridge + SceneStore ───────────────┐ │ │ ┌─────────────── SceneOperations ─────────────────────┐ │
│ │ headless Zustand store + Zundo │ │ │ │ shared MCP / REST operation boundary │ │
│ │ local SQLite storage at ~/.pascal/data/pascal.db │ │ │ │ wraps SceneBridge + local SQLite SceneStore │ │
│ │ Zod validation at every boundary │ │ │ │ Zod validation at every boundary │ │
│ └──────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
@@ -147,15 +147,15 @@ and `save_scene`; the scene is openable at `/scene/<id>`.
4. **`loadAssetUrl`/`saveAsset` are browser-only.** Items with `asset://<id>` URLs can't be resolved in Node. Supply absolute URLs or `data:` URIs if you need them usable outside the browser. 4. **`loadAssetUrl`/`saveAsset` are browser-only.** Items with `asset://<id>` 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. 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. 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 ## Cross-cutting changes
Documented in [`packages/mcp/CROSS_CUTTING.md`](./CROSS_CUTTING.md): 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). 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. 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 local scene API routes and pages that read from the same SQLite `SceneStore` as MCP. 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. 4. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2.
## Checklist ## Checklist
+8 -1
View File
@@ -33,12 +33,19 @@ Load an initial scene from disk:
pascal-mcp --stdio --scene ./my-scene.json pascal-mcp --stdio --scene ./my-scene.json
``` ```
Expose it as HTTP for remote hosts: Expose it over loopback HTTP:
```bash ```bash
pascal-mcp --http --port 8787 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 ## Local scene storage
Scenes saved through MCP are stored in a local SQLite database: Scenes saved through MCP are stored in a local SQLite database:
+5
View File
@@ -15,6 +15,11 @@
"types": "./dist/storage/index.d.ts", "types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js", "import": "./dist/storage/index.js",
"default": "./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": { "bin": {
+12 -2
View File
@@ -19,6 +19,9 @@ OPTIONS:
--stdio Use stdio transport (default) --stdio Use stdio transport (default)
--http Use Streamable HTTP transport --http Use Streamable HTTP transport
--port <n> HTTP port (default 3917) --port <n> HTTP port (default 3917)
--host <host> HTTP bind host (default 127.0.0.1)
--auth-token <t> Bearer token required for HTTP calls
--cors-origin <o> Repeatable allowed HTTP CORS origin
--scene <path> Initial scene JSON to load --scene <path> Initial scene JSON to load
--version Print version --version Print version
--help Print this help --help Print this help
@@ -30,6 +33,9 @@ async function main(): Promise<void> {
stdio: { type: 'boolean', default: false }, stdio: { type: 'boolean', default: false },
http: { type: 'boolean', default: false }, http: { type: 'boolean', default: false },
port: { type: 'string', default: '3917' }, 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' }, scene: { type: 'string' },
help: { type: 'boolean', default: false }, help: { type: 'boolean', default: false },
version: { type: 'boolean', default: false }, version: { type: 'boolean', default: false },
@@ -61,8 +67,12 @@ async function main(): Promise<void> {
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) { if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
throw new Error(`invalid --port value: ${values.port}`) throw new Error(`invalid --port value: ${values.port}`)
} }
const handle = await connectHttp(server, portNum) const handle = await connectHttp(server, portNum, {
console.error(`[pascal-mcp] HTTP server listening on :${handle.port}`) 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 () => { const shutdown = async () => {
try { try {
await handle.close() await handle.close()
+1
View File
@@ -1,4 +1,5 @@
export { SceneBridge } from './bridge/scene-bridge' export { SceneBridge } from './bridge/scene-bridge'
export { createSceneOperations, type SceneOperations } from './operations'
export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server' export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server'
export const version = '0.1.0' export const version = '0.1.0'
+5
View File
@@ -0,0 +1,5 @@
export {
type CreateSceneOperationsOptions,
createSceneOperations,
type SceneOperations,
} from './scene-operations'
@@ -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<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void
exportJSON(): SceneGraph & { collections: Record<string, unknown> }
exportSceneGraph(): SceneGraph
loadJSON(json: string | SceneGraph): void
getNode(id: AnyNodeId): AnyNode | null
getNodes(): Record<AnyNodeId, AnyNode>
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<AnyNode>): 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<SceneMeta>
loadStoredScene(id: string): Promise<SceneWithGraph | null>
listScenes(options?: SceneListOptions): Promise<SceneMeta[]>
deleteStoredScene(id: string, options?: SceneMutateOptions): Promise<boolean>
renameStoredScene(id: string, newName: string, options?: SceneMutateOptions): Promise<SceneMeta>
appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent | null>
listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]>
}
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<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]): void {
this.requireBridge().setScene(nodes, rootNodeIds)
}
exportJSON(): SceneGraph & { collections: Record<string, unknown> } {
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<AnyNodeId, AnyNode> {
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<AnyNode>): 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<SceneMeta> {
return this.requireStore().save(options)
}
async loadStoredScene(id: string): Promise<SceneWithGraph | null> {
return this.requireStore().load(id)
}
async listScenes(options?: SceneListOptions): Promise<SceneMeta[]> {
return this.requireStore().list(options)
}
async deleteStoredScene(id: string, options?: SceneMutateOptions): Promise<boolean> {
return this.requireStore().delete(id, options)
}
async renameStoredScene(
id: string,
newName: string,
options?: SceneMutateOptions,
): Promise<SceneMeta> {
return this.requireStore().rename(id, newName, options)
}
async appendSceneEvent(options: SceneEventAppendOptions): Promise<SceneEvent | null> {
const append = this.requireStore().appendSceneEvent
if (!append) return null
return append(options)
}
async listSceneEvents(id: string, options?: SceneEventListOptions): Promise<SceneEvent[]> {
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
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [ const PREAMBLE = [
@@ -37,7 +37,7 @@ export function buildFromBriefPrompt(args: {
return parts.join('\n') return parts.join('\n')
} }
export function registerFromBrief(server: McpServer, _bridge: SceneBridge): void { export function registerFromBrief(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt( server.registerPrompt(
'from_brief', 'from_brief',
{ {
+5 -5
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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 { registerFromBrief } from './from-brief'
import { registerIterateOnFeedback } from './iterate-on-feedback' import { registerIterateOnFeedback } from './iterate-on-feedback'
import { registerRenovationFromPhotos } from './renovation-from-photos' 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 * - `iterate_on_feedback` — minimal-diff patches from user feedback
* - `renovation_from_photos` — photo-driven renovation plan via vision tools * - `renovation_from_photos` — photo-driven renovation plan via vision tools
*/ */
export function registerPrompts(server: McpServer, bridge: SceneBridge): void { export function registerPrompts(server: McpServer, operations: SceneOperations): void {
registerFromBrief(server, bridge) registerFromBrief(server, operations)
registerIterateOnFeedback(server, bridge) registerIterateOnFeedback(server, operations)
registerRenovationFromPhotos(server, bridge) registerRenovationFromPhotos(server, operations)
} }
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance' import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [ const PREAMBLE = [
@@ -25,7 +25,7 @@ export function buildIterateOnFeedbackPrompt(args: { feedback: string }): string
return [PREAMBLE, '', '## User feedback', args.feedback.trim()].join('\n') 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( server.registerPrompt(
'iterate_on_feedback', 'iterate_on_feedback',
{ {
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
const PREAMBLE = [ const PREAMBLE = [
'You are renovating an existing room based on photos of the current space and reference photos of the target aesthetic.', '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 return messages
} }
export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneBridge): void { export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt( server.registerPrompt(
'renovation_from_photos', 'renovation_from_photos',
{ {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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 = [ export const AGENT_GUIDE = [
'# Pascal MCP 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.', '- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.',
].join('\n') ].join('\n')
export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void { export function registerAgentGuide(server: McpServer, _bridge: SceneOperations): void {
server.registerResource( server.registerResource(
'agent-guide', 'agent-guide',
'pascal://agent/guide', 'pascal://agent/guide',
+2 -2
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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' 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 * The editor UI owns the full catalog. MCP intentionally keeps a dependency-free
* subset so headless agents can still place realistic furniture and fixtures. * 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( server.registerResource(
'catalog-items', 'catalog-items',
'pascal://catalog/items', 'pascal://catalog/items',
+6 -3
View File
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ResourceTemplate } 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 type { AnyNode, SlabNode, WallNode } from '@pascal-app/core/schema'
import { getWallPlanFootprint } from '@pascal-app/core/wall' import { getWallPlanFootprint } from '@pascal-app/core/wall'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
type WallFootprint = { type WallFootprint = {
wallId: string wallId: string
@@ -36,7 +36,10 @@ const EMPTY_MITER_DATA: Parameters<typeof getWallPlanFootprint>[1] = {
junctions: new Map(), 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) const level = bridge.getNode(levelId as never)
if (!level || level.type !== 'level') { if (!level || level.type !== 'level') {
return { return {
@@ -72,7 +75,7 @@ function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload
* input hints for agents: slab nodes (with polygons/holes/elevation) + each * input hints for agents: slab nodes (with polygons/holes/elevation) + each
* wall's plan-view footprint polygon. * wall's plan-view footprint polygon.
*/ */
export function registerConstraints(server: McpServer, bridge: SceneBridge): void { export function registerConstraints(server: McpServer, bridge: SceneOperations): void {
server.registerResource( server.registerResource(
'constraints', 'constraints',
new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }), new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }),
+7 -7
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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 { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items' import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints' import { registerConstraints } from './constraints'
@@ -16,10 +16,10 @@ import { registerSceneSummary } from './scene-summary'
* - `pascal://constraints/{levelId}` — application/json, per-level constraints * - `pascal://constraints/{levelId}` — application/json, per-level constraints
* - `pascal://agent/guide` — text/markdown, MCP-first construction guide * - `pascal://agent/guide` — text/markdown, MCP-first construction guide
*/ */
export function registerResources(server: McpServer, bridge: SceneBridge): void { export function registerResources(server: McpServer, operations: SceneOperations): void {
registerAgentGuide(server, bridge) registerAgentGuide(server, operations)
registerSceneCurrent(server, bridge) registerSceneCurrent(server, operations)
registerSceneSummary(server, bridge) registerSceneSummary(server, operations)
registerCatalogItems(server, bridge) registerCatalogItems(server, operations)
registerConstraints(server, bridge) registerConstraints(server, operations)
} }
+2 -2
View File
@@ -1,12 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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. * `pascal://scene/current` — full `{ nodes, rootNodeIds, collections }` snapshot.
* *
* Static URI (not a template). MIME `application/json`. * 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( server.registerResource(
'scene-current', 'scene-current',
'pascal://scene/current', 'pascal://scene/current',
+5 -3
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeType } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeType } from '@pascal-app/core/schema'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
type Poly2D = ReadonlyArray<readonly [number, number]> type Poly2D = ReadonlyArray<readonly [number, number]>
@@ -77,7 +77,9 @@ function countByType(nodes: AnyNode[]): Record<string, number> {
} }
/** Build the markdown summary. Pure over the SceneGraph snapshot. */ /** Build the markdown summary. Pure over the SceneGraph snapshot. */
export function buildSceneSummaryMarkdown(snapshot: ReturnType<SceneBridge['exportJSON']>): string { export function buildSceneSummaryMarkdown(
snapshot: ReturnType<SceneOperations['exportJSON']>,
): string {
const { nodes, rootNodeIds } = snapshot const { nodes, rootNodeIds } = snapshot
const allNodes = Object.values(nodes) as AnyNode[] const allNodes = Object.values(nodes) as AnyNode[]
@@ -193,7 +195,7 @@ function walkToLevel(node: AnyNode, nodes: Record<string, AnyNode>): string | nu
* `pascal://scene/current/summary` — human-readable scene overview. * `pascal://scene/current/summary` — human-readable scene overview.
* MIME `text/markdown`. * MIME `text/markdown`.
*/ */
export function registerSceneSummary(server: McpServer, bridge: SceneBridge): void { export function registerSceneSummary(server: McpServer, bridge: SceneOperations): void {
server.registerResource( server.registerResource(
'scene-summary', 'scene-summary',
'pascal://scene/current/summary', 'pascal://scene/current/summary',
+7 -4
View File
@@ -1,5 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from './bridge/scene-bridge' import type { SceneBridge } from './bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from './operations'
import { registerPrompts } from './prompts' import { registerPrompts } from './prompts'
import { registerResources } from './resources' import { registerResources } from './resources'
import { createSceneStore } from './storage' import { createSceneStore } from './storage'
@@ -19,6 +20,7 @@ import { registerVisionTools } from './tools/vision'
export type CreatePascalMcpServerOptions = { export type CreatePascalMcpServerOptions = {
bridge: SceneBridge bridge: SceneBridge
operations?: SceneOperations
/** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */ /** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */
store?: SceneStore store?: SceneStore
name?: string name?: string
@@ -31,10 +33,11 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe
version: opts.version ?? '0.1.0', version: opts.version ?? '0.1.0',
}) })
const store = opts.store ?? createLazySceneStore() const store = opts.store ?? createLazySceneStore()
registerTools(server, opts.bridge, store) const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store })
registerVisionTools(server, opts.bridge) registerTools(server, operations)
registerResources(server, opts.bridge) registerVisionTools(server, operations)
registerPrompts(server, opts.bridge) registerResources(server, operations)
registerPrompts(server, operations)
return server return server
} }
+4 -8
View File
@@ -1,8 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types' import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { PatchSchema } from './schemas' import { PatchSchema } from './schemas'
@@ -17,11 +17,7 @@ export const applyPatchOutput = {
createdIds: z.array(z.string()), createdIds: z.array(z.string()),
} }
export function registerApplyPatch( export function registerApplyPatch(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'apply_patch', 'apply_patch',
{ {
@@ -56,7 +52,7 @@ export function registerApplyPatch(
try { try {
const result = bridge.applyPatch(bridgePatches) const result = bridge.applyPatch(bridgePatches)
await publishLiveSceneSnapshot(bridge, store, 'apply_patch') await publishLiveSceneSnapshot(bridge, 'apply_patch')
const payload = { const payload = {
appliedOps: result.appliedOps, appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[], deletedIds: result.deletedIds as unknown as string[],
+2 -2
View File
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema' import type { AnyNodeId, ItemNode } from '@pascal-app/core/schema'
import { getScaledDimensions } from '@pascal-app/core/schema' import { getScaledDimensions } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
export const checkCollisionsInput = { 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 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( server.registerTool(
'check_collisions', 'check_collisions',
{ {
@@ -4,8 +4,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LevelNode } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge' import { SceneBridge } from '../bridge/scene-bridge'
import { registerSceneQueryTools } from './scene-query'
import { registerConstructionTools } from './construction-tools' import { registerConstructionTools } from './construction-tools'
import { registerSceneQueryTools } from './scene-query'
describe('construction tools', () => { describe('construction tools', () => {
let client: Client let client: Client
+15 -18
View File
@@ -11,8 +11,7 @@ import {
WallNode, WallNode,
} from '@pascal-app/core/schema' } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas' import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
@@ -107,17 +106,15 @@ function textResult<T extends Record<string, unknown>>(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) const node = bridge.getNode(id as AnyNodeId)
if (!node) throw new Error(`${type} not found: ${id}`) if (!node) throw new Error(`${type} not found: ${id}`)
if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`) if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`)
return node return node
} }
function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId { function getBuildingIdForLevel(bridge: SceneOperations, levelId: string): AnyNodeId {
const building = bridge const building = bridge.getAncestry(levelId as AnyNodeId).find((node) => node.type === 'building')
.getAncestry(levelId as AnyNodeId)
.find((node) => node.type === 'building')
if (!building) { if (!building) {
throw new Error(`Building ancestor not found for level: ${levelId}`) 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 const existing = bridge
.getChildren(buildingId) .getChildren(buildingId)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level') .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 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( return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
) )
} }
function firstNodeOnLevel( function firstNodeOnLevel(
bridge: SceneBridge, bridge: SceneOperations,
levelId: string, levelId: string,
type: 'slab' | 'ceiling', type: 'slab' | 'ceiling',
): AnyNode | null { ): AnyNode | null {
@@ -204,11 +205,7 @@ function withHole(
} as Partial<AnyNode> } as Partial<AnyNode>
} }
export function registerConstructionTools( export function registerConstructionTools(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'create_story_shell', 'create_story_shell',
{ {
@@ -285,7 +282,7 @@ export function registerConstructionTools(
} }
const result = bridge.applyPatch(patches) const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_story_shell') await publishLiveSceneSnapshot(bridge, 'create_story_shell')
return textResult({ return textResult({
levelId, levelId,
wallIds, wallIds,
@@ -379,7 +376,7 @@ export function registerConstructionTools(
{ op: 'create', node: roof, parentId: targetRoofLevelId }, { op: 'create', node: roof, parentId: targetRoofLevelId },
{ op: 'create', node: segment, parentId: roof.id as AnyNodeId }, { op: 'create', node: segment, parentId: roof.id as AnyNodeId },
]) ])
await publishLiveSceneSnapshot(bridge, store, 'create_roof') await publishLiveSceneSnapshot(bridge, 'create_roof')
return textResult({ return textResult({
referenceLevelId: levelId, referenceLevelId: levelId,
roofLevelId: targetRoofLevelId, roofLevelId: targetRoofLevelId,
@@ -500,7 +497,7 @@ export function registerConstructionTools(
} }
bridge.applyPatch(patches) bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels') await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels')
return textResult({ return textResult({
stairId: stair.id, stairId: stair.id,
stairSegmentId: segment.id, stairSegmentId: segment.id,
+3 -8
View File
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { LevelNode } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
@@ -19,11 +18,7 @@ export const createLevelOutput = {
levelId: z.string(), levelId: z.string(),
} }
export function registerCreateLevel( export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'create_level', 'create_level',
{ {
@@ -57,7 +52,7 @@ export function registerCreateLevel(
}) })
const id = bridge.createNode(levelNode, buildingId as AnyNodeId) 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 } const payload = { levelId: id as string }
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }], content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+4 -2
View File
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../bridge/scene-bridge' import { SceneBridge } from '../bridge/scene-bridge'
import { createSceneOperations } from '../operations'
import type { SceneMeta, SceneStore } from '../storage/types' import type { SceneMeta, SceneStore } from '../storage/types'
import { registerCreateWall } from './create-wall' import { registerCreateWall } from './create-wall'
@@ -97,8 +98,9 @@ describe('create_wall', () => {
} }
const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' }) const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' })
const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' }) const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' })
bridge.setActiveScene(savedMeta) const operations = createSceneOperations({ bridge, store })
registerCreateWall(liveServer, bridge, store) operations.setActiveScene(savedMeta)
registerCreateWall(liveServer, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)]) await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)])
+3 -8
View File
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { WallNode } from '@pascal-app/core/schema' import { WallNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas' import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -20,11 +19,7 @@ export const createWallOutput = {
wallId: z.string(), wallId: z.string(),
} }
export function registerCreateWall( export function registerCreateWall(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'create_wall', 'create_wall',
{ {
@@ -64,7 +59,7 @@ export function registerCreateWall(
...(height !== undefined ? { height } : {}), ...(height !== undefined ? { height } : {}),
}) })
const id = bridge.createNode(wall, levelId as AnyNodeId) 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 } const payload = { wallId: id as string }
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }], content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+3 -8
View File
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { DoorNode, WindowNode } from '@pascal-app/core/schema' import { DoorNode, WindowNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { wallLength, wallLocalXFromT } from './geometry' import { wallLength, wallLocalXFromT } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
@@ -21,11 +20,7 @@ export const cutOpeningOutput = {
openingId: z.string(), openingId: z.string(),
} }
export function registerCutOpening( export function registerCutOpening(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'cut_opening', 'cut_opening',
{ {
@@ -73,7 +68,7 @@ export function registerCutOpening(
position: [base.position[0], 0.9 + height / 2, 0], position: [base.position[0], 0.9 + height / 2, 0],
}) })
const id = bridge.createNode(opening, wallId as AnyNodeId) 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 } const payload = { openingId: id as string }
return { return {
+3 -8
View File
@@ -1,8 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
@@ -16,11 +15,7 @@ export const deleteNodeOutput = {
deletedIds: z.array(z.string()), deletedIds: z.array(z.string()),
} }
export function registerDeleteNode( export function registerDeleteNode(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'delete_node', 'delete_node',
{ {
@@ -37,7 +32,7 @@ export function registerDeleteNode(
} }
try { try {
const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false) const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false)
await publishLiveSceneSnapshot(bridge, store, 'delete_node') await publishLiveSceneSnapshot(bridge, 'delete_node')
const payload = { deletedIds: removed } const payload = { deletedIds: removed }
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }], content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas' 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( server.registerTool(
'describe_node', 'describe_node',
{ {
+4 -8
View File
@@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph' import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge' import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types' import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
@@ -17,11 +17,7 @@ export const duplicateLevelOutput = {
newNodeIds: z.array(z.string()), newNodeIds: z.array(z.string()),
} }
export function registerDuplicateLevel( export function registerDuplicateLevel(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'duplicate_level', 'duplicate_level',
{ {
@@ -63,7 +59,7 @@ export function registerDuplicateLevel(
}) })
const result = bridge.applyPatch(patches) const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'duplicate_level') await publishLiveSceneSnapshot(bridge, 'duplicate_level')
const payload = { const payload = {
newLevelId: newLevelId as string, newLevelId: newLevelId as string,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
export const exportGlbInput = {} export const exportGlbInput = {}
@@ -9,7 +9,7 @@ export const exportGlbOutput = {
reason: z.string(), reason: z.string(),
} }
export function registerExportGlb(server: McpServer, _bridge: SceneBridge): void { export function registerExportGlb(server: McpServer, _bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'export_glb', 'export_glb',
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
export const exportJsonInput = { export const exportJsonInput = {
pretty: z.boolean().optional(), pretty: z.boolean().optional(),
@@ -10,7 +10,7 @@ export const exportJsonOutput = {
json: z.string(), json: z.string(),
} }
export function registerExportJson(server: McpServer, bridge: SceneBridge): void { export function registerExportJson(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'export_json', 'export_json',
{ {
+2 -2
View File
@@ -2,7 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId, AnyNodeType } from '@pascal-app/core/schema'
import { pointInPolygon } from '@pascal-app/core/spatial-grid' import { pointInPolygon } from '@pascal-app/core/spatial-grid'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
const ALL_NODE_TYPES = [ const ALL_NODE_TYPES = [
@@ -68,7 +68,7 @@ function getPointForZoneFilter(node: AnyNode): [number, number] | null {
return null return null
} }
export function registerFindNodes(server: McpServer, bridge: SceneBridge): void { export function registerFindNodes(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'find_nodes', 'find_nodes',
{ {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
@@ -13,7 +13,7 @@ export const getNodeOutput = {
node: z.record(z.string(), z.unknown()), 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( server.registerTool(
'get_node', 'get_node',
{ {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
export const getSceneInput = {} export const getSceneInput = {}
@@ -10,7 +10,7 @@ export const getSceneOutput = {
collections: z.record(z.string(), z.unknown()).optional(), 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( server.registerTool(
'get_scene', 'get_scene',
{ {
+30 -31
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { registerApplyPatch } from './apply-patch' import { registerApplyPatch } from './apply-patch'
import { registerCheckCollisions } from './check-collisions' import { registerCheckCollisions } from './check-collisions'
import { registerConstructionTools } from './construction-tools' import { registerConstructionTools } from './construction-tools'
@@ -34,35 +33,35 @@ import { registerVariantTools } from './variants'
* separately via `registerVisionTools` (Agent E). * separately via `registerVisionTools` (Agent E).
* *
* Scene-lifecycle tools (save/load/list/delete/rename scene) are registered * 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 { export function registerTools(server: McpServer, operations: SceneOperations): void {
registerGetScene(server, bridge) registerGetScene(server, operations)
registerGetNode(server, bridge) registerGetNode(server, operations)
registerDescribeNode(server, bridge) registerDescribeNode(server, operations)
registerFindNodes(server, bridge) registerFindNodes(server, operations)
registerSceneQueryTools(server, bridge) registerSceneQueryTools(server, operations)
registerMeasure(server, bridge) registerMeasure(server, operations)
registerConstructionTools(server, bridge, store) registerConstructionTools(server, operations)
registerRoomTools(server, bridge, store) registerRoomTools(server, operations)
registerApplyPatch(server, bridge, store) registerApplyPatch(server, operations)
registerCreateLevel(server, bridge, store) registerCreateLevel(server, operations)
registerCreateWall(server, bridge, store) registerCreateWall(server, operations)
registerPlaceItem(server, bridge, store) registerPlaceItem(server, operations)
registerCutOpening(server, bridge, store) registerCutOpening(server, operations)
registerSetZone(server, bridge, store) registerSetZone(server, operations)
registerDuplicateLevel(server, bridge, store) registerDuplicateLevel(server, operations)
registerDeleteNode(server, bridge, store) registerDeleteNode(server, operations)
registerUndo(server, bridge, store) registerUndo(server, operations)
registerRedo(server, bridge, store) registerRedo(server, operations)
registerExportJson(server, bridge) registerExportJson(server, operations)
registerExportGlb(server, bridge) registerExportGlb(server, operations)
registerValidateScene(server, bridge) registerValidateScene(server, operations)
registerCheckCollisions(server, bridge) registerCheckCollisions(server, operations)
registerTemplateTools(server, bridge, store) registerTemplateTools(server, operations)
if (store) { if (operations.hasStore) {
registerSceneLifecycleTools(server, bridge, store) registerSceneLifecycleTools(server, operations)
registerVariantTools(server, bridge, store) registerVariantTools(server, operations)
registerPhotoToSceneTool(server, bridge, store) registerPhotoToSceneTool(server, operations)
} }
} }
+16 -22
View File
@@ -1,13 +1,13 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings' import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { type SceneStore, SceneVersionConflictError } from '../storage/types' import { SceneVersionConflictError } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
export function syncDerivedStairOpenings(bridge: SceneBridge): number { export function syncDerivedStairOpenings(operations: SceneOperations): number {
const updates = syncAutoStairOpenings(bridge.getNodes()) const updates = syncAutoStairOpenings(operations.getNodes())
if (updates.length === 0) return 0 if (updates.length === 0) return 0
bridge.applyPatch( operations.applyPatch(
updates.map((update) => ({ updates.map((update) => ({
op: 'update' as const, op: 'update' as const,
id: update.id, id: update.id,
@@ -23,24 +23,18 @@ export function syncDerivedStairOpenings(bridge: SceneBridge): number {
* bound to a saved scene. * bound to a saved scene.
*/ */
export async function publishLiveSceneSnapshot( export async function publishLiveSceneSnapshot(
bridge: SceneBridge, operations: SceneOperations,
store: SceneStore | undefined,
kind: string, kind: string,
): Promise<void> { ): Promise<void> {
syncDerivedStairOpenings(bridge) syncDerivedStairOpenings(operations)
const active = bridge.getActiveScene() const active = operations.getActiveScene()
if (!active || !store?.appendSceneEvent) return if (!active || !operations.canAppendSceneEvents) return
const exported = bridge.exportJSON() const graph = operations.exportSceneGraph()
const graph: SceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
try { try {
const meta = await store.save({ const meta = await operations.saveScene({
id: active.id, id: active.id,
name: active.name, name: active.name,
projectId: active.projectId, projectId: active.projectId,
@@ -49,8 +43,8 @@ export async function publishLiveSceneSnapshot(
graph, graph,
expectedVersion: active.version, expectedVersion: active.version,
}) })
bridge.setActiveScene(meta) operations.setActiveScene(meta)
await store.appendSceneEvent({ await operations.appendSceneEvent({
sceneId: meta.id, sceneId: meta.id,
version: meta.version, version: meta.version,
kind, kind,
@@ -69,12 +63,12 @@ export async function publishLiveSceneSnapshot(
} }
export async function appendLiveSceneEvent( export async function appendLiveSceneEvent(
store: SceneStore, operations: SceneOperations,
sceneId: string, sceneId: string,
version: number, version: number,
kind: string, kind: string,
graph: SceneGraph, graph: SceneGraph,
): Promise<void> { ): Promise<void> {
if (!store.appendSceneEvent) return if (!operations.canAppendSceneEvents) return
await store.appendSceneEvent({ sceneId, version, kind, graph }) await operations.appendSceneEvent({ sceneId, version, kind, graph })
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas' import { NodeIdSchema } from './schemas'
@@ -84,7 +84,7 @@ function shoelaceArea(polygon: Array<[number, number]>): number {
return Math.abs(sum) / 2 return Math.abs(sum) / 2
} }
export function registerMeasure(server: McpServer, bridge: SceneBridge): void { export function registerMeasure(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'measure', 'measure',
{ {
+5 -10
View File
@@ -1,20 +1,15 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { registerPhotoToScene } from './photo-to-scene' import { registerPhotoToScene } from './photo-to-scene'
/** /**
* Register the `photo_to_scene` orchestrator tool. Chains the vision * Register the `photo_to_scene` orchestrator tool. Chains the vision
* (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph * (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph
* synthesis → optional `SceneStore.save``bridge.setScene` so callers get a * synthesis → optional scene save → bridge setScene so callers get a navigable
* navigable Pascal scene from a single photo upload. * Pascal scene from a single photo upload.
*/ */
export function registerPhotoToSceneTool( export function registerPhotoToSceneTool(server: McpServer, bridge: SceneOperations): void {
server: McpServer, registerPhotoToScene(server, bridge)
bridge: SceneBridge,
store: SceneStore,
): void {
registerPhotoToScene(server, bridge, store)
} }
export { export {
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js' import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { SceneBridge } from '../../bridge/scene-bridge' import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import { InMemorySceneStore } from '../scene-lifecycle/test-utils' import { InMemorySceneStore } from '../scene-lifecycle/test-utils'
import { registerPhotoToScene } from './photo-to-scene' import { registerPhotoToScene } from './photo-to-scene'
@@ -22,8 +23,9 @@ async function makeWiredPair(opts: { withSampling: boolean; samplingHandler?: Ha
const bridge = new SceneBridge() const bridge = new SceneBridge()
bridge.setScene({}, []) bridge.setScene({}, [])
const store = new InMemorySceneStore() const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerPhotoToScene(server, bridge, store) registerPhotoToScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
@@ -11,8 +11,7 @@ import {
ZoneNode, ZoneNode,
} from '@pascal-app/core/schema' } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { appendLiveSceneEvent } from '../live-sync' import { appendLiveSceneEvent } from '../live-sync'
/** /**
@@ -341,11 +340,7 @@ function buildSceneGraphFromVision(
} }
} }
export function registerPhotoToScene( export function registerPhotoToScene(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
server.registerTool( server.registerTool(
'photo_to_scene', 'photo_to_scene',
{ {
@@ -375,12 +370,12 @@ export function registerPhotoToScene(
// 4. Save or return inline. // 4. Save or return inline.
if (save) { if (save) {
const meta = await store.save({ const meta = await bridge.saveScene({
name, name,
graph, graph,
}) })
bridge.setActiveScene(meta) 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: { const payload: {
sceneId: string sceneId: string
url: string url: string
+3 -8
View File
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { ItemNode } from '@pascal-app/core/schema' import { ItemNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { findCatalogItem } from './asset-catalog' import { findCatalogItem } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { projectWorldPointToWallLocalX, wallLength } from './geometry' import { projectWorldPointToWallLocalX, wallLength } from './geometry'
@@ -22,11 +21,7 @@ export const placeItemOutput = {
status: z.string().optional(), status: z.string().optional(),
} }
export function registerPlaceItem( export function registerPlaceItem(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'place_item', 'place_item',
{ {
@@ -102,7 +97,7 @@ export function registerPlaceItem(
...wallExtras, ...wallExtras,
}) })
const id = bridge.createNode(item, parentId as AnyNodeId) const id = bridge.createNode(item, parentId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'place_item') await publishLiveSceneSnapshot(bridge, 'place_item')
const payload = { const payload = {
itemId: id as string, itemId: id as string,
status: catalogAsset ? 'ok' : 'catalog_unavailable', status: catalogAsset ? 'ok' : 'catalog_unavailable',
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
export const redoInput = { export const redoInput = {
@@ -12,7 +11,7 @@ export const redoOutput = {
redone: z.number(), redone: z.number(),
} }
export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { export function registerRedo(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'redo', 'redo',
{ {
@@ -24,7 +23,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge, store?: Sce
}, },
async ({ steps }) => { async ({ steps }) => {
const redone = bridge.redo(steps ?? 1) const redone = bridge.redo(steps ?? 1)
if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo') if (redone > 0) await publishLiveSceneSnapshot(bridge, 'redo')
const payload = { redone } const payload = { redone }
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }], content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+17 -34
View File
@@ -10,8 +10,7 @@ import {
ZoneNode, ZoneNode,
} from '@pascal-app/core/schema' } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { findCatalogItem, searchCatalogItems } from './asset-catalog' import { findCatalogItem, searchCatalogItems } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { import {
@@ -118,7 +117,7 @@ function textResult<T extends Record<string, unknown>>(payload: T) {
} }
} }
function assertLevel(bridge: SceneBridge, levelId: string): AnyNode { function assertLevel(bridge: SceneOperations, levelId: string): AnyNode {
const level = bridge.getNode(levelId as AnyNodeId) const level = bridge.getNode(levelId as AnyNodeId)
if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`) if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
if (level.type !== 'level') { if (level.type !== 'level') {
@@ -138,7 +137,7 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
return level 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) const wall = bridge.getNode(wallId as AnyNodeId)
if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`) if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`)
if (wall.type !== 'wall') { if (wall.type !== 'wall') {
@@ -148,7 +147,7 @@ function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wal
} }
function inferRoomGeometry( function inferRoomGeometry(
bridge: SceneBridge, bridge: SceneOperations,
levelId: string | undefined, levelId: string | undefined,
polygon: Vec2[] | undefined, polygon: Vec2[] | undefined,
zoneId: string | undefined, zoneId: string | undefined,
@@ -385,11 +384,7 @@ export function registerSearchAssets(server: McpServer): void {
) )
} }
export function registerCreateRoom( export function registerCreateRoom(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'create_room', 'create_room',
{ {
@@ -431,7 +426,7 @@ export function registerCreateRoom(
parentId: levelId as AnyNodeId, parentId: levelId as AnyNodeId,
})), })),
]) ])
await publishLiveSceneSnapshot(bridge, store, 'create_room') await publishLiveSceneSnapshot(bridge, 'create_room')
return textResult({ return textResult({
zoneId: zone.id, 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( server.registerTool(
'add_door', 'add_door',
{ {
@@ -475,17 +470,13 @@ export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?:
...(swingDirection ? { swingDirection } : {}), ...(swingDirection ? { swingDirection } : {}),
}) })
const id = bridge.createNode(door, wallId as AnyNodeId) const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_door') await publishLiveSceneSnapshot(bridge, 'add_door')
return textResult({ doorId: id, localX }) return textResult({ doorId: id, localX })
}, },
) )
} }
export function registerAddWindow( export function registerAddWindow(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'add_window', 'add_window',
{ {
@@ -514,17 +505,13 @@ export function registerAddWindow(
height, height,
}) })
const id = bridge.createNode(windowNode, wallId as AnyNodeId) 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 }) return textResult({ windowId: id, localX, sillHeight })
}, },
) )
} }
export function registerFurnishRoom( export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'furnish_room', 'furnish_room',
{ {
@@ -582,7 +569,7 @@ export function registerFurnishRoom(
parentId: room.levelId as AnyNodeId, parentId: room.levelId as AnyNodeId,
})), })),
) )
await publishLiveSceneSnapshot(bridge, store, 'furnish_room') await publishLiveSceneSnapshot(bridge, 'furnish_room')
} }
return textResult({ return textResult({
@@ -594,14 +581,10 @@ export function registerFurnishRoom(
) )
} }
export function registerRoomTools( export function registerRoomTools(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
registerSearchAssets(server) registerSearchAssets(server)
registerCreateRoom(server, bridge, store) registerCreateRoom(server, bridge)
registerAddDoor(server, bridge, store) registerAddDoor(server, bridge)
registerAddWindow(server, bridge, store) registerAddWindow(server, bridge)
registerFurnishRoom(server, bridge, store) registerFurnishRoom(server, bridge)
} }
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerDeleteScene } from './delete-scene' 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: [] } const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('delete_scene', () => {
beforeEach(async () => { beforeEach(async () => {
store = new InMemorySceneStore() store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDeleteScene(server, store) registerDeleteScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' 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' import { ErrorCode, throwMcpError } from '../errors'
export const deleteSceneInput = { export const deleteSceneInput = {
@@ -12,7 +13,7 @@ export const deleteSceneOutput = {
deleted: z.boolean(), deleted: z.boolean(),
} }
export function registerDeleteScene(server: McpServer, store: SceneStore): void { export function registerDeleteScene(server: McpServer, operations: SceneOperations): void {
server.registerTool( server.registerTool(
'delete_scene', 'delete_scene',
{ {
@@ -24,7 +25,7 @@ export function registerDeleteScene(server: McpServer, store: SceneStore): void
}, },
async ({ id, expectedVersion }) => { async ({ id, expectedVersion }) => {
try { try {
const deleted = await store.delete(id, { const deleted = await operations.deleteStoredScene(id, {
...(expectedVersion !== undefined ? { expectedVersion } : {}), ...(expectedVersion !== undefined ? { expectedVersion } : {}),
}) })
const payload = { deleted } const payload = { deleted }
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { registerDeleteScene } from './delete-scene' import { registerDeleteScene } from './delete-scene'
import { registerListScenes } from './list-scenes' import { registerListScenes } from './list-scenes'
import { registerLoadScene } from './load-scene' import { registerLoadScene } from './load-scene'
@@ -10,19 +9,15 @@ import { registerSaveScene } from './save-scene'
/** /**
* Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`, * Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`,
* `list_scenes`, `delete_scene`, `rename_scene`) against the given server. * `list_scenes`, `delete_scene`, `rename_scene`) against the given server.
* All tools operate against the supplied `SceneStore` so tests can inject an * All tools operate against shared scene operations so MCP, REST, and future CLI
* in-memory implementation. * entry points share the same storage boundary.
*/ */
export function registerSceneLifecycleTools( export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
server: McpServer, registerSaveScene(server, operations)
bridge: SceneBridge, registerLoadScene(server, operations)
store: SceneStore, registerListScenes(server, operations)
): void { registerDeleteScene(server, operations)
registerSaveScene(server, bridge, store) registerRenameScene(server, operations)
registerLoadScene(server, bridge, store)
registerListScenes(server, store)
registerDeleteScene(server, store)
registerRenameScene(server, store)
} }
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene' export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerListScenes } from './list-scenes' 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: [] } const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('list_scenes', () => {
beforeEach(async () => { beforeEach(async () => {
store = new InMemorySceneStore() store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerListScenes(server, store) registerListScenes(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneStore } from '../../storage/types' import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors' import { ErrorCode, throwMcpError } from '../errors'
const DEFAULT_LIMIT = 100 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( server.registerTool(
'list_scenes', 'list_scenes',
{ {
@@ -39,7 +39,7 @@ export function registerListScenes(server: McpServer, store: SceneStore): void {
}, },
async ({ projectId, limit }) => { async ({ projectId, limit }) => {
try { try {
const scenes = await store.list({ const scenes = await operations.listScenes({
...(projectId !== undefined ? { projectId } : {}), ...(projectId !== undefined ? { projectId } : {}),
limit: limit ?? DEFAULT_LIMIT, limit: limit ?? DEFAULT_LIMIT,
}) })
@@ -5,7 +5,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../../bridge/scene-bridge' import { SceneBridge } from '../../bridge/scene-bridge'
import { registerLoadScene } from './load-scene' 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', () => { describe('load_scene', () => {
let client: Client let client: Client
@@ -17,8 +22,9 @@ describe('load_scene', () => {
bridge.setScene({}, []) bridge.setScene({}, [])
bridge.loadDefault() bridge.loadDefault()
store = new InMemorySceneStore() store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerLoadScene(server, bridge, store) registerLoadScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors' import { ErrorCode, throwMcpError } from '../errors'
export const loadSceneInput = { export const loadSceneInput = {
@@ -21,7 +20,7 @@ export const loadSceneOutput = {
nodeCount: z.number(), nodeCount: z.number(),
} }
export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'load_scene', 'load_scene',
{ {
@@ -32,7 +31,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store:
outputSchema: loadSceneOutput, outputSchema: loadSceneOutput,
}, },
async ({ id }) => { async ({ id }) => {
const result = await store.load(id) const result = await bridge.loadStoredScene(id)
if (!result) { if (!result) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id }) throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id })
} }
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerRenameScene } from './rename-scene' 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: [] } const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('rename_scene', () => {
beforeEach(async () => { beforeEach(async () => {
store = new InMemorySceneStore() store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerRenameScene(server, store) registerRenameScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' 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' import { ErrorCode, throwMcpError } from '../errors'
export const renameSceneInput = { export const renameSceneInput = {
@@ -22,7 +23,7 @@ export const renameSceneOutput = {
nodeCount: z.number(), nodeCount: z.number(),
} }
export function registerRenameScene(server: McpServer, store: SceneStore): void { export function registerRenameScene(server: McpServer, operations: SceneOperations): void {
server.registerTool( server.registerTool(
'rename_scene', 'rename_scene',
{ {
@@ -34,7 +35,7 @@ export function registerRenameScene(server: McpServer, store: SceneStore): void
}, },
async ({ id, newName, expectedVersion }) => { async ({ id, newName, expectedVersion }) => {
try { try {
const meta = await store.rename(id, newName, { const meta = await operations.renameStoredScene(id, newName, {
...(expectedVersion !== undefined ? { expectedVersion } : {}), ...(expectedVersion !== undefined ? { expectedVersion } : {}),
}) })
const payload = { const payload = {
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge' import { SceneBridge } from '../../bridge/scene-bridge'
import { registerSaveScene } from './save-scene' 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', () => { describe('save_scene', () => {
let client: Client let client: Client
@@ -16,8 +21,9 @@ describe('save_scene', () => {
bridge.setScene({}, []) bridge.setScene({}, [])
bridge.loadDefault() bridge.loadDefault()
store = new InMemorySceneStore() store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerSaveScene(server, bridge, store) registerSaveScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { AnyNode } from '@pascal-app/core/schema' import { AnyNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import { type SceneStore, SceneVersionConflictError } from '../../storage/types' import { SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors' import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync' import { appendLiveSceneEvent } from '../live-sync'
@@ -39,7 +39,7 @@ export const saveSceneOutput = {
url: z.string(), url: z.string(),
} }
export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void { export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'save_scene', 'save_scene',
{ {
@@ -56,12 +56,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
if (!validation.valid) { if (!validation.valid) {
throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors }) throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors })
} }
const exported = bridge.exportJSON() sceneGraph = bridge.exportSceneGraph()
sceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
} else { } else {
if (!graph) { if (!graph) {
throwMcpError( throwMcpError(
@@ -96,7 +91,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
} }
try { try {
const meta = await store.save({ const meta = await bridge.saveScene({
...(id !== undefined ? { id } : {}), ...(id !== undefined ? { id } : {}),
name, name,
...(projectId !== undefined ? { projectId } : {}), ...(projectId !== undefined ? { projectId } : {}),
@@ -104,7 +99,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}), ...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}), ...(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) { if (includeCurrentScene) {
bridge.setActiveScene(meta) bridge.setActiveScene(meta)
} }
@@ -1,3 +1,5 @@
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from '../../operations'
import { import {
type SceneListOptions, type SceneListOptions,
type SceneMeta, type SceneMeta,
@@ -15,6 +17,20 @@ export function parseToolText(content: StoredTextContent[]): Record<string, unkn
return JSON.parse(content[0]!.text) as Record<string, unknown> return JSON.parse(content[0]!.text) as Record<string, unknown>
} }
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. * In-memory `SceneStore` for tests. Backed by a plain `Map` keyed by id.
* Implements the full interface including optimistic concurrency via * Implements the full interface including optimistic concurrency via
+3 -1
View File
@@ -159,7 +159,9 @@ describe('scene query tools', () => {
) )
expect(listPayload.occupiedStoryCount).toBe(2) expect(listPayload.occupiedStoryCount).toBe(2)
expect(listPayload.roofLevelIds).toEqual([roofLevel.id]) 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', role: 'roof',
isSupportLevel: true, isSupportLevel: true,
referenceLevelId: upper.id, referenceLevelId: upper.id,
+21 -15
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import { import {
distance2D, distance2D,
pointInPolygon, pointInPolygon,
@@ -88,7 +88,7 @@ function textResult<T extends Record<string, unknown>>(payload: T) {
} }
} }
function getLevels(bridge: SceneBridge): AnyNode[] { function getLevels(bridge: SceneOperations): AnyNode[] {
return bridge.findNodes({ type: 'level' }).sort((a, b) => { return bridge.findNodes({ type: 'level' }).sort((a, b) => {
const aa = a.type === 'level' ? a.level : 0 const aa = a.type === 'level' ? a.level : 0
const bb = b.type === 'level' ? b.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 if (requested) return requested as AnyNodeId
const level = getLevels(bridge)[0] const level = getLevels(bridge)[0]
return (level?.id as AnyNodeId | undefined) ?? null 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( return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId, (node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
) )
@@ -140,7 +143,7 @@ function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole {
return 'occupied' return 'occupied'
} }
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) { function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) {
return bridge return bridge
.getChildren(wallId) .getChildren(wallId)
.filter((child) => child.type === 'door' || child.type === 'window') .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 if (wall.type !== 'wall') return null
const length = distance2D(wall.start, wall.end) const length = distance2D(wall.start, wall.end)
return { return {
@@ -280,7 +283,10 @@ function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransfor
return transforms 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') { if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
return [ return [
@@ -345,7 +351,7 @@ function getLevelNumber(
} }
function targetLevelIdsForStair( function targetLevelIdsForStair(
bridge: SceneBridge, bridge: SceneOperations,
stair: AnyNode & { type: 'stair' }, stair: AnyNode & { type: 'stair' },
): AnyNodeId[] { ): AnyNodeId[] {
const nodes = bridge.getNodes() 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) const level = bridge.getNode(levelId)
if (!level || level.type !== 'level') { if (!level || level.type !== 'level') {
throw new Error(`Level not found: ${levelId}`) 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( server.registerTool(
'list_levels', '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( server.registerTool(
'get_level_summary', '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( server.registerTool(
'get_walls', '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( server.registerTool(
'get_zones', '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( server.registerTool(
'verify_scene', '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) registerListLevels(server, bridge)
registerGetLevelSummary(server, bridge) registerGetLevelSummary(server, bridge)
registerGetWalls(server, bridge) registerGetWalls(server, bridge)
+3 -4
View File
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema' import type { AnyNodeId } from '@pascal-app/core/schema'
import { ZoneNode } from '@pascal-app/core/schema' import { ZoneNode } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors' import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas' import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -19,7 +18,7 @@ export const setZoneOutput = {
zoneId: z.string(), zoneId: z.string(),
} }
export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { export function registerSetZone(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'set_zone', 'set_zone',
{ {
@@ -58,7 +57,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?:
metadata: properties ?? {}, metadata: properties ?? {},
}) })
const id = bridge.createNode(zone, levelId as AnyNodeId) 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 } const payload = { zoneId: id as string }
return { return {
@@ -2,9 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph' import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children' 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 { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors' import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync' import { appendLiveSceneEvent } from '../live-sync'
@@ -22,9 +21,10 @@ export const createFromTemplateInput = {
.optional() .optional()
.describe('Optional display name for the saved scene. Defaults to the template name.'), .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` * When persistence operations are wired into the MCP server, set this flag to
* to immediately save the instantiated template and return its `SceneMeta`. * `true` to immediately save the instantiated template and return its
* When `false` (default) the template is applied to the bridge only. * `SceneMeta`. When `false` (default) the template is applied to the bridge
* only.
*/ */
save: z.boolean().default(false), save: z.boolean().default(false),
projectId: z.string().optional(), projectId: z.string().optional(),
@@ -54,17 +54,13 @@ export const createFromTemplateOutput = {
/** /**
* `create_from_template` — instantiate a seed template into the bridge, and * `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 * The source template is cloned with fresh ids (`cloneSceneGraph`) so the
* deterministic placeholders (`site_empty`, `wall_n`, …) don't collide * deterministic placeholders (`site_empty`, `wall_n`, …) don't collide
* across repeated calls or with other scenes. * across repeated calls or with other scenes.
*/ */
export function registerCreateFromTemplate( export function registerCreateFromTemplate(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
server.registerTool( server.registerTool(
'create_from_template', '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 // Graceful no-store mode: report that save was skipped rather than
// erroring — this makes the tool usable in headless bridge-only // erroring — this makes the tool usable in headless bridge-only
// deployments (tests, smoke scripts) without crashing. // deployments (tests, smoke scripts) without crashing.
@@ -125,13 +121,13 @@ export function registerCreateFromTemplate(
} }
try { try {
const meta = await store.save({ const meta = await bridge.saveScene({
name: name ?? entry.name, name: name ?? entry.name,
...(projectId !== undefined ? { projectId } : {}), ...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds }, graph: { nodes, rootNodeIds },
}) })
bridge.setActiveScene(meta) bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', { await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
nodes, nodes,
rootNodeIds, rootNodeIds,
}) })
+5 -11
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { registerCreateFromTemplate } from './create-from-template' import { registerCreateFromTemplate } from './create-from-template'
import { registerListTemplates } from './list-templates' import { registerListTemplates } from './list-templates'
@@ -8,17 +7,12 @@ import { registerListTemplates } from './list-templates'
* Register the template MCP tools (`list_templates`, `create_from_template`) * Register the template MCP tools (`list_templates`, `create_from_template`)
* against the given server. * against the given server.
* *
* `store` is optional: when omitted, `create_from_template` still applies the * When persistence operations are unavailable, `create_from_template` still
* template to the bridge but skips the save step. This makes the tool safe * applies the template to the bridge but skips the save step.
* to wire into headless bridge-only deployments.
*/ */
export function registerTemplateTools( export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
registerListTemplates(server) registerListTemplates(server)
registerCreateFromTemplate(server, bridge, store) registerCreateFromTemplate(server, bridge)
} }
export { export {
@@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge' import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import { import {
InMemorySceneStore, InMemorySceneStore,
parseToolText, parseToolText,
@@ -59,8 +60,9 @@ describe('create_from_template', () => {
bridge = new SceneBridge() bridge = new SceneBridge()
bridge.setScene({}, []) bridge.setScene({}, [])
store = new InMemorySceneStore() store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, bridge, store) registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -138,9 +140,10 @@ describe('create_from_template without a store', () => {
beforeEach(async () => { beforeEach(async () => {
bridge = new SceneBridge() bridge = new SceneBridge()
bridge.setScene({}, []) bridge.setScene({}, [])
const operations = createSceneOperations({ bridge })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
// No store passed → save should be gracefully skipped. // No store in operations → save should be gracefully skipped.
registerCreateFromTemplate(server, bridge) registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' }) client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
import type { SceneStore } from '../storage/types'
import { publishLiveSceneSnapshot } from './live-sync' import { publishLiveSceneSnapshot } from './live-sync'
export const undoInput = { export const undoInput = {
@@ -12,7 +11,7 @@ export const undoOutput = {
undone: z.number(), undone: z.number(),
} }
export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void { export function registerUndo(server: McpServer, bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'undo', 'undo',
{ {
@@ -24,7 +23,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge, store?: Sce
}, },
async ({ steps }) => { async ({ steps }) => {
const undone = bridge.undo(steps ?? 1) const undone = bridge.undo(steps ?? 1)
if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo') if (undone > 0) await publishLiveSceneSnapshot(bridge, 'undo')
const payload = { undone } const payload = { undone }
return { return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }], content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge' import type { SceneOperations } from '../operations'
export const validateSceneInput = {} 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( server.registerTool(
'validate_scene', 'validate_scene',
{ {
@@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { SceneBridge } from '../../bridge/scene-bridge' import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import { import {
InMemorySceneStore, InMemorySceneStore,
parseToolText, parseToolText,
@@ -56,8 +57,9 @@ async function setup(): Promise<{
bridge.setScene({}, []) bridge.setScene({}, [])
bridge.loadDefault() bridge.loadDefault()
const store = new InMemorySceneStore() const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' }) const server = new McpServer({ name: 'test', version: '0.0.0' })
registerGenerateVariants(server, bridge, store) registerGenerateVariants(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair() const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test-client', version: '0.0.0' }) const client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)]) await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -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 { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema' import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors' import { ErrorCode, throwMcpError } from '../errors'
import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations' import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations'
@@ -93,17 +92,13 @@ function countInvalidNodes(graph: SceneGraph): number {
return invalid return invalid
} }
export function registerGenerateVariants( export function registerGenerateVariants(server: McpServer, bridge: SceneOperations): void {
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
server.registerTool( server.registerTool(
'generate_variants', 'generate_variants',
{ {
title: 'Generate variants', title: 'Generate variants',
description: 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, inputSchema: generateVariantsInput,
outputSchema: generateVariantsOutput, outputSchema: generateVariantsOutput,
}, },
@@ -112,19 +107,14 @@ export function registerGenerateVariants(
let base: SceneGraph let base: SceneGraph
let baseName = 'scene' let baseName = 'scene'
if (baseSceneId) { if (baseSceneId) {
const loaded = await store.load(baseSceneId) const loaded = await bridge.loadStoredScene(baseSceneId)
if (!loaded) { if (!loaded) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId }) throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId })
} }
base = loaded.graph base = loaded.graph
baseName = loaded.name baseName = loaded.name
} else { } else {
const exported = bridge.exportJSON() base = bridge.exportSceneGraph()
base = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
} }
// 2. Seed the RNG. Default seed is a time-ish number so runs vary, but // 2. Seed the RNG. Default seed is a time-ish number so runs vary, but
@@ -167,7 +157,7 @@ export function registerGenerateVariants(
if (save) { if (save) {
try { try {
const meta = await store.save({ const meta = await bridge.saveScene({
name: `${baseName}-variant-${i + 1}`, name: `${baseName}-variant-${i + 1}`,
graph: forked, graph: forked,
}) })
+4 -11
View File
@@ -1,19 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
import type { SceneStore } from '../../storage/types'
import { registerGenerateVariants } from './generate-variants' import { registerGenerateVariants } from './generate-variants'
/** /**
* Register the variant-generation MCP tools against the given server. Uses the * Register the variant-generation MCP tools against shared scene operations.
* supplied `SceneStore` both to load a `baseSceneId` (when provided) and to
* persist variants when `save=true`.
*/ */
export function registerVariantTools( export function registerVariantTools(server: McpServer, bridge: SceneOperations): void {
server: McpServer, registerGenerateVariants(server, bridge)
bridge: SceneBridge,
store: SceneStore,
): void {
registerGenerateVariants(server, bridge, store)
} }
export { export {
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
/** /**
* Input shape for `analyze_floorplan_image`. * Input shape for `analyze_floorplan_image`.
@@ -118,7 +118,7 @@ function extractText(
return texts.join('\n').trim() return texts.join('\n').trim()
} }
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneBridge): void { export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'analyze_floorplan_image', 'analyze_floorplan_image',
{ {
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js' import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod' import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge' import type { SceneOperations } from '../../operations'
/** /**
* Input shape for `analyze_room_photo`. * Input shape for `analyze_room_photo`.
@@ -99,7 +99,7 @@ function extractText(
return texts.join('\n').trim() return texts.join('\n').trim()
} }
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneBridge): void { export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneOperations): void {
server.registerTool( server.registerTool(
'analyze_room_photo', 'analyze_room_photo',
{ {
+4 -4
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 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 { registerAnalyzeFloorplanImage } from './analyze-floorplan-image'
import { registerAnalyzeRoomPhoto } from './analyze-room-photo' import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
@@ -9,9 +9,9 @@ import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
* not advertise `sampling` support, calling either tool returns * not advertise `sampling` support, calling either tool returns
* `sampling_unavailable`. * `sampling_unavailable`.
*/ */
export function registerVisionTools(server: McpServer, bridge: SceneBridge): void { export function registerVisionTools(server: McpServer, operations: SceneOperations): void {
registerAnalyzeFloorplanImage(server, bridge) registerAnalyzeFloorplanImage(server, operations)
registerAnalyzeRoomPhoto(server, bridge) registerAnalyzeRoomPhoto(server, operations)
} }
export { export {
+36
View File
@@ -62,3 +62,39 @@ test('connectHttp close() stops the server', async () => {
} }
expect(didConnect).toBe(false) 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')
})
+189 -6
View File
@@ -1,15 +1,38 @@
import { randomUUID } from 'node:crypto' import { randomUUID, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http' import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.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 = { 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 the server is actually listening on (useful when caller passed 0). */
port: number port: number
/** Gracefully close the HTTP server and the MCP transport. */ /** Gracefully close the HTTP server and the MCP transport. */
close(): Promise<void> close(): Promise<void>
} }
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. * 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)`. * `IncomingMessage`/`ServerResponse` directly via `handleRequest(req, res)`.
* A new session ID is generated per connection (stateful mode). * A new session ID is generated per connection (stateful mode).
* *
* Listens on `0.0.0.0:<port>` (pass `0` for an ephemeral port in tests). The * Listens on `127.0.0.1:<port>` (pass `0` for an ephemeral port in tests). The
* returned handle exposes the actual bound port and a `close()` that stops * 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<HttpTransportHandle> { export async function connectHttp(
server: McpServer,
port: number,
options: HttpTransportOptions = {},
): Promise<HttpTransportHandle> {
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({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
}) })
await server.connect(transport) await server.connect(transport)
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {
if (!guard(req, res)) return
transport.handleRequest(req, res).catch((err) => { transport.handleRequest(req, res).catch((err) => {
// Log to stderr; never touch stdout (stdio transport uses it). // Log to stderr; never touch stdout (stdio transport uses it).
console.error('[pascal-mcp] http transport error', err) console.error('[pascal-mcp] http transport error', err)
@@ -52,13 +94,14 @@ export async function connectHttp(server: McpServer, port: number): Promise<Http
} }
httpServer.once('error', onError) httpServer.once('error', onError)
httpServer.once('listening', onListening) httpServer.once('listening', onListening)
httpServer.listen(port) httpServer.listen(port, host)
}) })
const address = httpServer.address() const address = httpServer.address()
const boundPort = typeof address === 'object' && address !== null ? address.port : port const boundPort = typeof address === 'object' && address !== null ? address.port : port
return { return {
host,
port: boundPort, port: boundPort,
close: async () => { close: async () => {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -71,3 +114,143 @@ export async function connectHttp(server: McpServer, port: number): Promise<Http
}, },
} }
} }
function createHttpGuard(options: {
authToken?: string
allowedOrigins: string[]
rateLimitPerMinute: number
}): (req: IncomingMessage, res: ServerResponse) => boolean {
const buckets = new Map<string, { count: number; resetAt: number }>()
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<string>): 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<string>,
): 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
}