@triscope/mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,2678 @@
1
+ // Triscope MCP server.
2
+ //
3
+ // Exposes tools that talk to a running triscope dev server (default
4
+ // http://localhost:5173) and the on-disk telemetry sink it writes:
5
+ // - list_elements : GET /__manifest
6
+ // - read_telemetry : read /tmp/<project>-state.json, optional jq-style path
7
+ // - set_knob : POST /__knob (harness polls and applies live)
8
+ // - capture_views : drives a fresh headed Chromium via CDP, calls
9
+ // window.__TRISCOPE__.captureViews(), writes per-camera
10
+ // PNGs to /tmp/<project>-capture-<element>/<camera>.png
11
+ // - run_smoke : spawns `triscope smoke <element>` and returns pass/fail
12
+ //
13
+ // Configuration env vars:
14
+ // TRISCOPE_URL (default http://localhost:5173)
15
+ // TRISCOPE_PROJECT (default: derived from process.cwd()/package.json#name)
16
+ // TRISCOPE_DEBUG_PORT (default 9230)
17
+ // CHROME_BIN (default 'chromium')
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+
24
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
25
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
26
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
27
+ import { PNG } from 'pngjs';
28
+ import { z } from 'zod';
29
+ import { createBrowserPool } from './browser.js';
30
+ import { createLogger } from './logger.js';
31
+ import { coordinateDescent, knobMatches } from './optimize.js';
32
+ import {
33
+ composeFilmstrip,
34
+ diffReference,
35
+ diffReferenceMotion,
36
+ motionMagnitudeFromFrames,
37
+ refsMotionPaths,
38
+ refsPath,
39
+ setReference,
40
+ setReferenceMotion,
41
+ } from './refs.js';
42
+ import { evaluateTargets } from './targets.js';
43
+
44
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
45
+
46
+ export function readProjectName(cwd) {
47
+ if (process.env.TRISCOPE_PROJECT) return process.env.TRISCOPE_PROJECT;
48
+ try {
49
+ const p = join(cwd, 'package.json');
50
+ if (!existsSync(p)) return 'triscope-project';
51
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
52
+ return String(pkg.name ?? 'triscope-project').replace(/[^A-Za-z0-9._-]/g, '-');
53
+ } catch {
54
+ return 'triscope-project';
55
+ }
56
+ }
57
+
58
+ const DEV_URL = (process.env.TRISCOPE_URL ?? 'http://localhost:5173').replace(/\/$/, '');
59
+ const PROJECT = readProjectName(process.cwd());
60
+ const STATE_PATH = join(tmpdir(), `${PROJECT}-state.json`);
61
+ // Inline image payload safety cap. MCP stdio JSON-RPC has practical message
62
+ // size limits and ours exceeded them at 12 cameras × 1280×720 PNG base64 →
63
+ // the server process got OOM-killed mid-response. Override with
64
+ // TRISCOPE_INLINE_PAYLOAD_BUDGET (bytes) if you really need bigger.
65
+ const INLINE_PAYLOAD_BUDGET = Number(process.env.TRISCOPE_INLINE_PAYLOAD_BUDGET ?? 1024 * 1024);
66
+
67
+ // ---- Resilience: ring buffer of recent errors + process-level handlers --
68
+ // A single rogue async exception used to crash the entire MCP server (the
69
+ // process died, Claude Code didn't auto-restart it, and subsequent calls
70
+ // timed out with cryptic "Connection closed"). Now we log and continue so
71
+ // individual tool failures stay isolated from the server lifecycle.
72
+ const SERVER_START_TIME = Date.now();
73
+ const RECENT_ERRORS_CAP = 16;
74
+ const recentErrors: string[] = [];
75
+ const logger = createLogger(PROJECT);
76
+ const browserPool = createBrowserPool({ logger });
77
+ const shutdown = () => browserPool.dispose();
78
+ process.on('exit', shutdown);
79
+ process.on('SIGINT', () => {
80
+ shutdown();
81
+ process.exit(130);
82
+ });
83
+ process.on('SIGTERM', () => {
84
+ shutdown();
85
+ process.exit(143);
86
+ });
87
+ export function recordError(source: string, err: unknown) {
88
+ const detail = (err as any)?.stack ?? (err as any)?.message ?? String(err);
89
+ const msg = `[${new Date().toISOString()}] ${source}: ${detail}`;
90
+ logger.error(source, String((err as any)?.message ?? err), { stack: (err as any)?.stack });
91
+ recentErrors.push(msg);
92
+ if (recentErrors.length > RECENT_ERRORS_CAP) recentErrors.shift();
93
+ }
94
+ process.on('uncaughtException', (err) => recordError('uncaughtException', err));
95
+ process.on('unhandledRejection', (err) => recordError('unhandledRejection', err));
96
+
97
+ export function applyPath(data, path) {
98
+ if (!path) return data;
99
+ const segs = path.replace(/^\./, '').split('.').filter(Boolean);
100
+ let cur = data;
101
+ for (const s of segs) {
102
+ if (cur == null) return undefined;
103
+ cur = cur[s];
104
+ }
105
+ return cur;
106
+ }
107
+
108
+ async function fetchManifest(devUrl: string = DEV_URL): Promise<any> {
109
+ try {
110
+ const r = await fetch(`${devUrl}/__manifest`);
111
+ if (!r.ok) return null;
112
+ const m = await r.json();
113
+ // Shape: { elements: { [name]: { element, labUrl, cameras, knobs } } }
114
+ return m && typeof m === 'object' ? m : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ // Short-TTL manifest cache so a batch of set_knob calls (e.g. auto_tune's
121
+ // inner loop) doesn't fire one GET /__manifest per knob just to look up specs.
122
+ // Keyed by devUrl so cross-project calls don't read each other's manifest.
123
+ const _manifestCache = new Map<string, { value: any; at: number }>();
124
+ async function fetchManifestCached(devUrl: string = DEV_URL, ttlMs = 1000): Promise<any> {
125
+ const now = Date.now();
126
+ const hit = _manifestCache.get(devUrl);
127
+ if (hit && now - hit.at < ttlMs) return hit.value;
128
+ const value = await fetchManifest(devUrl);
129
+ _manifestCache.set(devUrl, { value, at: now });
130
+ return value;
131
+ }
132
+
133
+ /**
134
+ * Resolve the dev-server origin for a per-call request. Precedence: explicit
135
+ * `devUrl` → the origin of `labUrl` → the boot DEV_URL. Lets the
136
+ * dev-server-bound tools (read_telemetry/set_knob/list_elements/get_scene/
137
+ * auto_tune/multi_tune) target a lab/project other than the one the MCP booted
138
+ * against, mirroring the per-call `labUrl` the browser tools already accept.
139
+ */
140
+ export function resolveDevUrl({ devUrl, labUrl }: { devUrl?: string; labUrl?: string }): string {
141
+ if (devUrl) return devUrl.replace(/\/$/, '');
142
+ if (labUrl) {
143
+ try {
144
+ return new URL(labUrl).origin;
145
+ } catch {
146
+ /* not an absolute URL — fall through to default */
147
+ }
148
+ }
149
+ return DEV_URL;
150
+ }
151
+
152
+ /**
153
+ * Fetch the LAST telemetry the harness posted, live over HTTP from the dev
154
+ * server (GET /__state), regardless of which /tmp file it was written to. The
155
+ * harness stamps `postedAt` so the caller can tell how stale the snapshot is
156
+ * (the lab tab may be backgrounded/closed). Returns null on any failure.
157
+ */
158
+ async function fetchState(devUrl: string): Promise<{ data: any; staleMs: number | null } | null> {
159
+ try {
160
+ const r = await fetch(`${devUrl}/__state`, { signal: AbortSignal.timeout(2000) });
161
+ if (!r.ok) return null;
162
+ const data = (await r.json()) as any;
163
+ // GET /__state returns the literal `{}` (HTTP 200) when no lab tab has posted
164
+ // yet (telemetry.ts). Treat an empty object as "no telemetry" → null, so the
165
+ // caller falls through to the disk read / the actionable "open a lab page"
166
+ // error instead of silently returning undefined for every path.
167
+ if (!data || typeof data !== 'object' || Object.keys(data).length === 0) return null;
168
+ const staleMs = typeof data.postedAt === 'number' ? Date.now() - data.postedAt : null;
169
+ return { data, staleMs };
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ const STALE_MS = Number(process.env.TRISCOPE_STALE_MS ?? 1500);
176
+
177
+ /**
178
+ * Server-local mirror of @triscope/core's `clampKnob` (kept here to preserve
179
+ * the node-only MCP / browser-only core boundary — the same reason
180
+ * probeStatsFromPng mirrors the harness's sampleGpuProbes rather than importing
181
+ * it). The harness clamp is authoritative; clamping here too means out-of-range
182
+ * values are corrected *before* they persist in /__knob and the agent gets
183
+ * immediate `{requested, final}` feedback instead of having to read telemetry.
184
+ */
185
+ export function clampKnobValue(
186
+ spec: any,
187
+ value: number | string | boolean,
188
+ ): { clamped: boolean; final: number | string | boolean } {
189
+ if (!spec || typeof spec !== 'object') return { clamped: false, final: value };
190
+ switch (spec.type) {
191
+ case 'number': {
192
+ const n = Number(value);
193
+ if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
194
+ let next = n;
195
+ if (typeof spec.step === 'number' && spec.step > 0) {
196
+ next = spec.min + Math.round((next - spec.min) / spec.step) * spec.step;
197
+ }
198
+ next = Math.min(spec.max, Math.max(spec.min, next));
199
+ return { clamped: Math.abs(next - n) >= 1e-9, final: next };
200
+ }
201
+ case 'int': {
202
+ const n = Number(value);
203
+ if (!Number.isFinite(n)) return { clamped: true, final: spec.default };
204
+ const next = Math.min(spec.max, Math.max(spec.min, Math.round(n)));
205
+ return { clamped: next !== n, final: next };
206
+ }
207
+ case 'color':
208
+ return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(value))
209
+ ? { clamped: false, final: value }
210
+ : { clamped: true, final: spec.default };
211
+ case 'boolean':
212
+ return { clamped: typeof value !== 'boolean', final: Boolean(value) };
213
+ default:
214
+ return { clamped: false, final: value }; // trigger / unknown → passthrough
215
+ }
216
+ }
217
+
218
+ /** Build {element: {knobKey: spec}} from the live manifest's knob arrays. */
219
+ export function knobSpecMapFromManifest(manifest: any): Record<string, Record<string, any>> {
220
+ const out: Record<string, Record<string, any>> = {};
221
+ for (const [el, entry] of Object.entries((manifest?.elements ?? {}) as Record<string, any>)) {
222
+ const byKey: Record<string, any> = {};
223
+ for (const k of entry?.knobs ?? []) {
224
+ if (!k?.name) continue;
225
+ // Index by the advertised name (the display key `element.knob` in a
226
+ // namespaced scene) AND by the element-local key, so set_knob's clamp
227
+ // feedback resolves whether the caller passes `cube.spin` or `spin`.
228
+ byKey[k.name] = k;
229
+ const local = el && k.name.startsWith(`${el}.`) ? k.name.slice(el.length + 1) : k.name;
230
+ if (!(local in byKey)) byKey[local] = k;
231
+ }
232
+ out[el] = byKey;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ export function readProjectLabMap(cwd) {
238
+ try {
239
+ const p = join(cwd, 'package.json');
240
+ if (!existsSync(p)) return {};
241
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
242
+ const m = pkg?.triscope?.labs;
243
+ return m && typeof m === 'object' ? m : {};
244
+ } catch {
245
+ return {};
246
+ }
247
+ }
248
+
249
+ const PROJECT_LABS = readProjectLabMap(process.cwd());
250
+
251
+ export function absolutize(maybePath, base: string = DEV_URL) {
252
+ if (!maybePath) return null;
253
+ if (/^https?:\/\//.test(maybePath)) return maybePath;
254
+ return `${base}${maybePath.startsWith('/') ? '' : '/'}${maybePath}`;
255
+ }
256
+
257
+ async function resolveLabUrl({
258
+ element,
259
+ labUrl,
260
+ devUrl,
261
+ }: {
262
+ element?: string;
263
+ labUrl?: string;
264
+ devUrl?: string;
265
+ }): Promise<string> {
266
+ // 1. Explicit arg wins.
267
+ if (labUrl) return absolutize(labUrl);
268
+ const base = resolveDevUrl({ devUrl });
269
+ if (!element) return base;
270
+ // 2. Live manifest from a running lab (on the target dev server).
271
+ const manifest = await fetchManifest(base);
272
+ const entry = manifest?.elements?.[element];
273
+ if (entry?.labUrl) return absolutize(entry.labUrl, base);
274
+ // 3. Per-project escape hatch in package.json#triscope.labs.
275
+ if (PROJECT_LABS[element]) return absolutize(PROJECT_LABS[element], base);
276
+ // 4. Convention fallback.
277
+ return `${base}/labs/${element}.html`;
278
+ }
279
+
280
+ async function listElements({ devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {}) {
281
+ const m = await fetchManifest(resolveDevUrl({ devUrl, labUrl }));
282
+ if (!m || !m.elements || Object.keys(m.elements).length === 0) {
283
+ return {
284
+ manifest: null,
285
+ note: 'Dev server is up but no manifest has been posted yet. Load a lab page first.',
286
+ };
287
+ }
288
+ return { manifest: m };
289
+ }
290
+
291
+ async function readTelemetry(
292
+ path?: string,
293
+ { devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {},
294
+ ) {
295
+ const url = resolveDevUrl({ devUrl, labUrl });
296
+ // Live-first: GET ${url}/__state fetches the dev server's OWN state file over
297
+ // HTTP — works for any project/port without the MCP knowing the /tmp path
298
+ // (the original "No telemetry at /tmp/...-state.json" cross-project failure).
299
+ // Fall back to the local file only for the default dev server (a non-default
300
+ // project's state-file path is unknown to this process).
301
+ let data: any = null;
302
+ let source: 'live' | 'disk' | null = null;
303
+ let staleMs: number | null = null;
304
+ const live = await fetchState(url);
305
+ if (live) {
306
+ data = live.data;
307
+ source = 'live';
308
+ staleMs = live.staleMs;
309
+ } else if (url === DEV_URL && existsSync(STATE_PATH)) {
310
+ data = safeReadState();
311
+ source = 'disk';
312
+ staleMs = typeof data?.postedAt === 'number' ? Date.now() - data.postedAt : null;
313
+ }
314
+ if (data == null) {
315
+ throw new Error(
316
+ `No telemetry from ${url}/__state (and no local file). Is the dev server running with a lab page open?`,
317
+ );
318
+ }
319
+ const sliced = applyPath(data, path);
320
+ // Back-compat: a `path` query returns the BARE sliced value (what every
321
+ // existing consumer + the E2E expect). For the FULL snapshot (no path) we
322
+ // annotate freshness via non-colliding `__source`/`__staleMs` keys so a stale
323
+ // tab (backgrounded/closed) is detectable without changing the value shape.
324
+ if (path == null && sliced && typeof sliced === 'object' && !Array.isArray(sliced)) {
325
+ return {
326
+ ...sliced,
327
+ __source: source,
328
+ ...(staleMs != null ? { __staleMs: staleMs } : {}),
329
+ ...(staleMs != null && staleMs > STALE_MS
330
+ ? { __warning: `telemetry is ${staleMs}ms old — the lab tab may be backgrounded or closed` }
331
+ : {}),
332
+ };
333
+ }
334
+ return sliced;
335
+ }
336
+
337
+ async function setKnob(payload) {
338
+ // `payload` is either a single {element,key,value} or {updates:[...]}.
339
+ // The /__knob endpoint accepts arrays natively (telemetry.ts line 94).
340
+ const batched = Array.isArray(payload?.updates);
341
+ const updates: Array<{ element: string; key: string; value: unknown }> = batched
342
+ ? payload.updates
343
+ : [payload];
344
+ const url = resolveDevUrl({ devUrl: payload?.devUrl, labUrl: payload?.labUrl });
345
+
346
+ // Best-effort clamp against the live manifest's knob specs so out-of-range
347
+ // values are corrected before they persist + the agent learns what applied.
348
+ const specs = knobSpecMapFromManifest(await fetchManifestCached(url));
349
+ const clampedReports: Array<{
350
+ element: string;
351
+ key: string;
352
+ requested: unknown;
353
+ final: unknown;
354
+ }> = [];
355
+ const outgoing = updates.map((u) => {
356
+ const spec = specs[u.element]?.[u.key];
357
+ if (!spec) return u;
358
+ const c = clampKnobValue(spec, u.value as number | string | boolean);
359
+ if (c.clamped) {
360
+ clampedReports.push({ element: u.element, key: u.key, requested: u.value, final: c.final });
361
+ }
362
+ return { ...u, value: c.final };
363
+ });
364
+
365
+ const body = JSON.stringify(batched ? outgoing : outgoing[0]);
366
+ const r = await fetch(`${url}/__knob`, {
367
+ method: 'POST',
368
+ headers: { 'content-type': 'application/json' },
369
+ body,
370
+ });
371
+ if (!r.ok) throw new Error(`__knob returned ${r.status}`);
372
+ return {
373
+ ok: true,
374
+ count: outgoing.length,
375
+ ...(clampedReports.length ? { clamped: clampedReports } : {}),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Trim + annotate the telemetry snapshot embedded in a capture response:
381
+ * - flag that `perf.fps` is sampled right after the navigate, so it can read
382
+ * low while WebGPU warms (use read_telemetry for steady-state) — otherwise
383
+ * the capture's fps looks catastrophic vs reality;
384
+ * - drop the per-probe `samples` arrays (the payload bulk — a busy scene's full
385
+ * motion history burns context for nothing; keep the stats + a count).
386
+ * Never throws — a capture must not fail over telemetry cosmetics.
387
+ */
388
+ function trimCaptureTelemetry(sample: any): any {
389
+ if (!sample || typeof sample !== 'object') return sample;
390
+ try {
391
+ if (sample.perf && typeof sample.perf === 'object') {
392
+ sample.perf = {
393
+ ...sample.perf,
394
+ note: 'capture-time fps — reads low right after a navigate while WebGPU warms; use read_telemetry for steady-state',
395
+ };
396
+ }
397
+ for (const el of Object.values(sample.elements ?? {}) as any[]) {
398
+ for (const probe of Object.values(el?.motion ?? {}) as any[]) {
399
+ if (probe && Array.isArray(probe.samples)) {
400
+ probe.sampleCount = probe.samples.length;
401
+ delete probe.samples;
402
+ }
403
+ }
404
+ }
405
+ } catch {
406
+ /* leave the snapshot as-is */
407
+ }
408
+ return sample;
409
+ }
410
+
411
+ async function captureViews({
412
+ element,
413
+ labUrl,
414
+ inline = true,
415
+ fresh = false,
416
+ }: {
417
+ element?: string;
418
+ labUrl?: string;
419
+ inline?: boolean;
420
+ fresh?: boolean;
421
+ }) {
422
+ // Persistent Chromium: first call cold-starts (~3s), subsequent calls
423
+ // reuse the same browser/page and just navigate if the URL changed.
424
+ const target = await resolveLabUrl({ element, labUrl });
425
+ const outDir = join(tmpdir(), `${PROJECT}-capture-${element ?? 'scene'}`);
426
+ mkdirSync(outDir, { recursive: true });
427
+
428
+ const t0 = Date.now();
429
+ const timings: Record<string, number> = {};
430
+ const tNavStart = Date.now();
431
+ const { call } = await browserPool.getPage(target, { reload: fresh });
432
+ timings.navigate = Date.now() - tNavStart;
433
+ const tRenderStart = Date.now();
434
+ const result = await call('Runtime.evaluate', {
435
+ expression: 'window.__TRISCOPE__.captureViews()',
436
+ awaitPromise: true,
437
+ returnByValue: true,
438
+ });
439
+ timings.render = Date.now() - tRenderStart;
440
+ const views = result.result.result.value;
441
+ if (!views || typeof views !== 'object') {
442
+ // By the time we get here browserPool.getPage has already proven the
443
+ // harness mounted, so an empty result means the Element declared zero
444
+ // cameras OR captureViews itself errored without returning.
445
+ throw new Error(
446
+ `captureViews returned no images for element="${element ?? '(scene)'}" at ${target}. ` +
447
+ `Most likely the Element declares no cameras — check the manifest: ` +
448
+ `mcp__triscope__list_elements.`,
449
+ );
450
+ }
451
+ const written = {};
452
+ const base64ByCam = {};
453
+ const tWriteStart = Date.now();
454
+ for (const [cam, dataUrl] of Object.entries(views)) {
455
+ if (typeof dataUrl !== 'string') continue;
456
+ const b64 = dataUrl.replace(/^data:image\/png;base64,/, '');
457
+ const path = join(outDir, `${cam}.png`);
458
+ writeFileSync(path, Buffer.from(b64, 'base64'));
459
+ written[cam] = path;
460
+ base64ByCam[cam] = b64;
461
+ }
462
+ timings.writePngs = Date.now() - tWriteStart;
463
+ const tTelStart = Date.now();
464
+ const telemetry = await call('Runtime.evaluate', {
465
+ expression: 'JSON.stringify(window.__TRISCOPE__.sampleTelemetry())',
466
+ returnByValue: true,
467
+ });
468
+ const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
469
+ timings.telemetry = Date.now() - tTelStart;
470
+
471
+ // captureViews() populates window.__TRISCOPE__.lastGpuProbes as a side
472
+ // effect (per-camera luminance/p5/p95/dynamicRange). Surface it in the
473
+ // tool response so consumers don't have to do a second CDP eval.
474
+ // If the harness didn't populate it (lab page that doesn't use
475
+ // @triscope/core runLab — e.g. water3d's scene lab which has a custom
476
+ // capture path), we decode the captured PNGs server-side with pngjs
477
+ // and compute the same scalars. Slower (~5 ms per camera) but means
478
+ // GPU probes are available for any captured PNG.
479
+ const tProbeStart = Date.now();
480
+ let gpuProbes: Record<string, any> | null = null;
481
+ let gpuProbesSource: 'harness' | 'server-fallback' | 'unavailable' = 'unavailable';
482
+ try {
483
+ const probesResp = await call('Runtime.evaluate', {
484
+ expression: 'JSON.stringify(window.__TRISCOPE__.lastGpuProbes ?? null)',
485
+ returnByValue: true,
486
+ });
487
+ const fromHarness = JSON.parse(probesResp.result.result.value);
488
+ if (fromHarness && Object.keys(fromHarness).length > 0) {
489
+ gpuProbes = fromHarness;
490
+ gpuProbesSource = 'harness';
491
+ }
492
+ } catch {
493
+ /* old core — fall through to server-side */
494
+ }
495
+ if (!gpuProbes) {
496
+ gpuProbes = {};
497
+ for (const [cam, b64] of Object.entries(base64ByCam) as [string, string][]) {
498
+ try {
499
+ gpuProbes[cam] = probeStatsFromPng(Buffer.from(b64, 'base64'));
500
+ } catch {
501
+ /* skip cameras whose PNGs fail to decode */
502
+ }
503
+ }
504
+ if (Object.keys(gpuProbes).length === 0) gpuProbes = null;
505
+ else gpuProbesSource = 'server-fallback';
506
+ }
507
+ timings.probes = Date.now() - tProbeStart;
508
+
509
+ // Cameras the GPU drew black (luminance < threshold) — set by the harness
510
+ // probe or the server-side fallback. Surfaced top-level so the agent sees a
511
+ // dead pane immediately instead of inspecting per-camera gpuProbes.
512
+ const blackFrames = gpuProbes
513
+ ? Object.entries(gpuProbes)
514
+ .filter(([, s]) => (s as any)?.blackFrame)
515
+ .map(([cam]) => cam)
516
+ : [];
517
+ // Cameras that rendered a (near-)UNIFORM frame — dynamicRange ≈ 1 means p95≈p5,
518
+ // i.e. nothing but the flat clear-color is in view (the element drifted out of
519
+ // frame / wasn't drawn). blackFrames misses this when the clear-color sits
520
+ // above the black threshold (the "empty but not black" case), so it reads as a
521
+ // false OK. Excludes genuinely black panes (already in blackFrames).
522
+ const flatFrames = gpuProbes
523
+ ? Object.entries(gpuProbes)
524
+ .filter(([, s]) => {
525
+ const p = s as any;
526
+ return p && !p.blackFrame && typeof p.dynamicRange === 'number' && p.dynamicRange <= 1.05;
527
+ })
528
+ .map(([cam]) => cam)
529
+ : [];
530
+
531
+ return {
532
+ element: element ?? null,
533
+ dir: outDir,
534
+ files: written,
535
+ cameraOrder: Object.keys(written),
536
+ telemetry: sample,
537
+ gpuProbes,
538
+ gpuProbesSource,
539
+ blackFrames,
540
+ flatFrames,
541
+ inline,
542
+ captureMs: Date.now() - t0,
543
+ timings,
544
+ _base64ByCam: base64ByCam,
545
+ };
546
+ }
547
+
548
+ /** Server-side fallback: same math the harness does, but starting from a
549
+ * decoded PNG buffer instead of a 2D canvas. Stride-samples to ~2300 px
550
+ * so the cost is bounded (~5 ms per 1280×720 PNG). */
551
+ // Mean Rec.709 luminance below which a pane counts as a black render. Matches
552
+ // @triscope/core's BLACK_FRAME_LUMINANCE (mirrored across the node boundary).
553
+ const BLACK_FRAME_LUMINANCE = 0.004;
554
+
555
+ export function probeStatsFromPng(pngBuf: Buffer): {
556
+ luminance: number;
557
+ p5: number;
558
+ p95: number;
559
+ dynamicRange: number;
560
+ samples: number;
561
+ blackFrame?: boolean;
562
+ } {
563
+ const img = PNG.sync.read(pngBuf);
564
+ const stride = Math.max(1, Math.floor(Math.sqrt((img.width * img.height) / 2304)));
565
+ const lums: number[] = [];
566
+ let sum = 0;
567
+ for (let y = 0; y < img.height; y += stride) {
568
+ for (let x = 0; x < img.width; x += stride) {
569
+ const i = (y * img.width + x) * 4;
570
+ const r = img.data[i] / 255;
571
+ const g = img.data[i + 1] / 255;
572
+ const b = img.data[i + 2] / 255;
573
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
574
+ lums.push(lum);
575
+ sum += lum;
576
+ }
577
+ }
578
+ lums.sort((a, b) => a - b);
579
+ const n = lums.length;
580
+ const p5 = lums[Math.floor(n * 0.05)];
581
+ const p95 = lums[Math.floor(n * 0.95)];
582
+ const luminance = +(sum / n).toFixed(4);
583
+ return {
584
+ luminance,
585
+ p5: +p5.toFixed(4),
586
+ p95: +p95.toFixed(4),
587
+ dynamicRange: +(p95 / Math.max(p5, 1 / 255)).toFixed(2),
588
+ samples: n,
589
+ ...(luminance < BLACK_FRAME_LUMINANCE ? { blackFrame: true } : {}),
590
+ };
591
+ }
592
+
593
+ async function captureMotionFramesRaw({
594
+ element,
595
+ camera,
596
+ frames,
597
+ dt,
598
+ mode,
599
+ labUrl,
600
+ }: any): Promise<string[]> {
601
+ // Like captureMotion but for ONE camera, returns the raw base64 PNG frames.
602
+ // Used internally by set_reference_motion + diff_reference_motion.
603
+ const target = await resolveLabUrl({ element, labUrl });
604
+ const { call } = await browserPool.getPage(target);
605
+ const result = await call('Runtime.evaluate', {
606
+ expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camera)}, ${JSON.stringify({ frames, dt, mode })})`,
607
+ awaitPromise: true,
608
+ returnByValue: true,
609
+ });
610
+ const frames_ = result.result.result.value;
611
+ if (!Array.isArray(frames_) || frames_.length === 0) {
612
+ throw new Error(`captureMotionFrames returned empty for camera "${camera}"`);
613
+ }
614
+ return frames_.map((du) => du.replace(/^data:image\/png;base64,/, ''));
615
+ }
616
+
617
+ async function captureMotion({
618
+ element,
619
+ camera,
620
+ frames = 6,
621
+ dt = 0.25,
622
+ mode = 'time',
623
+ labUrl,
624
+ fresh = false,
625
+ }: any) {
626
+ // Multi-frame capture per camera through the persistent browser pool.
627
+ // Returns per-camera filmstrip base64 + motionMagnitude scalar + telemetry.
628
+ const target = await resolveLabUrl({ element, labUrl });
629
+ const outDir = join(tmpdir(), `${PROJECT}-motion-${element ?? 'scene'}`);
630
+ mkdirSync(outDir, { recursive: true });
631
+ const t0 = Date.now();
632
+ const { call } = await browserPool.getPage(target, { reload: fresh });
633
+
634
+ // Pick the camera set: explicit single name or all cameras for the element.
635
+ let cameraOrder: string[];
636
+ if (camera) {
637
+ cameraOrder = [camera];
638
+ } else {
639
+ const camsProbe = await call('Runtime.evaluate', {
640
+ expression: 'Object.keys(window.__TRISCOPE__.cameras)',
641
+ returnByValue: true,
642
+ });
643
+ cameraOrder = camsProbe.result.result.value ?? [];
644
+ }
645
+ if (!Array.isArray(cameraOrder) || cameraOrder.length === 0) {
646
+ throw new Error('no cameras available — is the harness mounted?');
647
+ }
648
+
649
+ const filmstripPaths = {};
650
+ const filmstripBase64 = {};
651
+ const magnitudeByCam = {};
652
+ for (const camName of cameraOrder) {
653
+ const result = await call('Runtime.evaluate', {
654
+ expression: `window.__TRISCOPE__.captureMotionFrames(${JSON.stringify(camName)}, ${JSON.stringify({ frames, dt, mode })})`,
655
+ awaitPromise: true,
656
+ returnByValue: true,
657
+ });
658
+ const dataUrls = result.result.result.value;
659
+ if (!Array.isArray(dataUrls) || dataUrls.length === 0) {
660
+ throw new Error(`captureMotionFrames returned empty for camera "${camName}"`);
661
+ }
662
+ const strip = composeFilmstrip(dataUrls);
663
+ const stripPath = join(outDir, `${camName}.filmstrip.png`);
664
+ writeFileSync(stripPath, strip);
665
+ filmstripPaths[camName] = stripPath;
666
+ filmstripBase64[camName] = strip.toString('base64');
667
+ magnitudeByCam[camName] = motionMagnitudeFromFrames(dataUrls);
668
+ }
669
+
670
+ const telemetry = await call('Runtime.evaluate', {
671
+ expression: 'JSON.stringify(window.__TRISCOPE__.sampleTelemetry())',
672
+ returnByValue: true,
673
+ });
674
+ const sample = trimCaptureTelemetry(JSON.parse(telemetry.result.result.value));
675
+
676
+ return {
677
+ element: element ?? null,
678
+ frames,
679
+ dt,
680
+ mode,
681
+ dir: outDir,
682
+ filmstrips: filmstripPaths,
683
+ cameraOrder,
684
+ motionMagnitude: magnitudeByCam,
685
+ captureMs: Date.now() - t0,
686
+ telemetry: sample,
687
+ _filmstripBase64: filmstripBase64,
688
+ };
689
+ }
690
+
691
+ /**
692
+ * auto_tune (1D): golden-section search over one knob, minimising
693
+ * (1 - SSIM) between the captured target_camera view and the stored
694
+ * reference for that (element, camera). Each iteration: set_knob → wait
695
+ * for the harness to apply (knobPollMs + telemetry tick + margin) →
696
+ * captureViews → diff_reference. Returns the best knob value found,
697
+ * the final SSIM, the iteration history, and total ms.
698
+ *
699
+ * Why golden-section: derivative-free, deterministic, converges
700
+ * exponentially (range × 1/φ per iter ≈ 0.618). 12 iterations narrow a
701
+ * range to ~0.7% — plenty for shader tuning. Robust to noise because we
702
+ * use SSIM (perceptual) rather than meanAbsDiff (pixel-level).
703
+ */
704
+ /**
705
+ * Snapshot / restore via git tags.
706
+ *
707
+ * A snapshot freezes a moment in the iteration loop: the git commit you
708
+ * were on + the knob values applied at that moment. Restoring checks out
709
+ * that commit and re-posts the knobs, so a careful tuning state can be
710
+ * recovered after a risky shader rewrite. Tags live under
711
+ * `triscope/snapshot/<name>` so they don't pollute the user's namespace.
712
+ *
713
+ * Guard rails:
714
+ * - snapshot refuses on a dirty working tree — the commit you'd point
715
+ * at wouldn't actually contain your in-progress edits, so the
716
+ * restore would silently revert them.
717
+ * - restore refuses on a dirty WT too, for the same reason in reverse.
718
+ * - PNG refs are intentionally not bundled into the snapshot for the
719
+ * MVP — they live next to the project as before (refs/<el>/<cam>.png)
720
+ * and are recovered with the git checkout. Keeping the JSON small.
721
+ */
722
+ const SNAPSHOT_TAG_PREFIX = 'triscope/snapshot/';
723
+
724
+ // Windows portability: npm/git/code are .cmd scripts on Win32. child_process
725
+ // .spawn refuses to invoke them without `shell: true`. On Linux/macOS the
726
+ // real binaries are on PATH and shell isn't needed. We set the flag
727
+ // conditionally everywhere we spawn one of those tools.
728
+ const NEED_SHELL = process.platform === 'win32';
729
+
730
+ // For tools that forward an agent-supplied argument to the `triscope` CLI we
731
+ // spawn the binary WITHOUT a shell (explicit .cmd on Windows) so there's no
732
+ // shell to inject into, and we reject any argument that would smuggle a flag.
733
+ const TRISCOPE_BIN = process.platform === 'win32' ? 'triscope.cmd' : 'triscope';
734
+ function rejectFlagArg(value: unknown, label: string): asserts value is string {
735
+ if (typeof value !== 'string' || value.length === 0 || value.startsWith('-')) {
736
+ throw new Error(
737
+ `unsafe ${label}: ${JSON.stringify(value)} (must be a non-empty value not starting with '-')`,
738
+ );
739
+ }
740
+ }
741
+
742
+ function git(
743
+ args: string[],
744
+ cwd: string = process.cwd(),
745
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
746
+ return new Promise((resolve, reject) => {
747
+ const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell: NEED_SHELL });
748
+ let stdout = '';
749
+ let stderr = '';
750
+ child.stdout.on('data', (d) => (stdout += d));
751
+ child.stderr.on('data', (d) => (stderr += d));
752
+ child.on('error', reject);
753
+ child.on('exit', (code) =>
754
+ resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }),
755
+ );
756
+ });
757
+ }
758
+
759
+ async function assertCleanWt(cwd: string, action: string): Promise<void> {
760
+ const status = await git(['status', '--porcelain'], cwd);
761
+ if (status.stdout.length > 0) {
762
+ throw new Error(
763
+ `${action} refuses to run with a dirty working tree. Commit, stash, or revert your in-progress edits first.\nDirty paths:\n${status.stdout.split('\n').slice(0, 10).join('\n')}`,
764
+ );
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Wait until the harness telemetry confirms a knob took the requested value,
770
+ * polling the on-disk state every 50 ms. Replaces fixed sleeps in the tuning
771
+ * loops: a knob usually lands in ~100-600 ms (knob poll + telemetry tick), so
772
+ * this returns as soon as it's applied instead of always sleeping the worst
773
+ * case. Returns false on timeout (caller does a short settle fallback).
774
+ */
775
+ async function waitForKnobApplied(
776
+ key: string,
777
+ value: number | string | boolean,
778
+ timeoutMs = 2500,
779
+ element?: string,
780
+ devUrl?: string,
781
+ ): Promise<boolean> {
782
+ // Telemetry exposes knobs by DISPLAY key. In a namespaced scene the caller may
783
+ // pass the element-local key (`spin`) while telemetry holds `cube.spin`, so
784
+ // also probe the namespaced form — otherwise this never confirms and the
785
+ // tuning loop burns the full timeout every iteration.
786
+ const ns = element ? `${element}.${key}` : null;
787
+ // For the default dev server poll the local file (fast); for a non-default
788
+ // devUrl the file path is unknown here, so poll its /__state over HTTP — else
789
+ // cross-project tuning never confirms and burns the full timeout each round.
790
+ const url = resolveDevUrl({ devUrl });
791
+ const useHttp = url !== DEV_URL;
792
+ const start = Date.now();
793
+ while (Date.now() - start < timeoutMs) {
794
+ try {
795
+ let st: any = null;
796
+ if (useHttp) {
797
+ st = (await fetchState(url))?.data ?? null;
798
+ } else if (existsSync(STATE_PATH)) {
799
+ st = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
800
+ }
801
+ const applied = st?.knobs?.[key] ?? (ns ? st?.knobs?.[ns] : undefined);
802
+ if (applied !== undefined && knobMatches(value, applied)) return true;
803
+ } catch {
804
+ /* state file mid-write / transient fetch — retry */
805
+ }
806
+ await wait(50);
807
+ }
808
+ return false;
809
+ }
810
+
811
+ async function fetchPersistedKnobs(): Promise<Record<string, Record<string, unknown>>> {
812
+ try {
813
+ const r = await fetch(`${DEV_URL}/__knob/current`);
814
+ if (!r.ok) return {};
815
+ return (await r.json()) as Record<string, Record<string, unknown>>;
816
+ } catch {
817
+ return {};
818
+ }
819
+ }
820
+
821
+ async function snapshot({ name, message }: { name: string; message?: string }) {
822
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
823
+ throw new Error(`snapshot name must be [A-Za-z0-9._-]+ (got "${name}")`);
824
+ }
825
+ const cwd = process.cwd();
826
+ await assertCleanWt(cwd, 'snapshot');
827
+ const head = await git(['rev-parse', 'HEAD'], cwd);
828
+ if (head.code !== 0) throw new Error(`git rev-parse failed: ${head.stderr}`);
829
+ const knobs = await fetchPersistedKnobs();
830
+ const payload = {
831
+ name,
832
+ createdAt: new Date().toISOString(),
833
+ commit: head.stdout,
834
+ message: message ?? '',
835
+ knobs, // { [element]: { [knobKey]: value, ... } }
836
+ };
837
+ const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
838
+ // Annotated tag stores the JSON payload as the tag message — no extra
839
+ // working-tree files, no rebase noise, easy to list+read with `git tag`.
840
+ const tagBody = `triscope snapshot v1\n\n${JSON.stringify(payload, null, 2)}`;
841
+ const tag = await git(['tag', '-a', tagName, '-m', tagBody, payload.commit], cwd);
842
+ if (tag.code !== 0) {
843
+ if (tag.stderr.includes('already exists')) {
844
+ throw new Error(
845
+ `snapshot "${name}" already exists. Pick a different name or delete the existing tag: git tag -d ${tagName}`,
846
+ );
847
+ }
848
+ throw new Error(`git tag failed: ${tag.stderr}`);
849
+ }
850
+ return {
851
+ ok: true,
852
+ tag: tagName,
853
+ ...payload,
854
+ hint: 'Restore later with mcp__triscope__restore name=' + name,
855
+ };
856
+ }
857
+
858
+ async function listSnapshots() {
859
+ const cwd = process.cwd();
860
+ const list = await git(
861
+ [
862
+ 'tag',
863
+ '--list',
864
+ `${SNAPSHOT_TAG_PREFIX}*`,
865
+ '--format=%(refname:short)|%(creatordate:iso)|%(subject)',
866
+ ],
867
+ cwd,
868
+ );
869
+ if (list.code !== 0) throw new Error(`git tag --list failed: ${list.stderr}`);
870
+ const snapshots: Array<{ name: string; tag: string; created: string; subject: string }> = [];
871
+ for (const line of list.stdout.split('\n').filter(Boolean)) {
872
+ const [tag, created, ...subj] = line.split('|');
873
+ snapshots.push({
874
+ name: tag.replace(SNAPSHOT_TAG_PREFIX, ''),
875
+ tag,
876
+ created,
877
+ subject: subj.join('|'),
878
+ });
879
+ }
880
+ return { count: snapshots.length, snapshots };
881
+ }
882
+
883
+ async function restore({ name }: { name: string }) {
884
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`invalid snapshot name`);
885
+ const cwd = process.cwd();
886
+ await assertCleanWt(cwd, 'restore');
887
+ const tagName = `${SNAPSHOT_TAG_PREFIX}${name}`;
888
+ const show = await git(['cat-file', '-p', tagName], cwd);
889
+ if (show.code !== 0) throw new Error(`snapshot "${name}" not found (tag ${tagName})`);
890
+ // Annotated tag object body: header lines, blank line, then the message
891
+ // we wrote in snapshot(). Find the JSON block.
892
+ const bodyMatch = show.stdout.match(/\n\n([\s\S]*)/);
893
+ const body = bodyMatch?.[1] ?? '';
894
+ const jsonStart = body.indexOf('{');
895
+ if (jsonStart < 0) throw new Error(`snapshot ${name} has no JSON payload`);
896
+ let payload: any;
897
+ try {
898
+ payload = JSON.parse(body.slice(jsonStart));
899
+ } catch {
900
+ throw new Error(`snapshot ${name} payload is not valid JSON`);
901
+ }
902
+ // Checkout the commit the snapshot pointed at (detached HEAD — safe,
903
+ // user can create a branch from there if they want to keep working).
904
+ const checkout = await git(['checkout', payload.commit], cwd);
905
+ if (checkout.code !== 0)
906
+ throw new Error(`git checkout ${payload.commit} failed: ${checkout.stderr}`);
907
+ // Re-post knobs. The harness will pick them up via its 100ms poll once
908
+ // it next mounts (or immediately if already mounted on the same commit).
909
+ const updates: Array<{ element: string; key: string; value: unknown }> = [];
910
+ for (const [elName, kv] of Object.entries(payload.knobs ?? {})) {
911
+ for (const [k, v] of Object.entries(kv as Record<string, unknown>)) {
912
+ updates.push({ element: elName, key: k, value: v });
913
+ }
914
+ }
915
+ if (updates.length > 0) {
916
+ try {
917
+ await setKnob({ updates });
918
+ } catch {
919
+ /* dev server may be down — knobs will re-hydrate on next runLab */
920
+ }
921
+ }
922
+ return {
923
+ ok: true,
924
+ tag: tagName,
925
+ restoredCommit: payload.commit,
926
+ knobUpdates: updates.length,
927
+ payload,
928
+ };
929
+ }
930
+
931
+ async function autoTune({
932
+ element,
933
+ knob,
934
+ range,
935
+ target_camera,
936
+ max_iterations = 12,
937
+ labUrl,
938
+ }: {
939
+ element: string;
940
+ knob: string;
941
+ range: [number, number];
942
+ target_camera: string;
943
+ max_iterations?: number;
944
+ labUrl?: string;
945
+ }) {
946
+ const refPath = refsPath(process.cwd(), element, target_camera);
947
+ if (!existsSync(refPath)) {
948
+ throw new Error(
949
+ `auto_tune needs a reference image at ${refPath}. Call set_reference ` +
950
+ `with element=${element}, camera=${target_camera} first (or paste a PNG path/base64).`,
951
+ );
952
+ }
953
+ const target = await resolveLabUrl({ element, labUrl });
954
+ // Derive the knob origin from the RESOLVED page, not the raw labUrl arg, so
955
+ // an element whose manifest advertises an absolute cross-origin labUrl still
956
+ // tunes the page we capture (capture + knob origins must agree).
957
+ const devUrl = resolveDevUrl({ labUrl: target });
958
+ const { call } = await browserPool.getPage(target);
959
+
960
+ const phi = (1 + Math.sqrt(5)) / 2;
961
+ const invPhi = 1 / phi;
962
+ let [a, b] = range;
963
+ if (!(b > a))
964
+ throw new Error(`auto_tune range must be [min, max] with max > min, got [${a}, ${b}]`);
965
+
966
+ const cache = new Map<string, number>(); // memoize SSIM by knob value
967
+ const history: Array<{ iter: number; knob: number; ssim: number; ms: number }> = [];
968
+ const t0 = Date.now();
969
+
970
+ async function evalAt(x: number): Promise<number> {
971
+ const key = x.toFixed(6);
972
+ if (cache.has(key)) return cache.get(key)!;
973
+ const iterStart = Date.now();
974
+ // 1. Post knob via the harness's /__knob endpoint (same origin as capture).
975
+ await setKnob({ element, key: knob, value: x, devUrl });
976
+ // 2. Wait (event-driven) for the harness to confirm it applied — returns as
977
+ // soon as telemetry reflects the value instead of always sleeping 800ms.
978
+ const applied = await waitForKnobApplied(knob, x, 2500, element, devUrl);
979
+ if (!applied) await wait(300); // settle fallback if telemetry didn't confirm
980
+ // 3. Capture target camera in-tab.
981
+ const cap = await call('Runtime.evaluate', {
982
+ expression: 'window.__TRISCOPE__.captureViews()',
983
+ awaitPromise: true,
984
+ returnByValue: true,
985
+ });
986
+ const views = cap.result.result.value ?? {};
987
+ const b64 = String(views[target_camera] ?? '').replace(/^data:image\/png;base64,/, '');
988
+ if (!b64)
989
+ throw new Error(`auto_tune: captureViews returned no PNG for camera "${target_camera}"`);
990
+ // 4. Diff against reference; we minimise (1 - SSIM).
991
+ const diff = diffReference({
992
+ cwd: process.cwd(),
993
+ element,
994
+ camera: target_camera,
995
+ currentBase64: b64,
996
+ });
997
+ const score = diff.ssim;
998
+ cache.set(key, score);
999
+ history.push({ iter: history.length, knob: x, ssim: score, ms: Date.now() - iterStart });
1000
+ return score;
1001
+ }
1002
+
1003
+ // Initial bracket.
1004
+ let c = b - (b - a) * invPhi;
1005
+ let d = a + (b - a) * invPhi;
1006
+ let fc = await evalAt(c);
1007
+ let fd = await evalAt(d);
1008
+
1009
+ for (let i = 0; i < max_iterations - 2; i++) {
1010
+ // Maximise SSIM ⇔ minimise (1 - SSIM): keep the side with higher SSIM.
1011
+ if (fc > fd) {
1012
+ b = d;
1013
+ d = c;
1014
+ fd = fc;
1015
+ c = b - (b - a) * invPhi;
1016
+ fc = await evalAt(c);
1017
+ } else {
1018
+ a = c;
1019
+ c = d;
1020
+ fc = fd;
1021
+ d = a + (b - a) * invPhi;
1022
+ fd = await evalAt(d);
1023
+ }
1024
+ // Early stop when the bracket is below 1% of the original range.
1025
+ if (Math.abs(b - a) < (range[1] - range[0]) * 0.01) break;
1026
+ }
1027
+
1028
+ const best = [...cache.entries()]
1029
+ .map(([k, v]) => ({ knob: Number(k), ssim: v }))
1030
+ .sort((x, y) => y.ssim - x.ssim)[0];
1031
+
1032
+ // Leave the knob at the best value so the user sees the converged state.
1033
+ // Must target the same origin as the tuned page (D5: omitting devUrl would
1034
+ // commit `best` to the boot DEV_URL, leaving the actual lab at the last
1035
+ // evaluated value and falsely reporting "left at best").
1036
+ await setKnob({ element, key: knob, value: best.knob, devUrl });
1037
+
1038
+ return {
1039
+ element,
1040
+ knob,
1041
+ target_camera,
1042
+ bestKnobValue: best.knob,
1043
+ bestSsim: best.ssim,
1044
+ iterations: history.length,
1045
+ history,
1046
+ totalMs: Date.now() - t0,
1047
+ hint: 'SSIM 1.0 = identical to reference, 0.9+ = visually close, <0.7 = clearly different. The knob has been left at bestKnobValue in the live lab.',
1048
+ };
1049
+ }
1050
+
1051
+ /**
1052
+ * multi_tune: coordinate-descent over 2-N knobs against a reference (SSIM),
1053
+ * reusing the event-driven evalAt loop. Greedy but robust for the smooth,
1054
+ * weakly-coupled knobs shader tuning involves. Hard-capped on total evaluations
1055
+ * so worst-case wall time stays bounded.
1056
+ */
1057
+ async function multiTune({
1058
+ element,
1059
+ knobs,
1060
+ target_camera,
1061
+ max_cycles = 2,
1062
+ per_knob_iters = 8,
1063
+ compute_interactions = false,
1064
+ max_evaluations,
1065
+ labUrl,
1066
+ }: {
1067
+ element: string;
1068
+ knobs: Array<{ key: string; range: [number, number]; start?: number }>;
1069
+ target_camera: string;
1070
+ max_cycles?: number;
1071
+ per_knob_iters?: number;
1072
+ compute_interactions?: boolean;
1073
+ max_evaluations?: number;
1074
+ labUrl?: string;
1075
+ }) {
1076
+ const cwd = process.cwd();
1077
+ const refPath = refsPath(cwd, element, target_camera);
1078
+ if (!existsSync(refPath)) {
1079
+ throw new Error(
1080
+ `multi_tune needs a reference image at ${refPath}. Call set_reference (element=${element}, camera=${target_camera}) first.`,
1081
+ );
1082
+ }
1083
+ const target = await resolveLabUrl({ element, labUrl });
1084
+ // Knob origin derived from the resolved page (see autoTune) — capture + knob
1085
+ // must agree even for an absolute cross-origin manifest labUrl.
1086
+ const devUrl = resolveDevUrl({ labUrl: target });
1087
+ const { call } = await browserPool.getPage(target);
1088
+ const t0 = Date.now();
1089
+ const cache = new Map<string, number>();
1090
+
1091
+ const evalAt = async (values: Record<string, number>): Promise<number> => {
1092
+ const ck = JSON.stringify(values);
1093
+ const hit = cache.get(ck);
1094
+ if (hit !== undefined) return hit;
1095
+ const updates = Object.entries(values).map(([key, value]) => ({ element, key, value }));
1096
+ await setKnob({ updates, devUrl });
1097
+ const last = updates[updates.length - 1];
1098
+ const ok = await waitForKnobApplied(last.key, last.value, 2500, element, devUrl);
1099
+ if (!ok) await wait(300);
1100
+ const cap = await call('Runtime.evaluate', {
1101
+ expression: 'window.__TRISCOPE__.captureViews()',
1102
+ awaitPromise: true,
1103
+ returnByValue: true,
1104
+ });
1105
+ const views = cap.result.result.value ?? {};
1106
+ const b64 = String(views[target_camera] ?? '').replace(/^data:image\/png;base64,/, '');
1107
+ if (!b64) throw new Error(`multi_tune: captureViews returned no PNG for "${target_camera}"`);
1108
+ const diff = diffReference({ cwd, element, camera: target_camera, currentBase64: b64 });
1109
+ cache.set(ck, diff.ssim);
1110
+ return diff.ssim;
1111
+ };
1112
+
1113
+ const specs = knobs.map((kn) => ({
1114
+ key: kn.key,
1115
+ min: kn.range[0],
1116
+ max: kn.range[1],
1117
+ start: kn.start ?? (kn.range[0] + kn.range[1]) / 2,
1118
+ }));
1119
+ const hardCap = max_evaluations ?? Math.min(specs.length * per_knob_iters * max_cycles + 4, 64);
1120
+ const result = await coordinateDescent({
1121
+ knobs: specs,
1122
+ evalAt,
1123
+ maxCycles: max_cycles,
1124
+ perKnobIters: per_knob_iters,
1125
+ maxEvaluations: hardCap,
1126
+ });
1127
+
1128
+ // Optional pairwise interaction matrix (2nd-order mixed difference at the
1129
+ // optimum). Off by default — it adds ~3 evals per knob pair.
1130
+ let interactions: Array<{ a: string; b: string; interaction: number }> | undefined;
1131
+ if (compute_interactions && specs.length >= 2) {
1132
+ interactions = [];
1133
+ const base = await evalAt({ ...result.best });
1134
+ for (let i = 0; i < specs.length; i++) {
1135
+ for (let j = i + 1; j < specs.length; j++) {
1136
+ const A = specs[i];
1137
+ const B = specs[j];
1138
+ const dA = (A.max - A.min) * 0.05;
1139
+ const dB = (B.max - B.min) * 0.05;
1140
+ const clamp = (v: number, s: typeof A) => Math.min(s.max, Math.max(s.min, v));
1141
+ const fa = await evalAt({ ...result.best, [A.key]: clamp(result.best[A.key] + dA, A) });
1142
+ const fb = await evalAt({ ...result.best, [B.key]: clamp(result.best[B.key] + dB, B) });
1143
+ const fab = await evalAt({
1144
+ ...result.best,
1145
+ [A.key]: clamp(result.best[A.key] + dA, A),
1146
+ [B.key]: clamp(result.best[B.key] + dB, B),
1147
+ });
1148
+ interactions.push({ a: A.key, b: B.key, interaction: +(fab - fa - fb + base).toFixed(4) });
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Leave the lab at the converged values (same origin as the tuned page — D5).
1154
+ await setKnob({
1155
+ updates: Object.entries(result.best).map(([key, value]) => ({ element, key, value })),
1156
+ devUrl,
1157
+ });
1158
+
1159
+ return {
1160
+ element,
1161
+ target_camera,
1162
+ best: result.best,
1163
+ bestSsim: +result.bestScore.toFixed(4),
1164
+ cycles: result.cycles,
1165
+ evaluations: result.evaluations,
1166
+ totalMs: Date.now() - t0,
1167
+ interactions,
1168
+ hint: 'SSIM 1.0 = identical, 0.9+ = close. Knobs left at best values in the live lab. interaction>0 = the two knobs reinforce, <0 = they fight (only present when compute_interactions=true).',
1169
+ };
1170
+ }
1171
+
1172
+ /**
1173
+ * check_targets: load .claude/element-targets.json and evaluate the per-camera
1174
+ * convergence constraints against the current capture — a machine-readable
1175
+ * "definition of done". SSIM/meanAbsDiff constraints need a stored reference
1176
+ * (else they're skipped, not failed); luminance/dynamicRange come from the GPU
1177
+ * probe and are always evaluable.
1178
+ */
1179
+ async function checkTargets({ element, labUrl }: { element: string; labUrl?: string }) {
1180
+ const cwd = process.cwd();
1181
+ const file = join(cwd, '.claude', 'element-targets.json');
1182
+ if (!existsSync(file)) {
1183
+ return { checked: 0, allPassed: true, note: `no targets file at ${file}` };
1184
+ }
1185
+ let all: any;
1186
+ try {
1187
+ all = JSON.parse(readFileSync(file, 'utf8'));
1188
+ } catch (e: any) {
1189
+ throw new Error(`invalid ${file}: ${e?.message ?? e}`);
1190
+ }
1191
+ const targetsByCamera = all?.[element];
1192
+ if (!targetsByCamera || typeof targetsByCamera !== 'object') {
1193
+ return { checked: 0, allPassed: true, note: `no targets for element "${element}" in ${file}` };
1194
+ }
1195
+ const cap = await captureViews({ element, labUrl, inline: true });
1196
+ const capturedByCamera: Record<string, any> = {};
1197
+ for (const [camera, probe] of Object.entries((cap.gpuProbes ?? {}) as Record<string, any>)) {
1198
+ capturedByCamera[camera] = { luminance: probe.luminance, dynamicRange: probe.dynamicRange };
1199
+ }
1200
+ // For cameras with an ssim/meanAbsDiff constraint AND a stored reference, diff.
1201
+ for (const camera of Object.keys(targetsByCamera)) {
1202
+ const c = targetsByCamera[camera] ?? {};
1203
+ const wantsRefMetric = c.ssim !== undefined || c.meanAbsDiff !== undefined;
1204
+ if (wantsRefMetric && existsSync(refsPath(cwd, element, camera))) {
1205
+ const b64 = cap._base64ByCam?.[camera];
1206
+ if (b64) {
1207
+ const d = diffReference({ cwd, element, camera, currentBase64: b64 });
1208
+ capturedByCamera[camera] = {
1209
+ ...(capturedByCamera[camera] ?? {}),
1210
+ ssim: d.ssim,
1211
+ meanAbsDiff: d.meanAbsDiff,
1212
+ };
1213
+ }
1214
+ }
1215
+ }
1216
+ const report = evaluateTargets(targetsByCamera, capturedByCamera);
1217
+ return {
1218
+ element,
1219
+ file,
1220
+ ...report,
1221
+ hint: report.allPassed
1222
+ ? 'All evaluated constraints pass — this view meets its target.'
1223
+ : 'Some constraints fail (see results[].pass=false). Skipped constraints need a set_reference to evaluate.',
1224
+ };
1225
+ }
1226
+
1227
+ async function inspect({ element, camera }: { element: string; camera?: string }) {
1228
+ // Resolve the lab URL the same way capture_views does, then append the
1229
+ // ?inspect=<el>&camera=<name> query so the harness boots in solo view.
1230
+ const baseUrl = await resolveLabUrl({ element });
1231
+ const sep = baseUrl.includes('?') ? '&' : '?';
1232
+ const inspectUrl = `${baseUrl}${sep}inspect=${encodeURIComponent(element)}${camera ? `&camera=${encodeURIComponent(camera)}` : ''}`;
1233
+ const t0 = Date.now();
1234
+ await browserPool.getPage(inspectUrl);
1235
+ return {
1236
+ element,
1237
+ camera: camera ?? null,
1238
+ url: inspectUrl,
1239
+ navMs: Date.now() - t0,
1240
+ hint: 'Right-drag to orbit, scroll to zoom, left-click to pick a mesh. Read .selection from telemetry after the user clicks.',
1241
+ };
1242
+ }
1243
+
1244
+ async function addElement({
1245
+ name,
1246
+ element,
1247
+ labUrl,
1248
+ }: {
1249
+ name: string;
1250
+ element?: string;
1251
+ labUrl?: string;
1252
+ }) {
1253
+ if (typeof name !== 'string' || name.length === 0) {
1254
+ throw new Error('add_element requires a non-empty `name`');
1255
+ }
1256
+ // `element`/`labUrl` only locate the running scene page (any mounted element
1257
+ // resolves to it); `name` is the registry element to mount.
1258
+ const target = await resolveLabUrl({ element, labUrl });
1259
+ const { call } = await browserPool.getPage(target);
1260
+ const res = await call('Runtime.evaluate', {
1261
+ expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.addElement)return{ok:false,error:'addElement unavailable — rebuild the lab against newer @triscope/core'};var ok=t.addElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
1262
+ returnByValue: true,
1263
+ });
1264
+ return JSON.parse(res.result.result.value);
1265
+ }
1266
+
1267
+ async function removeElement({
1268
+ name,
1269
+ element,
1270
+ labUrl,
1271
+ }: {
1272
+ name: string;
1273
+ element?: string;
1274
+ labUrl?: string;
1275
+ }) {
1276
+ if (typeof name !== 'string' || name.length === 0) {
1277
+ throw new Error('remove_element requires a non-empty `name`');
1278
+ }
1279
+ const target = await resolveLabUrl({ element, labUrl });
1280
+ const { call } = await browserPool.getPage(target);
1281
+ const res = await call('Runtime.evaluate', {
1282
+ expression: `JSON.stringify((function(){var t=window.__TRISCOPE__;if(!t||!t.removeElement)return{ok:false,error:'removeElement unavailable — rebuild the lab against newer @triscope/core'};var ok=t.removeElement(${JSON.stringify(name)});return{ok:ok,name:${JSON.stringify(name)},mounted:t.mountedElements?t.mountedElements():[],available:t.availableElements?t.availableElements():[]};})())`,
1283
+ returnByValue: true,
1284
+ });
1285
+ return JSON.parse(res.result.result.value);
1286
+ }
1287
+
1288
+ async function inspectScene({
1289
+ element,
1290
+ maxNodes,
1291
+ labUrl,
1292
+ fresh = false,
1293
+ }: {
1294
+ element?: string;
1295
+ maxNodes?: number;
1296
+ labUrl?: string;
1297
+ fresh?: boolean;
1298
+ }) {
1299
+ const target = await resolveLabUrl({ element, labUrl });
1300
+ const { call } = await browserPool.getPage(target, { reload: fresh });
1301
+ const n = Number.isFinite(maxNodes) ? maxNodes : 500;
1302
+ const res = await call('Runtime.evaluate', {
1303
+ expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.queryScene ? window.__TRISCOPE__.queryScene(${n}) : { error: 'queryScene unavailable — the lab is on an older @triscope/core; rebuild it' })`,
1304
+ returnByValue: true,
1305
+ });
1306
+ return JSON.parse(res.result.result.value);
1307
+ }
1308
+
1309
+ async function readUniform({
1310
+ element,
1311
+ path,
1312
+ labUrl,
1313
+ }: {
1314
+ element?: string;
1315
+ path: string;
1316
+ labUrl?: string;
1317
+ }) {
1318
+ const target = await resolveLabUrl({ element, labUrl });
1319
+ const { call } = await browserPool.getPage(target);
1320
+ const res = await call('Runtime.evaluate', {
1321
+ expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.readUniform ? window.__TRISCOPE__.readUniform(${JSON.stringify(path)}) : { kind: 'not-found', error: 'readUniform unavailable — rebuild the lab against newer @triscope/core' })`,
1322
+ returnByValue: true,
1323
+ });
1324
+ return JSON.parse(res.result.result.value);
1325
+ }
1326
+
1327
+ async function setUniform({
1328
+ element,
1329
+ path,
1330
+ value,
1331
+ labUrl,
1332
+ }: {
1333
+ element?: string;
1334
+ path: string;
1335
+ value: unknown;
1336
+ labUrl?: string;
1337
+ }) {
1338
+ const target = await resolveLabUrl({ element, labUrl });
1339
+ const { call } = await browserPool.getPage(target);
1340
+ const res = await call('Runtime.evaluate', {
1341
+ expression: `JSON.stringify(window.__TRISCOPE__ && window.__TRISCOPE__.setUniform ? window.__TRISCOPE__.setUniform(${JSON.stringify(path)}, ${JSON.stringify(value)}) : { ok: false, kind: 'not-found', error: 'setUniform unavailable — rebuild the lab against newer @triscope/core' })`,
1342
+ returnByValue: true,
1343
+ });
1344
+ return JSON.parse(res.result.result.value);
1345
+ }
1346
+
1347
+ async function setSceneParam({
1348
+ cameras,
1349
+ knobs,
1350
+ elements,
1351
+ }: {
1352
+ cameras?: Record<string, { position?: number[]; target?: number[]; fov?: number }>;
1353
+ knobs?: Record<string, unknown>;
1354
+ elements?: Record<string, { enabled?: boolean }>;
1355
+ }) {
1356
+ const delta: Record<string, unknown> = {};
1357
+ if (cameras && Object.keys(cameras).length) delta.cameras = cameras;
1358
+ if (knobs && Object.keys(knobs).length) delta.knobs = knobs;
1359
+ if (elements && Object.keys(elements).length) delta.elements = elements;
1360
+ if (!delta.cameras && !delta.knobs && !delta.elements) {
1361
+ throw new Error('set_scene_param needs `cameras`, `knobs`, and/or `elements`');
1362
+ }
1363
+ const r = await fetch(`${DEV_URL}/__scene`, {
1364
+ method: 'POST',
1365
+ headers: { 'content-type': 'application/json' },
1366
+ body: JSON.stringify(delta),
1367
+ });
1368
+ if (!r.ok) throw new Error(`/__scene returned ${r.status}`);
1369
+ return {
1370
+ ok: true,
1371
+ applied: delta,
1372
+ note: 'Applied by the harness within ~100 ms (no reload) and persisted via /__scene so it survives a reload. Read back with get_scene or read_telemetry .cameras.',
1373
+ };
1374
+ }
1375
+
1376
+ async function getScene({ devUrl, labUrl }: { devUrl?: string; labUrl?: string } = {}) {
1377
+ const url = resolveDevUrl({ devUrl, labUrl });
1378
+ let sceneSpec: unknown = {};
1379
+ let live: unknown = {};
1380
+ try {
1381
+ const r = await fetch(`${url}/__scene/current`);
1382
+ if (r.ok) sceneSpec = await r.json();
1383
+ } catch {
1384
+ /* dev server down */
1385
+ }
1386
+ // Live scene state from the dev server's telemetry over HTTP (works for any
1387
+ // project/port; falls back to the local file only for the default dev server).
1388
+ const state = await fetchState(url);
1389
+ const st = state?.data ?? (url === DEV_URL && existsSync(STATE_PATH) ? safeReadState() : null);
1390
+ if (st) live = { cameras: st.cameras, knobs: st.knobs, elements: st.sceneElements };
1391
+ return { sceneSpec, live };
1392
+ }
1393
+
1394
+ function safeReadState(): any {
1395
+ try {
1396
+ return JSON.parse(readFileSync(STATE_PATH, 'utf8'));
1397
+ } catch {
1398
+ return null;
1399
+ }
1400
+ }
1401
+
1402
+ async function openSelection({ editor }: { editor?: string }) {
1403
+ // Read the current selection from the telemetry snapshot. The selection
1404
+ // is written by the inspect-mode click handler in the browser, then
1405
+ // surfaced into /tmp/<project>-state.json on the next telemetry tick.
1406
+ if (!existsSync(STATE_PATH)) {
1407
+ throw new Error(`No telemetry at ${STATE_PATH} — is the dev server running with a lab open?`);
1408
+ }
1409
+ const state: any = JSON.parse(readFileSync(STATE_PATH, 'utf8'));
1410
+ const sel = state?.selection;
1411
+ if (!sel?.source?.file) {
1412
+ throw new Error(
1413
+ 'No mesh selected yet. Open the lab in inspect mode and left-click something first.',
1414
+ );
1415
+ }
1416
+ // Convert vite-served URL to a filesystem path: strip protocol+host so
1417
+ // `code --goto` resolves it relative to cwd (the project the dev server
1418
+ // is serving). Falls through if `file` is already a path.
1419
+ const rawFile = String(sel.source.file);
1420
+ const fsPath = rawFile
1421
+ .replace(/^https?:\/\/[^/]+\//, '') // strip http://host:port/
1422
+ .replace(/^file:\/\//, '') // strip file://
1423
+ .replace(/[?#].*$/, ''); // strip query/hash
1424
+ const absPath = fsPath.startsWith('/') ? fsPath : join(process.cwd(), fsPath);
1425
+ const line = Number(sel.source.line ?? 1);
1426
+ const col = Number(sel.source.col ?? 1);
1427
+ // Editor resolution: explicit arg → $EDITOR → `code --goto`. We assume
1428
+ // `code` is on PATH for VS Code / Cursor / VSCodium users; others can
1429
+ // export $EDITOR (e.g. EDITOR="zed --goto") to override.
1430
+ const cmd = editor ?? process.env.EDITOR ?? 'code';
1431
+ // `code --goto file:line:col` is the standard VSCode invocation.
1432
+ const usesGoto = /code\b/.test(cmd);
1433
+ const args = usesGoto ? ['--goto', `${absPath}:${line}:${col}`] : [`${absPath}:${line}:${col}`];
1434
+ return await new Promise<{
1435
+ ok: boolean;
1436
+ cmd: string;
1437
+ args: string[];
1438
+ file: string;
1439
+ line: number;
1440
+ col: number;
1441
+ stderr?: string;
1442
+ }>((resolve, reject) => {
1443
+ const child = spawn(cmd, args, {
1444
+ stdio: ['ignore', 'pipe', 'pipe'],
1445
+ detached: true,
1446
+ shell: NEED_SHELL,
1447
+ });
1448
+ let stderr = '';
1449
+ child.stderr.on('data', (d) => (stderr += d));
1450
+ child.on('error', (err) => reject(new Error(`failed to spawn ${cmd}: ${err.message}`)));
1451
+ // Editor commands usually fork-and-detach; don't wait for exit, just
1452
+ // confirm we spawned without immediate error.
1453
+ setTimeout(() => {
1454
+ try {
1455
+ child.unref();
1456
+ } catch {}
1457
+ resolve({ ok: true, cmd, args, file: absPath, line, col, stderr: stderr || undefined });
1458
+ }, 200);
1459
+ });
1460
+ }
1461
+
1462
+ async function runSmoke({ element }) {
1463
+ return new Promise((resolve, reject) => {
1464
+ const args = ['smoke'];
1465
+ if (element) args.push(element);
1466
+ const child = spawn('triscope', args, { stdio: ['ignore', 'pipe', 'pipe'], shell: NEED_SHELL });
1467
+ let out = '';
1468
+ let err = '';
1469
+ child.stdout.on('data', (d) => (out += d));
1470
+ child.stderr.on('data', (d) => (err += d));
1471
+ child.on('error', reject);
1472
+ child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
1473
+ });
1474
+ }
1475
+
1476
+ async function importElement({ source, name }: { source: string; name?: string }) {
1477
+ rejectFlagArg(source, 'source');
1478
+ if (name !== undefined) rejectFlagArg(name, 'name');
1479
+ return new Promise((resolve, reject) => {
1480
+ const args = ['import', source];
1481
+ if (name) args.push('--name', name);
1482
+ const child = spawn(TRISCOPE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
1483
+ let out = '';
1484
+ let err = '';
1485
+ child.stdout.on('data', (d) => (out += d));
1486
+ child.stderr.on('data', (d) => (err += d));
1487
+ child.on('error', reject);
1488
+ child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
1489
+ });
1490
+ }
1491
+
1492
+ async function scaffoldFromGltf({ file, name }: { file: string; name?: string }) {
1493
+ rejectFlagArg(file, 'file');
1494
+ if (name !== undefined) rejectFlagArg(name, 'name');
1495
+ return new Promise((resolve, reject) => {
1496
+ const args = ['new-gltf', file];
1497
+ if (name) args.push('--name', name);
1498
+ const child = spawn(TRISCOPE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
1499
+ let out = '';
1500
+ let err = '';
1501
+ child.stdout.on('data', (d) => (out += d));
1502
+ child.stderr.on('data', (d) => (err += d));
1503
+ child.on('error', reject);
1504
+ child.on('exit', (code) => resolve({ exitCode: code ?? 0, stdout: out, stderr: err }));
1505
+ });
1506
+ }
1507
+
1508
+ const tools = [
1509
+ {
1510
+ name: 'list_elements',
1511
+ description:
1512
+ 'List elements registered with the running triscope dev server. Cameras + current knob values appear per element ONLY after a browser has mounted that lab (the harness POSTs the full manifest on mount); a cold call returns just the seeded `{element, labUrl}` from package.json#triscope.labs. Pass devUrl/labUrl to target a different dev server than the one the MCP booted against.',
1513
+ inputSchema: {
1514
+ type: 'object',
1515
+ properties: {
1516
+ devUrl: {
1517
+ type: 'string',
1518
+ description: 'Target dev-server origin (default: the boot one).',
1519
+ },
1520
+ labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
1521
+ },
1522
+ additionalProperties: false,
1523
+ },
1524
+ },
1525
+ {
1526
+ name: 'read_telemetry',
1527
+ description:
1528
+ 'Read the latest telemetry snapshot (live over GET /__state, falling back to the on-disk file — so it now works cross-project). Optional jq-style "path" (e.g. ".elements.ship.triangles", ".perf.fps") returns that slice VERBATIM. The FULL snapshot (no path) is annotated with `__source` ("live"/"disk") + `__staleMs` + a `__warning` when old (the lab tab may be backgrounded/closed). Use for hidden numeric state where screenshots lie. Pass devUrl/labUrl to read a different dev server/project.',
1529
+ inputSchema: {
1530
+ type: 'object',
1531
+ properties: {
1532
+ path: { type: 'string', description: 'Dot-separated jq-style path into the snapshot.' },
1533
+ devUrl: {
1534
+ type: 'string',
1535
+ description: 'Target dev-server origin (default: the boot one).',
1536
+ },
1537
+ labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
1538
+ },
1539
+ additionalProperties: false,
1540
+ },
1541
+ },
1542
+ {
1543
+ name: 'set_knob',
1544
+ description:
1545
+ 'Live-update one or many knobs in a single round trip. Either pass {element,key,value} for a single update OR {updates:[{element,key,value},...]} to batch. Use absolute values, never deltas. Changes take effect in the running browser within ~100 ms. Out-of-range / wrong-type values are clamped to the knob spec before they apply; when that happens the response includes clamped:[{element,key,requested,final}] so you know what actually took effect. Pass devUrl/labUrl to target a different dev server.',
1546
+ inputSchema: {
1547
+ type: 'object',
1548
+ properties: {
1549
+ element: { type: 'string', description: 'Element name (single-update form).' },
1550
+ key: { type: 'string', description: 'Knob key (single-update form).' },
1551
+ value: { description: 'Absolute value (number, "#aabbcc" color, boolean).' },
1552
+ updates: {
1553
+ type: 'array',
1554
+ description: 'Batch form: array of {element,key,value} entries applied atomically.',
1555
+ items: {
1556
+ type: 'object',
1557
+ properties: {
1558
+ element: { type: 'string' },
1559
+ key: { type: 'string' },
1560
+ value: {},
1561
+ },
1562
+ required: ['element', 'key', 'value'],
1563
+ },
1564
+ },
1565
+ devUrl: {
1566
+ type: 'string',
1567
+ description: 'Target dev-server origin (default: the boot one).',
1568
+ },
1569
+ labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
1570
+ },
1571
+ additionalProperties: false,
1572
+ },
1573
+ },
1574
+ {
1575
+ name: 'capture_views',
1576
+ description:
1577
+ 'Spawn Chromium against a lab page (resolved via Element.labUrl in the manifest, package.json#triscope.labs, or /labs/<element>.html as fallback) and render every named camera. Writes PNGs to /tmp/<project>-capture-<element>/<camera>.png AND returns each image inline as MCP image content blocks (so the model sees them directly without a Read call). Set inline=false to return paths only (smaller payload). The response includes blackFrames:[camera,...] (panes the GPU drew black — luminance below threshold, a render failure) AND flatFrames:[camera,...] (panes with dynamicRange≈1, i.e. only the flat clear-color is in view — the element drifted out of frame or was not drawn; blackFrames misses this when the clear-color is above the black threshold).',
1578
+ inputSchema: {
1579
+ type: 'object',
1580
+ properties: {
1581
+ element: {
1582
+ type: 'string',
1583
+ description: 'Element name. URL is resolved via manifest/config.',
1584
+ },
1585
+ labUrl: {
1586
+ type: 'string',
1587
+ description: 'Override the lab URL entirely (highest precedence).',
1588
+ },
1589
+ inline: {
1590
+ type: 'boolean',
1591
+ description:
1592
+ 'Return images as inline content blocks. Default false — safer for many-camera elements where the inline base64 payload can blow the MCP stdio message budget. Set true only when you specifically want inline.',
1593
+ default: false,
1594
+ },
1595
+ fresh: {
1596
+ type: 'boolean',
1597
+ description:
1598
+ 'Force a full page reload before capturing, so a source edit the pooled page would otherwise miss (e.g. a *.lab.ts change) is picked up. Costs a reload (~1-3s) and drops transient set_uniform writes. Default false.',
1599
+ default: false,
1600
+ },
1601
+ },
1602
+ additionalProperties: false,
1603
+ },
1604
+ },
1605
+ {
1606
+ name: 'set_reference',
1607
+ description:
1608
+ 'Save a reference image for an (element, camera) pair under <project>/refs/<element>/<camera>.png. Accepts EITHER a `path` to a file on disk (e.g. a chat-attachment path) OR `base64` inline PNG data. Use this when the user pastes a reference image they want the AI to converge toward.',
1609
+ inputSchema: {
1610
+ type: 'object',
1611
+ properties: {
1612
+ element: { type: 'string', description: 'Element name.' },
1613
+ camera: { type: 'string', description: 'Camera name (must match Element.cameras key).' },
1614
+ path: {
1615
+ type: 'string',
1616
+ description: 'Filesystem path to a PNG/JPEG (one of path or base64 required).',
1617
+ },
1618
+ base64: {
1619
+ type: 'string',
1620
+ description: 'Base64-encoded PNG (with or without data: prefix).',
1621
+ },
1622
+ },
1623
+ required: ['element', 'camera'],
1624
+ additionalProperties: false,
1625
+ },
1626
+ },
1627
+ {
1628
+ name: 'diff_reference',
1629
+ description:
1630
+ 'Capture the current view at (element, camera), diff it against the stored reference, and return: numeric meanAbsDiff (0-255) + ssim (1=identical); an 8×8 per-tile SSIM grid + the worstTile {row,col,ssim} so you know WHERE they differ; the side-by-side composite (left=ref, right=current); and a black→blue→yellow→red difference heatmap (hot = divergent region) as a second image. Requires a prior set_reference for the same (element, camera).',
1631
+ inputSchema: {
1632
+ type: 'object',
1633
+ properties: {
1634
+ element: { type: 'string', description: 'Element name.' },
1635
+ camera: { type: 'string', description: 'Camera name.' },
1636
+ labUrl: {
1637
+ type: 'string',
1638
+ description: 'Override the lab URL (otherwise resolved like capture_views).',
1639
+ },
1640
+ fresh: {
1641
+ type: 'boolean',
1642
+ description:
1643
+ 'Force a full reload before capturing (pick up source edits). Default false.',
1644
+ },
1645
+ },
1646
+ required: ['element', 'camera'],
1647
+ additionalProperties: false,
1648
+ },
1649
+ },
1650
+ {
1651
+ name: 'set_reference_motion',
1652
+ description:
1653
+ 'Capture the CURRENT motion sequence at (element, camera) and save it as the animated reference. Writes <project>/refs/<element>/<camera>.motion.png (filmstrip) + <camera>.motion.json (frames/dt/mode metadata). Use to lock in a known-good animation before risky shader/uniform edits, then diff_reference_motion confirms regressions visually + numerically.',
1654
+ inputSchema: {
1655
+ type: 'object',
1656
+ properties: {
1657
+ element: { type: 'string' },
1658
+ camera: { type: 'string' },
1659
+ frames: { type: 'number', description: 'Default 6.' },
1660
+ dt: { type: 'number', description: 'Seconds between frames. Default 0.25.' },
1661
+ mode: { type: 'string', enum: ['time', 'real'], description: 'Default "time".' },
1662
+ labUrl: { type: 'string' },
1663
+ },
1664
+ required: ['element', 'camera'],
1665
+ additionalProperties: false,
1666
+ },
1667
+ },
1668
+ {
1669
+ name: 'diff_reference_motion',
1670
+ description:
1671
+ 'Capture current motion at (element, camera), diff against the saved animated reference. Returns a vertically-stacked composite (reference filmstrip on top, current on bottom) inline AND a scalar motionDiff (0=identical animation, >5=visible drift, >30=clearly different). Requires a prior set_reference_motion for the same (element, camera).',
1672
+ inputSchema: {
1673
+ type: 'object',
1674
+ properties: {
1675
+ element: { type: 'string' },
1676
+ camera: { type: 'string' },
1677
+ frames: { type: 'number' },
1678
+ dt: { type: 'number' },
1679
+ mode: { type: 'string', enum: ['time', 'real'] },
1680
+ labUrl: { type: 'string' },
1681
+ },
1682
+ required: ['element', 'camera'],
1683
+ additionalProperties: false,
1684
+ },
1685
+ },
1686
+ {
1687
+ name: 'capture_motion',
1688
+ description:
1689
+ 'Capture N frames per camera spaced by dt seconds, compose each into an inline filmstrip image (frames tiled left-to-right), and return a numeric motionMagnitude per camera (0-255 scale; <1 = static, >5 = visible motion, >20 = vigorous). Use this WHEN THE ELEMENT HAS ANIMATION (shader-driven motion, sail billow, particle systems, oscillation) — a single capture_views frame cannot reveal whether motion is happening. For complementary numeric verification of hidden animated state, read_telemetry .elements.<name>.motion (if the Element declared motionProbes).',
1690
+ inputSchema: {
1691
+ type: 'object',
1692
+ properties: {
1693
+ element: { type: 'string' },
1694
+ camera: {
1695
+ type: 'string',
1696
+ description: 'Single camera. Omit to capture all cameras (one filmstrip each).',
1697
+ },
1698
+ frames: { type: 'number', description: 'Frames per filmstrip. Default 6.' },
1699
+ dt: { type: 'number', description: 'Seconds between captured frames. Default 0.25.' },
1700
+ mode: {
1701
+ type: 'string',
1702
+ enum: ['time', 'real'],
1703
+ description:
1704
+ '"time" (default) is deterministic (steps time.value, fast). "real" runs wall-clock (slower; needed for CPU-integrated state).',
1705
+ },
1706
+ labUrl: {
1707
+ type: 'string',
1708
+ description: 'Override the lab URL (otherwise resolved like capture_views).',
1709
+ },
1710
+ inline: {
1711
+ type: 'boolean',
1712
+ description: 'Include filmstrips as inline images. Default true.',
1713
+ },
1714
+ fresh: {
1715
+ type: 'boolean',
1716
+ description:
1717
+ 'Force a full reload before capturing (pick up source edits). Default false.',
1718
+ },
1719
+ },
1720
+ required: ['element'],
1721
+ additionalProperties: false,
1722
+ },
1723
+ },
1724
+ {
1725
+ name: 'health',
1726
+ description:
1727
+ 'Server health snapshot. Returns uptime, dev-server reachability, browser-pool state, pid, recent errors (last 16). Call this when other tools misbehave: a "Connection closed" error from a capture tool followed by a healthy health() call means the MCP server is alive but the browser pool needs to recover; a failed health() means the server itself is sick.',
1728
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1729
+ },
1730
+ {
1731
+ name: 'run_smoke',
1732
+ description:
1733
+ 'Run the headed-Chromium smoke harness against a lab page. Returns exit code, stdout, stderr. Use as a CI gate after a batch of knob changes.',
1734
+ inputSchema: {
1735
+ type: 'object',
1736
+ properties: {
1737
+ element: {
1738
+ type: 'string',
1739
+ description: 'Element lab to test (defaults to the scene lab).',
1740
+ },
1741
+ },
1742
+ additionalProperties: false,
1743
+ },
1744
+ },
1745
+ {
1746
+ name: 'import_element',
1747
+ description:
1748
+ 'Install a published element package (naming convention `triscope-element-<name>`, or `github:user/repo`) into the current project and wire its lab page + vite input + package.json#triscope.labs. Runs `triscope import`; returns exit code + output. After it succeeds, capture_views/inspect_scene the new element by name.',
1749
+ inputSchema: {
1750
+ type: 'object',
1751
+ properties: {
1752
+ source: { type: 'string', description: 'npm package or github:user/repo[#ref].' },
1753
+ name: { type: 'string', description: 'Override the local element/lab name.' },
1754
+ },
1755
+ required: ['source'],
1756
+ additionalProperties: false,
1757
+ },
1758
+ },
1759
+ {
1760
+ name: 'scaffold_from_gltf',
1761
+ description:
1762
+ 'Generate a wired triscope Element from a glTF/GLB asset path: auto-detects bounds + animation clips, copies the asset to /public, and writes src/elements/<name>.ts (GLTFLoader + AnimationMixer + fitted cameras + scale knob + dispose). Runs `triscope new-gltf`; returns exit code + output. Saves ~40-100 lines of loader/dispose boilerplate when bringing in a model.',
1763
+ inputSchema: {
1764
+ type: 'object',
1765
+ properties: {
1766
+ file: { type: 'string', description: 'Path to a .glb or .gltf file.' },
1767
+ name: {
1768
+ type: 'string',
1769
+ description: 'Override the element name (default: file basename).',
1770
+ },
1771
+ },
1772
+ required: ['file'],
1773
+ additionalProperties: false,
1774
+ },
1775
+ },
1776
+ {
1777
+ name: 'inspect_scene',
1778
+ description:
1779
+ 'Snapshot the live scene graph: every mesh/light/group with triangleCount, worldPosition, materialKind + materialColor, uniformNames, and the file:line `source` where it was added. Answers "what is in this scene / which object is X / where is its code" in ONE call instead of many capture_views probes. Returns `nodes` (sorted by triangle count, capped at maxNodes — default 120; `truncated`+`total` flag the rest, raise maxNodes to see it) PLUS a separate `lights` array — every light with type/intensity/color/position, ALWAYS included regardless of the cap (a named light is then read/writable via read_uniform/set_uniform "name.intensity").',
1780
+ inputSchema: {
1781
+ type: 'object',
1782
+ properties: {
1783
+ element: {
1784
+ type: 'string',
1785
+ description:
1786
+ 'Element/lab to introspect (resolved like capture_views). Omit for the scene lab.',
1787
+ },
1788
+ maxNodes: { type: 'number', description: 'Cap on returned nodes. Default 500.' },
1789
+ labUrl: { type: 'string', description: 'Override the lab URL.' },
1790
+ fresh: {
1791
+ type: 'boolean',
1792
+ description: 'Force a full reload first (pick up source edits). Default false.',
1793
+ },
1794
+ },
1795
+ additionalProperties: false,
1796
+ },
1797
+ },
1798
+ {
1799
+ name: 'read_uniform',
1800
+ description:
1801
+ 'Read ANY live material uniform / material property / object property by "objectName|uuid.key" path — even values never declared as knobs (e.g. "ship.metalness", "sun.intensity", a shader uniform "ocean.uChoppiness"). Use inspect_scene first to discover object names + uniformNames. Returns {kind, value} (colors as #hex, vectors as arrays); kind="not-found" if the path doesn\'t resolve.',
1802
+ inputSchema: {
1803
+ type: 'object',
1804
+ properties: {
1805
+ element: { type: 'string', description: 'Element/lab (resolved like capture_views).' },
1806
+ path: { type: 'string', description: '"<objectName|uuid>.<key>" — split on the last dot.' },
1807
+ labUrl: { type: 'string' },
1808
+ },
1809
+ required: ['path'],
1810
+ additionalProperties: false,
1811
+ },
1812
+ },
1813
+ {
1814
+ name: 'set_uniform',
1815
+ description:
1816
+ 'Write ANY live material uniform / material property / object property by "objectName|uuid.key" path WITHOUT a source edit or reload — for probing a shader parameter that isn\'t a declared knob. Colors accept "#rrggbb" or a number; vectors accept [x,y,z]. TRANSIENT: not persisted across reloads (use set_knob for values you want to keep). Returns {ok, previous, current}.',
1817
+ inputSchema: {
1818
+ type: 'object',
1819
+ properties: {
1820
+ element: { type: 'string' },
1821
+ path: { type: 'string', description: '"<objectName|uuid>.<key>".' },
1822
+ value: { description: 'Number, "#hex" / numeric color, boolean, or [x,y,z] vector.' },
1823
+ labUrl: { type: 'string' },
1824
+ },
1825
+ required: ['path', 'value'],
1826
+ additionalProperties: false,
1827
+ },
1828
+ },
1829
+ {
1830
+ name: 'inspect',
1831
+ description:
1832
+ 'Open the lab for an element in interactive inspect mode (solo full-canvas camera + OrbitControls + click-to-pick). Navigates the running browser via CDP to ?inspect=<element>&camera=<name>. Use when the user asks to "inspect" or "open" an element so they can rotate and click parts of it; subsequent clicks populate .selection in telemetry with source file:line.',
1833
+ inputSchema: {
1834
+ type: 'object',
1835
+ properties: {
1836
+ element: { type: 'string', description: 'Element to inspect (must match the manifest).' },
1837
+ camera: {
1838
+ type: 'string',
1839
+ description: "Starting camera (defaults to the element's first declared camera).",
1840
+ },
1841
+ },
1842
+ required: ['element'],
1843
+ additionalProperties: false,
1844
+ },
1845
+ },
1846
+ {
1847
+ name: 'open_selection',
1848
+ description:
1849
+ 'Open the file:line of the currently selected mesh (from inspect mode) in the user\'s editor. Reads .selection.source from the telemetry snapshot and spawns $EDITOR (or `code --goto` by default). Use after the user clicks a mesh and says "open this" / "show me the code".',
1850
+ inputSchema: {
1851
+ type: 'object',
1852
+ properties: {
1853
+ editor: {
1854
+ type: 'string',
1855
+ description: 'Override the editor command. Default: $EDITOR or `code`.',
1856
+ },
1857
+ },
1858
+ additionalProperties: false,
1859
+ },
1860
+ },
1861
+ {
1862
+ name: 'snapshot',
1863
+ description:
1864
+ "Freeze the current tuning state as a git tag (triscope/snapshot/<name>). Stores the HEAD commit + every persisted knob value across all elements, as JSON inside the tag's annotated message — no working-tree files written, no rebase noise. Refuses on a dirty working tree (would silently lose the in-progress edits on restore).",
1865
+ inputSchema: {
1866
+ type: 'object',
1867
+ properties: {
1868
+ name: { type: 'string', description: 'Snapshot name. Must match [A-Za-z0-9._-]+.' },
1869
+ message: {
1870
+ type: 'string',
1871
+ description: 'Optional human note for `git show triscope/snapshot/<name>`.',
1872
+ },
1873
+ },
1874
+ required: ['name'],
1875
+ additionalProperties: false,
1876
+ },
1877
+ },
1878
+ {
1879
+ name: 'restore',
1880
+ description:
1881
+ 'Restore a snapshot: git checkout the recorded commit and re-post every knob value via /__knob. Refuses on a dirty working tree. Leaves HEAD detached — branch from there if you want to keep iterating.',
1882
+ inputSchema: {
1883
+ type: 'object',
1884
+ properties: {
1885
+ name: {
1886
+ type: 'string',
1887
+ description: 'Snapshot name (matches `mcp__triscope__list_snapshots`).',
1888
+ },
1889
+ },
1890
+ required: ['name'],
1891
+ additionalProperties: false,
1892
+ },
1893
+ },
1894
+ {
1895
+ name: 'list_snapshots',
1896
+ description:
1897
+ 'List every triscope snapshot tag in this repo (name, creation date, message subject). Use to find which one to restore.',
1898
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
1899
+ },
1900
+ {
1901
+ name: 'auto_tune',
1902
+ description:
1903
+ 'Find the knob value that maximises SSIM (perceptual similarity) between the captured view and a stored reference image, using derivative-free golden-section search. Requires a prior set_reference for (element, target_camera). Iterations: post_knob → wait → captureViews → diff_reference. Use to converge a single shader parameter on a reference photo without manual bisection.',
1904
+ inputSchema: {
1905
+ type: 'object',
1906
+ properties: {
1907
+ element: { type: 'string', description: 'Element whose knob to tune.' },
1908
+ knob: {
1909
+ type: 'string',
1910
+ description: 'Knob key (must exist on Element.knobs and be type=number).',
1911
+ },
1912
+ range: {
1913
+ type: 'array',
1914
+ description:
1915
+ "Inclusive [min, max] search bracket. Should cover the knob's declared min/max.",
1916
+ items: { type: 'number' },
1917
+ minItems: 2,
1918
+ maxItems: 2,
1919
+ },
1920
+ target_camera: {
1921
+ type: 'string',
1922
+ description:
1923
+ 'Camera the SSIM is computed on. Must have a stored reference (set_reference first).',
1924
+ },
1925
+ max_iterations: {
1926
+ type: 'number',
1927
+ description:
1928
+ 'Cap on knob evaluations. Default 12 (golden section converges to ~0.7% of range).',
1929
+ },
1930
+ labUrl: {
1931
+ type: 'string',
1932
+ description: 'Override the lab URL (otherwise resolved like capture_views).',
1933
+ },
1934
+ },
1935
+ required: ['element', 'knob', 'range', 'target_camera'],
1936
+ additionalProperties: false,
1937
+ },
1938
+ },
1939
+ {
1940
+ name: 'multi_tune',
1941
+ description:
1942
+ 'Converge 2-N coupled knobs on a reference image at once via coordinate descent (golden-section per knob, repeated cycles) maximising SSIM. Use when several knobs jointly shape the look (e.g. choppiness + foamThreshold + exposure) — beats chaining single-knob auto_tune. Requires a prior set_reference for (element, target_camera). Hard-capped on total captures so wall time stays bounded; best with <=4 knobs. Leaves the lab at the converged values.',
1943
+ inputSchema: {
1944
+ type: 'object',
1945
+ properties: {
1946
+ element: { type: 'string' },
1947
+ knobs: {
1948
+ type: 'array',
1949
+ minItems: 1,
1950
+ description:
1951
+ 'Knobs to co-tune: [{key, range:[min,max], start?}]. Keep to <=4 for reasonable wall time.',
1952
+ items: {
1953
+ type: 'object',
1954
+ properties: {
1955
+ key: { type: 'string' },
1956
+ range: { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 },
1957
+ start: { type: 'number' },
1958
+ },
1959
+ required: ['key', 'range'],
1960
+ additionalProperties: false,
1961
+ },
1962
+ },
1963
+ target_camera: {
1964
+ type: 'string',
1965
+ description: 'Camera the SSIM is computed on (needs a stored reference).',
1966
+ },
1967
+ max_cycles: { type: 'number', description: 'Passes over all knobs. Default 2.' },
1968
+ per_knob_iters: {
1969
+ type: 'number',
1970
+ description: 'Golden-section evals per knob per cycle. Default 8.',
1971
+ },
1972
+ compute_interactions: {
1973
+ type: 'boolean',
1974
+ description:
1975
+ 'Also return a pairwise knob-interaction matrix (extra evals). Default false.',
1976
+ },
1977
+ max_evaluations: {
1978
+ type: 'number',
1979
+ description: 'Hard cap on total captures. Default min(knobs*iters*cycles+4, 64).',
1980
+ },
1981
+ labUrl: { type: 'string' },
1982
+ },
1983
+ required: ['element', 'knobs', 'target_camera'],
1984
+ additionalProperties: false,
1985
+ },
1986
+ },
1987
+ {
1988
+ name: 'check_targets',
1989
+ description:
1990
+ 'Evaluate per-camera convergence constraints from .claude/element-targets.json against the current capture — a machine-readable "is this view done?". Constraints per camera: ssim (min), meanAbsDiff (max), luminance {min,max}, dynamicRange {min,max}. ssim/meanAbsDiff need a stored reference (skipped, not failed, when absent); luminance/dynamicRange come from the GPU probe. Returns per-constraint pass/fail + allPassed. No targets file → no-op pass.',
1991
+ inputSchema: {
1992
+ type: 'object',
1993
+ properties: {
1994
+ element: { type: 'string' },
1995
+ labUrl: { type: 'string' },
1996
+ },
1997
+ required: ['element'],
1998
+ additionalProperties: false,
1999
+ },
2000
+ },
2001
+ {
2002
+ name: 'set_scene_param',
2003
+ description:
2004
+ 'Live-mutate the scene WITHOUT a code edit or reload (Scene Description Layer): repoint cameras (position/target/fov), override knobs, and show/hide composed elements (enabled true/false — the "solo a track" model). Applied by the harness within ~100 ms and persisted so it survives a reload. NOTE: `elements` toggles visibility of elements already composed into the scene; instantiating a brand-new element type still needs a code edit.',
2005
+ inputSchema: {
2006
+ type: 'object',
2007
+ properties: {
2008
+ cameras: {
2009
+ type: 'object',
2010
+ description: 'Map of cameraName → {position:[x,y,z], target:[x,y,z], fov} (any subset).',
2011
+ additionalProperties: {
2012
+ type: 'object',
2013
+ properties: {
2014
+ position: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 },
2015
+ target: { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 },
2016
+ fov: { type: 'number' },
2017
+ },
2018
+ additionalProperties: false,
2019
+ },
2020
+ },
2021
+ knobs: {
2022
+ type: 'object',
2023
+ description:
2024
+ 'Map of knobKey → value (same effect as set_knob; here for one-call scene edits).',
2025
+ },
2026
+ elements: {
2027
+ type: 'object',
2028
+ description:
2029
+ 'Map of elementName → {enabled:boolean} to show/hide a composed element live.',
2030
+ additionalProperties: {
2031
+ type: 'object',
2032
+ properties: { enabled: { type: 'boolean' } },
2033
+ additionalProperties: false,
2034
+ },
2035
+ },
2036
+ },
2037
+ additionalProperties: false,
2038
+ },
2039
+ },
2040
+ {
2041
+ name: 'get_scene',
2042
+ description:
2043
+ 'Return the current scene: `sceneSpec` (the persisted SDL overrides from set_scene_param) and `live` (the camera positions/targets/fov + knob values from telemetry). Use to see what a viewpoint currently is before repointing it. Pass devUrl/labUrl to target a different dev server.',
2044
+ inputSchema: {
2045
+ type: 'object',
2046
+ properties: {
2047
+ devUrl: {
2048
+ type: 'string',
2049
+ description: 'Target dev-server origin (default: the boot one).',
2050
+ },
2051
+ labUrl: { type: 'string', description: 'A lab URL; its origin is used as devUrl.' },
2052
+ },
2053
+ additionalProperties: false,
2054
+ },
2055
+ },
2056
+ {
2057
+ name: 'add_element',
2058
+ description:
2059
+ 'Instantiate a registered element into a runSceneLab scene LIVE (no reload): mounts it, adds its namespaced `<element>.<camera>` cameras + `<element>.<knob>` knobs, and rebuilds the grid. Returns {ok, mounted, available}. Only works on a multi-element scene booted with runSceneLab — on a single-element runLab page it returns {ok:false}. Use `available` from a prior add/remove (or list_elements) to see mountable names.',
2060
+ inputSchema: {
2061
+ type: 'object',
2062
+ properties: {
2063
+ name: { type: 'string', description: 'Registered element name to mount.' },
2064
+ element: {
2065
+ type: 'string',
2066
+ description: 'Any element in the scene, used only to locate the lab page.',
2067
+ },
2068
+ labUrl: { type: 'string', description: 'Explicit lab URL (alternative to element).' },
2069
+ },
2070
+ required: ['name'],
2071
+ additionalProperties: false,
2072
+ },
2073
+ },
2074
+ {
2075
+ name: 'remove_element',
2076
+ description:
2077
+ 'Dispose a mounted element from a runSceneLab scene LIVE (no reload): drops its cameras/knobs/telemetry and rebuilds the grid. The element stays in the registry and can be re-added with add_element. Returns {ok, mounted, available}.',
2078
+ inputSchema: {
2079
+ type: 'object',
2080
+ properties: {
2081
+ name: { type: 'string', description: 'Mounted element name to dispose.' },
2082
+ element: {
2083
+ type: 'string',
2084
+ description: 'Any element in the scene, used only to locate the lab page.',
2085
+ },
2086
+ labUrl: { type: 'string', description: 'Explicit lab URL (alternative to element).' },
2087
+ },
2088
+ required: ['name'],
2089
+ additionalProperties: false,
2090
+ },
2091
+ },
2092
+ ];
2093
+
2094
+ export function jsonResult(value) {
2095
+ let text: string;
2096
+ if (value === undefined) text = 'undefined';
2097
+ else if (typeof value === 'string') text = value;
2098
+ else text = JSON.stringify(value, null, 2);
2099
+ return {
2100
+ content: [{ type: 'text', text }],
2101
+ };
2102
+ }
2103
+
2104
+ export async function startServer() {
2105
+ const server = new Server(
2106
+ // Reported to every MCP client in the `initialize` handshake — keep in sync
2107
+ // with packages/mcp/package.json version on each release.
2108
+ { name: 'triscope-mcp', version: '0.4.0' },
2109
+ { capabilities: { tools: {} } },
2110
+ );
2111
+
2112
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
2113
+
2114
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
2115
+ const { name, arguments: args = {} } = req.params;
2116
+ // One info log per tool entry; on the way out either 'succeeded' or
2117
+ // 'failed' so a hung/dropped call leaves a half-pair in the log (no
2118
+ // succeeded/failed entry → the response never returned, e.g. the
2119
+ // process was OOM-killed during JSON encoding).
2120
+ const toolStart = Date.now();
2121
+ logger.info(`tool:${name}`, 'invoked', { args });
2122
+ const finish = (outcome: 'succeeded' | 'failed', extra?: Record<string, unknown>) =>
2123
+ logger.info(`tool:${name}`, outcome, { ms: Date.now() - toolStart, ...(extra ?? {}) });
2124
+ try {
2125
+ const result = await (async () => {
2126
+ switch (name) {
2127
+ case 'list_elements':
2128
+ return jsonResult(
2129
+ await listElements({
2130
+ devUrl: args.devUrl as string | undefined,
2131
+ labUrl: args.labUrl as string | undefined,
2132
+ }),
2133
+ );
2134
+ case 'read_telemetry':
2135
+ return jsonResult(
2136
+ await readTelemetry(args.path as string | undefined, {
2137
+ devUrl: args.devUrl as string | undefined,
2138
+ labUrl: args.labUrl as string | undefined,
2139
+ }),
2140
+ );
2141
+ case 'set_knob': {
2142
+ const value = z.union([z.number(), z.string(), z.boolean()]);
2143
+ const update = z.object({ element: z.string(), key: z.string(), value });
2144
+ const schema = z.union([z.object({ updates: z.array(update).min(1) }), update]);
2145
+ const parsed = schema.parse(args);
2146
+ const loc = z
2147
+ .object({ devUrl: z.string().optional(), labUrl: z.string().optional() })
2148
+ .parse(args);
2149
+ return jsonResult(await setKnob({ ...parsed, ...loc }));
2150
+ }
2151
+ case 'capture_views': {
2152
+ const res = await captureViews({
2153
+ element: args.element as string | undefined,
2154
+ labUrl: args.labUrl as string | undefined,
2155
+ inline: (args.inline ?? false) as boolean,
2156
+ fresh: (args.fresh ?? false) as boolean,
2157
+ });
2158
+ const { _base64ByCam, ...summary } = res;
2159
+ // Cap inline payload: 12-camera scenes can produce ~20 MB of base64
2160
+ // in a single JSON-RPC message over stdio, which OOM-kills the
2161
+ // server process (no catch, no log — Claude Code then auto-respawns
2162
+ // us and tools are temporarily unavailable). Auto-downgrade to
2163
+ // file paths when over budget and surface a warning so the model
2164
+ // knows to Read the files instead.
2165
+ let inlineBytes = 0;
2166
+ for (const b64 of Object.values(_base64ByCam) as string[]) inlineBytes += b64.length;
2167
+ const inlineCapped = res.inline && inlineBytes > INLINE_PAYLOAD_BUDGET;
2168
+ const finalInline = res.inline && !inlineCapped;
2169
+ const summaryWithWarn = inlineCapped
2170
+ ? {
2171
+ ...summary,
2172
+ inline: false,
2173
+ inlineCapped: true,
2174
+ inlineWarning: `inline payload would have been ${(inlineBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) — files are on disk, Read them by path.`,
2175
+ }
2176
+ : summary;
2177
+ const text = JSON.stringify(summaryWithWarn, null, 2);
2178
+ if (!finalInline) return { content: [{ type: 'text', text }] };
2179
+ const content: any[] = [{ type: 'text', text }];
2180
+ for (const cam of res.cameraOrder) {
2181
+ const data = _base64ByCam[cam];
2182
+ if (!data) continue;
2183
+ content.push({ type: 'image', data, mimeType: 'image/png' });
2184
+ }
2185
+ return { content };
2186
+ }
2187
+ case 'set_reference': {
2188
+ const parsed = z
2189
+ .object({
2190
+ element: z.string(),
2191
+ camera: z.string(),
2192
+ path: z.string().optional(),
2193
+ base64: z.string().optional(),
2194
+ })
2195
+ .parse(args);
2196
+ const result = setReference({ cwd: process.cwd(), ...parsed } as any);
2197
+ return jsonResult(result);
2198
+ }
2199
+ case 'diff_reference': {
2200
+ const parsed = z
2201
+ .object({
2202
+ element: z.string(),
2203
+ camera: z.string(),
2204
+ labUrl: z.string().optional(),
2205
+ fresh: z.boolean().optional(),
2206
+ })
2207
+ .parse(args);
2208
+ const refExists = existsSync(refsPath(process.cwd(), parsed.element, parsed.camera));
2209
+ if (!refExists) {
2210
+ return {
2211
+ isError: true,
2212
+ content: [
2213
+ {
2214
+ type: 'text',
2215
+ text: `no reference at ${refsPath(process.cwd(), parsed.element, parsed.camera)}. Call set_reference first.`,
2216
+ },
2217
+ ],
2218
+ };
2219
+ }
2220
+ const cap = await captureViews({
2221
+ element: parsed.element,
2222
+ labUrl: parsed.labUrl,
2223
+ inline: true,
2224
+ fresh: parsed.fresh ?? false,
2225
+ });
2226
+ const currentBase64 = cap._base64ByCam?.[parsed.camera];
2227
+ if (!currentBase64) {
2228
+ return {
2229
+ isError: true,
2230
+ content: [
2231
+ {
2232
+ type: 'text',
2233
+ text: `camera "${parsed.camera}" not found on element "${parsed.element}". Available: ${cap.cameraOrder.join(', ')}`,
2234
+ },
2235
+ ],
2236
+ };
2237
+ }
2238
+ const diff = diffReference({
2239
+ cwd: process.cwd(),
2240
+ element: parsed.element,
2241
+ camera: parsed.camera,
2242
+ currentBase64,
2243
+ });
2244
+ // Two images: the side-by-side composite (always) and the
2245
+ // difference heatmap (when it fits the inline budget). The heatmap
2246
+ // is what makes the worstTile/tileGrid spatially legible.
2247
+ const compositeBytes = diff.compositeBase64.length;
2248
+ const heatmapBytes = diff.heatmapBase64?.length ?? 0;
2249
+ const heatmapFits = compositeBytes + heatmapBytes <= INLINE_PAYLOAD_BUDGET;
2250
+ const content: any[] = [
2251
+ {
2252
+ type: 'text',
2253
+ text: JSON.stringify(
2254
+ {
2255
+ camera: diff.camera,
2256
+ refPath: diff.refPath,
2257
+ meanAbsDiff: diff.meanAbsDiff,
2258
+ ssim: diff.ssim,
2259
+ worstTile: diff.worstTile,
2260
+ tileStats: diff.tileStats,
2261
+ tileGrid: diff.tileGrid,
2262
+ heatmapIncluded: heatmapFits,
2263
+ hint: 'meanAbsDiff: 0 = identical, ~30 = visibly close, >80 = clearly different. ssim: 1.0 = identical, 0.9+ = visually close, <0.7 = clearly different (prefer SSIM, robust to AA noise). tileGrid is an 8×8 SSIM map (row 0 = top); worstTile points at the most divergent region. The second image is a black→blue→yellow→red difference heatmap — hot = where the frames differ.',
2264
+ },
2265
+ null,
2266
+ 2,
2267
+ ),
2268
+ },
2269
+ { type: 'image', data: diff.compositeBase64, mimeType: 'image/png' },
2270
+ ];
2271
+ if (heatmapFits && diff.heatmapBase64) {
2272
+ content.push({ type: 'image', data: diff.heatmapBase64, mimeType: 'image/png' });
2273
+ }
2274
+ return { content };
2275
+ }
2276
+ case 'set_reference_motion': {
2277
+ const parsed = z
2278
+ .object({
2279
+ element: z.string(),
2280
+ camera: z.string(),
2281
+ frames: z.number().int().min(2).max(32).optional(),
2282
+ dt: z.number().positive().max(5).optional(),
2283
+ mode: z.enum(['time', 'real']).optional(),
2284
+ labUrl: z.string().optional(),
2285
+ })
2286
+ .parse(args);
2287
+ const opts = {
2288
+ frames: parsed.frames ?? 6,
2289
+ dt: parsed.dt ?? 0.25,
2290
+ mode: parsed.mode ?? 'time',
2291
+ };
2292
+ const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
2293
+ const r = setReferenceMotion({
2294
+ cwd: process.cwd(),
2295
+ element: parsed.element,
2296
+ camera: parsed.camera,
2297
+ frameBase64s: frameB64s,
2298
+ meta: opts,
2299
+ });
2300
+ return jsonResult(r);
2301
+ }
2302
+ case 'diff_reference_motion': {
2303
+ const parsed = z
2304
+ .object({
2305
+ element: z.string(),
2306
+ camera: z.string(),
2307
+ frames: z.number().int().min(2).max(32).optional(),
2308
+ dt: z.number().positive().max(5).optional(),
2309
+ mode: z.enum(['time', 'real']).optional(),
2310
+ labUrl: z.string().optional(),
2311
+ })
2312
+ .parse(args);
2313
+ const { filmstrip, meta } = refsMotionPaths(
2314
+ process.cwd(),
2315
+ parsed.element,
2316
+ parsed.camera,
2317
+ );
2318
+ if (!existsSync(filmstrip)) {
2319
+ return {
2320
+ isError: true,
2321
+ content: [
2322
+ {
2323
+ type: 'text',
2324
+ text: `no motion reference at ${filmstrip}. Call set_reference_motion first.`,
2325
+ },
2326
+ ],
2327
+ };
2328
+ }
2329
+ // Inherit frame/dt/mode from saved metadata so the comparison is fair.
2330
+ let savedMeta: any = {};
2331
+ try {
2332
+ savedMeta = existsSync(meta) ? JSON.parse(readFileSync(meta, 'utf8')) : {};
2333
+ } catch {}
2334
+ const opts = {
2335
+ frames: parsed.frames ?? savedMeta.frames ?? 6,
2336
+ dt: parsed.dt ?? savedMeta.dt ?? 0.25,
2337
+ mode: parsed.mode ?? savedMeta.mode ?? 'time',
2338
+ };
2339
+ const frameB64s = await captureMotionFramesRaw({ ...parsed, ...opts });
2340
+ const diff = diffReferenceMotion({
2341
+ cwd: process.cwd(),
2342
+ element: parsed.element,
2343
+ camera: parsed.camera,
2344
+ currentFrames: frameB64s,
2345
+ });
2346
+ return {
2347
+ content: [
2348
+ {
2349
+ type: 'text',
2350
+ text: JSON.stringify(
2351
+ {
2352
+ camera: parsed.camera,
2353
+ refFilmstripPath: diff.refFilmstripPath,
2354
+ refMeta: diff.refMeta,
2355
+ motionDiff: diff.motionDiff,
2356
+ hint: '0 = identical animation, >5 = visible drift, >30 = clearly different',
2357
+ },
2358
+ null,
2359
+ 2,
2360
+ ),
2361
+ },
2362
+ { type: 'image', data: diff.compositeBase64, mimeType: 'image/png' },
2363
+ ],
2364
+ };
2365
+ }
2366
+ case 'capture_motion': {
2367
+ const parsed = z
2368
+ .object({
2369
+ element: z.string(),
2370
+ camera: z.string().optional(),
2371
+ frames: z.number().int().min(2).max(32).optional(),
2372
+ dt: z.number().positive().max(5).optional(),
2373
+ mode: z.enum(['time', 'real']).optional(),
2374
+ labUrl: z.string().optional(),
2375
+ inline: z.boolean().optional(),
2376
+ fresh: z.boolean().optional(),
2377
+ })
2378
+ .parse(args);
2379
+ const res = await captureMotion(parsed);
2380
+ const { _filmstripBase64, ...summary } = res;
2381
+ // Same inline budget guard as capture_views — filmstrips can be
2382
+ // even bigger (N frames per camera) so easier to blow the cap.
2383
+ let filmstripBytes = 0;
2384
+ for (const b64 of Object.values(_filmstripBase64) as string[])
2385
+ filmstripBytes += b64.length;
2386
+ const userWantsInline = parsed.inline !== false;
2387
+ const filmstripCapped = userWantsInline && filmstripBytes > INLINE_PAYLOAD_BUDGET;
2388
+ const finalInline = userWantsInline && !filmstripCapped;
2389
+ const text = JSON.stringify(
2390
+ {
2391
+ ...summary,
2392
+ ...(filmstripCapped
2393
+ ? {
2394
+ inlineCapped: true,
2395
+ inlineWarning: `filmstrip payload would have been ${(filmstripBytes / 1048576).toFixed(1)} MB (limit ${(INLINE_PAYLOAD_BUDGET / 1048576).toFixed(0)} MB) — files are on disk, Read them by path.`,
2396
+ }
2397
+ : {}),
2398
+ hint: '<1 = static, >5 = visible motion, >20 = vigorous (in motionMagnitude)',
2399
+ },
2400
+ null,
2401
+ 2,
2402
+ );
2403
+ if (!finalInline) return { content: [{ type: 'text', text }] };
2404
+ const content: any[] = [{ type: 'text', text }];
2405
+ for (const cam of res.cameraOrder) {
2406
+ const data = _filmstripBase64[cam];
2407
+ if (data) content.push({ type: 'image', data, mimeType: 'image/png' });
2408
+ }
2409
+ return { content };
2410
+ }
2411
+ case 'health': {
2412
+ let devServerOk = false;
2413
+ let manifestElements = [];
2414
+ try {
2415
+ const r = await fetch(`${DEV_URL}/__manifest`, { signal: AbortSignal.timeout(2000) });
2416
+ if (r.ok) {
2417
+ const m: any = await r.json();
2418
+ devServerOk = true;
2419
+ manifestElements = Object.keys(m?.elements ?? {});
2420
+ }
2421
+ } catch {}
2422
+ const mem = process.memoryUsage();
2423
+ return jsonResult({
2424
+ uptimeSec: Math.round((Date.now() - SERVER_START_TIME) / 1000),
2425
+ pid: process.pid,
2426
+ nodeVersion: process.version,
2427
+ project: PROJECT,
2428
+ devServer: { url: DEV_URL, reachable: devServerOk, manifestElements },
2429
+ memoryMB: {
2430
+ rss: +(mem.rss / 1048576).toFixed(1),
2431
+ heapUsed: +(mem.heapUsed / 1048576).toFixed(1),
2432
+ external: +(mem.external / 1048576).toFixed(1),
2433
+ },
2434
+ logPath: logger.logPath,
2435
+ recentErrors,
2436
+ });
2437
+ }
2438
+ case 'run_smoke':
2439
+ return jsonResult(await runSmoke({ element: args.element }));
2440
+ case 'import_element': {
2441
+ const parsed = z
2442
+ .object({ source: z.string(), name: z.string().optional() })
2443
+ .parse(args);
2444
+ return jsonResult(
2445
+ await importElement({ source: parsed.source as string, name: parsed.name }),
2446
+ );
2447
+ }
2448
+ case 'scaffold_from_gltf': {
2449
+ const parsed = z.object({ file: z.string(), name: z.string().optional() }).parse(args);
2450
+ return jsonResult(
2451
+ await scaffoldFromGltf({ file: parsed.file as string, name: parsed.name }),
2452
+ );
2453
+ }
2454
+ case 'read_uniform': {
2455
+ const parsed = z
2456
+ .object({
2457
+ element: z.string().optional(),
2458
+ path: z.string(),
2459
+ labUrl: z.string().optional(),
2460
+ })
2461
+ .parse(args);
2462
+ return jsonResult(
2463
+ await readUniform({
2464
+ element: parsed.element,
2465
+ path: parsed.path as string,
2466
+ labUrl: parsed.labUrl,
2467
+ }),
2468
+ );
2469
+ }
2470
+ case 'set_uniform': {
2471
+ const parsed = z
2472
+ .object({
2473
+ element: z.string().optional(),
2474
+ path: z.string(),
2475
+ value: z.union([z.number(), z.string(), z.boolean(), z.array(z.number())]),
2476
+ labUrl: z.string().optional(),
2477
+ })
2478
+ .parse(args);
2479
+ return jsonResult(
2480
+ await setUniform({
2481
+ element: parsed.element,
2482
+ path: parsed.path as string,
2483
+ value: parsed.value,
2484
+ labUrl: parsed.labUrl,
2485
+ }),
2486
+ );
2487
+ }
2488
+ case 'inspect_scene': {
2489
+ const parsed = z
2490
+ .object({
2491
+ element: z.string().optional(),
2492
+ maxNodes: z.number().int().min(1).max(5000).optional(),
2493
+ labUrl: z.string().optional(),
2494
+ fresh: z.boolean().optional(),
2495
+ })
2496
+ .parse(args);
2497
+ return jsonResult(await inspectScene(parsed));
2498
+ }
2499
+ case 'inspect': {
2500
+ const parsed = z
2501
+ .object({
2502
+ element: z.string(),
2503
+ camera: z.string().optional(),
2504
+ })
2505
+ .parse(args);
2506
+ return jsonResult(await inspect({ element: parsed.element, camera: parsed.camera }));
2507
+ }
2508
+ case 'open_selection': {
2509
+ const parsed = z
2510
+ .object({
2511
+ editor: z.string().optional(),
2512
+ })
2513
+ .parse(args);
2514
+ return jsonResult(await openSelection(parsed));
2515
+ }
2516
+ case 'snapshot': {
2517
+ const parsed = z
2518
+ .object({
2519
+ name: z.string(),
2520
+ message: z.string().optional(),
2521
+ })
2522
+ .parse(args);
2523
+ return jsonResult(await snapshot({ name: parsed.name, message: parsed.message }));
2524
+ }
2525
+ case 'restore': {
2526
+ const parsed = z.object({ name: z.string() }).parse(args);
2527
+ return jsonResult(await restore({ name: parsed.name }));
2528
+ }
2529
+ case 'list_snapshots':
2530
+ return jsonResult(await listSnapshots());
2531
+ case 'auto_tune': {
2532
+ const parsed = z
2533
+ .object({
2534
+ element: z.string(),
2535
+ knob: z.string(),
2536
+ range: z.tuple([z.number(), z.number()]),
2537
+ target_camera: z.string(),
2538
+ max_iterations: z.number().int().min(2).max(50).optional(),
2539
+ labUrl: z.string().optional(),
2540
+ })
2541
+ .parse(args);
2542
+ return jsonResult(
2543
+ await autoTune({
2544
+ element: parsed.element,
2545
+ knob: parsed.knob,
2546
+ range: [parsed.range[0], parsed.range[1]],
2547
+ target_camera: parsed.target_camera,
2548
+ max_iterations: parsed.max_iterations,
2549
+ labUrl: parsed.labUrl,
2550
+ }),
2551
+ );
2552
+ }
2553
+ case 'multi_tune': {
2554
+ const parsed = z
2555
+ .object({
2556
+ element: z.string(),
2557
+ knobs: z
2558
+ .array(
2559
+ z.object({
2560
+ key: z.string(),
2561
+ range: z.tuple([z.number(), z.number()]),
2562
+ start: z.number().optional(),
2563
+ }),
2564
+ )
2565
+ .min(1),
2566
+ target_camera: z.string(),
2567
+ max_cycles: z.number().int().min(1).max(6).optional(),
2568
+ per_knob_iters: z.number().int().min(2).max(20).optional(),
2569
+ compute_interactions: z.boolean().optional(),
2570
+ max_evaluations: z.number().int().min(2).max(300).optional(),
2571
+ labUrl: z.string().optional(),
2572
+ })
2573
+ .parse(args);
2574
+ return jsonResult(
2575
+ await multiTune({
2576
+ element: parsed.element as string,
2577
+ knobs: (
2578
+ parsed.knobs as Array<{ key: string; range: [number, number]; start?: number }>
2579
+ ).map((k) => ({
2580
+ key: k.key,
2581
+ range: [k.range[0], k.range[1]] as [number, number],
2582
+ start: k.start,
2583
+ })),
2584
+ target_camera: parsed.target_camera as string,
2585
+ max_cycles: parsed.max_cycles,
2586
+ per_knob_iters: parsed.per_knob_iters,
2587
+ compute_interactions: parsed.compute_interactions,
2588
+ max_evaluations: parsed.max_evaluations,
2589
+ labUrl: parsed.labUrl,
2590
+ }),
2591
+ );
2592
+ }
2593
+ case 'check_targets': {
2594
+ const parsed = z
2595
+ .object({ element: z.string(), labUrl: z.string().optional() })
2596
+ .parse(args);
2597
+ return jsonResult(
2598
+ await checkTargets({ element: parsed.element as string, labUrl: parsed.labUrl }),
2599
+ );
2600
+ }
2601
+ case 'set_scene_param': {
2602
+ const parsed = z
2603
+ .object({
2604
+ cameras: z
2605
+ .record(
2606
+ z.object({
2607
+ position: z.array(z.number()).optional(),
2608
+ target: z.array(z.number()).optional(),
2609
+ fov: z.number().optional(),
2610
+ }),
2611
+ )
2612
+ .optional(),
2613
+ knobs: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
2614
+ elements: z.record(z.object({ enabled: z.boolean().optional() })).optional(),
2615
+ })
2616
+ .parse(args);
2617
+ return jsonResult(await setSceneParam(parsed));
2618
+ }
2619
+ case 'get_scene':
2620
+ return jsonResult(
2621
+ await getScene({
2622
+ devUrl: args.devUrl as string | undefined,
2623
+ labUrl: args.labUrl as string | undefined,
2624
+ }),
2625
+ );
2626
+ case 'add_element': {
2627
+ const parsed = z
2628
+ .object({
2629
+ name: z.string(),
2630
+ element: z.string().optional(),
2631
+ labUrl: z.string().optional(),
2632
+ })
2633
+ .parse(args);
2634
+ return jsonResult(
2635
+ await addElement({
2636
+ name: parsed.name,
2637
+ element: parsed.element,
2638
+ labUrl: parsed.labUrl,
2639
+ }),
2640
+ );
2641
+ }
2642
+ case 'remove_element': {
2643
+ const parsed = z
2644
+ .object({
2645
+ name: z.string(),
2646
+ element: z.string().optional(),
2647
+ labUrl: z.string().optional(),
2648
+ })
2649
+ .parse(args);
2650
+ return jsonResult(
2651
+ await removeElement({
2652
+ name: parsed.name,
2653
+ element: parsed.element,
2654
+ labUrl: parsed.labUrl,
2655
+ }),
2656
+ );
2657
+ }
2658
+ default:
2659
+ return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
2660
+ }
2661
+ })();
2662
+ finish('succeeded');
2663
+ return result;
2664
+ } catch (err: any) {
2665
+ finish('failed');
2666
+ recordError(`tool:${name}`, err);
2667
+ return {
2668
+ isError: true,
2669
+ content: [{ type: 'text', text: `${name} failed: ${err?.message ?? String(err)}` }],
2670
+ };
2671
+ }
2672
+ });
2673
+
2674
+ const transport = new StdioServerTransport();
2675
+ await server.connect(transport);
2676
+ // eslint-disable-next-line no-console
2677
+ console.error(`[triscope-mcp] connected. dev server: ${DEV_URL}, project: ${PROJECT}`);
2678
+ }