Files
editor/packages/mcp/test-reports/t2-http/run.ts
T
Adrian PerezandClaude Opus 4.7 b37e88cb83 fix(mcp): apply_patch preserves schema-defaulted ids in multi-op batches
SceneBridge.applyPatch now threads the Zod-parsed node through to the
apply phase instead of the raw input. Previously, when a caller sent a
create patch without an `id` field, the schema's objectId default ran
during dry-run parsing but only in `res.data`; the apply phase pushed
the unparsed `p.node` (no id) to the store, so subsequent tools that
walked `level.children` crashed on undefined entries (e.g.
duplicate_level -> cloneLevelSubtree -> extractIdPrefix(undefined)).

Also adds test-reports/ artefacts from live end-to-end testing:
- t1-stdio: 21/21 tools pass via stdio (~106ms)
- t2-http: connect/single-session behaviour (HTTP transport quirk
  documented)
- t3-scenario: 2-bedroom apartment built end-to-end — 12/12 steps
  after this fix (24 final nodes, validate=true, apartment.json
  exported)
- t4-errors: 24/24 invalid-input cases rejected with proper MCP errors
- t5-resources-prompts: 4/4 resources, 3/3 prompts, dev server
  /api/health 200 OK

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

950 lines
30 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 { 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'
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,
)
}
// ---- 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,
)
}
}
// ---- 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,
)
}
}
// ---- 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,
)
}
}
// ---- 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)
})