Files
editor/packages/mcp/test-reports/villa-azul/v9-spatial.ts
T
Adrian PerezandClaude Opus 4.7 f230d9a401 test(mcp): Villa Azul + 10-agent deep verification
Builds a larger, richer house than Casa del Sol via MCP save_scene
(no injection hack), then dispatches 10 parallel verifiers across
schema, geometry, dimensions, openings, HTTP, page render,
parentage, round-trip, spatial, visual.

Villa Azul — 56 nodes, validate_scene=true, 44KB on disk:
- 15x10m building envelope (vs Casa del Sol's 12x8)
- 9 interior zones (master bed/bath, bed 2/3, shared bath,
  living/dining, kitchen, entry hall, corridor)
- 10 doors + 12 windows (all cut successfully)
- 4 exterior zones (pool 8x4 + basin slab at -2m, outdoor kitchen,
  driveway, back patio)
- 5 rail-style fences (vs Casa del Sol's privacy) with 2m entrance gap

Verification: 108 checks, 104 PASS, 4 findings:
- V1 schema: 56/56
- V2 geometry: 7/7 (perimeter closes, interior T-junctions, no
  zone overlaps, fence gap verified)
- V3 dimensions: 13/13 zone areas exact (1 spec mismatch on site
  polygon default, not a build bug)
- V4 openings: 22/22 dimensional fit, surfaced a tool gap in
  cut_opening (no adjacency check) + my build packed too tightly
- V5 HTTP: 10/10 (GET/PUT/PATCH/DELETE/HEAD, If-Match conflicts)
- V6 page: 14/14 (/scene/:id 81KB, /scenes 20KB, 404 fallback)
- V7 parentage: surfaced CROSS_CUTTING §2 site->building->level
  parentId=null (pre-existing in core's loadScene)
- V8 round-trip: 10/10 byte-equal, duplicate_level -> 110 nodes
- V9 spatial: 12/12 (find_nodes, measure, constraints resource)
- V10 visual: HTML fallback (Chrome extension disconnected during
  run); API layer intact

Follow-up tracked: `cut_opening` should check opening-adjacency on
the same wall (minimum gap) to catch tight packing during patch
construction. Currently returns success and relies on the UI to
visualise the overlap.

Live at http://localhost:3002/scene/a6e7919eacbe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:39:08 +02:00

379 lines
14 KiB
TypeScript

/**
* Villa Azul - Phase 9 Verifier V9: Spatial queries + MCP tool validation.
*
* Spawns stdio MCP against PASCAL_DATA_DIR=/tmp/pascal-villa, loads Villa Azul,
* and exercises find_nodes / describe_node / measure / check_collisions /
* get_node / zone-filter plus the scene-summary and constraints resources.
*
* Usage:
* PASCAL_DATA_DIR=/tmp/pascal-villa \
* bun run packages/mcp/test-reports/villa-azul/v9-spatial.ts
*/
import { writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const REPO_ROOT = resolve(__dirname, '../../../..')
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
const REPORT_PATH = resolve(__dirname, 'v9-spatial.md')
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-villa'
const SCENE_ID = 'a6e7919eacbe'
type Status = 'PASS' | 'FAIL'
type Row = { name: string; status: Status; note: string }
const rows: Row[] = []
function record(name: string, status: Status, note: string): void {
rows.push({ name, status, note })
const tag = status === 'PASS' ? '[PASS]' : '[FAIL]'
console.log(`${tag} ${name} - ${note}`)
}
function pickText(result: { content?: unknown }): string {
const content = result.content as Array<{ type?: string; text?: string }> | undefined
if (!Array.isArray(content) || content.length === 0) return ''
return content[0]?.text ?? ''
}
type Node = {
id: string
type: string
name?: string
polygon?: Array<[number, number]>
elevation?: number
parentId?: string | null
position?: [number, number, number]
start?: [number, number]
end?: [number, number]
[k: string]: unknown
}
async function main(): Promise<void> {
const t0 = Date.now()
console.log('---- Phase 9 V9 spatial + MCP tool validation ----')
console.log(`BIN=${BIN_PATH}`)
console.log(`DATA_DIR=${DATA_DIR}`)
console.log(`SCENE_ID=${SCENE_ID}`)
const transport = new StdioClientTransport({
command: 'bun',
args: [BIN_PATH, '--stdio'],
stderr: 'inherit',
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
})
const client = new Client({ name: 'v9-spatial', version: '0.0.0' })
await client.connect(transport)
console.log('OK client connected')
async function callTool<T = Record<string, unknown>>(
name: string,
args: Record<string, unknown> = {},
): Promise<{ ok: boolean; structured?: T; text: string; err?: string }> {
try {
const res = (await client.callTool({ name, arguments: args })) as {
isError?: boolean
structuredContent?: T
content?: unknown
}
const text = pickText(res)
if (res.isError) {
return { ok: false, structured: res.structuredContent, text, err: text }
}
return { ok: true, structured: res.structuredContent, text }
} catch (err) {
return { ok: false, text: '', err: err instanceof Error ? err.message : String(err) }
}
}
try {
// --- load the Villa Azul scene ---
const loadRes = await callTool<{ id: string; name: string; nodeCount: number }>('load_scene', {
id: SCENE_ID,
})
if (loadRes.ok && loadRes.structured?.id === SCENE_ID) {
record(
'load_scene',
'PASS',
`id=${loadRes.structured.id} name='${loadRes.structured.name}' nodes=${loadRes.structured.nodeCount}`,
)
} else {
record('load_scene', 'FAIL', loadRes.err ?? 'scene did not load')
throw new Error(`load_scene failed: ${loadRes.err ?? 'unknown'}`)
}
// Discover levelId via find_nodes.
const levels = await callTool<{ nodes: Node[] }>('find_nodes', { type: 'level' })
const levelId = levels.structured?.nodes?.[0]?.id
if (!levelId) throw new Error('no level found in Villa Azul')
console.log(`levelId=${levelId}`)
// -----------------------------------------------------------------
// 1. find_nodes zones on level -> 13
// -----------------------------------------------------------------
const zonesRes = await callTool<{ nodes: Node[] }>('find_nodes', {
type: 'zone',
levelId,
})
const zones = zonesRes.structured?.nodes ?? []
record(
'1. find_nodes(zone, levelId)',
zonesRes.ok && zones.length === 13 ? 'PASS' : 'FAIL',
`got ${zones.length} (expected 13)`,
)
// Index zones by name for later lookups.
const zoneByName = new Map<string, Node>()
for (const z of zones) {
if (typeof z.name === 'string') zoneByName.set(z.name, z)
}
// -----------------------------------------------------------------
// 2. find_nodes doors -> 10
// -----------------------------------------------------------------
const doorsRes = await callTool<{ nodes: Node[] }>('find_nodes', { type: 'door' })
const doors = doorsRes.structured?.nodes ?? []
record(
'2. find_nodes(door)',
doorsRes.ok && doors.length === 10 ? 'PASS' : 'FAIL',
`got ${doors.length} (expected 10)`,
)
// -----------------------------------------------------------------
// 3. find_nodes windows -> 12
// -----------------------------------------------------------------
const winsRes = await callTool<{ nodes: Node[] }>('find_nodes', { type: 'window' })
const wins = winsRes.structured?.nodes ?? []
record(
'3. find_nodes(window)',
winsRes.ok && wins.length === 12 ? 'PASS' : 'FAIL',
`got ${wins.length} (expected 12)`,
)
// -----------------------------------------------------------------
// 4. find_nodes fences -> 5
// -----------------------------------------------------------------
const fencesRes = await callTool<{ nodes: Node[] }>('find_nodes', { type: 'fence' })
const fences = fencesRes.structured?.nodes ?? []
record(
'4. find_nodes(fence)',
fencesRes.ok && fences.length === 5 ? 'PASS' : 'FAIL',
`got ${fences.length} (expected 5)`,
)
// -----------------------------------------------------------------
// 5. describe_node on Living dining zone
// -----------------------------------------------------------------
const livingDining = zoneByName.get('Living dining')
if (!livingDining) {
record('5. describe_node(living-dining)', 'FAIL', 'Living dining zone not found')
} else {
const dn = await callTool<{ type: string; description: string }>('describe_node', {
id: livingDining.id,
})
const desc = dn.structured?.description ?? ''
const hasZone = /zone/i.test(desc)
// "area hint" = vertex count, name, or polygon dims. describe() returns
// `Zone "<name>" with <N> vertices` which is the area hint.
const hasAreaHint = /"Living dining"/.test(desc) && /\d+\s*vertices/i.test(desc)
record(
'5. describe_node(living-dining)',
dn.ok && hasZone && hasAreaHint ? 'PASS' : 'FAIL',
`desc='${desc}'`,
)
}
// -----------------------------------------------------------------
// 6. measure master-bedroom -> pool (expect > 10 m)
// -----------------------------------------------------------------
const master = zoneByName.get('Master bedroom')
const pool = zoneByName.get('Pool')
if (!master || !pool) {
record('6. measure(master,pool)', 'FAIL', 'missing zone(s)')
} else {
const m = await callTool<{ distanceMeters: number }>('measure', {
fromId: master.id,
toId: pool.id,
})
const d = m.structured?.distanceMeters ?? -1
record(
'6. measure(master,pool)',
m.ok && d > 10 ? 'PASS' : 'FAIL',
`distance=${d.toFixed(3)}m (expected > 10m)`,
)
}
// -----------------------------------------------------------------
// 7. check_collisions on level -> 0
// -----------------------------------------------------------------
const coll = await callTool<{ collisions: unknown[] }>('check_collisions', { levelId })
const collCount = coll.structured?.collisions?.length ?? -1
record(
'7. check_collisions',
coll.ok && collCount === 0 ? 'PASS' : 'FAIL',
`collisions=${collCount} (expected 0)`,
)
// -----------------------------------------------------------------
// 8. get_node on pool slab -> elevation -2
// -----------------------------------------------------------------
const slabsRes = await callTool<{ nodes: Node[] }>('find_nodes', { type: 'slab' })
const slabs = slabsRes.structured?.nodes ?? []
// pool basin was created with metadata.kind='pool-basin' and elevation -2.
const poolSlab =
slabs.find((s) => {
const meta = (s as { metadata?: { kind?: string } }).metadata
return meta?.kind === 'pool-basin'
}) ??
slabs.find((s) => typeof s.elevation === 'number' && Math.abs((s.elevation ?? 0) + 2) < 1e-6)
if (!poolSlab) {
record('8. get_node(pool-slab)', 'FAIL', `no pool slab among ${slabs.length} slabs`)
} else {
const gn = await callTool<{ node: Node }>('get_node', { id: poolSlab.id })
const elev = gn.structured?.node?.elevation
record(
'8. get_node(pool-slab)',
gn.ok && elev === -2 ? 'PASS' : 'FAIL',
`slab ${poolSlab.id} elevation=${String(elev)} (expected -2)`,
)
}
// -----------------------------------------------------------------
// 9. find_nodes with zoneId=<Living dining> -> doors/windows whose
// representative point falls in that polygon.
// -----------------------------------------------------------------
if (!livingDining) {
record('9. find_nodes(zoneId=living-dining)', 'FAIL', 'Living dining zone missing')
} else {
const zr = await callTool<{ nodes: Node[] }>('find_nodes', {
zoneId: livingDining.id,
})
const hits = zr.structured?.nodes ?? []
const hitTypes = hits.reduce<Record<string, number>>((acc, n) => {
acc[n.type] = (acc[n.type] ?? 0) + 1
return acc
}, {})
const hitDoorOrWindow = hits.some((n) => n.type === 'door' || n.type === 'window')
record(
'9. find_nodes(zoneId=living-dining)',
zr.ok && hitDoorOrWindow ? 'PASS' : 'FAIL',
`${hits.length} nodes in polygon, types=${JSON.stringify(hitTypes)}`,
)
}
// -----------------------------------------------------------------
// 10. resource pascal://scene/current/summary
// expect markdown, zone count 13 or "Villa Azul".
// -----------------------------------------------------------------
try {
const r = await client.readResource({ uri: 'pascal://scene/current/summary' })
const c = r.contents?.[0]
const text = typeof c?.text === 'string' ? c.text : ''
const mime = String(c?.mimeType ?? '')
const hasName = /Villa Azul/.test(text)
// look for either a "zone=13" or "zone = 13" style token
const hasZoneCount = /zone=13/.test(text)
const ok = mime === 'text/markdown' && (hasName || hasZoneCount)
record(
'10. resource scene/current/summary',
ok ? 'PASS' : 'FAIL',
`mime=${mime} bytes=${text.length} hasVillaAzul=${hasName} hasZone=13=${hasZoneCount}`,
)
} catch (err) {
record(
'10. resource scene/current/summary',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
// -----------------------------------------------------------------
// 11. resource pascal://constraints/{levelId}
// expect slabs + wallPolygons arrays, slabs include the pool.
// -----------------------------------------------------------------
try {
const r = await client.readResource({ uri: `pascal://constraints/${levelId}` })
const c = r.contents?.[0]
const text = typeof c?.text === 'string' ? c.text : ''
const mime = String(c?.mimeType ?? '')
const parsed = JSON.parse(text) as {
slabs?: Array<{ id: string; elevation?: number; metadata?: { kind?: string } }>
wallPolygons?: Array<{ wallId: string; footprint: Array<[number, number]> }>
error?: string
}
const slabCount = Array.isArray(parsed.slabs) ? parsed.slabs.length : -1
const wallPolyCount = Array.isArray(parsed.wallPolygons) ? parsed.wallPolygons.length : -1
const poolInSlabs = Array.isArray(parsed.slabs)
? parsed.slabs.some(
(s) =>
s.metadata?.kind === 'pool-basin' ||
(typeof s.elevation === 'number' && Math.abs(s.elevation + 2) < 1e-6),
)
: false
const ok =
mime === 'application/json' &&
!parsed.error &&
slabCount > 0 &&
wallPolyCount > 0 &&
poolInSlabs
record(
'11. resource constraints/{levelId}',
ok ? 'PASS' : 'FAIL',
`mime=${mime} slabs=${slabCount} wallPolygons=${wallPolyCount} poolInSlabs=${poolInSlabs}`,
)
} catch (err) {
record(
'11. resource constraints/{levelId}',
'FAIL',
`threw: ${err instanceof Error ? err.message : String(err)}`,
)
}
} finally {
try {
await client.close()
} catch {
/* ignore */
}
}
const pass = rows.filter((r) => r.status === 'PASS').length
const fail = rows.length - pass
const overall = fail === 0 ? 'PASS' : 'FAIL'
const durationMs = Date.now() - t0
// --- write report ---
const lines: string[] = []
lines.push('# Villa Azul - V9 Spatial + MCP Tool Validation Report')
lines.push('')
lines.push(`- Scene id: \`${SCENE_ID}\``)
lines.push(`- Data dir: \`${DATA_DIR}\``)
lines.push(`- Transport: stdio (\`${BIN_PATH}\`)`)
lines.push(`- Generated: ${new Date().toISOString()}`)
lines.push(`- Duration: ${durationMs} ms`)
lines.push('')
lines.push('## Results')
lines.push('')
lines.push('| # | Check | Status | Note |')
lines.push('|---|---|:---:|---|')
for (const r of rows) {
lines.push(`| | ${r.name} | ${r.status} | ${r.note} |`)
}
lines.push('')
lines.push('## Summary')
lines.push('')
lines.push(`- Pass: ${pass}/${rows.length}`)
lines.push(`- Fail: ${fail}`)
lines.push(`- Overall: ${overall}`)
writeFileSync(REPORT_PATH, `${lines.join('\n')}\n`, 'utf8')
console.log(`\nwrote ${REPORT_PATH}`)
console.log(`Overall: ${overall} (${pass}/${rows.length})`)
}
main().catch((err) => {
console.error('[v9-spatial] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
process.exit(1)
})