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:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
pull_request:
paths:
- 'packages/mcp/**'
- 'packages/core/**'
- 'apps/editor/app/api/scenes/**'
- 'apps/editor/lib/scene-*'
- 'apps/editor/package.json'
- 'bun.lock'
- '.github/workflows/mcp-ci.yml'
jobs:
ci:
@@ -37,5 +45,8 @@ jobs:
- name: Test mcp
run: bun test --cwd packages/mcp
- name: Test editor scene API
run: bun test apps/editor/lib/scene-store-server.test.ts apps/editor/lib/scene-api-security.test.ts
- name: Biome check
run: bunx biome check packages/mcp
run: bunx biome check packages/mcp apps/editor/lib/scene-store-server.ts apps/editor/lib/scene-api-security.ts apps/editor/app/api/scenes
+33 -18
View File
@@ -1,5 +1,10 @@
import { NextResponse } from 'next/server'
import { getSceneStore } from '@/lib/scene-store-server'
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -10,17 +15,24 @@ const POLL_MS = 250
const HEARTBEAT_MS = 15_000
const MAX_EVENTS_PER_POLL = 50
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params
const store = await getSceneStore()
export function OPTIONS(request: Request) {
return sceneApiPreflight(request)
}
if (!store.listSceneEvents) {
return NextResponse.json({ error: 'scene_events_unavailable' }, { status: 501 })
export async function GET(request: Request, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const operations = await getSceneOperations()
if (!operations.canListSceneEvents) {
return sceneApiJson(request, { error: 'scene_events_unavailable' }, { status: 501 })
}
const scene = await store.load(id)
const scene = await operations.loadStoredScene(id)
if (!scene) {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const url = new URL(request.url)
@@ -61,7 +73,7 @@ export async function GET(request: Request, { params }: RouteParams) {
const poll = async () => {
if (closed) return
try {
const events = await store.listSceneEvents!(id, {
const events = await operations.listSceneEvents(id, {
afterEventId: cursor,
limit: MAX_EVENTS_PER_POLL,
})
@@ -90,12 +102,15 @@ export async function GET(request: Request, { params }: RouteParams) {
},
})
return new Response(stream, {
headers: {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no',
},
})
return withSceneApiHeaders(
request,
new Response(stream, {
headers: {
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'Content-Type': 'text/event-stream; charset=utf-8',
'X-Accel-Buffering': 'no',
},
}),
)
}
+61 -33
View File
@@ -1,7 +1,13 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { apiGraphSchema } from '@/lib/graph-schema'
import { getSceneStore } from '@/lib/scene-store-server'
import {
guardSceneApiRequest,
sceneApiJson,
sceneApiPreflight,
withSceneApiHeaders,
} from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
@@ -19,30 +25,41 @@ const patchSceneSchema = z.object({
expectedVersion: z.number().int().nonnegative().optional(),
})
export async function GET(_request: NextRequest, { params }: RouteParams) {
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const store = await getSceneStore()
const operations = await getSceneOperations()
try {
const scene = await store.load(id)
const scene = await operations.loadStoredScene(id)
if (!scene) {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return NextResponse.json(scene, {
return sceneApiJson(request, scene, {
headers: { ETag: `"${scene.version}"` },
})
} catch (error) {
return handleStoreError(error)
return handleStoreError(request, error)
}
}
export async function PUT(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
@@ -50,7 +67,8 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
const parsed = putSceneSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
@@ -59,13 +77,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const store = await getSceneStore()
const operations = await getSceneOperations()
try {
const existing = await store.load(id)
const existing = await operations.loadStoredScene(id)
if (!existing) {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
const meta = await store.save({
const meta = await operations.saveScene({
id,
name: parsed.data.name ?? existing.name,
projectId: existing.projectId,
@@ -75,38 +93,45 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
parsed.data.thumbnailUrl === undefined ? existing.thumbnailUrl : parsed.data.thumbnailUrl,
expectedVersion: expectedVersion ?? existing.version,
})
return NextResponse.json(meta, {
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(error, { includeCurrentVersionFor: id })
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const store = await getSceneStore()
const operations = await getSceneOperations()
try {
const removed = await store.delete(id, { expectedVersion: ifMatch })
const removed = await operations.deleteStoredScene(id, { expectedVersion: ifMatch })
if (!removed) {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
return new NextResponse(null, { status: 204 })
return withSceneApiHeaders(request, new NextResponse(null, { status: 204 }))
} catch (error) {
return handleStoreError(error, { includeCurrentVersionFor: id })
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const { id } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
@@ -114,7 +139,8 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const parsed = patchSceneSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
@@ -123,14 +149,14 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const ifMatch = parseIfMatch(request.headers.get('If-Match'))
const expectedVersion = ifMatch ?? parsed.data.expectedVersion
const store = await getSceneStore()
const operations = await getSceneOperations()
try {
const meta = await store.rename(id, parsed.data.name, { expectedVersion })
return NextResponse.json(meta, {
const meta = await operations.renameStoredScene(id, parsed.data.name, { expectedVersion })
return sceneApiJson(request, meta, {
headers: { ETag: `"${meta.version}"` },
})
} catch (error) {
return handleStoreError(error, { includeCurrentVersionFor: id })
return handleStoreError(request, error, { includeCurrentVersionFor: id })
}
}
@@ -152,6 +178,7 @@ function parseIfMatch(raw: string | null): number | undefined {
}
async function handleStoreError(
request: NextRequest,
error: unknown,
opts: { includeCurrentVersionFor?: string } = {},
): Promise<NextResponse> {
@@ -160,14 +187,15 @@ async function handleStoreError(
let currentVersion: number | undefined
if (opts.includeCurrentVersionFor) {
try {
const store = await getSceneStore()
const current = await store.load(opts.includeCurrentVersionFor)
const operations = await getSceneOperations()
const current = await operations.loadStoredScene(opts.includeCurrentVersionFor)
currentVersion = current?.version
} catch {
// Best-effort; skip reporting currentVersion on secondary failure.
}
}
return NextResponse.json(
return sceneApiJson(
request,
currentVersion === undefined
? { error: 'version_conflict' }
: { error: 'version_conflict', currentVersion },
@@ -175,14 +203,14 @@ async function handleStoreError(
)
}
if (code === 'not_found') {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return NextResponse.json({ error: 'too_large' }, { status: 413 })
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return NextResponse.json({ error: 'invalid' }, { status: 400 })
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return NextResponse.json({ error: 'internal_error', message }, { status: 500 })
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}
+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 { apiGraphSchema } from '@/lib/graph-schema'
import { getSceneStore } from '@/lib/scene-store-server'
import { guardSceneApiRequest, sceneApiJson, sceneApiPreflight } from '@/lib/scene-api-security'
import { getSceneOperations } from '@/lib/scene-store-server'
export const dynamic = 'force-dynamic'
@@ -18,33 +19,45 @@ const listQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).optional(),
})
export function OPTIONS(request: NextRequest) {
return sceneApiPreflight(request)
}
export async function GET(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
const url = new URL(request.url)
const parsed = listQuerySchema.safeParse({
projectId: url.searchParams.get('projectId') ?? undefined,
limit: url.searchParams.get('limit') ?? undefined,
})
if (!parsed.success) {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const store = await getSceneStore()
const scenes = await store.list({
const operations = await getSceneOperations()
const scenes = await operations.listScenes({
projectId: parsed.data.projectId,
limit: parsed.data.limit,
})
return NextResponse.json({ scenes })
return sceneApiJson(request, { scenes })
}
export async function POST(request: NextRequest) {
const guard = guardSceneApiRequest(request)
if (guard) return guard
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: 'body must be valid JSON' },
{ status: 400 },
)
@@ -52,44 +65,45 @@ export async function POST(request: NextRequest) {
const parsed = createSceneSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
return sceneApiJson(
request,
{ error: 'invalid_request', details: parsed.error.issues },
{ status: 400 },
)
}
const store = await getSceneStore()
const operations = await getSceneOperations()
try {
const meta = await store.save({
const meta = await operations.saveScene({
id: parsed.data.id,
name: parsed.data.name,
projectId: parsed.data.projectId ?? null,
graph: parsed.data.graph as never,
thumbnailUrl: parsed.data.thumbnailUrl ?? null,
})
return NextResponse.json(meta, {
return sceneApiJson(request, meta, {
status: 201,
headers: { Location: `/scene/${meta.id}` },
})
} catch (error) {
return handleStoreError(error)
return handleStoreError(request, error)
}
}
function handleStoreError(error: unknown): NextResponse {
function handleStoreError(request: NextRequest, error: unknown): NextResponse {
const code = (error as { code?: string })?.code
if (code === 'version_conflict') {
return NextResponse.json({ error: 'version_conflict' }, { status: 409 })
return sceneApiJson(request, { error: 'version_conflict' }, { status: 409 })
}
if (code === 'not_found') {
return NextResponse.json({ error: 'not_found' }, { status: 404 })
return sceneApiJson(request, { error: 'not_found' }, { status: 404 })
}
if (code === 'too_large') {
return NextResponse.json({ error: 'too_large' }, { status: 413 })
return sceneApiJson(request, { error: 'too_large' }, { status: 413 })
}
if (code === 'invalid') {
return NextResponse.json({ error: 'invalid' }, { status: 400 })
return sceneApiJson(request, { error: 'invalid' }, { status: 400 })
}
const message = error instanceof Error ? error.message : 'unexpected_error'
return NextResponse.json({ error: 'internal_error', message }, { status: 500 })
return sceneApiJson(request, { error: 'internal_error', message }, { status: 500 })
}
@@ -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', () => {
beforeEach(() => {
mock.module('@pascal-app/mcp/operations', () => ({
createSceneOperations: ({ store }: { store: unknown }) => ({
__store: store,
hasStore: true,
}),
}))
mock.module('@pascal-app/mcp/storage', () => {
let callCount = 0
return {
@@ -55,4 +61,14 @@ describe('getSceneStore', () => {
expect(first).not.toBe(second)
})
test('getSceneOperations wraps the cached store', async () => {
const mod = await import('./scene-store-server')
mod.__resetSceneStoreForTests()
const store = await mod.getSceneStore()
const operations = await mod.getSceneOperations()
expect((operations as unknown as { __store: unknown }).__store).toBe(store)
})
})
+21 -10
View File
@@ -1,8 +1,4 @@
// TODO: auth — every call in this module currently runs unauthenticated.
// v0.1 is scoped to local-first use on a developer machine. A hosted editor
// should pass request-scoped user context through this factory before exposing
// these routes publicly.
import type { SceneOperations } from '@pascal-app/mcp/operations'
import type { SceneStore } from '@pascal-app/mcp/storage'
/**
@@ -10,18 +6,32 @@ import type { SceneStore } from '@pascal-app/mcp/storage'
* dynamically imported — we cache the in-flight promise so concurrent calls
* during a cold start share a single instantiation.
*/
let cached: Promise<SceneStore> | null = null
let cachedStore: Promise<SceneStore> | null = null
let cachedOperations: Promise<SceneOperations> | null = null
export function getSceneStore(): Promise<SceneStore> {
if (!cached) {
cached = (async () => {
if (!cachedStore) {
cachedStore = (async () => {
const mod = (await import('@pascal-app/mcp/storage')) as {
createSceneStore: (env?: NodeJS.ProcessEnv) => Promise<SceneStore>
}
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.
*/
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
- **Reversibility**
---
## 1. `packages/core/package.json` — added subpath exports
### What
@@ -79,15 +77,15 @@ Align `SiteNode.children` to `z.array(z.string())` + migration in `setScene.migr
### What
Adds a CI workflow that runs on pushes to `main` and on pull requests touching `packages/mcp/`, `packages/core/`, or `bun.lock`. The job installs deps with Bun, builds `@pascal-app/core` then `@pascal-app/mcp`, runs `bun test` in the mcp package, and runs `bunx biome check packages/mcp`.
Adds a CI workflow that runs on pushes to `main` and on pull requests touching `packages/mcp/`, `packages/core/`, the editor scene API surface, `.github/workflows/mcp-ci.yml`, or `bun.lock`. The job installs deps with Bun, builds `@pascal-app/core` then `@pascal-app/mcp`, runs `bun test` in the mcp package, runs focused editor scene API tests, and runs Biome over the MCP package plus the editor scene API files.
### Why
The existing `.github/workflows/release.yml` is `workflow_dispatch`-only (manual releases for `core` / `viewer`). There was no automated pre-merge check for MCP builds/tests. A new workflow is needed so that PRs touching mcp/core are verified before merge.
The existing `.github/workflows/release.yml` is `workflow_dispatch`-only (manual releases for `core` / `viewer`). There was no automated pre-merge check for MCP builds/tests. A new workflow is still needed so that PRs touching mcp/core are verified before merge, and it now covers the editor scene API because those routes consume the same MCP operations layer. Full `apps/editor` typecheck was evaluated but is not part of this workflow because it currently fails on unrelated `packages/editor` type errors.
### Impact
None on existing workflows; purely additive. The new workflow only triggers for paths under `packages/mcp/`, `packages/core/`, or `bun.lock`, so unrelated PRs remain unaffected. `release.yml` is untouched.
None on existing workflows; purely additive. The workflow only triggers for MCP/core/editor scene API paths, the workflow file itself, or `bun.lock`, so unrelated PRs remain unaffected. `release.yml` is untouched.
### Reversibility
@@ -95,15 +93,15 @@ Delete `.github/workflows/mcp-ci.yml`.
---
## 4. `packages/mcp/package.json` — added `./storage` subpath export
## 4. `packages/mcp/package.json` — added `./storage` and `./operations` subpath exports
### What
Added a `./storage` entry to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}`. The existing `"."` entry is unchanged.
Added `./storage` and `./operations` entries to the `"exports"` map of `@pascal-app/mcp`, pointing at the built `dist/storage/index.{js,d.ts}` and `dist/operations/index.{js,d.ts}`. The existing `"."` entry is unchanged.
### Why
The Next.js editor (`apps/editor`) needs access to `createSceneStore()` + the `SceneStore` types/errors in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs the storage adapter. The subpath export lets `apps/editor` do `import type { SceneStore } from '@pascal-app/mcp/storage'` and dynamically import `createSceneStore` without re-declaring the storage contract.
The Next.js editor (`apps/editor`) needs access to `createSceneStore()`, `SceneStore` types/errors, and the shared `SceneOperations` service layer in server-only code (API route handlers + `lib/scene-store-server.ts`). The main entry `.` pulls in the full MCP server surface (tools, transports, MCP SDK), which is overkill for a consumer that only needs storage/operations. The subpath exports let `apps/editor` dynamically import storage and operations without re-declaring either contract.
The concrete backend is now `SqliteSceneStore`, backed by built-in SQLite drivers (`bun:sqlite` for the MCP CLI and `node:sqlite` for the Next.js editor server). It writes to `~/.pascal/data/pascal.db` by default and also supports `PASCAL_DATA_DIR`, `PASCAL_DB_PATH`, and `PASCAL_MAX_SCENE_BYTES`.
@@ -113,12 +111,12 @@ Zero on existing consumers. Purely additive. The `.` entry continues to export `
### Reversibility
Remove the `./storage` entry from `exports` and update `apps/editor` to use a different factory. No data or behavior changes — pure module-graph shaping.
Remove the `./storage`/`./operations` entries from `exports` and update `apps/editor` to use a different factory. No data or behavior changes — pure module-graph shaping.
### Related
- `apps/editor/package.json` adds `@pascal-app/mcp` as a workspace dependency so the subpath resolves.
- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume this subpath.
- `apps/editor/lib/scene-store-server.ts` and `apps/editor/app/api/scenes/**` consume these subpaths.
- `packages/mcp/src/storage/sqlite-scene-store.ts` is the only production storage backend.
---
@@ -200,3 +198,35 @@ Delete `packages/core/src/schema/asset-url.ts` and revert the five imports in
scene-bridge test update is self-contained.
---
## 6. `apps/editor` / `packages/editor` scene-loading support
### What
The PR still touches the editor app and editor package, but the remaining files
are tied to the MCP scene workflow:
- `apps/editor/app/api/scenes/**`, `apps/editor/components/save-button.tsx`,
and `apps/editor/components/scene-loader.tsx` expose saved MCP scenes in the
web editor.
- `packages/editor/src/hooks/use-auto-frame.ts` plus
`packages/editor/src/lib/scene-bounds.ts` frame the camera after a stored scene
is loaded, avoiding an apparently empty viewport when MCP loads a scene away
from the default camera pose.
- The large demo fixture `apps/editor/public/dev/casa-sol.json` was removed from
this PR to keep the diff focused.
### Why
The MCP package can save scenes without these editor changes, but the PR goal is
to let contributors open and continue scenes saved by MCP. The API/UI pieces and
auto-frame hook are the minimum editor-side bridge for that workflow. They do
not change `@pascal-app/viewer` exports.
### Reversibility
If maintainers want a narrower MCP-only PR, revert the editor app pages/routes
and the auto-frame helper files, then keep only `@pascal-app/mcp`, the required
`@pascal-app/core` subpath/schema changes, and `bun.lock`.
---
+6 -6
View File
@@ -78,9 +78,9 @@ Issue [#74 "Viewer component API definition"](https://github.com/pascalorg/edito
│ └────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────── SceneBridge + SceneStore ───────────────┐ │
│ │ headless Zustand store + Zundo │ │
│ │ local SQLite storage at ~/.pascal/data/pascal.db │ │
│ ┌─────────────── SceneOperations ─────────────────────┐ │
│ │ shared MCP / REST operation boundary │ │
│ │ wraps SceneBridge + local SQLite SceneStore │ │
│ │ Zod validation at every boundary │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
@@ -147,15 +147,15 @@ and `save_scene`; the scene is openable at `/scene/<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.
5. **`SiteNode.children` inconsistency.** Site's children hold full node objects while every other container holds ID strings (see `CROSS_CUTTING.md` §2). MCP works around this by traversing via the flat `nodes` dict. Upstream alignment proposed as a follow-up.
6. **Catalog unavailable in headless mode.** `pascal://catalog/items` and `place_item`'s catalog resolution fall back to a placeholder asset payload until the core exposes a Node-consumable catalog.
7. **Local-only auth boundary.** The HTTP transport and editor scene API are intended for local development in this PR. Do not expose them on a public network without an auth layer.
7. **HTTP/API exposure is guarded.** MCP HTTP binds to `127.0.0.1` by default and requires `PASCAL_MCP_HTTP_TOKEN`/`--auth-token` before binding non-loopback hosts. The editor scene API allows tokenless loopback development, but non-loopback requests require `PASCAL_SCENE_API_TOKEN`; both paths include CORS handling and in-memory rate limiting.
## Cross-cutting changes
Documented in [`packages/mcp/CROSS_CUTTING.md`](./CROSS_CUTTING.md):
1. **`packages/core/package.json` — additive subpath exports.** Adds `./schema`, `./store`, `./material-library`, `./spatial-grid`, `./wall`. Needed because the main entry re-exports browser-only systems; subpath entries let Node consumers skip them. Zero impact on existing consumers (`apps/editor`, `@pascal-app/viewer` still use the main entry).
2. **`.github/workflows/mcp-ci.yml` — new CI.** Runs on PRs touching mcp/core; installs with Bun 1.3.0, builds, tests, biome-checks.
3. **`apps/editor` scene routes.** Adds local scene API routes and pages that read from the same SQLite `SceneStore` as MCP.
2. **`.github/workflows/mcp-ci.yml` — new CI.** Kept because the repo otherwise only has manual release CI. It runs on PRs touching MCP/core/editor scene API code; installs with Bun 1.3.0, builds MCP, runs MCP tests, runs focused editor scene API tests, and biome-checks the touched surface.
3. **`apps/editor` scene routes.** Adds scene API routes and pages that read from the same SQLite-backed `SceneOperations` layer as MCP.
4. (Observation, not fixed) **`SiteNode.children` inconsistency.** Detailed in CROSS_CUTTING §2.
## Checklist
+8 -1
View File
@@ -33,12 +33,19 @@ Load an initial scene from disk:
pascal-mcp --stdio --scene ./my-scene.json
```
Expose it as HTTP for remote hosts:
Expose it over loopback HTTP:
```bash
pascal-mcp --http --port 8787
```
Binding a non-loopback host requires a bearer token:
```bash
PASCAL_MCP_HTTP_TOKEN="$(openssl rand -hex 32)" \
pascal-mcp --http --host 0.0.0.0 --port 8787 --cors-origin https://editor.example
```
## Local scene storage
Scenes saved through MCP are stored in a local SQLite database:
+5
View File
@@ -15,6 +15,11 @@
"types": "./dist/storage/index.d.ts",
"import": "./dist/storage/index.js",
"default": "./dist/storage/index.js"
},
"./operations": {
"types": "./dist/operations/index.d.ts",
"import": "./dist/operations/index.js",
"default": "./dist/operations/index.js"
}
},
"bin": {
+12 -2
View File
@@ -19,6 +19,9 @@ OPTIONS:
--stdio Use stdio transport (default)
--http Use Streamable HTTP transport
--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
--version Print version
--help Print this help
@@ -30,6 +33,9 @@ async function main(): Promise<void> {
stdio: { type: 'boolean', default: false },
http: { type: 'boolean', default: false },
port: { type: 'string', default: '3917' },
host: { type: 'string', default: '127.0.0.1' },
'auth-token': { type: 'string' },
'cors-origin': { type: 'string', multiple: true, default: [] },
scene: { type: 'string' },
help: { type: 'boolean', default: false },
version: { type: 'boolean', default: false },
@@ -61,8 +67,12 @@ async function main(): Promise<void> {
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
throw new Error(`invalid --port value: ${values.port}`)
}
const handle = await connectHttp(server, portNum)
console.error(`[pascal-mcp] HTTP server listening on :${handle.port}`)
const handle = await connectHttp(server, portNum, {
host: values.host,
authToken: values['auth-token'],
allowedOrigins: values['cors-origin'],
})
console.error(`[pascal-mcp] HTTP server listening on ${handle.host}:${handle.port}`)
const shutdown = async () => {
try {
await handle.close()
+1
View File
@@ -1,4 +1,5 @@
export { SceneBridge } from './bridge/scene-bridge'
export { createSceneOperations, type SceneOperations } from './operations'
export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server'
export const version = '0.1.0'
+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 { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
@@ -37,7 +37,7 @@ export function buildFromBriefPrompt(args: {
return parts.join('\n')
}
export function registerFromBrief(server: McpServer, _bridge: SceneBridge): void {
export function registerFromBrief(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'from_brief',
{
+5 -5
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { registerFromBrief } from './from-brief'
import { registerIterateOnFeedback } from './iterate-on-feedback'
import { registerRenovationFromPhotos } from './renovation-from-photos'
@@ -10,8 +10,8 @@ import { registerRenovationFromPhotos } from './renovation-from-photos'
* - `iterate_on_feedback` — minimal-diff patches from user feedback
* - `renovation_from_photos` — photo-driven renovation plan via vision tools
*/
export function registerPrompts(server: McpServer, bridge: SceneBridge): void {
registerFromBrief(server, bridge)
registerIterateOnFeedback(server, bridge)
registerRenovationFromPhotos(server, bridge)
export function registerPrompts(server: McpServer, operations: SceneOperations): void {
registerFromBrief(server, operations)
registerIterateOnFeedback(server, operations)
registerRenovationFromPhotos(server, operations)
}
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { SCENE_DESIGN_GUIDANCE } from './scene-guidance'
const PREAMBLE = [
@@ -25,7 +25,7 @@ export function buildIterateOnFeedbackPrompt(args: { feedback: string }): string
return [PREAMBLE, '', '## User feedback', args.feedback.trim()].join('\n')
}
export function registerIterateOnFeedback(server: McpServer, _bridge: SceneBridge): void {
export function registerIterateOnFeedback(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'iterate_on_feedback',
{
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
const PREAMBLE = [
'You are renovating an existing room based on photos of the current space and reference photos of the target aesthetic.',
@@ -138,7 +138,7 @@ export function buildRenovationMessages(args: {
return messages
}
export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneBridge): void {
export function registerRenovationFromPhotos(server: McpServer, _bridge: SceneOperations): void {
server.registerPrompt(
'renovation_from_photos',
{
+2 -2
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const AGENT_GUIDE = [
'# Pascal MCP agent guide',
@@ -46,7 +46,7 @@ export const AGENT_GUIDE = [
'- Use `apply_patch` for bulk exact edits after semantic tools have established the main structure.',
].join('\n')
export function registerAgentGuide(server: McpServer, _bridge: SceneBridge): void {
export function registerAgentGuide(server: McpServer, _bridge: SceneOperations): void {
server.registerResource(
'agent-guide',
'pascal://agent/guide',
+2 -2
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog'
/**
@@ -8,7 +8,7 @@ import { MCP_CATALOG_ITEMS } from '../tools/asset-catalog'
* The editor UI owns the full catalog. MCP intentionally keeps a dependency-free
* subset so headless agents can still place realistic furniture and fixtures.
*/
export function registerCatalogItems(server: McpServer, _bridge: SceneBridge): void {
export function registerCatalogItems(server: McpServer, _bridge: SceneOperations): void {
server.registerResource(
'catalog-items',
'pascal://catalog/items',
+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 type { AnyNode, SlabNode, WallNode } from '@pascal-app/core/schema'
import { getWallPlanFootprint } from '@pascal-app/core/wall'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
type WallFootprint = {
wallId: string
@@ -36,7 +36,10 @@ const EMPTY_MITER_DATA: Parameters<typeof getWallPlanFootprint>[1] = {
junctions: new Map(),
}
function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload | ConstraintsError {
function buildPayload(
bridge: SceneOperations,
levelId: string,
): ConstraintsPayload | ConstraintsError {
const level = bridge.getNode(levelId as never)
if (!level || level.type !== 'level') {
return {
@@ -72,7 +75,7 @@ function buildPayload(bridge: SceneBridge, levelId: string): ConstraintsPayload
* input hints for agents: slab nodes (with polygons/holes/elevation) + each
* wall's plan-view footprint polygon.
*/
export function registerConstraints(server: McpServer, bridge: SceneBridge): void {
export function registerConstraints(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'constraints',
new ResourceTemplate('pascal://constraints/{levelId}', { list: undefined }),
+7 -7
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { registerAgentGuide } from './agent-guide'
import { registerCatalogItems } from './catalog-items'
import { registerConstraints } from './constraints'
@@ -16,10 +16,10 @@ import { registerSceneSummary } from './scene-summary'
* - `pascal://constraints/{levelId}` — application/json, per-level constraints
* - `pascal://agent/guide` — text/markdown, MCP-first construction guide
*/
export function registerResources(server: McpServer, bridge: SceneBridge): void {
registerAgentGuide(server, bridge)
registerSceneCurrent(server, bridge)
registerSceneSummary(server, bridge)
registerCatalogItems(server, bridge)
registerConstraints(server, bridge)
export function registerResources(server: McpServer, operations: SceneOperations): void {
registerAgentGuide(server, operations)
registerSceneCurrent(server, operations)
registerSceneSummary(server, operations)
registerCatalogItems(server, operations)
registerConstraints(server, operations)
}
+2 -2
View File
@@ -1,12 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
/**
* `pascal://scene/current` — full `{ nodes, rootNodeIds, collections }` snapshot.
*
* Static URI (not a template). MIME `application/json`.
*/
export function registerSceneCurrent(server: McpServer, bridge: SceneBridge): void {
export function registerSceneCurrent(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'scene-current',
'pascal://scene/current',
+5 -3
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeType } from '@pascal-app/core/schema'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
type Poly2D = ReadonlyArray<readonly [number, number]>
@@ -77,7 +77,9 @@ function countByType(nodes: AnyNode[]): Record<string, number> {
}
/** 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 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.
* MIME `text/markdown`.
*/
export function registerSceneSummary(server: McpServer, bridge: SceneBridge): void {
export function registerSceneSummary(server: McpServer, bridge: SceneOperations): void {
server.registerResource(
'scene-summary',
'pascal://scene/current/summary',
+7 -4
View File
@@ -1,5 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from './bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from './operations'
import { registerPrompts } from './prompts'
import { registerResources } from './resources'
import { createSceneStore } from './storage'
@@ -19,6 +20,7 @@ import { registerVisionTools } from './tools/vision'
export type CreatePascalMcpServerOptions = {
bridge: SceneBridge
operations?: SceneOperations
/** Injected `SceneStore`. When omitted, `createSceneStore()` is used lazily. */
store?: SceneStore
name?: string
@@ -31,10 +33,11 @@ export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpSe
version: opts.version ?? '0.1.0',
})
const store = opts.store ?? createLazySceneStore()
registerTools(server, opts.bridge, store)
registerVisionTools(server, opts.bridge)
registerResources(server, opts.bridge)
registerPrompts(server, opts.bridge)
const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store })
registerTools(server, operations)
registerVisionTools(server, operations)
registerResources(server, operations)
registerPrompts(server, operations)
return server
}
+4 -8
View File
@@ -1,8 +1,8 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { PatchSchema } from './schemas'
@@ -17,11 +17,7 @@ export const applyPatchOutput = {
createdIds: z.array(z.string()),
}
export function registerApplyPatch(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerApplyPatch(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'apply_patch',
{
@@ -56,7 +52,7 @@ export function registerApplyPatch(
try {
const result = bridge.applyPatch(bridgePatches)
await publishLiveSceneSnapshot(bridge, store, 'apply_patch')
await publishLiveSceneSnapshot(bridge, 'apply_patch')
const payload = {
appliedOps: result.appliedOps,
deletedIds: result.deletedIds as unknown as string[],
+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 { getScaledDimensions } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas'
export const checkCollisionsInput = {
@@ -38,7 +38,7 @@ function aabbOverlap(a: AABB, b: AABB): boolean {
return a.minX < b.maxX && a.maxX > b.minX && a.minZ < b.maxZ && a.maxZ > b.minZ
}
export function registerCheckCollisions(server: McpServer, bridge: SceneBridge): void {
export function registerCheckCollisions(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'check_collisions',
{
@@ -4,8 +4,8 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { LevelNode } from '@pascal-app/core/schema'
import { SceneBridge } from '../bridge/scene-bridge'
import { registerSceneQueryTools } from './scene-query'
import { registerConstructionTools } from './construction-tools'
import { registerSceneQueryTools } from './scene-query'
describe('construction tools', () => {
let client: Client
+15 -18
View File
@@ -11,8 +11,7 @@ import {
WallNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema, Vec3Schema } from './schemas'
@@ -107,17 +106,15 @@ function textResult<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)
if (!node) throw new Error(`${type} not found: ${id}`)
if (node.type !== type) throw new Error(`Node ${id} is a ${node.type}, expected ${type}`)
return node
}
function getBuildingIdForLevel(bridge: SceneBridge, levelId: string): AnyNodeId {
const building = bridge
.getAncestry(levelId as AnyNodeId)
.find((node) => node.type === 'building')
function getBuildingIdForLevel(bridge: SceneOperations, levelId: string): AnyNodeId {
const building = bridge.getAncestry(levelId as AnyNodeId).find((node) => node.type === 'building')
if (!building) {
throw new Error(`Building ancestor not found for level: ${levelId}`)
}
@@ -134,7 +131,11 @@ function isRoofLevel(level: AnyNode): boolean {
)
}
function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLevel: AnyNode): number {
function nextLevelIndex(
bridge: SceneOperations,
buildingId: AnyNodeId,
referenceLevel: AnyNode,
): number {
const existing = bridge
.getChildren(buildingId)
.filter((node): node is AnyNode & { type: 'level' } => node.type === 'level')
@@ -144,14 +145,14 @@ function nextLevelIndex(bridge: SceneBridge, buildingId: AnyNodeId, referenceLev
return existing.includes(candidate) ? Math.max(candidate, ...existing) + 1 : candidate
}
function nodesOnLevel(bridge: SceneBridge, levelId: string): AnyNode[] {
function nodesOnLevel(bridge: SceneOperations, levelId: string): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
}
function firstNodeOnLevel(
bridge: SceneBridge,
bridge: SceneOperations,
levelId: string,
type: 'slab' | 'ceiling',
): AnyNode | null {
@@ -204,11 +205,7 @@ function withHole(
} as Partial<AnyNode>
}
export function registerConstructionTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerConstructionTools(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_story_shell',
{
@@ -285,7 +282,7 @@ export function registerConstructionTools(
}
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_story_shell')
await publishLiveSceneSnapshot(bridge, 'create_story_shell')
return textResult({
levelId,
wallIds,
@@ -379,7 +376,7 @@ export function registerConstructionTools(
{ op: 'create', node: roof, parentId: targetRoofLevelId },
{ op: 'create', node: segment, parentId: roof.id as AnyNodeId },
])
await publishLiveSceneSnapshot(bridge, store, 'create_roof')
await publishLiveSceneSnapshot(bridge, 'create_roof')
return textResult({
referenceLevelId: levelId,
roofLevelId: targetRoofLevelId,
@@ -500,7 +497,7 @@ export function registerConstructionTools(
}
bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'create_stair_between_levels')
await publishLiveSceneSnapshot(bridge, 'create_stair_between_levels')
return textResult({
stairId: stair.id,
stairSegmentId: segment.id,
+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 { LevelNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -19,11 +18,7 @@ export const createLevelOutput = {
levelId: z.string(),
}
export function registerCreateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateLevel(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_level',
{
@@ -57,7 +52,7 @@ export function registerCreateLevel(
})
const id = bridge.createNode(levelNode, buildingId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_level')
await publishLiveSceneSnapshot(bridge, 'create_level')
const payload = { levelId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+4 -2
View File
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../bridge/scene-bridge'
import { createSceneOperations } from '../operations'
import type { SceneMeta, SceneStore } from '../storage/types'
import { registerCreateWall } from './create-wall'
@@ -97,8 +98,9 @@ describe('create_wall', () => {
}
const liveServer = new McpServer({ name: 'test-live', version: '0.0.0' })
const liveClient = new Client({ name: 'test-live-client', version: '0.0.0' })
bridge.setActiveScene(savedMeta)
registerCreateWall(liveServer, bridge, store)
const operations = createSceneOperations({ bridge, store })
operations.setActiveScene(savedMeta)
registerCreateWall(liveServer, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
await Promise.all([liveServer.connect(srvT), liveClient.connect(cliT)])
+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 { WallNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -20,11 +19,7 @@ export const createWallOutput = {
wallId: z.string(),
}
export function registerCreateWall(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateWall(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_wall',
{
@@ -64,7 +59,7 @@ export function registerCreateWall(
...(height !== undefined ? { height } : {}),
})
const id = bridge.createNode(wall, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'create_wall')
await publishLiveSceneSnapshot(bridge, 'create_wall')
const payload = { wallId: id as string }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+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 { DoorNode, WindowNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { wallLength, wallLocalXFromT } from './geometry'
import { publishLiveSceneSnapshot } from './live-sync'
@@ -21,11 +20,7 @@ export const cutOpeningOutput = {
openingId: z.string(),
}
export function registerCutOpening(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCutOpening(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'cut_opening',
{
@@ -73,7 +68,7 @@ export function registerCutOpening(
position: [base.position[0], 0.9 + height / 2, 0],
})
const id = bridge.createNode(opening, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'cut_opening')
await publishLiveSceneSnapshot(bridge, 'cut_opening')
const payload = { openingId: id as string }
return {
+3 -8
View File
@@ -1,8 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -16,11 +15,7 @@ export const deleteNodeOutput = {
deletedIds: z.array(z.string()),
}
export function registerDeleteNode(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerDeleteNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'delete_node',
{
@@ -37,7 +32,7 @@ export function registerDeleteNode(
}
try {
const removed = bridge.deleteNode(id as AnyNodeId, cascade ?? false)
await publishLiveSceneSnapshot(bridge, store, 'delete_node')
await publishLiveSceneSnapshot(bridge, 'delete_node')
const payload = { deletedIds: removed }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -59,7 +59,7 @@ function describe(node: AnyNode): string {
}
}
export function registerDescribeNode(server: McpServer, bridge: SceneBridge): void {
export function registerDescribeNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'describe_node',
{
+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 type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { Patch as BridgePatch, SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { Patch as BridgePatch } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema } from './schemas'
@@ -17,11 +17,7 @@ export const duplicateLevelOutput = {
newNodeIds: z.array(z.string()),
}
export function registerDuplicateLevel(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerDuplicateLevel(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'duplicate_level',
{
@@ -63,7 +59,7 @@ export function registerDuplicateLevel(
})
const result = bridge.applyPatch(patches)
await publishLiveSceneSnapshot(bridge, store, 'duplicate_level')
await publishLiveSceneSnapshot(bridge, 'duplicate_level')
const payload = {
newLevelId: newLevelId as string,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const exportGlbInput = {}
@@ -9,7 +9,7 @@ export const exportGlbOutput = {
reason: z.string(),
}
export function registerExportGlb(server: McpServer, _bridge: SceneBridge): void {
export function registerExportGlb(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'export_glb',
{
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const exportJsonInput = {
pretty: z.boolean().optional(),
@@ -10,7 +10,7 @@ export const exportJsonOutput = {
json: z.string(),
}
export function registerExportJson(server: McpServer, bridge: SceneBridge): void {
export function registerExportJson(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'export_json',
{
+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 { pointInPolygon } from '@pascal-app/core/spatial-grid'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { NodeIdSchema } from './schemas'
const ALL_NODE_TYPES = [
@@ -68,7 +68,7 @@ function getPointForZoneFilter(node: AnyNode): [number, number] | null {
return null
}
export function registerFindNodes(server: McpServer, bridge: SceneBridge): void {
export function registerFindNodes(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'find_nodes',
{
+2 -2
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -13,7 +13,7 @@ export const getNodeOutput = {
node: z.record(z.string(), z.unknown()),
}
export function registerGetNode(server: McpServer, bridge: SceneBridge): void {
export function registerGetNode(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_node',
{
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const getSceneInput = {}
@@ -10,7 +10,7 @@ export const getSceneOutput = {
collections: z.record(z.string(), z.unknown()).optional(),
}
export function registerGetScene(server: McpServer, bridge: SceneBridge): void {
export function registerGetScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_scene',
{
+30 -31
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { registerApplyPatch } from './apply-patch'
import { registerCheckCollisions } from './check-collisions'
import { registerConstructionTools } from './construction-tools'
@@ -34,35 +33,35 @@ import { registerVariantTools } from './variants'
* separately via `registerVisionTools` (Agent E).
*
* Scene-lifecycle tools (save/load/list/delete/rename scene) are registered
* when a `store` is provided; callers that pass `undefined` skip them.
* when persistence operations are available.
*/
export function registerTools(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
registerGetScene(server, bridge)
registerGetNode(server, bridge)
registerDescribeNode(server, bridge)
registerFindNodes(server, bridge)
registerSceneQueryTools(server, bridge)
registerMeasure(server, bridge)
registerConstructionTools(server, bridge, store)
registerRoomTools(server, bridge, store)
registerApplyPatch(server, bridge, store)
registerCreateLevel(server, bridge, store)
registerCreateWall(server, bridge, store)
registerPlaceItem(server, bridge, store)
registerCutOpening(server, bridge, store)
registerSetZone(server, bridge, store)
registerDuplicateLevel(server, bridge, store)
registerDeleteNode(server, bridge, store)
registerUndo(server, bridge, store)
registerRedo(server, bridge, store)
registerExportJson(server, bridge)
registerExportGlb(server, bridge)
registerValidateScene(server, bridge)
registerCheckCollisions(server, bridge)
registerTemplateTools(server, bridge, store)
if (store) {
registerSceneLifecycleTools(server, bridge, store)
registerVariantTools(server, bridge, store)
registerPhotoToSceneTool(server, bridge, store)
export function registerTools(server: McpServer, operations: SceneOperations): void {
registerGetScene(server, operations)
registerGetNode(server, operations)
registerDescribeNode(server, operations)
registerFindNodes(server, operations)
registerSceneQueryTools(server, operations)
registerMeasure(server, operations)
registerConstructionTools(server, operations)
registerRoomTools(server, operations)
registerApplyPatch(server, operations)
registerCreateLevel(server, operations)
registerCreateWall(server, operations)
registerPlaceItem(server, operations)
registerCutOpening(server, operations)
registerSetZone(server, operations)
registerDuplicateLevel(server, operations)
registerDeleteNode(server, operations)
registerUndo(server, operations)
registerRedo(server, operations)
registerExportJson(server, operations)
registerExportGlb(server, operations)
registerValidateScene(server, operations)
registerCheckCollisions(server, operations)
registerTemplateTools(server, operations)
if (operations.hasStore) {
registerSceneLifecycleTools(server, operations)
registerVariantTools(server, operations)
registerPhotoToSceneTool(server, operations)
}
}
+16 -22
View File
@@ -1,13 +1,13 @@
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { syncAutoStairOpenings } from '@pascal-app/core/stair-openings'
import type { SceneBridge } from '../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../storage/types'
import type { SceneOperations } from '../operations'
import { SceneVersionConflictError } from '../storage/types'
import { ErrorCode, throwMcpError } from './errors'
export function syncDerivedStairOpenings(bridge: SceneBridge): number {
const updates = syncAutoStairOpenings(bridge.getNodes())
export function syncDerivedStairOpenings(operations: SceneOperations): number {
const updates = syncAutoStairOpenings(operations.getNodes())
if (updates.length === 0) return 0
bridge.applyPatch(
operations.applyPatch(
updates.map((update) => ({
op: 'update' as const,
id: update.id,
@@ -23,24 +23,18 @@ export function syncDerivedStairOpenings(bridge: SceneBridge): number {
* bound to a saved scene.
*/
export async function publishLiveSceneSnapshot(
bridge: SceneBridge,
store: SceneStore | undefined,
operations: SceneOperations,
kind: string,
): Promise<void> {
syncDerivedStairOpenings(bridge)
syncDerivedStairOpenings(operations)
const active = bridge.getActiveScene()
if (!active || !store?.appendSceneEvent) return
const active = operations.getActiveScene()
if (!active || !operations.canAppendSceneEvents) return
const exported = bridge.exportJSON()
const graph: SceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
const graph = operations.exportSceneGraph()
try {
const meta = await store.save({
const meta = await operations.saveScene({
id: active.id,
name: active.name,
projectId: active.projectId,
@@ -49,8 +43,8 @@ export async function publishLiveSceneSnapshot(
graph,
expectedVersion: active.version,
})
bridge.setActiveScene(meta)
await store.appendSceneEvent({
operations.setActiveScene(meta)
await operations.appendSceneEvent({
sceneId: meta.id,
version: meta.version,
kind,
@@ -69,12 +63,12 @@ export async function publishLiveSceneSnapshot(
}
export async function appendLiveSceneEvent(
store: SceneStore,
operations: SceneOperations,
sceneId: string,
version: number,
kind: string,
graph: SceneGraph,
): Promise<void> {
if (!store.appendSceneEvent) return
await store.appendSceneEvent({ sceneId, version, kind, graph })
if (!operations.canAppendSceneEvents) return
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 { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { NodeIdSchema } from './schemas'
@@ -84,7 +84,7 @@ function shoelaceArea(polygon: Array<[number, number]>): number {
return Math.abs(sum) / 2
}
export function registerMeasure(server: McpServer, bridge: SceneBridge): void {
export function registerMeasure(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'measure',
{
+5 -10
View File
@@ -1,20 +1,15 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerPhotoToScene } from './photo-to-scene'
/**
* Register the `photo_to_scene` orchestrator tool. Chains the vision
* (`analyze_floorplan_image`-equivalent sampling call) → SceneGraph
* synthesis → optional `SceneStore.save``bridge.setScene` so callers get a
* navigable Pascal scene from a single photo upload.
* synthesis → optional scene save → bridge setScene so callers get a navigable
* Pascal scene from a single photo upload.
*/
export function registerPhotoToSceneTool(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerPhotoToScene(server, bridge, store)
export function registerPhotoToSceneTool(server: McpServer, bridge: SceneOperations): void {
registerPhotoToScene(server, bridge)
}
export {
@@ -4,6 +4,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { CreateMessageRequestSchema } from '@modelcontextprotocol/sdk/types.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import { InMemorySceneStore } from '../scene-lifecycle/test-utils'
import { registerPhotoToScene } from './photo-to-scene'
@@ -22,8 +23,9 @@ async function makeWiredPair(opts: { withSampling: boolean; samplingHandler?: Ha
const bridge = new SceneBridge()
bridge.setScene({}, [])
const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerPhotoToScene(server, bridge, store)
registerPhotoToScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
@@ -11,8 +11,7 @@ import {
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { appendLiveSceneEvent } from '../live-sync'
/**
@@ -341,11 +340,7 @@ function buildSceneGraphFromVision(
}
}
export function registerPhotoToScene(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
export function registerPhotoToScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'photo_to_scene',
{
@@ -375,12 +370,12 @@ export function registerPhotoToScene(
// 4. Save or return inline.
if (save) {
const meta = await store.save({
const meta = await bridge.saveScene({
name,
graph,
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'photo_to_scene', graph)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'photo_to_scene', graph)
const payload: {
sceneId: string
url: string
+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 { ItemNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { findCatalogItem } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import { projectWorldPointToWallLocalX, wallLength } from './geometry'
@@ -22,11 +21,7 @@ export const placeItemOutput = {
status: z.string().optional(),
}
export function registerPlaceItem(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerPlaceItem(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'place_item',
{
@@ -102,7 +97,7 @@ export function registerPlaceItem(
...wallExtras,
})
const id = bridge.createNode(item, parentId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'place_item')
await publishLiveSceneSnapshot(bridge, 'place_item')
const payload = {
itemId: id as string,
status: catalogAsset ? 'ok' : 'catalog_unavailable',
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
export const redoInput = {
@@ -12,7 +11,7 @@ export const redoOutput = {
redone: z.number(),
}
export function registerRedo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerRedo(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'redo',
{
@@ -24,7 +23,7 @@ export function registerRedo(server: McpServer, bridge: SceneBridge, store?: Sce
},
async ({ steps }) => {
const redone = bridge.redo(steps ?? 1)
if (redone > 0) await publishLiveSceneSnapshot(bridge, store, 'redo')
if (redone > 0) await publishLiveSceneSnapshot(bridge, 'redo')
const payload = { redone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+17 -34
View File
@@ -10,8 +10,7 @@ import {
ZoneNode,
} from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { findCatalogItem, searchCatalogItems } from './asset-catalog'
import { ErrorCode, throwMcpError } from './errors'
import {
@@ -118,7 +117,7 @@ function textResult<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)
if (!level) throwMcpError(ErrorCode.InvalidParams, `Level not found: ${levelId}`)
if (level.type !== 'level') {
@@ -138,7 +137,7 @@ function assertLevel(bridge: SceneBridge, levelId: string): AnyNode {
return level
}
function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wall' } {
function assertWall(bridge: SceneOperations, wallId: string): AnyNode & { type: 'wall' } {
const wall = bridge.getNode(wallId as AnyNodeId)
if (!wall) throwMcpError(ErrorCode.InvalidParams, `Wall not found: ${wallId}`)
if (wall.type !== 'wall') {
@@ -148,7 +147,7 @@ function assertWall(bridge: SceneBridge, wallId: string): AnyNode & { type: 'wal
}
function inferRoomGeometry(
bridge: SceneBridge,
bridge: SceneOperations,
levelId: string | undefined,
polygon: Vec2[] | undefined,
zoneId: string | undefined,
@@ -385,11 +384,7 @@ export function registerSearchAssets(server: McpServer): void {
)
}
export function registerCreateRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateRoom(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_room',
{
@@ -431,7 +426,7 @@ export function registerCreateRoom(
parentId: levelId as AnyNodeId,
})),
])
await publishLiveSceneSnapshot(bridge, store, 'create_room')
await publishLiveSceneSnapshot(bridge, 'create_room')
return textResult({
zoneId: zone.id,
@@ -444,7 +439,7 @@ export function registerCreateRoom(
)
}
export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerAddDoor(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'add_door',
{
@@ -475,17 +470,13 @@ export function registerAddDoor(server: McpServer, bridge: SceneBridge, store?:
...(swingDirection ? { swingDirection } : {}),
})
const id = bridge.createNode(door, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_door')
await publishLiveSceneSnapshot(bridge, 'add_door')
return textResult({ doorId: id, localX })
},
)
}
export function registerAddWindow(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerAddWindow(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'add_window',
{
@@ -514,17 +505,13 @@ export function registerAddWindow(
height,
})
const id = bridge.createNode(windowNode, wallId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'add_window')
await publishLiveSceneSnapshot(bridge, 'add_window')
return textResult({ windowId: id, localX, sillHeight })
},
)
}
export function registerFurnishRoom(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerFurnishRoom(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'furnish_room',
{
@@ -582,7 +569,7 @@ export function registerFurnishRoom(
parentId: room.levelId as AnyNodeId,
})),
)
await publishLiveSceneSnapshot(bridge, store, 'furnish_room')
await publishLiveSceneSnapshot(bridge, 'furnish_room')
}
return textResult({
@@ -594,14 +581,10 @@ export function registerFurnishRoom(
)
}
export function registerRoomTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerRoomTools(server: McpServer, bridge: SceneOperations): void {
registerSearchAssets(server)
registerCreateRoom(server, bridge, store)
registerAddDoor(server, bridge, store)
registerAddWindow(server, bridge, store)
registerFurnishRoom(server, bridge, store)
registerCreateRoom(server, bridge)
registerAddDoor(server, bridge)
registerAddWindow(server, bridge)
registerFurnishRoom(server, bridge)
}
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerDeleteScene } from './delete-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('delete_scene', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerDeleteScene(server, store)
registerDeleteScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
export const deleteSceneInput = {
@@ -12,7 +13,7 @@ export const deleteSceneOutput = {
deleted: z.boolean(),
}
export function registerDeleteScene(server: McpServer, store: SceneStore): void {
export function registerDeleteScene(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'delete_scene',
{
@@ -24,7 +25,7 @@ export function registerDeleteScene(server: McpServer, store: SceneStore): void
},
async ({ id, expectedVersion }) => {
try {
const deleted = await store.delete(id, {
const deleted = await operations.deleteStoredScene(id, {
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
const payload = { deleted }
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerDeleteScene } from './delete-scene'
import { registerListScenes } from './list-scenes'
import { registerLoadScene } from './load-scene'
@@ -10,19 +9,15 @@ import { registerSaveScene } from './save-scene'
/**
* Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`,
* `list_scenes`, `delete_scene`, `rename_scene`) against the given server.
* All tools operate against the supplied `SceneStore` so tests can inject an
* in-memory implementation.
* All tools operate against shared scene operations so MCP, REST, and future CLI
* entry points share the same storage boundary.
*/
export function registerSceneLifecycleTools(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerSaveScene(server, bridge, store)
registerLoadScene(server, bridge, store)
registerListScenes(server, store)
registerDeleteScene(server, store)
registerRenameScene(server, store)
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
registerSaveScene(server, operations)
registerLoadScene(server, operations)
registerListScenes(server, operations)
registerDeleteScene(server, operations)
registerRenameScene(server, operations)
}
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerListScenes } from './list-scenes'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('list_scenes', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerListScenes(server, store)
registerListScenes(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
const DEFAULT_LIMIT = 100
@@ -27,7 +27,7 @@ export const listScenesOutput = {
),
}
export function registerListScenes(server: McpServer, store: SceneStore): void {
export function registerListScenes(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'list_scenes',
{
@@ -39,7 +39,7 @@ export function registerListScenes(server: McpServer, store: SceneStore): void {
},
async ({ projectId, limit }) => {
try {
const scenes = await store.list({
const scenes = await operations.listScenes({
...(projectId !== undefined ? { projectId } : {}),
limit: limit ?? DEFAULT_LIMIT,
})
@@ -5,7 +5,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerLoadScene } from './load-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('load_scene', () => {
let client: Client
@@ -17,8 +22,9 @@ describe('load_scene', () => {
bridge.setScene({}, [])
bridge.loadDefault()
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerLoadScene(server, bridge, store)
registerLoadScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
export const loadSceneInput = {
@@ -21,7 +20,7 @@ export const loadSceneOutput = {
nodeCount: z.number(),
}
export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'load_scene',
{
@@ -32,7 +31,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store:
outputSchema: loadSceneOutput,
},
async ({ id }) => {
const result = await store.load(id)
const result = await bridge.loadStoredScene(id)
if (!result) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id })
}
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { registerRenameScene } from './rename-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
@@ -14,8 +19,9 @@ describe('rename_scene', () => {
beforeEach(async () => {
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerRenameScene(server, store)
registerRenameScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -1,6 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
export const renameSceneInput = {
@@ -22,7 +23,7 @@ export const renameSceneOutput = {
nodeCount: z.number(),
}
export function registerRenameScene(server: McpServer, store: SceneStore): void {
export function registerRenameScene(server: McpServer, operations: SceneOperations): void {
server.registerTool(
'rename_scene',
{
@@ -34,7 +35,7 @@ export function registerRenameScene(server: McpServer, store: SceneStore): void
},
async ({ id, newName, expectedVersion }) => {
try {
const meta = await store.rename(id, newName, {
const meta = await operations.renameStoredScene(id, newName, {
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
const payload = {
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { registerSaveScene } from './save-scene'
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
import {
createTestSceneOperations,
InMemorySceneStore,
parseToolText,
type StoredTextContent,
} from './test-utils'
describe('save_scene', () => {
let client: Client
@@ -16,8 +21,9 @@ describe('save_scene', () => {
bridge.setScene({}, [])
bridge.loadDefault()
store = new InMemorySceneStore()
const { operations } = createTestSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerSaveScene(server, bridge, store)
registerSaveScene(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { AnyNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { type SceneStore, SceneVersionConflictError } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { SceneVersionConflictError } from '../../storage/types'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
@@ -39,7 +39,7 @@ export const saveSceneOutput = {
url: z.string(),
}
export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'save_scene',
{
@@ -56,12 +56,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
if (!validation.valid) {
throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors })
}
const exported = bridge.exportJSON()
sceneGraph = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
sceneGraph = bridge.exportSceneGraph()
} else {
if (!graph) {
throwMcpError(
@@ -96,7 +91,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
}
try {
const meta = await store.save({
const meta = await bridge.saveScene({
...(id !== undefined ? { id } : {}),
name,
...(projectId !== undefined ? { projectId } : {}),
@@ -104,7 +99,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
...(expectedVersion !== undefined ? { expectedVersion } : {}),
})
await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph)
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
if (includeCurrentScene) {
bridge.setActiveScene(meta)
}
@@ -1,3 +1,5 @@
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations, type SceneOperations } from '../../operations'
import {
type SceneListOptions,
type SceneMeta,
@@ -15,6 +17,20 @@ export function parseToolText(content: StoredTextContent[]): Record<string, unkn
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.
* 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.roofLevelIds).toEqual([roofLevel.id])
expect(listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id)).toMatchObject({
expect(
listPayload.levels.find((level: { id: string }) => level.id === roofLevel.id),
).toMatchObject({
role: 'roof',
isSupportLevel: true,
referenceLevelId: upper.id,
+21 -15
View File
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
import {
distance2D,
pointInPolygon,
@@ -88,7 +88,7 @@ function textResult<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) => {
const aa = a.type === 'level' ? a.level : 0
const bb = b.type === 'level' ? b.level : 0
@@ -96,13 +96,16 @@ function getLevels(bridge: SceneBridge): AnyNode[] {
})
}
function getDefaultLevelId(bridge: SceneBridge, requested?: string | undefined): AnyNodeId | null {
function getDefaultLevelId(
bridge: SceneOperations,
requested?: string | undefined,
): AnyNodeId | null {
if (requested) return requested as AnyNodeId
const level = getLevels(bridge)[0]
return (level?.id as AnyNodeId | undefined) ?? null
}
function nodesOnLevel(bridge: SceneBridge, levelId: AnyNodeId): AnyNode[] {
function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] {
return Object.values(bridge.getNodes()).filter(
(node) => node.id !== levelId && bridge.resolveLevelId(node.id as AnyNodeId) === levelId,
)
@@ -140,7 +143,7 @@ function classifyLevel(level: AnyNode, counts: ContentCounts): LevelRole {
return 'occupied'
}
function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) {
return bridge
.getChildren(wallId)
.filter((child) => child.type === 'door' || child.type === 'window')
@@ -153,7 +156,7 @@ function openingSummaries(bridge: SceneBridge, wallId: AnyNodeId) {
}))
}
function wallSummary(bridge: SceneBridge, wall: AnyNode) {
function wallSummary(bridge: SceneOperations, wall: AnyNode) {
if (wall.type !== 'wall') return null
const length = distance2D(wall.start, wall.end)
return {
@@ -280,7 +283,10 @@ function computeSegmentTransforms(segments: StairSegmentLike[]): SegmentTransfor
return transforms
}
function stairFootprintPolygons(bridge: SceneBridge, stair: AnyNode & { type: 'stair' }): Vec2[][] {
function stairFootprintPolygons(
bridge: SceneOperations,
stair: AnyNode & { type: 'stair' },
): Vec2[][] {
if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
return [
@@ -345,7 +351,7 @@ function getLevelNumber(
}
function targetLevelIdsForStair(
bridge: SceneBridge,
bridge: SceneOperations,
stair: AnyNode & { type: 'stair' },
): AnyNodeId[] {
const nodes = bridge.getNodes()
@@ -388,7 +394,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean {
})
}
function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
function levelSummary(bridge: SceneOperations, levelId: AnyNodeId) {
const level = bridge.getNode(levelId)
if (!level || level.type !== 'level') {
throw new Error(`Level not found: ${levelId}`)
@@ -454,7 +460,7 @@ function levelSummary(bridge: SceneBridge, levelId: AnyNodeId) {
}
}
export function registerListLevels(server: McpServer, bridge: SceneBridge): void {
export function registerListLevels(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'list_levels',
{
@@ -497,7 +503,7 @@ export function registerListLevels(server: McpServer, bridge: SceneBridge): void
)
}
export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge): void {
export function registerGetLevelSummary(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_level_summary',
{
@@ -515,7 +521,7 @@ export function registerGetLevelSummary(server: McpServer, bridge: SceneBridge):
)
}
export function registerGetWalls(server: McpServer, bridge: SceneBridge): void {
export function registerGetWalls(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_walls',
{
@@ -536,7 +542,7 @@ export function registerGetWalls(server: McpServer, bridge: SceneBridge): void {
)
}
export function registerGetZones(server: McpServer, bridge: SceneBridge): void {
export function registerGetZones(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'get_zones',
{
@@ -557,7 +563,7 @@ export function registerGetZones(server: McpServer, bridge: SceneBridge): void {
)
}
export function registerVerifyScene(server: McpServer, bridge: SceneBridge): void {
export function registerVerifyScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'verify_scene',
{
@@ -817,7 +823,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneBridge): voi
)
}
export function registerSceneQueryTools(server: McpServer, bridge: SceneBridge): void {
export function registerSceneQueryTools(server: McpServer, bridge: SceneOperations): void {
registerListLevels(server, bridge)
registerGetLevelSummary(server, bridge)
registerGetWalls(server, bridge)
+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 { ZoneNode } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { ErrorCode, throwMcpError } from './errors'
import { publishLiveSceneSnapshot } from './live-sync'
import { NodeIdSchema, Vec2Schema } from './schemas'
@@ -19,7 +18,7 @@ export const setZoneOutput = {
zoneId: z.string(),
}
export function registerSetZone(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerSetZone(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'set_zone',
{
@@ -58,7 +57,7 @@ export function registerSetZone(server: McpServer, bridge: SceneBridge, store?:
metadata: properties ?? {},
})
const id = bridge.createNode(zone, levelId as AnyNodeId)
await publishLiveSceneSnapshot(bridge, store, 'set_zone')
await publishLiveSceneSnapshot(bridge, 'set_zone')
const payload = { zoneId: id as string }
return {
@@ -2,9 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
@@ -22,9 +21,10 @@ export const createFromTemplateInput = {
.optional()
.describe('Optional display name for the saved scene. Defaults to the template name.'),
/**
* When a `SceneStore` is wired into the MCP server, set this flag to `true`
* to immediately save the instantiated template and return its `SceneMeta`.
* When `false` (default) the template is applied to the bridge only.
* When persistence operations are wired into the MCP server, set this flag to
* `true` to immediately save the instantiated template and return its
* `SceneMeta`. When `false` (default) the template is applied to the bridge
* only.
*/
save: z.boolean().default(false),
projectId: z.string().optional(),
@@ -54,17 +54,13 @@ export const createFromTemplateOutput = {
/**
* `create_from_template` — instantiate a seed template into the bridge, and
* optionally persist it via the attached `SceneStore`.
* optionally persist it via the attached scene operations.
*
* The source template is cloned with fresh ids (`cloneSceneGraph`) so the
* deterministic placeholders (`site_empty`, `wall_n`, …) don't collide
* across repeated calls or with other scenes.
*/
export function registerCreateFromTemplate(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateFromTemplate(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_from_template',
{
@@ -112,7 +108,7 @@ export function registerCreateFromTemplate(
}
}
if (!store) {
if (!bridge.hasStore) {
// Graceful no-store mode: report that save was skipped rather than
// erroring — this makes the tool usable in headless bridge-only
// deployments (tests, smoke scripts) without crashing.
@@ -125,13 +121,13 @@ export function registerCreateFromTemplate(
}
try {
const meta = await store.save({
const meta = await bridge.saveScene({
name: name ?? entry.name,
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', {
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
nodes,
rootNodeIds,
})
+5 -11
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerCreateFromTemplate } from './create-from-template'
import { registerListTemplates } from './list-templates'
@@ -8,17 +7,12 @@ import { registerListTemplates } from './list-templates'
* Register the template MCP tools (`list_templates`, `create_from_template`)
* against the given server.
*
* `store` is optional: when omitted, `create_from_template` still applies the
* template to the bridge but skips the save step. This makes the tool safe
* to wire into headless bridge-only deployments.
* When persistence operations are unavailable, `create_from_template` still
* applies the template to the bridge but skips the save step.
*/
export function registerTemplateTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
registerListTemplates(server)
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, bridge)
}
export {
@@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import {
InMemorySceneStore,
parseToolText,
@@ -59,8 +60,9 @@ describe('create_from_template', () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -138,9 +140,10 @@ describe('create_from_template without a store', () => {
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
const operations = createSceneOperations({ bridge })
const server = new McpServer({ name: 'test', version: '0.0.0' })
// No store passed → save should be gracefully skipped.
registerCreateFromTemplate(server, bridge)
// No store in operations → save should be gracefully skipped.
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
+3 -4
View File
@@ -1,7 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneStore } from '../storage/types'
import type { SceneOperations } from '../operations'
import { publishLiveSceneSnapshot } from './live-sync'
export const undoInput = {
@@ -12,7 +11,7 @@ export const undoOutput = {
undone: z.number(),
}
export function registerUndo(server: McpServer, bridge: SceneBridge, store?: SceneStore): void {
export function registerUndo(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'undo',
{
@@ -24,7 +23,7 @@ export function registerUndo(server: McpServer, bridge: SceneBridge, store?: Sce
},
async ({ steps }) => {
const undone = bridge.undo(steps ?? 1)
if (undone > 0) await publishLiveSceneSnapshot(bridge, store, 'undo')
if (undone > 0) await publishLiveSceneSnapshot(bridge, 'undo')
const payload = { undone }
return {
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
+2 -2
View File
@@ -1,6 +1,6 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import type { SceneBridge } from '../bridge/scene-bridge'
import type { SceneOperations } from '../operations'
export const validateSceneInput = {}
@@ -15,7 +15,7 @@ export const validateSceneOutput = {
),
}
export function registerValidateScene(server: McpServer, bridge: SceneBridge): void {
export function registerValidateScene(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'validate_scene',
{
@@ -5,6 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNodeId, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import {
InMemorySceneStore,
parseToolText,
@@ -56,8 +57,9 @@ async function setup(): Promise<{
bridge.setScene({}, [])
bridge.loadDefault()
const store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerGenerateVariants(server, bridge, store)
registerGenerateVariants(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -2,8 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { forkSceneGraph, type SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { type AnyNode, AnyNode as AnyNodeSchema } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { ErrorCode, throwMcpError } from '../errors'
import { applyMutation, describeVariant, type MutationKind, mulberry32 } from './mutations'
@@ -93,17 +92,13 @@ function countInvalidNodes(graph: SceneGraph): number {
return invalid
}
export function registerGenerateVariants(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
export function registerGenerateVariants(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'generate_variants',
{
title: 'Generate variants',
description:
'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via the SceneStore and returned with an id + URL; otherwise the graph is returned inline.',
'Generate N variations of a base scene by forking and applying seeded mutations. Example: "give me 5 variations of this kitchen". If `save=true`, each variant is persisted via scene operations and returned with an id + URL; otherwise the graph is returned inline.',
inputSchema: generateVariantsInput,
outputSchema: generateVariantsOutput,
},
@@ -112,19 +107,14 @@ export function registerGenerateVariants(
let base: SceneGraph
let baseName = 'scene'
if (baseSceneId) {
const loaded = await store.load(baseSceneId)
const loaded = await bridge.loadStoredScene(baseSceneId)
if (!loaded) {
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id: baseSceneId })
}
base = loaded.graph
baseName = loaded.name
} else {
const exported = bridge.exportJSON()
base = {
nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'],
}
base = bridge.exportSceneGraph()
}
// 2. Seed the RNG. Default seed is a time-ish number so runs vary, but
@@ -167,7 +157,7 @@ export function registerGenerateVariants(
if (save) {
try {
const meta = await store.save({
const meta = await bridge.saveScene({
name: `${baseName}-variant-${i + 1}`,
graph: forked,
})
+4 -11
View File
@@ -1,19 +1,12 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerGenerateVariants } from './generate-variants'
/**
* Register the variant-generation MCP tools against the given server. Uses the
* supplied `SceneStore` both to load a `baseSceneId` (when provided) and to
* persist variants when `save=true`.
* Register the variant-generation MCP tools against shared scene operations.
*/
export function registerVariantTools(
server: McpServer,
bridge: SceneBridge,
store: SceneStore,
): void {
registerGenerateVariants(server, bridge, store)
export function registerVariantTools(server: McpServer, bridge: SceneOperations): void {
registerGenerateVariants(server, bridge)
}
export {
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
/**
* Input shape for `analyze_floorplan_image`.
@@ -118,7 +118,7 @@ function extractText(
return texts.join('\n').trim()
}
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneBridge): void {
export function registerAnalyzeFloorplanImage(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'analyze_floorplan_image',
{
@@ -1,7 +1,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
/**
* Input shape for `analyze_room_photo`.
@@ -99,7 +99,7 @@ function extractText(
return texts.join('\n').trim()
}
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneBridge): void {
export function registerAnalyzeRoomPhoto(server: McpServer, _bridge: SceneOperations): void {
server.registerTool(
'analyze_room_photo',
{
+4 -4
View File
@@ -1,5 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneOperations } from '../../operations'
import { registerAnalyzeFloorplanImage } from './analyze-floorplan-image'
import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
@@ -9,9 +9,9 @@ import { registerAnalyzeRoomPhoto } from './analyze-room-photo'
* not advertise `sampling` support, calling either tool returns
* `sampling_unavailable`.
*/
export function registerVisionTools(server: McpServer, bridge: SceneBridge): void {
registerAnalyzeFloorplanImage(server, bridge)
registerAnalyzeRoomPhoto(server, bridge)
export function registerVisionTools(server: McpServer, operations: SceneOperations): void {
registerAnalyzeFloorplanImage(server, operations)
registerAnalyzeRoomPhoto(server, operations)
}
export {
+36
View File
@@ -62,3 +62,39 @@ test('connectHttp close() stops the server', async () => {
}
expect(didConnect).toBe(false)
})
test('connectHttp requires auth when binding a non-loopback host', async () => {
await expect(connectHttp(server, 0, { host: '0.0.0.0' })).rejects.toThrow(
/requires PASCAL_MCP_HTTP_TOKEN/,
)
})
test('connectHttp rejects unauthenticated requests when a token is configured', async () => {
handle = await connectHttp(server, 0, { authToken: 'secret' })
const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
})
expect(response.status).toBe(401)
})
test('connectHttp handles allowed CORS preflight', async () => {
handle = await connectHttp(server, 0, {
authToken: 'secret',
allowedOrigins: ['https://app.example'],
})
const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, {
method: 'OPTIONS',
headers: {
origin: 'https://app.example',
'access-control-request-method': 'POST',
},
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example')
})
+189 -6
View File
@@ -1,15 +1,38 @@
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_RATE_LIMIT_PER_MINUTE = 120
const WINDOW_MS = 60_000
const ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
const ALLOWED_HEADERS =
'authorization, content-type, mcp-session-id, mcp-protocol-version, x-pascal-mcp-token'
export type HttpTransportHandle = {
/** Host interface the server is listening on. */
host: string
/** Port the server is actually listening on (useful when caller passed 0). */
port: number
/** Gracefully close the HTTP server and the MCP transport. */
close(): Promise<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.
*
@@ -17,17 +40,36 @@ export type HttpTransportHandle = {
* `IncomingMessage`/`ServerResponse` directly via `handleRequest(req, res)`.
* A new session ID is generated per connection (stateful mode).
*
* Listens on `0.0.0.0:<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
* 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({
sessionIdGenerator: () => randomUUID(),
})
await server.connect(transport)
const httpServer = createServer((req, res) => {
if (!guard(req, res)) return
transport.handleRequest(req, res).catch((err) => {
// Log to stderr; never touch stdout (stdio transport uses it).
console.error('[pascal-mcp] http transport error', err)
@@ -52,13 +94,14 @@ export async function connectHttp(server: McpServer, port: number): Promise<Http
}
httpServer.once('error', onError)
httpServer.once('listening', onListening)
httpServer.listen(port)
httpServer.listen(port, host)
})
const address = httpServer.address()
const boundPort = typeof address === 'object' && address !== null ? address.port : port
return {
host,
port: boundPort,
close: async () => {
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
}