fix(mcp): add shared operations and secure scene APIs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
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',
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)])
|
||||
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,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',
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user