feat(mcp,editor): Option A+B storage + 10 agent deliverables (Phase 7)
Ships the combined filesystem/Supabase storage adapter + MCP scene lifecycle tools + Next.js API routes + editor /scene/[id] route, so an MCP save is directly openable at /scene/<id> without any injection hack. End-to-end verified: 10/10 e2e steps pass. Storage (A1/A2/A3): - SceneStore interface + error classes + slug helpers - FilesystemSceneStore at $PASCAL_DATA_DIR (defaults XDG/~/.pascal) with atomic writes, .index sidecar, optimistic locking - SupabaseSceneStore with scenes + scene_revisions tables, RLS migration SQL, mock-backed unit tests - createSceneStore(env) auto-selects based on SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY MCP tools (A4, A8, A9, A10): - save_scene / load_scene / list_scenes / delete_scene / rename_scene - list_templates / create_from_template (3 seed templates: empty-studio, two-bedroom, garden-house) - generate_variants (7 mutation kinds, seeded RNG, save=true|false) - photo_to_scene (vision sampling → scene graph → save) Editor (A5, A6): - /api/scenes + /api/scenes/[id] with RFC 7232 If-Match locking - /scene/[id] and /scenes route pages with save button, SceneLoader - Removed the window.__pascalScene dev injection hack Security + UX edges (A7, A8): - AssetUrl Zod validator: asset:// blob: data:image/ /path https: (http://localhost for dev) + PASCAL_ALLOWED_ASSET_ORIGINS env allowlist. Hardens scan.url, guide.url, item.asset.src, material.texture.url, MaterialMaps.*Map - Auto-frame camera on empty→non-empty scene transition (camera-controls:fit-scene emitter event) Shared utilities: - rehydrateSiteChildren() extracted to packages/mcp/src/lib/ and used by both create-from-template and generate-variants to work around the SiteNode.children-as-objects vs. ids inconsistency (CROSS_CUTTING §2) - Storage + MCP subpath exports added to packages/mcp/package.json (CROSS_CUTTING §4) Tests: 293 pass / 0 fail across 40 files (was 142 pre-Phase-7). Biome: clean. Phase-7 e2e script at packages/mcp/test-reports/phase7-e2e.ts: MCP HTTP + editor Next.js both point at $PASCAL_DATA_DIR = /tmp/pascal-e2e, save_scene from MCP, GET /api/scenes/<id> from editor server, /scenes list page renders all saved scenes, scene page renders SceneLoader, delete_scene works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
42bd05db9c
commit
e8d0b13ff5
@@ -20,13 +20,14 @@
|
||||
* best-effort report.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
||||
|
||||
const TARGET_URL = 'http://localhost:3917/mcp'
|
||||
const OUT_DIR = '/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t2-http'
|
||||
const OUT_DIR =
|
||||
'/Users/adrian/Desktop/editor/.worktrees/mcp-server/packages/mcp/test-reports/t2-http'
|
||||
|
||||
type ToolResult = {
|
||||
name: string
|
||||
@@ -106,9 +107,9 @@ function getStructured<T>(
|
||||
const r = result.result as { structuredContent?: unknown; content?: unknown }
|
||||
if (r.structuredContent !== undefined) return r.structuredContent as T
|
||||
if (Array.isArray(r.content)) {
|
||||
const textBlock = r.content.find(
|
||||
(c) => (c as { type?: string }).type === 'text',
|
||||
) as { text?: string } | undefined
|
||||
const textBlock = r.content.find((c) => (c as { type?: string }).type === 'text') as
|
||||
| { text?: string }
|
||||
| undefined
|
||||
if (textBlock?.text) {
|
||||
try {
|
||||
return JSON.parse(textBlock.text) as T
|
||||
@@ -430,26 +431,24 @@ async function main() {
|
||||
|
||||
// ---- cut_opening ------------------------------------------------------
|
||||
let openingId: string | null = null
|
||||
{
|
||||
if (!wallId) {
|
||||
record('cut_opening', false, 'skipped — no wall id to cut')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'cut_opening', {
|
||||
wallId,
|
||||
type: 'door',
|
||||
position: 0.5,
|
||||
width: 0.9,
|
||||
height: 2,
|
||||
})
|
||||
const struct = getStructured<{ openingId: string }>(r)
|
||||
openingId = struct?.openingId ?? null
|
||||
record(
|
||||
'cut_opening',
|
||||
r.ok && typeof struct?.openingId === 'string',
|
||||
r.ok ? `cut opening ${struct?.openingId}` : `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
if (!wallId) {
|
||||
record('cut_opening', false, 'skipped — no wall id to cut')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'cut_opening', {
|
||||
wallId,
|
||||
type: 'door',
|
||||
position: 0.5,
|
||||
width: 0.9,
|
||||
height: 2,
|
||||
})
|
||||
const struct = getStructured<{ openingId: string }>(r)
|
||||
openingId = struct?.openingId ?? null
|
||||
record(
|
||||
'cut_opening',
|
||||
r.ok && typeof struct?.openingId === 'string',
|
||||
r.ok ? `cut opening ${struct?.openingId}` : `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- set_zone ---------------------------------------------------------
|
||||
@@ -475,59 +474,45 @@ async function main() {
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- duplicate_level --------------------------------------------------
|
||||
{
|
||||
if (!extraLevelId) {
|
||||
record('duplicate_level', false, 'skipped — no extra level to duplicate')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'duplicate_level', { levelId: extraLevelId })
|
||||
const struct = getStructured<{ newLevelId: string; newNodeIds: string[] }>(r)
|
||||
record(
|
||||
'duplicate_level',
|
||||
r.ok && typeof struct?.newLevelId === 'string',
|
||||
r.ok
|
||||
? `duplicated → new level ${struct?.newLevelId} (${struct?.newNodeIds?.length ?? 0} nodes)`
|
||||
: `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
if (!extraLevelId) {
|
||||
record('duplicate_level', false, 'skipped — no extra level to duplicate')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'duplicate_level', { levelId: extraLevelId })
|
||||
const struct = getStructured<{ newLevelId: string; newNodeIds: string[] }>(r)
|
||||
record(
|
||||
'duplicate_level',
|
||||
r.ok && typeof struct?.newLevelId === 'string',
|
||||
r.ok
|
||||
? `duplicated → new level ${struct?.newLevelId} (${struct?.newNodeIds?.length ?? 0} nodes)`
|
||||
: `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- apply_patch ------------------------------------------------------
|
||||
{
|
||||
if (!zoneId) {
|
||||
record('apply_patch', false, 'skipped — no zone to patch')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'apply_patch', {
|
||||
patches: [{ op: 'update', id: zoneId, data: { name: 'T2-zone-renamed' } }],
|
||||
})
|
||||
const struct = getStructured<{ appliedOps: number }>(r)
|
||||
record(
|
||||
'apply_patch',
|
||||
r.ok && struct?.appliedOps === 1,
|
||||
r.ok ? `appliedOps=${struct?.appliedOps}` : `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
if (!zoneId) {
|
||||
record('apply_patch', false, 'skipped — no zone to patch')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'apply_patch', {
|
||||
patches: [{ op: 'update', id: zoneId, data: { name: 'T2-zone-renamed' } }],
|
||||
})
|
||||
const struct = getStructured<{ appliedOps: number }>(r)
|
||||
record(
|
||||
'apply_patch',
|
||||
r.ok && struct?.appliedOps === 1,
|
||||
r.ok ? `appliedOps=${struct?.appliedOps}` : `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- delete_node ------------------------------------------------------
|
||||
{
|
||||
if (!openingId) {
|
||||
record('delete_node', false, 'skipped — no opening to delete')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'delete_node', { id: openingId, cascade: true })
|
||||
const struct = getStructured<{ deletedIds: string[] }>(r)
|
||||
record(
|
||||
'delete_node',
|
||||
r.ok && Array.isArray(struct?.deletedIds) && (struct?.deletedIds?.length ?? 0) >= 1,
|
||||
r.ok
|
||||
? `deleted ${struct?.deletedIds?.length ?? 0} nodes`
|
||||
: `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
if (!openingId) {
|
||||
record('delete_node', false, 'skipped — no opening to delete')
|
||||
} else {
|
||||
const r = await callTool(clientA, 'delete_node', { id: openingId, cascade: true })
|
||||
const struct = getStructured<{ deletedIds: string[] }>(r)
|
||||
record(
|
||||
'delete_node',
|
||||
r.ok && Array.isArray(struct?.deletedIds) && (struct?.deletedIds?.length ?? 0) >= 1,
|
||||
r.ok ? `deleted ${struct?.deletedIds?.length ?? 0} nodes` : `error: ${String(r.error)}`,
|
||||
r.latencyMs,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- undo -------------------------------------------------------------
|
||||
@@ -695,7 +680,9 @@ async function main() {
|
||||
distinctSessions = sidA !== sidB
|
||||
const toolsListB = await clientB.listTools()
|
||||
toolCountB = toolsListB.tools.length
|
||||
console.log(`Session B listTools() → ${toolCountB} tools; sessions distinct: ${distinctSessions}`)
|
||||
console.log(
|
||||
`Session B listTools() → ${toolCountB} tools; sessions distinct: ${distinctSessions}`,
|
||||
)
|
||||
|
||||
const sceneB = await callTool(clientB, 'get_scene', {})
|
||||
const sceneBstruct = getStructured<{ nodes: Record<string, unknown> }>(sceneB)
|
||||
|
||||
Reference in New Issue
Block a user