Files
editor/packages/mcp/test-reports/t2-http/run.ts
T
Adrian PerezandClaude Opus 4.7 e8d0b13ff5 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>
2026-04-18 19:29:28 +02:00

937 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* T2: Exercise every MCP tool over the HTTP transport.
*
* Target: http://localhost:3917/mcp (already running via `bun packages/mcp/dist/bin/pascal-mcp.js --http --port 3917`).
*
* Emits a pass/fail matrix plus latency percentiles for get_scene,
* and reports the behaviour of two concurrent sessions sharing the
* SceneBridge singleton.
*
* IMPORTANT: `connectHttp` in `packages/mcp/src/transports/http.ts`
* instantiates a SINGLE `StreamableHTTPServerTransport` with stateful
* session-id generation. The SDK's server transport sets `_initialized=true`
* on the first valid `initialize` POST and never clears it — meaning only
* ONE session is ever accepted for the lifetime of the process. If any prior
* client initialized, new clients receive:
*
* 400 {"error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}
*
* We detect this state, report it as an HTTP-specific finding, and emit a
* best-effort report.
*/
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'
type ToolResult = {
name: string
pass: boolean
note: string
latencyMs?: number
rawError?: string
}
const results: ToolResult[] = []
function record(
name: string,
pass: boolean,
note: string,
latencyMs?: number,
rawError?: string,
): void {
results.push({ name, pass, note, latencyMs, rawError })
const tag = pass ? 'PASS' : 'FAIL'
const lat = latencyMs !== undefined ? ` (${latencyMs.toFixed(1)}ms)` : ''
console.log(`[${tag}] ${name}${lat}${note}`)
}
/** Expected structured errors that count as passes. */
const EXPECTED_STRUCTURED_ERRORS = new Set([
'not_implemented',
'catalog_unavailable',
'sampling_unavailable',
'sampling_response_unparseable',
'sampling_response_invalid',
])
function errorIsExpected(err: unknown): { expected: boolean; label: string } {
const msg = err instanceof Error ? err.message : String(err)
for (const tok of EXPECTED_STRUCTURED_ERRORS) {
if (msg.includes(tok)) return { expected: true, label: tok }
}
return { expected: false, label: msg }
}
async function callTool<T = unknown>(
client: Client,
name: string,
args: Record<string, unknown>,
): Promise<
| { ok: true; result: unknown; latencyMs: number }
| { ok: false; error: unknown; latencyMs: number }
> {
const t0 = performance.now()
try {
const res = await client.callTool({ name, arguments: args })
const latencyMs = performance.now() - t0
const maybeIsError = (res as { isError?: boolean }).isError
if (maybeIsError === true) {
const text = Array.isArray(res.content)
? res.content
.filter((c) => (c as { type?: string }).type === 'text')
.map((c) => (c as { text: string }).text)
.join('\n')
: ''
return { ok: false, error: new Error(text || 'isError=true'), latencyMs }
}
return { ok: true, result: res, latencyMs }
} catch (err) {
const latencyMs = performance.now() - t0
return { ok: false, error: err, latencyMs }
}
}
function getStructured<T>(
result:
| { ok: true; result: unknown; latencyMs: number }
| { ok: false; error: unknown; latencyMs: number },
): T | null {
if (!result.ok) return null
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
if (textBlock?.text) {
try {
return JSON.parse(textBlock.text) as T
} catch {
return null
}
}
}
return null
}
async function connectClient(
label: string,
): Promise<{ client: Client; sessionId?: string } | { error: Error }> {
const transport = new StreamableHTTPClientTransport(new URL(TARGET_URL))
const client = new Client({ name: `t2-http-${label}`, version: '0.0.1' })
try {
await client.connect(transport)
} catch (err) {
return { error: err instanceof Error ? err : new Error(String(err)) }
}
const sid = (transport as unknown as { sessionId?: string }).sessionId
return { client, sessionId: sid }
}
/** Small curl-equivalent probe used to characterise server state. */
async function probeServer(): Promise<{ probed: string; status: number; body: string }[]> {
const probes: { name: string; init: RequestInit }[] = [
{
name: 'POST initialize (no session)',
init: {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 'probe-init',
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 't2-probe', version: '0.0.1' },
},
}),
},
},
{
name: 'POST tools/list (no session)',
init: {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
},
},
{
name: 'GET /mcp (no session)',
init: { method: 'GET', headers: { accept: 'text/event-stream' } },
},
{
name: 'DELETE /mcp (no session)',
init: { method: 'DELETE' },
},
]
const out: { probed: string; status: number; body: string }[] = []
for (const p of probes) {
try {
const res = await fetch(TARGET_URL, p.init)
const text = await res.text()
out.push({ probed: p.name, status: res.status, body: text.slice(0, 200) })
} catch (err) {
out.push({
probed: p.name,
status: -1,
body: err instanceof Error ? err.message : String(err),
})
}
}
return out
}
// ---- Main ----------------------------------------------------------------
async function main() {
console.log('=== T2 MCP HTTP transport smoke test ===')
console.log(`Target: ${TARGET_URL}`)
console.log('')
// ---- Server state probe ---------------------------------------------
console.log('--- Server state probe ---')
const probes = await probeServer()
for (const p of probes) {
console.log(` ${p.probed}${p.status} body: ${p.body}`)
}
console.log('')
const serverIsLocked = probes.some(
(p) =>
p.probed === 'POST initialize (no session)' &&
p.status === 400 &&
/Server already initialized/i.test(p.body),
)
// ---- Session A --------------------------------------------------------
console.log('--- Session A connect ---')
const connA = await connectClient('A')
let clientA: Client | null = null
let sidA: string | undefined
let initErrorA: string | null = null
if ('error' in connA) {
initErrorA = connA.error.message
console.error(`Session A connect FAILED: ${initErrorA}`)
} else {
clientA = connA.client
sidA = connA.sessionId
console.log(`Session A connected (sessionId: ${sidA ?? '<unknown>'})`)
}
// If we cannot connect, there is nothing left to do but report.
if (!clientA) {
const note = serverIsLocked
? 'server locked to an earlier session (StreamableHTTPServerTransport single-session stateful mode; `_initialized=true` is sticky)'
: `connect failed: ${initErrorA}`
for (const name of ALL_TOOLS) {
record(name, false, note)
}
const report = buildReport({
connectedA: false,
sidA: null,
sidB: null,
toolCountA1: 0,
toolCountA2: 0,
toolCountB: 0,
sessionStateStable: null,
distinctSessions: null,
sharedBridgeNote: 'n/a (could not connect)',
latencies: [],
serverIsLocked,
probes,
initErrorA,
})
writeFileSync(join(OUT_DIR, 'REPORT.md'), report, 'utf8')
console.log('')
console.log('=== SUMMARY ===')
console.log(`Passes: 0/${ALL_TOOLS.length}`)
console.log(`Server appears locked to an earlier session: ${serverIsLocked}`)
return
}
const toolsListA1 = await clientA.listTools()
const toolCountA1 = toolsListA1.tools.length
console.log(`Session A listTools()#1 → ${toolCountA1} tools`)
const toolsListA2 = await clientA.listTools()
const toolCountA2 = toolsListA2.tools.length
const sessionStateStable = toolCountA1 === toolCountA2
console.log(
`Session A listTools()#2 → ${toolCountA2} tools — state stable: ${sessionStateStable}`,
)
// ---- get_scene --------------------------------------------------------
const sceneRes = await callTool(clientA, 'get_scene', {})
record(
'get_scene',
sceneRes.ok,
sceneRes.ok ? 'scene returned ok' : `error: ${String(sceneRes.error)}`,
sceneRes.latencyMs,
)
const scene = getStructured<{
nodes: Record<string, { type: string; id: string; parentId: string | null }>
rootNodeIds: string[]
}>(sceneRes)
if (!scene) {
console.error('get_scene did not return usable scene; skipping downstream tool tests.')
const reason = 'cannot proceed — get_scene returned no structured content'
for (const name of ALL_TOOLS) {
if (!results.find((r) => r.name === name)) record(name, false, reason)
}
await clientA.close()
return
}
let buildingId: string | null = null
let levelId: string | null = null
for (const n of Object.values(scene.nodes)) {
if (!buildingId && n.type === 'building') buildingId = n.id
if (!levelId && n.type === 'level') levelId = n.id
}
console.log(`Discovered: building=${buildingId} level=${levelId}`)
if (!buildingId || !levelId) {
const reason = 'default scene missing building or level'
for (const name of ALL_TOOLS) {
if (!results.find((r) => r.name === name)) record(name, false, reason)
}
await clientA.close()
return
}
// ---- get_node ---------------------------------------------------------
{
const r = await callTool(clientA, 'get_node', { id: levelId })
const struct = getStructured<{ node: { id: string; type: string } }>(r)
record(
'get_node',
r.ok && struct?.node?.id === levelId,
r.ok ? `returned node ${struct?.node?.id}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- describe_node ----------------------------------------------------
{
const r = await callTool(clientA, 'describe_node', { id: levelId })
const struct = getStructured<{ id: string; description: string }>(r)
record(
'describe_node',
r.ok && struct?.id === levelId,
r.ok ? `description: "${struct?.description}"` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- find_nodes -------------------------------------------------------
{
const r = await callTool(clientA, 'find_nodes', { type: 'level' })
const struct = getStructured<{ nodes: unknown[] }>(r)
record(
'find_nodes',
r.ok && Array.isArray(struct?.nodes),
r.ok ? `found ${struct?.nodes?.length ?? 0} level nodes` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- measure ----------------------------------------------------------
{
const r = await callTool(clientA, 'measure', { fromId: levelId, toId: levelId })
const struct = getStructured<{ distanceMeters: number; units: string }>(r)
record(
'measure',
r.ok && struct?.units === 'meters',
r.ok ? `self-distance=${struct?.distanceMeters}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- create_level -----------------------------------------------------
let extraLevelId: string | null = null
{
const r = await callTool(clientA, 'create_level', {
buildingId,
elevation: 3,
height: 2.7,
label: 'T2-test-level',
})
const struct = getStructured<{ levelId: string }>(r)
extraLevelId = struct?.levelId ?? null
record(
'create_level',
r.ok && typeof struct?.levelId === 'string',
r.ok ? `created level ${struct?.levelId}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- create_wall ------------------------------------------------------
let wallId: string | null = null
{
const r = await callTool(clientA, 'create_wall', {
levelId,
start: [0, 0],
end: [3, 0],
thickness: 0.1,
height: 2.5,
})
const struct = getStructured<{ wallId: string }>(r)
wallId = struct?.wallId ?? null
record(
'create_wall',
r.ok && typeof struct?.wallId === 'string',
r.ok ? `created wall ${struct?.wallId}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- place_item -------------------------------------------------------
{
const target = wallId
if (!target) {
record('place_item', false, 'skipped — no wall id to place against')
} else {
const r = await callTool(clientA, 'place_item', {
catalogItemId: 'test-chair',
targetNodeId: target,
position: [1.5, 0, 0],
rotation: 0,
})
const struct = getStructured<{ itemId: string; status?: string }>(r)
record(
'place_item',
r.ok && typeof struct?.itemId === 'string',
r.ok
? `placed ${struct?.itemId} (status=${struct?.status ?? 'none'})`
: `error: ${String(r.error)}`,
r.latencyMs,
)
}
}
// ---- 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,
)
}
// ---- set_zone ---------------------------------------------------------
let zoneId: string | null = null
{
const r = await callTool(clientA, 'set_zone', {
levelId,
polygon: [
[0, 0],
[5, 0],
[5, 5],
[0, 5],
],
label: 'T2-zone',
properties: { owner: 't2-http' },
})
const struct = getStructured<{ zoneId: string }>(r)
zoneId = struct?.zoneId ?? null
record(
'set_zone',
r.ok && typeof struct?.zoneId === 'string',
r.ok ? `created zone ${struct?.zoneId}` : `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,
)
}
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 (!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 -------------------------------------------------------------
{
const r = await callTool(clientA, 'undo', { steps: 1 })
const struct = getStructured<{ undone: number }>(r)
record(
'undo',
r.ok && typeof struct?.undone === 'number',
r.ok ? `undone=${struct?.undone}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- redo -------------------------------------------------------------
{
const r = await callTool(clientA, 'redo', { steps: 1 })
const struct = getStructured<{ redone: number }>(r)
record(
'redo',
r.ok && typeof struct?.redone === 'number',
r.ok ? `redone=${struct?.redone}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- export_json ------------------------------------------------------
{
const r = await callTool(clientA, 'export_json', { pretty: true })
const struct = getStructured<{ json: string }>(r)
let usable = false
try {
if (struct?.json) {
JSON.parse(struct.json)
usable = true
}
} catch {
usable = false
}
record(
'export_json',
r.ok && usable,
r.ok ? `json length=${struct?.json?.length ?? 0} chars` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- export_glb -------------------------------------------------------
{
const r = await callTool(clientA, 'export_glb', {})
if (r.ok) {
const struct = getStructured<{ status: string; reason: string }>(r)
const good = struct?.status === 'not_implemented'
record(
'export_glb',
good,
good
? `structured not_implemented (expected)`
: `unexpected payload: ${JSON.stringify(struct)}`,
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'export_glb',
info.expected,
info.expected ? `structured error ${info.label} (expected)` : `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- validate_scene ---------------------------------------------------
{
const r = await callTool(clientA, 'validate_scene', {})
const struct = getStructured<{ valid: boolean; errors: unknown[] }>(r)
record(
'validate_scene',
r.ok && typeof struct?.valid === 'boolean',
r.ok
? `valid=${struct?.valid}, errors=${struct?.errors?.length ?? 0}`
: `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- check_collisions -------------------------------------------------
{
const r = await callTool(clientA, 'check_collisions', { levelId })
const struct = getStructured<{ collisions: unknown[] }>(r)
record(
'check_collisions',
r.ok && Array.isArray(struct?.collisions),
r.ok ? `collisions=${struct?.collisions?.length ?? 0}` : `error: ${String(r.error)}`,
r.latencyMs,
)
}
// ---- analyze_floorplan_image -----------------------------------------
{
const r = await callTool(clientA, 'analyze_floorplan_image', {
image: Buffer.from('not-a-real-image').toString('base64'),
scaleHint: '1 cm = 1 m',
})
if (r.ok) {
const struct = getStructured<unknown>(r)
record(
'analyze_floorplan_image',
struct !== null,
`host responded with structured payload (sampling apparently available)`,
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'analyze_floorplan_image',
info.expected,
info.expected
? `structured error ${info.label} (expected — host lacks sampling)`
: `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- analyze_room_photo ----------------------------------------------
{
const r = await callTool(clientA, 'analyze_room_photo', {
image: Buffer.from('not-a-real-image').toString('base64'),
})
if (r.ok) {
const struct = getStructured<unknown>(r)
record(
'analyze_room_photo',
struct !== null,
'host responded with structured payload',
r.latencyMs,
)
} else {
const info = errorIsExpected(r.error)
record(
'analyze_room_photo',
info.expected,
info.expected ? `structured error ${info.label} (expected)` : `unexpected: ${info.label}`,
r.latencyMs,
)
}
}
// ---- Second concurrent session (B) ------------------------------------
console.log('')
console.log('--- Session B connect (concurrent) ---')
const connB = await connectClient('B')
let sidB: string | undefined
let toolCountB = 0
let distinctSessions: boolean | null = null
let sharedBridgeNote = 'n/a'
if ('error' in connB) {
console.error(`Session B connect failed: ${connB.error.message}`)
sharedBridgeNote = `session B could not connect — server in single-session mode: ${connB.error.message.slice(0, 160)}`
} else {
const clientB = connB.client
sidB = connB.sessionId
console.log(`Session B connected (sessionId: ${sidB ?? '<unknown>'})`)
distinctSessions = sidA !== sidB
const toolsListB = await clientB.listTools()
toolCountB = toolsListB.tools.length
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)
if (sceneBstruct && scene) {
const nodesA = Object.keys(scene.nodes).length
const nodesB = Object.keys(sceneBstruct.nodes).length
sharedBridgeNote = `A initial snapshot: ${nodesA} nodes; B fresh snapshot: ${nodesB} nodes (both view the same SceneBridge singleton, so mutations from A are visible to B — expected)`
} else {
sharedBridgeNote = 'get_scene on session B returned no structured content'
}
await clientB.close()
}
// ---- Latency: 20 × get_scene on session A ---------------------------
console.log('')
console.log('--- Measuring latency of get_scene × 20 on session A ---')
const latencies: number[] = []
for (let i = 0; i < 20; i++) {
const r = await callTool(clientA, 'get_scene', {})
latencies.push(r.latencyMs)
}
const sorted = [...latencies].sort((a, b) => a - b)
const percentile = (p: number): number => {
const idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx] ?? 0
}
const p50 = percentile(50)
const p99 = percentile(99)
const mean = latencies.reduce((a, b) => a + b, 0) / latencies.length
const min = sorted[0] ?? 0
const max = sorted[sorted.length - 1] ?? 0
console.log(
`Latency: p50=${p50.toFixed(1)}ms, p99=${p99.toFixed(1)}ms, mean=${mean.toFixed(1)}ms, min=${min.toFixed(1)}ms, max=${max.toFixed(1)}ms`,
)
await clientA.close()
// ---- Summary + Report -----------------------------------------------
const passes = results.filter((r) => r.pass).length
const total = results.length
console.log('')
console.log('=== SUMMARY ===')
console.log(`Tools exercised: ${total}`)
console.log(`Passes: ${passes}/${total}`)
console.log(`Session state stable across two listTools: ${sessionStateStable}`)
console.log(`Two sessions got distinct IDs: ${distinctSessions}`)
console.log(`Shared SceneBridge observation: ${sharedBridgeNote}`)
console.log(`Latency p50=${p50.toFixed(1)}ms, p99=${p99.toFixed(1)}ms`)
const report = buildReport({
connectedA: true,
sidA: sidA ?? null,
sidB: sidB ?? null,
toolCountA1,
toolCountA2,
toolCountB,
sessionStateStable,
distinctSessions,
sharedBridgeNote,
latencies,
serverIsLocked,
probes,
initErrorA,
})
writeFileSync(join(OUT_DIR, 'REPORT.md'), report, 'utf8')
console.log('')
console.log(`Wrote ${join(OUT_DIR, 'REPORT.md')}`)
}
// Always-in-order list of tools so we can fill in "not tested" rows if we
// have to abort early.
const ALL_TOOLS = [
'get_scene',
'get_node',
'describe_node',
'find_nodes',
'measure',
'apply_patch',
'create_level',
'create_wall',
'place_item',
'cut_opening',
'set_zone',
'duplicate_level',
'delete_node',
'undo',
'redo',
'export_json',
'export_glb',
'validate_scene',
'check_collisions',
'analyze_floorplan_image',
'analyze_room_photo',
] as const
function buildReport(args: {
connectedA: boolean
sidA: string | null
sidB: string | null
toolCountA1: number
toolCountA2: number
toolCountB: number
sessionStateStable: boolean | null
distinctSessions: boolean | null
sharedBridgeNote: string
latencies: number[]
serverIsLocked: boolean
probes: { probed: string; status: number; body: string }[]
initErrorA: string | null
}): string {
const {
connectedA,
sidA,
sidB,
toolCountA1,
toolCountA2,
toolCountB,
sessionStateStable,
distinctSessions,
sharedBridgeNote,
latencies,
serverIsLocked,
probes,
initErrorA,
} = args
const sorted = [...latencies].sort((a, b) => a - b)
const percentile = (p: number): number => {
if (sorted.length === 0) return 0
const idx = Math.max(0, Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[idx] ?? 0
}
const p50 = percentile(50)
const p99 = percentile(99)
const mean = latencies.length ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0
const min = sorted[0] ?? 0
const max = sorted[sorted.length - 1] ?? 0
const passes = results.filter((r) => r.pass).length
const total = results.length
const rows = ALL_TOOLS.map((name) => {
const r = results.find((x) => x.name === name)
if (!r) return `| ${name} | NOT_RUN | | (tool was not reached) |`
const status = r.pass ? 'PASS' : 'FAIL'
const lat = r.latencyMs !== undefined ? `${r.latencyMs.toFixed(1)}` : ''
const note = r.note.replace(/\|/g, '\\|').replace(/\n/g, ' ')
return `| ${name} | ${status} | ${lat} | ${note} |`
}).join('\n')
const probeBlock = probes
.map((p) => `- ${p.probed}${p.status}: \`${p.body.replace(/`/g, '\\`').slice(0, 200)}\``)
.join('\n')
return `# T2 MCP HTTP transport report
Generated: ${new Date().toISOString()}
Target: ${TARGET_URL}
Transport: Streamable HTTP (${serverIsLocked ? 'single-session, already claimed' : 'single-session stateful'})
## Summary
- Tools exercised: ${total}
- Passes: ${passes}/${total}
- Expected tool count (21) on first listTools: ${toolCountA1 === 21 ? 'OK' : `(got ${toolCountA1})`}
- Session state stable across two listTools() calls: ${sessionStateStable ?? 'n/a'}
- Session A connected: ${connectedA}${sidA ? ` (id=${sidA})` : ''}${initErrorA ? ` — error: ${initErrorA}` : ''}
- Session B connected: ${sidB ? `yes (id=${sidB})` : 'no'}
- Two clients got distinct session IDs: ${distinctSessions ?? 'n/a'}
- Session B listTools count: ${toolCountB || 'n/a'}
- Shared SceneBridge observation: ${sharedBridgeNote}
## Latency (get_scene × ${latencies.length} on session A)
| Metric | ms |
|--------|----|
| p50 | ${p50.toFixed(1)} |
| p99 | ${p99.toFixed(1)} |
| mean | ${mean.toFixed(1)} |
| min | ${min.toFixed(1)} |
| max | ${max.toFixed(1)} |
${
latencies.length
? `Individual samples (ms): ${latencies.map((l) => l.toFixed(1)).join(', ')}`
: 'No latency samples were captured (could not connect).'
}
## Pass/Fail matrix
| Tool | Status | Latency (ms) | Note |
|------|--------|--------------|------|
${rows}
## Server state probes
Before the SDK-based test run, these HTTP probes were executed:
${probeBlock}
## HTTP-specific quirks
- \`packages/mcp/src/transports/http.ts\` uses a single
\`StreamableHTTPServerTransport\` per process with stateful session-id
generation. The SDK's transport sets \`_initialized=true\` on the first
valid \`initialize\` POST and never clears it. Consequence: the running
server can only ever accept **one** session for its lifetime; subsequent
\`initialize\` requests receive HTTP 400 \`{"code":-32600,"message":"Invalid Request: Server already initialized"}\`.
- Because both sessions (when connect succeeds) share the same
\`SceneBridge\` singleton, any mutation made on one session is visible to
the other. This is expected given the server holds one bridge process-wide.
- \`not_implemented\`, \`catalog_unavailable\`, and \`sampling_unavailable\`
responses are treated as passes per the agreed test protocol.
## Notes
${
serverIsLocked
? '- The running server was already claimed by an earlier client before this run started; we could not open a new session. See the server-state probes above for reproducers.'
: '- Server was in a clean state and accepted both sessions.'
}
`
}
main().catch((err) => {
console.error('FATAL:', err)
try {
const partial = `# T2 MCP HTTP transport report — FATAL
Fatal error during run: ${err instanceof Error ? err.stack : String(err)}
Results so far:
${results
.map(
(r) =>
`- [${r.pass ? 'PASS' : 'FAIL'}] ${r.name}${r.note}${
r.latencyMs !== undefined ? ` (${r.latencyMs.toFixed(1)}ms)` : ''
}`,
)
.join('\n')}
`
writeFileSync(join(OUT_DIR, 'REPORT.md'), partial, 'utf8')
} catch {
// ignore
}
process.exit(1)
})