@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 triscope contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @triscope/mcp
2
+
3
+ The triscope **MCP server** (stdio JSON-RPC) — gives an AI agent structured,
4
+ machine-readable control of a running [`@triscope/core`](https://www.npmjs.com/package/@triscope/core)
5
+ lab over a headless Chromium/WebGPU pool.
6
+
7
+ ## What it exposes
8
+
9
+ Tools for the full converge-a-3D-scene loop, including:
10
+
11
+ - **`capture_views`** / **`capture_motion`** — render the lab's cameras to PNGs
12
+ (the only reliable WebGPU readback path) with GPU + flat/black-frame probes.
13
+ - **`inspect_scene`** / **`read_uniform`** / **`set_uniform`** — scene-graph
14
+ introspection and live uniform/material/light read-write.
15
+ - **`read_telemetry`** / **`set_knob`** / **`list_elements`** — observe and tune,
16
+ with per-call `devUrl`/`labUrl` so one server can target any running lab.
17
+ - **`set_reference`** / **`diff_reference`** / **`check_targets`** — per-tile SSIM
18
+ + diff heatmaps and target gating.
19
+ - **`add_element`** / **`remove_element`** / **`set_scene_param`** — drive a
20
+ `runSceneLab` scene live.
21
+ - **`auto_tune`** / **`multi_tune`**, **`snapshot`** / **`restore`**,
22
+ **`import_element`**, **`scaffold_from_gltf`**.
23
+
24
+ ## Run
25
+
26
+ ```jsonc
27
+ // in your MCP client config
28
+ { "command": "npx", "args": ["-y", "@triscope/mcp"] }
29
+ ```
30
+
31
+ Part of the [triscope](https://github.com/tedin7/triscope) monorepo. MIT.
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Supervisor wrapper for triscope-mcp.
4
+ *
5
+ * Claude Code spawns this bin as a stdio MCP server. The wrapper itself
6
+ * doesn't speak MCP — it spawns the real triscope-mcp.mjs as a child with
7
+ * inherited stdio (so the child's stdin/stdout are the wrapper's, which
8
+ * are Claude Code's pipes). When the child exits unexpectedly the wrapper
9
+ * respawns it with exponential backoff, keeping the wrapper PID alive and
10
+ * stable from Claude Code's perspective.
11
+ *
12
+ * Why this exists: `claude mcp list` reports "Connected" based on config,
13
+ * not on the actual subprocess being alive. If the real server crashes
14
+ * mid-session (rogue exception, memory blow-up, signal), Claude Code does
15
+ * NOT auto-restart it — subsequent tool calls return cryptic "Connection
16
+ * closed" errors and the user has to exit + relaunch the whole CLI.
17
+ *
18
+ * To use, re-register the MCP pointing at THIS bin instead of the bare
19
+ * server:
20
+ *
21
+ * claude mcp add triscope-supervised \
22
+ * --scope user \
23
+ * --env TRISCOPE_URL=http://localhost:5173 \
24
+ * -- node /home/.../packages/mcp/bin/triscope-mcp-supervised.mjs
25
+ *
26
+ * Exit semantics:
27
+ * - Child exits with code 0 or by SIGTERM/SIGINT → wrapper exits too
28
+ * (clean shutdown, no respawn).
29
+ * - Child crashes → wrapper respawns with 0.5s → 30s exponential backoff.
30
+ * - Wrapper's stdin closes → Claude Code disconnected → kill child + exit.
31
+ */
32
+ import { spawn } from 'node:child_process';
33
+ import { dirname, join } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ const HERE = dirname(fileURLToPath(import.meta.url));
37
+ const REAL_BIN = join(HERE, 'triscope-mcp.mjs');
38
+ const MAX_BACKOFF_MS = 30000;
39
+ const RESET_BACKOFF_AFTER_MS = 5000;
40
+
41
+ let child = null;
42
+ let backoff = 0;
43
+ let shuttingDown = false;
44
+ let resetTimer = null;
45
+
46
+ function startChild() {
47
+ if (shuttingDown) return;
48
+ // eslint-disable-next-line no-console
49
+ console.error(`[triscope-supervisor] spawning ${REAL_BIN} (backoff=${backoff}ms)`);
50
+ child = spawn('node', [REAL_BIN], {
51
+ stdio: ['inherit', 'inherit', 'inherit'],
52
+ env: process.env,
53
+ });
54
+ child.on('exit', (code, sig) => {
55
+ child = null;
56
+ if (resetTimer) {
57
+ clearTimeout(resetTimer);
58
+ resetTimer = null;
59
+ }
60
+ if (shuttingDown) {
61
+ process.exit(code ?? 0);
62
+ return;
63
+ }
64
+ if (sig === 'SIGTERM' || sig === 'SIGINT' || code === 0) {
65
+ // Treat clean exits as "shutdown intent" — don't respawn.
66
+ process.exit(code ?? 0);
67
+ return;
68
+ }
69
+ // eslint-disable-next-line no-console
70
+ console.error(
71
+ `[triscope-supervisor] child exited code=${code} sig=${sig}, respawning in ${backoff || 500}ms`,
72
+ );
73
+ backoff = Math.min(backoff === 0 ? 500 : backoff * 2, MAX_BACKOFF_MS);
74
+ setTimeout(startChild, backoff);
75
+ });
76
+ child.on('error', (err) => {
77
+ // eslint-disable-next-line no-console
78
+ console.error('[triscope-supervisor] child spawn error:', err?.message ?? err);
79
+ });
80
+ // If the child lives more than 5 s, reset the backoff so the next crash
81
+ // gets a fresh 500ms start (avoids permanent slow-restart on a single
82
+ // late crash after a long healthy stretch).
83
+ resetTimer = setTimeout(() => {
84
+ backoff = 0;
85
+ }, RESET_BACKOFF_AFTER_MS);
86
+ }
87
+
88
+ function shutdown(code = 0) {
89
+ shuttingDown = true;
90
+ if (child && !child.killed) {
91
+ try {
92
+ child.kill('SIGTERM');
93
+ } catch {}
94
+ }
95
+ // Give the child a moment to flush before exiting the wrapper.
96
+ setTimeout(() => process.exit(code), 200);
97
+ }
98
+
99
+ // Parent (Claude Code) closed stdin → we should die.
100
+ process.stdin.on('end', () => shutdown(0));
101
+ process.stdin.on('close', () => shutdown(0));
102
+ process.on('SIGINT', () => shutdown(130));
103
+ process.on('SIGTERM', () => shutdown(143));
104
+ // Don't crash on our own rogue errors either.
105
+ process.on('uncaughtException', (err) => {
106
+ // eslint-disable-next-line no-console
107
+ console.error('[triscope-supervisor] uncaughtException:', err?.stack ?? err);
108
+ });
109
+ process.on('unhandledRejection', (err) => {
110
+ // eslint-disable-next-line no-console
111
+ console.error('[triscope-supervisor] unhandledRejection:', err?.stack ?? err);
112
+ });
113
+
114
+ startChild();
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // Triscope MCP server entry. Stdio JSON-RPC.
3
+ // Imports the tsup-built ESM bundle so we ship a single dist artifact and
4
+ // don't have to keep src/ in published tarballs for runtime.
5
+ import { startServer } from '../dist/server.mjs';
6
+
7
+ startServer().catch((err) => {
8
+ // eslint-disable-next-line no-console
9
+ console.error('[triscope-mcp] fatal:', err);
10
+ process.exit(1);
11
+ });
@@ -0,0 +1,348 @@
1
+ // src/browser.ts
2
+ import { spawn } from "child_process";
3
+ import { existsSync, readdirSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { join } from "path";
6
+ var wait = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ var DEFAULT_CHROME_ARGS = [
8
+ "--enable-unsafe-webgpu",
9
+ "--ignore-gpu-blocklist",
10
+ // Suppress the first-run wizard and the default-browser banner: both
11
+ // can block the new window from rendering or trigger a different code
12
+ // path that ignores --remote-debugging-port until dismissed. Both have
13
+ // been seen to leave Chromium "running" but with no CDP endpoint open,
14
+ // which manifests as a "DevTools endpoint did not become ready" error.
15
+ "--no-first-run",
16
+ "--no-default-browser-check"
17
+ ];
18
+ function parseExtraChromeArgs() {
19
+ const raw = process.env.TRISCOPE_CHROME_ARGS ?? "";
20
+ if (!raw.trim()) return [];
21
+ return raw.trim().split(/\s+/).filter(Boolean);
22
+ }
23
+ function tailLines(text, maxLines = 24) {
24
+ const lines = text.trim().split(/\r?\n/).filter(Boolean);
25
+ return lines.slice(-maxLines).join("\n");
26
+ }
27
+ async function readDevtoolsPages(port) {
28
+ try {
29
+ const pages = await fetch(`http://127.0.0.1:${port}/json`, {
30
+ signal: AbortSignal.timeout(1e3)
31
+ }).then((r) => r.json());
32
+ return Array.isArray(pages) && pages.length > 0 ? pages : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ function cdpClient(ws) {
38
+ const pending = /* @__PURE__ */ new Map();
39
+ let nextId = 0;
40
+ ws.onmessage = (event) => {
41
+ const msg = JSON.parse(event.data);
42
+ if (msg.id && pending.has(msg.id)) {
43
+ pending.get(msg.id)(msg);
44
+ pending.delete(msg.id);
45
+ }
46
+ };
47
+ const drain = (reason) => {
48
+ for (const cb of pending.values()) cb({ error: { message: reason } });
49
+ pending.clear();
50
+ };
51
+ ws.onclose = (event) => drain(`CDP WebSocket closed (code ${event?.code ?? "?"}) before the response arrived`);
52
+ ws.onerror = (event) => drain(`CDP WebSocket error (${event?.message ?? "unknown"}) before the response arrived`);
53
+ const call = (method, params = {}) => new Promise((resolve, reject) => {
54
+ const id = ++nextId;
55
+ ws.send(JSON.stringify({ id, method, params }));
56
+ const t = setTimeout(() => {
57
+ pending.delete(id);
58
+ reject(new Error(`CDP timeout for ${method}`));
59
+ }, 2e4);
60
+ pending.set(id, (msg) => {
61
+ clearTimeout(t);
62
+ if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
63
+ else resolve(msg);
64
+ });
65
+ });
66
+ return call;
67
+ }
68
+ function inferGraphicalEnv() {
69
+ if (process.platform !== "linux") return process.env;
70
+ const env = { ...process.env };
71
+ const uid = typeof process.getuid === "function" ? process.getuid() : void 0;
72
+ const runtimeDir = env.XDG_RUNTIME_DIR || (uid !== void 0 ? `/run/user/${uid}` : void 0);
73
+ if (runtimeDir && existsSync(runtimeDir)) {
74
+ env.XDG_RUNTIME_DIR = runtimeDir;
75
+ if (!env.WAYLAND_DISPLAY) {
76
+ try {
77
+ const waylandSocket = readdirSync(runtimeDir).find((name) => /^wayland-\d+$/.test(name));
78
+ if (waylandSocket) env.WAYLAND_DISPLAY = waylandSocket;
79
+ } catch {
80
+ }
81
+ }
82
+ }
83
+ if (!env.DISPLAY && existsSync("/tmp/.X11-unix/X0")) env.DISPLAY = ":0";
84
+ return env;
85
+ }
86
+ function defaultChromeBinary() {
87
+ if (process.platform === "win32") {
88
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
89
+ }
90
+ if (process.platform === "darwin") {
91
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
92
+ }
93
+ return "chromium";
94
+ }
95
+ function createBrowserPool({
96
+ chromeBin = process.env.CHROME_BIN ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? defaultChromeBinary(),
97
+ port = Number(process.env.TRISCOPE_DEBUG_PORT ?? 9230),
98
+ logger = void 0
99
+ } = {}) {
100
+ let chrome = null;
101
+ let chromeExit = null;
102
+ let chromeStderr = "";
103
+ let ws = null;
104
+ let call = null;
105
+ let currentUrl = null;
106
+ let lastWarmUrl = null;
107
+ async function connectToPage(initialUrl, pages) {
108
+ const page = pages.find((p) => p.url === initialUrl || p.url?.startsWith(initialUrl)) ?? pages[0];
109
+ ws = new WebSocket(page.webSocketDebuggerUrl);
110
+ await new Promise((res, rej) => {
111
+ ws.onopen = res;
112
+ ws.onerror = (e) => rej(new Error(`ws error: ${e?.message ?? "unknown"}`));
113
+ });
114
+ call = cdpClient(ws);
115
+ await call("Runtime.enable");
116
+ await call("Page.enable");
117
+ currentUrl = page.url ?? initialUrl;
118
+ if (currentUrl !== initialUrl) await navigateIfNeeded(initialUrl);
119
+ }
120
+ function chromeLaunchArgs(profile, initialUrl) {
121
+ return [
122
+ ...DEFAULT_CHROME_ARGS,
123
+ ...parseExtraChromeArgs(),
124
+ `--user-data-dir=${profile}`,
125
+ `--remote-debugging-port=${port}`,
126
+ "--window-size=1600,900",
127
+ initialUrl
128
+ ];
129
+ }
130
+ async function ensureBrowser(initialUrl) {
131
+ if (chrome && !chrome.killed && ws && ws.readyState === 1) return;
132
+ const existingPages = await readDevtoolsPages(port);
133
+ if (existingPages) {
134
+ logger?.info("browser", "attaching to existing DevTools endpoint", {
135
+ port,
136
+ pages: existingPages.length
137
+ });
138
+ await connectToPage(initialUrl, existingPages);
139
+ return;
140
+ }
141
+ const profile = join(
142
+ tmpdir(),
143
+ `triscope-mcp-profile-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
144
+ );
145
+ const args = chromeLaunchArgs(profile, initialUrl);
146
+ chromeExit = null;
147
+ chromeStderr = "";
148
+ const browserEnv = inferGraphicalEnv();
149
+ logger?.info("browser", "spawning chromium", {
150
+ chromeBin,
151
+ port,
152
+ profile,
153
+ args,
154
+ env: {
155
+ DISPLAY: browserEnv.DISPLAY,
156
+ WAYLAND_DISPLAY: browserEnv.WAYLAND_DISPLAY,
157
+ XDG_RUNTIME_DIR: browserEnv.XDG_RUNTIME_DIR
158
+ }
159
+ });
160
+ chrome = spawn(chromeBin, args, { stdio: ["ignore", "ignore", "pipe"], env: browserEnv });
161
+ chrome.stderr.on("data", (d) => {
162
+ chromeStderr += String(d);
163
+ if (chromeStderr.length > 12e3) chromeStderr = chromeStderr.slice(-12e3);
164
+ });
165
+ chrome.on("exit", (code, signal) => {
166
+ chromeExit = { code, signal };
167
+ logger?.warn("browser", "chromium exited", { code, signal, stderr: tailLines(chromeStderr) });
168
+ });
169
+ chrome.on("error", (err) => {
170
+ chromeExit = { code: 1, signal: null };
171
+ chromeStderr += `
172
+ spawn error: ${err?.message ?? String(err)}`;
173
+ logger?.error("browser", "chromium spawn error", {
174
+ message: err?.message ?? String(err)
175
+ });
176
+ });
177
+ let pages = null;
178
+ const start = Date.now();
179
+ while (Date.now() - start < 1e4) {
180
+ pages = await readDevtoolsPages(port);
181
+ if (pages) break;
182
+ if (chromeExit) break;
183
+ await wait(250);
184
+ }
185
+ if (!pages) {
186
+ const stderr = tailLines(chromeStderr);
187
+ const exit = chromeExit ? ` chromiumExit=${JSON.stringify(chromeExit)}` : "";
188
+ const silentExit = !chromeExit && !pages;
189
+ const hint = silentExit ? "Chromium process is alive but DevTools never opened \u2014 usually the host sandbox is blocking the bind on TRISCOPE_DEBUG_PORT, or another Chromium instance is reusing the same profile dir. Workarounds: (1) pre-launch Chrome yourself with --remote-debugging-port=" + port + " --enable-unsafe-webgpu \u2014 the MCP server auto-attaches to an existing endpoint when present; (2) set TRISCOPE_DEBUG_PORT to a port the sandbox permits." : "If this runs under Codex/Claude and headed launch still fails, pre-launch Chrome with --remote-debugging-port=" + port + " or set TRISCOPE_CHROME_ARGS=--headless=new for non-interactive capture.";
190
+ throw new Error(
191
+ `DevTools endpoint did not become ready on 127.0.0.1:${port}.${exit}${stderr ? `
192
+ stderr:
193
+ ${stderr}` : ""}
194
+ ${hint}`
195
+ );
196
+ }
197
+ await connectToPage(initialUrl, pages);
198
+ }
199
+ function harnessNotMountedError(url) {
200
+ return new Error(
201
+ `window.__TRISCOPE__ did not mount within 10s on ${url}. Common causes: (1) the page never loaded \u2014 confirm the dev server is up and the URL is right (open it in a real browser tab); (2) WebGPU init failed \u2014 Linux Chrome needs --enable-unsafe-webgpu and either xvfb or a real display; (3) runLab() threw before mounting \u2014 check the #boot overlay text or the page console; (4) the lab page doesn't call runLab() at all \u2014 verify its entry script imports @triscope/core.`
202
+ );
203
+ }
204
+ async function mountErrorOrTimeout(url) {
205
+ try {
206
+ const r = await call("Runtime.evaluate", {
207
+ expression: "JSON.stringify(window.__TRISCOPE_MOUNT_ERROR__ ?? null)",
208
+ returnByValue: true
209
+ });
210
+ const me = JSON.parse(r.result.result.value);
211
+ if (me?.message) {
212
+ const probs = me.problems?.length ? `
213
+ Contract problems:
214
+ - ${me.problems.join("\n - ")}` : "";
215
+ return new Error(`element "${me.element ?? "?"}" failed to mount: ${me.message}${probs}`);
216
+ }
217
+ } catch {
218
+ }
219
+ return harnessNotMountedError(url);
220
+ }
221
+ async function navigateIfNeeded(url) {
222
+ if (url === currentUrl) return;
223
+ await call("Page.navigate", { url });
224
+ lastWarmUrl = null;
225
+ const start = Date.now();
226
+ while (Date.now() - start < 1e4) {
227
+ try {
228
+ const probe = await call("Runtime.evaluate", {
229
+ expression: '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
230
+ returnByValue: true
231
+ });
232
+ if (probe.result.result.value) {
233
+ currentUrl = url;
234
+ return;
235
+ }
236
+ } catch {
237
+ }
238
+ await wait(200);
239
+ }
240
+ throw await mountErrorOrTimeout(url);
241
+ }
242
+ async function waitForHarness() {
243
+ for (let i = 0; i < 40; i++) {
244
+ try {
245
+ const probe = await call("Runtime.evaluate", {
246
+ expression: '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
247
+ returnByValue: true
248
+ });
249
+ if (probe.result.result.value) return;
250
+ } catch {
251
+ }
252
+ await wait(250);
253
+ }
254
+ throw await mountErrorOrTimeout(currentUrl ?? "(initial page)");
255
+ }
256
+ async function waitForWarmFrame() {
257
+ if (currentUrl && currentUrl === lastWarmUrl) return;
258
+ for (let i = 0; i < 20; i++) {
259
+ try {
260
+ const probe = await call("Runtime.evaluate", {
261
+ expression: '(function(){var t=window.__TRISCOPE__;if(!t)return 0;if(typeof t.framesRendered==="function")return t.framesRendered();try{return (t.sampleTelemetry&&t.sampleTelemetry().perf&&t.sampleTelemetry().perf.fps>0)?2:0;}catch(e){return 0;}})()',
262
+ returnByValue: true
263
+ });
264
+ if ((probe.result.result.value ?? 0) >= 2) {
265
+ lastWarmUrl = currentUrl;
266
+ return;
267
+ }
268
+ } catch {
269
+ }
270
+ await wait(100);
271
+ }
272
+ lastWarmUrl = currentUrl;
273
+ }
274
+ async function isAlive() {
275
+ if (!chrome || chrome.killed || !ws || ws.readyState !== 1) return false;
276
+ try {
277
+ await Promise.race([
278
+ call("Runtime.evaluate", { expression: "1", returnByValue: true }),
279
+ new Promise((_, rej) => setTimeout(() => rej(new Error("alive probe timeout")), 1500))
280
+ ]);
281
+ return true;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+ function disposeQuiet() {
287
+ try {
288
+ ws?.close();
289
+ } catch {
290
+ }
291
+ try {
292
+ if (chrome && !chrome.killed) chrome.kill();
293
+ } catch {
294
+ }
295
+ ws = null;
296
+ chrome = null;
297
+ call = null;
298
+ currentUrl = null;
299
+ lastWarmUrl = null;
300
+ chromeExit = null;
301
+ chromeStderr = "";
302
+ }
303
+ return {
304
+ /** Lazy-spawn Chromium (first call) or reuse it (subsequent). Always
305
+ * guarantees the page is sitting on `url` with the harness mounted.
306
+ * Self-heals: if the previous Chromium died externally (manual kill,
307
+ * crash) the next call disposes the stale state and respawns. */
308
+ async getPage(url, opts = {}) {
309
+ const reload = opts?.reload === true;
310
+ if (chrome && !await isAlive()) {
311
+ disposeQuiet();
312
+ }
313
+ if (!chrome) {
314
+ await ensureBrowser(url);
315
+ await waitForHarness();
316
+ lastWarmUrl = null;
317
+ } else if (reload && url === currentUrl) {
318
+ try {
319
+ await call("Runtime.evaluate", {
320
+ expression: "try { window.__TRISCOPE__ = undefined; } catch (e) {}",
321
+ returnByValue: true
322
+ });
323
+ } catch {
324
+ }
325
+ lastWarmUrl = null;
326
+ await call("Page.reload", { ignoreCache: true });
327
+ await waitForHarness();
328
+ } else {
329
+ await navigateIfNeeded(url);
330
+ }
331
+ await waitForWarmFrame();
332
+ return { call };
333
+ },
334
+ /** Synchronous teardown. Safe to call multiple times. */
335
+ dispose() {
336
+ disposeQuiet();
337
+ }
338
+ };
339
+ }
340
+ export {
341
+ cdpClient,
342
+ createBrowserPool,
343
+ defaultChromeBinary,
344
+ inferGraphicalEnv,
345
+ parseExtraChromeArgs,
346
+ tailLines
347
+ };
348
+ //# sourceMappingURL=browser.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts"],"sourcesContent":["// Persistent Chromium pool. The MCP server keeps one Chromium child +\n// one CDP websocket alive across capture_views / diff_reference calls so\n// subsequent captures skip the 3-5 s cold start. First call lazy-spawns;\n// later calls navigate the existing page if the URL changed, otherwise reuse.\n//\n// One pool per MCP process. Disposed on process exit (kill -9 also clears\n// the user-data-dir via the OS, since /tmp is volatile).\n\nimport type { ChildProcessWithoutNullStreams } from 'node:child_process';\nimport { spawn } from 'node:child_process';\nimport { existsSync, readdirSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\nimport type { Logger } from './logger.js';\n\nconst wait = (ms) => new Promise((r) => setTimeout(r, ms));\n\nconst DEFAULT_CHROME_ARGS = [\n '--enable-unsafe-webgpu',\n '--ignore-gpu-blocklist',\n // Suppress the first-run wizard and the default-browser banner: both\n // can block the new window from rendering or trigger a different code\n // path that ignores --remote-debugging-port until dismissed. Both have\n // been seen to leave Chromium \"running\" but with no CDP endpoint open,\n // which manifests as a \"DevTools endpoint did not become ready\" error.\n '--no-first-run',\n '--no-default-browser-check',\n];\n\nexport function parseExtraChromeArgs(): string[] {\n const raw = process.env.TRISCOPE_CHROME_ARGS ?? '';\n if (!raw.trim()) return [];\n // Keep this intentionally simple: env-configured args are whitespace split.\n // Flags containing spaces should be wrapped in a tiny launcher script and\n // provided via CHROME_BIN instead.\n return raw.trim().split(/\\s+/).filter(Boolean);\n}\n\nexport function tailLines(text: string, maxLines = 24): string {\n const lines = text.trim().split(/\\r?\\n/).filter(Boolean);\n return lines.slice(-maxLines).join('\\n');\n}\n\nasync function readDevtoolsPages(port: number): Promise<any[] | null> {\n try {\n const pages = await fetch(`http://127.0.0.1:${port}/json`, {\n signal: AbortSignal.timeout(1000),\n }).then((r) => r.json());\n return Array.isArray(pages) && pages.length > 0 ? pages : null;\n } catch {\n return null;\n }\n}\n\nexport function cdpClient(ws) {\n const pending = new Map();\n let nextId = 0;\n ws.onmessage = (event) => {\n const msg = JSON.parse(event.data);\n if (msg.id && pending.has(msg.id)) {\n pending.get(msg.id)(msg);\n pending.delete(msg.id);\n }\n };\n // When the socket closes or errors mid-call, every pending request would\n // otherwise sit until its 20s timeout, blocking the next tool call for up to\n // 20s × N. Drain them immediately with a synthetic error so the failing tool\n // call rejects fast (and health() can report a sick pool).\n const drain = (reason: string) => {\n for (const cb of pending.values()) cb({ error: { message: reason } });\n pending.clear();\n };\n ws.onclose = (event) =>\n drain(`CDP WebSocket closed (code ${event?.code ?? '?'}) before the response arrived`);\n ws.onerror = (event) =>\n drain(`CDP WebSocket error (${event?.message ?? 'unknown'}) before the response arrived`);\n const call = (method, params = {}) =>\n new Promise((resolve, reject) => {\n const id = ++nextId;\n ws.send(JSON.stringify({ id, method, params }));\n const t = setTimeout(() => {\n pending.delete(id);\n reject(new Error(`CDP timeout for ${method}`));\n }, 20000);\n pending.set(id, (msg) => {\n clearTimeout(t);\n if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));\n else resolve(msg);\n });\n });\n return call;\n}\n\n/**\n * Locate a Chromium-family browser on disk. Resolution order matches\n * puppeteer/playwright so users who already set one of these for their\n * existing tooling don't have to duplicate config:\n * 1. explicit arg\n * 2. CHROME_BIN\n * 3. PUPPETEER_EXECUTABLE_PATH\n * 4. OS-typical defaults (Windows: Program Files\\Google\\Chrome; macOS:\n * /Applications/Google Chrome.app; Linux: PATH-relative `chromium`)\n */\n\nexport function inferGraphicalEnv(): NodeJS.ProcessEnv {\n if (process.platform !== 'linux') return process.env;\n\n const env: NodeJS.ProcessEnv = { ...process.env };\n const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;\n const runtimeDir = env.XDG_RUNTIME_DIR || (uid !== undefined ? `/run/user/${uid}` : undefined);\n\n if (runtimeDir && existsSync(runtimeDir)) {\n env.XDG_RUNTIME_DIR = runtimeDir;\n if (!env.WAYLAND_DISPLAY) {\n try {\n const waylandSocket = readdirSync(runtimeDir).find((name) => /^wayland-\\d+$/.test(name));\n if (waylandSocket) env.WAYLAND_DISPLAY = waylandSocket;\n } catch {\n /* best-effort */\n }\n }\n }\n\n if (!env.DISPLAY && existsSync('/tmp/.X11-unix/X0')) env.DISPLAY = ':0';\n return env;\n}\n\nexport function defaultChromeBinary(): string {\n if (process.platform === 'win32') {\n // Windows users normally install Chrome under Program Files. We don't\n // touch the filesystem to verify — Chrome's own startup will surface\n // an ENOENT clearly enough — but we pick the typical 64-bit path so\n // most installs Just Work without setting CHROME_BIN.\n return 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe';\n }\n if (process.platform === 'darwin') {\n return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';\n }\n return 'chromium';\n}\n\nexport function createBrowserPool({\n chromeBin = process.env.CHROME_BIN ??\n process.env.PUPPETEER_EXECUTABLE_PATH ??\n defaultChromeBinary(),\n port = Number(process.env.TRISCOPE_DEBUG_PORT ?? 9230),\n logger = undefined as Logger | undefined,\n} = {}) {\n let chrome: ChildProcessWithoutNullStreams | null = null;\n let chromeExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;\n let chromeStderr = '';\n let ws = null;\n let call = null;\n let currentUrl = null;\n // URL last confirmed to have rendered ≥1 real frame. Lets getPage skip the\n // warm-frame wait for an already-warm page (so e.g. auto_tune's capture loop\n // isn't slowed); reset whenever we navigate or force a reload.\n let lastWarmUrl: string | null = null;\n\n async function connectToPage(initialUrl: string, pages: any[]) {\n const page =\n pages.find((p) => p.url === initialUrl || p.url?.startsWith(initialUrl)) ?? pages[0];\n ws = new WebSocket(page.webSocketDebuggerUrl);\n await new Promise((res, rej) => {\n ws.onopen = res;\n ws.onerror = (e) => rej(new Error(`ws error: ${e?.message ?? 'unknown'}`));\n });\n call = cdpClient(ws);\n await call('Runtime.enable');\n await call('Page.enable');\n currentUrl = page.url ?? initialUrl;\n if (currentUrl !== initialUrl) await navigateIfNeeded(initialUrl);\n }\n\n function chromeLaunchArgs(profile: string, initialUrl: string): string[] {\n return [\n ...DEFAULT_CHROME_ARGS,\n ...parseExtraChromeArgs(),\n `--user-data-dir=${profile}`,\n `--remote-debugging-port=${port}`,\n '--window-size=1600,900',\n initialUrl,\n ];\n }\n\n async function ensureBrowser(initialUrl: string) {\n if (chrome && !chrome.killed && ws && ws.readyState === 1) return;\n\n // First attach to an already-running browser on the configured port. This\n // is the reliable path in sandboxed agents where opening a headed GUI may\n // require a user-approved launcher outside the MCP process.\n const existingPages = await readDevtoolsPages(port);\n if (existingPages) {\n logger?.info('browser', 'attaching to existing DevTools endpoint', {\n port,\n pages: existingPages.length,\n });\n await connectToPage(initialUrl, existingPages);\n return;\n }\n\n // Profile dir: pid + monotonic timestamp + random suffix → unique per\n // spawn so two concurrent MCP servers (or one that respawned after a\n // crash, leaving SingletonLock pointing at a dead PID) can never share\n // the same dir. Chromium's singleton check is path-based — same dir =\n // forwards URL to the \"running\" instance and exits without binding\n // --remote-debugging-port, which is the silent-fail mode that's hard\n // to diagnose.\n const profile = join(\n tmpdir(),\n `triscope-mcp-profile-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n );\n const args = chromeLaunchArgs(profile, initialUrl);\n chromeExit = null;\n chromeStderr = '';\n const browserEnv = inferGraphicalEnv();\n logger?.info('browser', 'spawning chromium', {\n chromeBin,\n port,\n profile,\n args,\n env: {\n DISPLAY: browserEnv.DISPLAY,\n WAYLAND_DISPLAY: browserEnv.WAYLAND_DISPLAY,\n XDG_RUNTIME_DIR: browserEnv.XDG_RUNTIME_DIR,\n },\n });\n chrome = spawn(chromeBin, args, { stdio: ['ignore', 'ignore', 'pipe'], env: browserEnv });\n chrome.stderr.on('data', (d) => {\n chromeStderr += String(d);\n if (chromeStderr.length > 12000) chromeStderr = chromeStderr.slice(-12000);\n });\n chrome.on('exit', (code, signal) => {\n chromeExit = { code, signal };\n logger?.warn('browser', 'chromium exited', { code, signal, stderr: tailLines(chromeStderr) });\n });\n chrome.on('error', (err) => {\n chromeExit = { code: 1, signal: null };\n chromeStderr += `\\nspawn error: ${(err as any)?.message ?? String(err)}`;\n logger?.error('browser', 'chromium spawn error', {\n message: (err as any)?.message ?? String(err),\n });\n });\n\n let pages: any[] | null = null;\n const start = Date.now();\n while (Date.now() - start < 10000) {\n pages = await readDevtoolsPages(port);\n if (pages) break;\n if (chromeExit) break;\n await wait(250);\n }\n if (!pages) {\n const stderr = tailLines(chromeStderr);\n const exit = chromeExit ? ` chromiumExit=${JSON.stringify(chromeExit)}` : '';\n // Silent-exit pattern: chromiumExit is null (process didn't die) but\n // port stayed closed. Almost always: sandboxed Chromium can't bind\n // network ports, or the singleton check forwarded the URL to a\n // (non-debuggable) sibling instance.\n const silentExit = !chromeExit && !pages;\n const hint = silentExit\n ? 'Chromium process is alive but DevTools never opened — usually the host sandbox is blocking the bind on TRISCOPE_DEBUG_PORT, or another Chromium instance is reusing the same profile dir. Workarounds: (1) pre-launch Chrome yourself with --remote-debugging-port=' +\n port +\n ' --enable-unsafe-webgpu — the MCP server auto-attaches to an existing endpoint when present; (2) set TRISCOPE_DEBUG_PORT to a port the sandbox permits.'\n : 'If this runs under Codex/Claude and headed launch still fails, pre-launch Chrome with --remote-debugging-port=' +\n port +\n ' or set TRISCOPE_CHROME_ARGS=--headless=new for non-interactive capture.';\n throw new Error(\n `DevTools endpoint did not become ready on 127.0.0.1:${port}.${exit}${stderr ? `\\nstderr:\\n${stderr}` : ''}\\n${hint}`,\n );\n }\n await connectToPage(initialUrl, pages);\n }\n\n function harnessNotMountedError(url: string) {\n return new Error(\n `window.__TRISCOPE__ did not mount within 10s on ${url}. ` +\n `Common causes: ` +\n `(1) the page never loaded — confirm the dev server is up and the URL is right (open it in a real browser tab); ` +\n `(2) WebGPU init failed — Linux Chrome needs --enable-unsafe-webgpu and either xvfb or a real display; ` +\n `(3) runLab() threw before mounting — check the #boot overlay text or the page console; ` +\n `(4) the lab page doesn't call runLab() at all — verify its entry script imports @triscope/core.`,\n );\n }\n\n // When the harness never mounts, check whether runLab() recorded a precise\n // mount failure (bad element / mount() threw) and surface THAT instead of the\n // generic timeout — turns \"not mounted within 10s\" into \"element X failed to\n // mount: <real error at line N>\".\n async function mountErrorOrTimeout(url: string): Promise<Error> {\n try {\n const r = await call('Runtime.evaluate', {\n expression: 'JSON.stringify(window.__TRISCOPE_MOUNT_ERROR__ ?? null)',\n returnByValue: true,\n });\n const me = JSON.parse(r.result.result.value);\n if (me?.message) {\n const probs = me.problems?.length\n ? `\\nContract problems:\\n - ${me.problems.join('\\n - ')}`\n : '';\n return new Error(`element \"${me.element ?? '?'}\" failed to mount: ${me.message}${probs}`);\n }\n } catch {\n /* fall through to the generic message */\n }\n return harnessNotMountedError(url);\n }\n\n async function navigateIfNeeded(url) {\n if (url === currentUrl) return;\n await call('Page.navigate', { url });\n lastWarmUrl = null; // new document → not warm until a frame renders\n // Wait for the harness to remount on the new page.\n const start = Date.now();\n while (Date.now() - start < 10000) {\n try {\n const probe = await call('Runtime.evaluate', {\n expression:\n '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === \"function\")',\n returnByValue: true,\n });\n if (probe.result.result.value) {\n currentUrl = url;\n return;\n }\n } catch {}\n await wait(200);\n }\n throw await mountErrorOrTimeout(url);\n }\n\n async function waitForHarness() {\n for (let i = 0; i < 40; i++) {\n try {\n const probe = await call('Runtime.evaluate', {\n expression:\n '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === \"function\")',\n returnByValue: true,\n });\n if (probe.result.result.value) return;\n } catch {\n // Transient \"execution context destroyed\" while a reload tears down the\n // old document — retry (mirrors navigateIfNeeded). Without this a probe\n // landing mid-teardown throws raw out of a fresh:true call.\n }\n await wait(250);\n }\n throw await mountErrorOrTimeout(currentUrl ?? '(initial page)');\n }\n\n // Wait for the harness to render a REAL frame before a capture. The mount\n // probe (waitForHarness/navigateIfNeeded) only proves window.__TRISCOPE__\n // exists — not that anything was drawn. Capturing in that gap yields a black\n // pre-render frame (observed: diff_reference came back black right after a\n // fresh navigate while a warmed capture_views rendered fine). Polls the\n // harness's monotonic framesRendered() (true on the first RAF), falling back\n // to fps>0 for older labs. Skips entirely when the page is already warm.\n async function waitForWarmFrame() {\n if (currentUrl && currentUrl === lastWarmUrl) return;\n for (let i = 0; i < 20; i++) {\n try {\n const probe = await call('Runtime.evaluate', {\n expression:\n '(function(){var t=window.__TRISCOPE__;if(!t)return 0;if(typeof t.framesRendered===\"function\")return t.framesRendered();try{return (t.sampleTelemetry&&t.sampleTelemetry().perf&&t.sampleTelemetry().perf.fps>0)?2:0;}catch(e){return 0;}})()',\n returnByValue: true,\n });\n if ((probe.result.result.value ?? 0) >= 2) {\n lastWarmUrl = currentUrl;\n return;\n }\n } catch {\n /* transient eval failure — retry */\n }\n await wait(100);\n }\n // Timed out without a confirmed frame — proceed anyway; per-camera\n // blackFrames detection in capture_views is the backstop. Cache as \"warm\"\n // so a perpetually-slow page doesn't re-block every capture.\n lastWarmUrl = currentUrl;\n }\n\n async function isAlive() {\n if (!chrome || chrome.killed || !ws || ws.readyState !== 1) return false;\n // Even with WS open, the page may be hung. Probe with a fast no-op\n // CDP call and short timeout so a stalled Chromium is detected here\n // rather than at the much heavier Page.navigate that follows.\n try {\n await Promise.race([\n call('Runtime.evaluate', { expression: '1', returnByValue: true }),\n new Promise((_, rej) => setTimeout(() => rej(new Error('alive probe timeout')), 1500)),\n ]);\n return true;\n } catch {\n return false;\n }\n }\n\n function disposeQuiet() {\n try {\n ws?.close();\n } catch {}\n try {\n if (chrome && !chrome.killed) chrome.kill();\n } catch {}\n ws = null;\n chrome = null;\n call = null;\n currentUrl = null;\n lastWarmUrl = null;\n chromeExit = null;\n chromeStderr = '';\n }\n\n return {\n /** Lazy-spawn Chromium (first call) or reuse it (subsequent). Always\n * guarantees the page is sitting on `url` with the harness mounted.\n * Self-heals: if the previous Chromium died externally (manual kill,\n * crash) the next call disposes the stale state and respawns. */\n async getPage(url, opts: { reload?: boolean } = {}) {\n const reload = opts?.reload === true;\n if (chrome && !(await isAlive())) {\n disposeQuiet();\n }\n if (!chrome) {\n await ensureBrowser(url);\n await waitForHarness();\n lastWarmUrl = null;\n } else if (reload && url === currentUrl) {\n // Force a fresh load of the SAME url to pick up source edits the pool's\n // cached page would otherwise miss (e.g. a *.lab.ts edit that doesn't\n // match the dev plugin's forceReloadOn regex).\n //\n // Page.reload resolves on *initiation*, not load — the old execution\n // context stays scriptable briefly, so waitForHarness/waitForWarmFrame\n // would otherwise confirm the STALE page (its __TRISCOPE__ + monotonic\n // framesRendered both still satisfy the probes) and we'd capture the\n // pre-edit content. Invalidate the old global first so the probes only\n // pass once the NEW document has mounted a fresh harness.\n try {\n await call('Runtime.evaluate', {\n expression: 'try { window.__TRISCOPE__ = undefined; } catch (e) {}',\n returnByValue: true,\n });\n } catch {\n /* old context already gone — fine */\n }\n lastWarmUrl = null;\n await call('Page.reload', { ignoreCache: true });\n await waitForHarness();\n } else {\n await navigateIfNeeded(url); // navigates only when url changed\n }\n await waitForWarmFrame();\n return { call };\n },\n /** Synchronous teardown. Safe to call multiple times. */\n dispose() {\n disposeQuiet();\n },\n };\n}\n"],"mappings":";AASA,SAAS,aAAa;AACtB,SAAS,YAAY,mBAAmB;AACxC,SAAS,cAAc;AACvB,SAAS,YAAY;AAGrB,IAAM,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,EAAE,CAAC;AAEzD,IAAM,sBAAsB;AAAA,EAC1B;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA;AAAA,EACA;AACF;AAEO,SAAS,uBAAiC;AAC/C,QAAM,MAAM,QAAQ,IAAI,wBAAwB;AAChD,MAAI,CAAC,IAAI,KAAK,EAAG,QAAO,CAAC;AAIzB,SAAO,IAAI,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AAC/C;AAEO,SAAS,UAAU,MAAc,WAAW,IAAY;AAC7D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,OAAO,EAAE,OAAO,OAAO;AACvD,SAAO,MAAM,MAAM,CAAC,QAAQ,EAAE,KAAK,IAAI;AACzC;AAEA,eAAe,kBAAkB,MAAqC;AACpE,MAAI;AACF,UAAM,QAAQ,MAAM,MAAM,oBAAoB,IAAI,SAAS;AAAA,MACzD,QAAQ,YAAY,QAAQ,GAAI;AAAA,IAClC,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC;AACvB,WAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,IAAI,QAAQ;AAAA,EAC5D,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,UAAU,IAAI;AAC5B,QAAM,UAAU,oBAAI,IAAI;AACxB,MAAI,SAAS;AACb,KAAG,YAAY,CAAC,UAAU;AACxB,UAAM,MAAM,KAAK,MAAM,MAAM,IAAI;AACjC,QAAI,IAAI,MAAM,QAAQ,IAAI,IAAI,EAAE,GAAG;AACjC,cAAQ,IAAI,IAAI,EAAE,EAAE,GAAG;AACvB,cAAQ,OAAO,IAAI,EAAE;AAAA,IACvB;AAAA,EACF;AAKA,QAAM,QAAQ,CAAC,WAAmB;AAChC,eAAW,MAAM,QAAQ,OAAO,EAAG,IAAG,EAAE,OAAO,EAAE,SAAS,OAAO,EAAE,CAAC;AACpE,YAAQ,MAAM;AAAA,EAChB;AACA,KAAG,UAAU,CAAC,UACZ,MAAM,8BAA8B,OAAO,QAAQ,GAAG,+BAA+B;AACvF,KAAG,UAAU,CAAC,UACZ,MAAM,wBAAwB,OAAO,WAAW,SAAS,+BAA+B;AAC1F,QAAM,OAAO,CAAC,QAAQ,SAAS,CAAC,MAC9B,IAAI,QAAQ,CAAC,SAAS,WAAW;AAC/B,UAAM,KAAK,EAAE;AACb,OAAG,KAAK,KAAK,UAAU,EAAE,IAAI,QAAQ,OAAO,CAAC,CAAC;AAC9C,UAAM,IAAI,WAAW,MAAM;AACzB,cAAQ,OAAO,EAAE;AACjB,aAAO,IAAI,MAAM,mBAAmB,MAAM,EAAE,CAAC;AAAA,IAC/C,GAAG,GAAK;AACR,YAAQ,IAAI,IAAI,CAAC,QAAQ;AACvB,mBAAa,CAAC;AACd,UAAI,IAAI,MAAO,QAAO,IAAI,MAAM,GAAG,MAAM,KAAK,IAAI,MAAM,OAAO,EAAE,CAAC;AAAA,UAC7D,SAAQ,GAAG;AAAA,IAClB,CAAC;AAAA,EACH,CAAC;AACH,SAAO;AACT;AAaO,SAAS,oBAAuC;AACrD,MAAI,QAAQ,aAAa,QAAS,QAAO,QAAQ;AAEjD,QAAM,MAAyB,EAAE,GAAG,QAAQ,IAAI;AAChD,QAAM,MAAM,OAAO,QAAQ,WAAW,aAAa,QAAQ,OAAO,IAAI;AACtE,QAAM,aAAa,IAAI,oBAAoB,QAAQ,SAAY,aAAa,GAAG,KAAK;AAEpF,MAAI,cAAc,WAAW,UAAU,GAAG;AACxC,QAAI,kBAAkB;AACtB,QAAI,CAAC,IAAI,iBAAiB;AACxB,UAAI;AACF,cAAM,gBAAgB,YAAY,UAAU,EAAE,KAAK,CAAC,SAAS,gBAAgB,KAAK,IAAI,CAAC;AACvF,YAAI,cAAe,KAAI,kBAAkB;AAAA,MAC3C,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,WAAW,WAAW,mBAAmB,EAAG,KAAI,UAAU;AACnE,SAAO;AACT;AAEO,SAAS,sBAA8B;AAC5C,MAAI,QAAQ,aAAa,SAAS;AAKhC,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,aAAa,UAAU;AACjC,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEO,SAAS,kBAAkB;AAAA,EAChC,YAAY,QAAQ,IAAI,cACtB,QAAQ,IAAI,6BACZ,oBAAoB;AAAA,EACtB,OAAO,OAAO,QAAQ,IAAI,uBAAuB,IAAI;AAAA,EACrD,SAAS;AACX,IAAI,CAAC,GAAG;AACN,MAAI,SAAgD;AACpD,MAAI,aAA4E;AAChF,MAAI,eAAe;AACnB,MAAI,KAAK;AACT,MAAI,OAAO;AACX,MAAI,aAAa;AAIjB,MAAI,cAA6B;AAEjC,iBAAe,cAAc,YAAoB,OAAc;AAC7D,UAAM,OACJ,MAAM,KAAK,CAAC,MAAM,EAAE,QAAQ,cAAc,EAAE,KAAK,WAAW,UAAU,CAAC,KAAK,MAAM,CAAC;AACrF,SAAK,IAAI,UAAU,KAAK,oBAAoB;AAC5C,UAAM,IAAI,QAAQ,CAAC,KAAK,QAAQ;AAC9B,SAAG,SAAS;AACZ,SAAG,UAAU,CAAC,MAAM,IAAI,IAAI,MAAM,aAAa,GAAG,WAAW,SAAS,EAAE,CAAC;AAAA,IAC3E,CAAC;AACD,WAAO,UAAU,EAAE;AACnB,UAAM,KAAK,gBAAgB;AAC3B,UAAM,KAAK,aAAa;AACxB,iBAAa,KAAK,OAAO;AACzB,QAAI,eAAe,WAAY,OAAM,iBAAiB,UAAU;AAAA,EAClE;AAEA,WAAS,iBAAiB,SAAiB,YAA8B;AACvE,WAAO;AAAA,MACL,GAAG;AAAA,MACH,GAAG,qBAAqB;AAAA,MACxB,mBAAmB,OAAO;AAAA,MAC1B,2BAA2B,IAAI;AAAA,MAC/B;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,iBAAe,cAAc,YAAoB;AAC/C,QAAI,UAAU,CAAC,OAAO,UAAU,MAAM,GAAG,eAAe,EAAG;AAK3D,UAAM,gBAAgB,MAAM,kBAAkB,IAAI;AAClD,QAAI,eAAe;AACjB,cAAQ,KAAK,WAAW,2CAA2C;AAAA,QACjE;AAAA,QACA,OAAO,cAAc;AAAA,MACvB,CAAC;AACD,YAAM,cAAc,YAAY,aAAa;AAC7C;AAAA,IACF;AASA,UAAM,UAAU;AAAA,MACd,OAAO;AAAA,MACP,wBAAwB,QAAQ,GAAG,IAAI,KAAK,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAAA,IAC7F;AACA,UAAM,OAAO,iBAAiB,SAAS,UAAU;AACjD,iBAAa;AACb,mBAAe;AACf,UAAM,aAAa,kBAAkB;AACrC,YAAQ,KAAK,WAAW,qBAAqB;AAAA,MAC3C;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,KAAK;AAAA,QACH,SAAS,WAAW;AAAA,QACpB,iBAAiB,WAAW;AAAA,QAC5B,iBAAiB,WAAW;AAAA,MAC9B;AAAA,IACF,CAAC;AACD,aAAS,MAAM,WAAW,MAAM,EAAE,OAAO,CAAC,UAAU,UAAU,MAAM,GAAG,KAAK,WAAW,CAAC;AACxF,WAAO,OAAO,GAAG,QAAQ,CAAC,MAAM;AAC9B,sBAAgB,OAAO,CAAC;AACxB,UAAI,aAAa,SAAS,KAAO,gBAAe,aAAa,MAAM,KAAM;AAAA,IAC3E,CAAC;AACD,WAAO,GAAG,QAAQ,CAAC,MAAM,WAAW;AAClC,mBAAa,EAAE,MAAM,OAAO;AAC5B,cAAQ,KAAK,WAAW,mBAAmB,EAAE,MAAM,QAAQ,QAAQ,UAAU,YAAY,EAAE,CAAC;AAAA,IAC9F,CAAC;AACD,WAAO,GAAG,SAAS,CAAC,QAAQ;AAC1B,mBAAa,EAAE,MAAM,GAAG,QAAQ,KAAK;AACrC,sBAAgB;AAAA,eAAmB,KAAa,WAAW,OAAO,GAAG,CAAC;AACtE,cAAQ,MAAM,WAAW,wBAAwB;AAAA,QAC/C,SAAU,KAAa,WAAW,OAAO,GAAG;AAAA,MAC9C,CAAC;AAAA,IACH,CAAC;AAED,QAAI,QAAsB;AAC1B,UAAM,QAAQ,KAAK,IAAI;AACvB,WAAO,KAAK,IAAI,IAAI,QAAQ,KAAO;AACjC,cAAQ,MAAM,kBAAkB,IAAI;AACpC,UAAI,MAAO;AACX,UAAI,WAAY;AAChB,YAAM,KAAK,GAAG;AAAA,IAChB;AACA,QAAI,CAAC,OAAO;AACV,YAAM,SAAS,UAAU,YAAY;AACrC,YAAM,OAAO,aAAa,iBAAiB,KAAK,UAAU,UAAU,CAAC,KAAK;AAK1E,YAAM,aAAa,CAAC,cAAc,CAAC;AACnC,YAAM,OAAO,aACT,6QACA,OACA,iKACA,mHACA,OACA;AACJ,YAAM,IAAI;AAAA,QACR,uDAAuD,IAAI,IAAI,IAAI,GAAG,SAAS;AAAA;AAAA,EAAc,MAAM,KAAK,EAAE;AAAA,EAAK,IAAI;AAAA,MACrH;AAAA,IACF;AACA,UAAM,cAAc,YAAY,KAAK;AAAA,EACvC;AAEA,WAAS,uBAAuB,KAAa;AAC3C,WAAO,IAAI;AAAA,MACT,mDAAmD,GAAG;AAAA,IAMxD;AAAA,EACF;AAMA,iBAAe,oBAAoB,KAA6B;AAC9D,QAAI;AACF,YAAM,IAAI,MAAM,KAAK,oBAAoB;AAAA,QACvC,YAAY;AAAA,QACZ,eAAe;AAAA,MACjB,CAAC;AACD,YAAM,KAAK,KAAK,MAAM,EAAE,OAAO,OAAO,KAAK;AAC3C,UAAI,IAAI,SAAS;AACf,cAAM,QAAQ,GAAG,UAAU,SACvB;AAAA;AAAA,KAA4B,GAAG,SAAS,KAAK,OAAO,CAAC,KACrD;AACJ,eAAO,IAAI,MAAM,YAAY,GAAG,WAAW,GAAG,sBAAsB,GAAG,OAAO,GAAG,KAAK,EAAE;AAAA,MAC1F;AAAA,IACF,QAAQ;AAAA,IAER;AACA,WAAO,uBAAuB,GAAG;AAAA,EACnC;AAEA,iBAAe,iBAAiB,KAAK;AACnC,QAAI,QAAQ,WAAY;AACxB,UAAM,KAAK,iBAAiB,EAAE,IAAI,CAAC;AACnC,kBAAc;AAEd,UAAM,QAAQ,KAAK,IAAI;AACvB,WAAO,KAAK,IAAI,IAAI,QAAQ,KAAO;AACjC,UAAI;AACF,cAAM,QAAQ,MAAM,KAAK,oBAAoB;AAAA,UAC3C,YACE;AAAA,UACF,eAAe;AAAA,QACjB,CAAC;AACD,YAAI,MAAM,OAAO,OAAO,OAAO;AAC7B,uBAAa;AACb;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAAC;AACT,YAAM,KAAK,GAAG;AAAA,IAChB;AACA,UAAM,MAAM,oBAAoB,GAAG;AAAA,EACrC;AAEA,iBAAe,iBAAiB;AAC9B,aAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,UAAI;AACF,cAAM,QAAQ,MAAM,KAAK,oBAAoB;AAAA,UAC3C,YACE;AAAA,UACF,eAAe;AAAA,QACjB,CAAC;AACD,YAAI,MAAM,OAAO,OAAO,MAAO;AAAA,MACjC,QAAQ;AAAA,MAIR;AACA,YAAM,KAAK,GAAG;AAAA,IAChB;AACA,UAAM,MAAM,oBAAoB,cAAc,gBAAgB;AAAA,EAChE;AASA,iBAAe,mBAAmB;AAChC,QAAI,cAAc,eAAe,YAAa;AAC9C,aAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,UAAI;AACF,cAAM,QAAQ,MAAM,KAAK,oBAAoB;AAAA,UAC3C,YACE;AAAA,UACF,eAAe;AAAA,QACjB,CAAC;AACD,aAAK,MAAM,OAAO,OAAO,SAAS,MAAM,GAAG;AACzC,wBAAc;AACd;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAER;AACA,YAAM,KAAK,GAAG;AAAA,IAChB;AAIA,kBAAc;AAAA,EAChB;AAEA,iBAAe,UAAU;AACvB,QAAI,CAAC,UAAU,OAAO,UAAU,CAAC,MAAM,GAAG,eAAe,EAAG,QAAO;AAInE,QAAI;AACF,YAAM,QAAQ,KAAK;AAAA,QACjB,KAAK,oBAAoB,EAAE,YAAY,KAAK,eAAe,KAAK,CAAC;AAAA,QACjE,IAAI,QAAQ,CAAC,GAAG,QAAQ,WAAW,MAAM,IAAI,IAAI,MAAM,qBAAqB,CAAC,GAAG,IAAI,CAAC;AAAA,MACvF,CAAC;AACD,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,WAAS,eAAe;AACtB,QAAI;AACF,UAAI,MAAM;AAAA,IACZ,QAAQ;AAAA,IAAC;AACT,QAAI;AACF,UAAI,UAAU,CAAC,OAAO,OAAQ,QAAO,KAAK;AAAA,IAC5C,QAAQ;AAAA,IAAC;AACT,SAAK;AACL,aAAS;AACT,WAAO;AACP,iBAAa;AACb,kBAAc;AACd,iBAAa;AACb,mBAAe;AAAA,EACjB;AAEA,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA,IAKL,MAAM,QAAQ,KAAK,OAA6B,CAAC,GAAG;AAClD,YAAM,SAAS,MAAM,WAAW;AAChC,UAAI,UAAU,CAAE,MAAM,QAAQ,GAAI;AAChC,qBAAa;AAAA,MACf;AACA,UAAI,CAAC,QAAQ;AACX,cAAM,cAAc,GAAG;AACvB,cAAM,eAAe;AACrB,sBAAc;AAAA,MAChB,WAAW,UAAU,QAAQ,YAAY;AAWvC,YAAI;AACF,gBAAM,KAAK,oBAAoB;AAAA,YAC7B,YAAY;AAAA,YACZ,eAAe;AAAA,UACjB,CAAC;AAAA,QACH,QAAQ;AAAA,QAER;AACA,sBAAc;AACd,cAAM,KAAK,eAAe,EAAE,aAAa,KAAK,CAAC;AAC/C,cAAM,eAAe;AAAA,MACvB,OAAO;AACL,cAAM,iBAAiB,GAAG;AAAA,MAC5B;AACA,YAAM,iBAAiB;AACvB,aAAO,EAAE,KAAK;AAAA,IAChB;AAAA;AAAA,IAEA,UAAU;AACR,mBAAa;AAAA,IACf;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,51 @@
1
+ // src/logger.ts
2
+ import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ var MAX_LOG_BYTES = 1024 * 1024;
6
+ function createLogger(project) {
7
+ const logPath = join(tmpdir(), `${project}-mcp.log`);
8
+ function rotateIfNeeded() {
9
+ try {
10
+ if (!existsSync(logPath)) return;
11
+ const size = statSync(logPath).size;
12
+ if (size < MAX_LOG_BYTES) return;
13
+ const rolled = `${logPath}.1`;
14
+ if (existsSync(rolled)) {
15
+ try {
16
+ unlinkSync(rolled);
17
+ } catch {
18
+ }
19
+ }
20
+ renameSync(logPath, rolled);
21
+ } catch {
22
+ }
23
+ }
24
+ function write(entry) {
25
+ const tag = `[triscope-mcp:${entry.level}:${entry.scope}]`;
26
+ if (entry.level === "error" || entry.level === "warn") {
27
+ console.error(tag, entry.msg, entry.meta ?? "");
28
+ } else if (process.env.TRISCOPE_MCP_DEBUG) {
29
+ console.error(tag, entry.msg, entry.meta ?? "");
30
+ }
31
+ try {
32
+ rotateIfNeeded();
33
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
34
+ } catch {
35
+ }
36
+ }
37
+ function make(level) {
38
+ return (scope, msg, meta) => write({ ts: (/* @__PURE__ */ new Date()).toISOString(), level, scope, msg, meta });
39
+ }
40
+ return {
41
+ info: make("info"),
42
+ warn: make("warn"),
43
+ error: make("error"),
44
+ debug: make("debug"),
45
+ logPath
46
+ };
47
+ }
48
+ export {
49
+ createLogger
50
+ };
51
+ //# sourceMappingURL=logger.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logger.ts"],"sourcesContent":["/**\n * Structured logger for the MCP server.\n *\n * Two output sinks:\n * - console.error (the existing convention — readable when run under\n * Claude Code, doesn't pollute stdout which is the MCP transport)\n * - /tmp/<project>-mcp.log with naive 1 MB rotation (rename to .1 then\n * truncate), so a long-running server keeps a persistent error trail\n * without leaking disk.\n *\n * Each entry is one JSON line:\n * {\"ts\":\"2026-05-17T15:00:00.000Z\",\"level\":\"error\",\"scope\":\"capture\",\n * \"msg\":\"…\",\"meta\":{…}}\n *\n * Designed for grep / jq, not for fancy log libraries.\n */\nimport { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join } from 'node:path';\n\nexport type LogLevel = 'info' | 'warn' | 'error' | 'debug';\n\nexport interface LogEntry {\n ts: string;\n level: LogLevel;\n scope: string;\n msg: string;\n meta?: Record<string, unknown>;\n}\n\nexport interface Logger {\n info(scope: string, msg: string, meta?: Record<string, unknown>): void;\n warn(scope: string, msg: string, meta?: Record<string, unknown>): void;\n error(scope: string, msg: string, meta?: Record<string, unknown>): void;\n debug(scope: string, msg: string, meta?: Record<string, unknown>): void;\n readonly logPath: string;\n}\n\nconst MAX_LOG_BYTES = 1024 * 1024; // 1 MB\n\nexport function createLogger(project: string): Logger {\n const logPath = join(tmpdir(), `${project}-mcp.log`);\n\n function rotateIfNeeded(): void {\n try {\n if (!existsSync(logPath)) return;\n const size = statSync(logPath).size;\n if (size < MAX_LOG_BYTES) return;\n const rolled = `${logPath}.1`;\n if (existsSync(rolled)) {\n try {\n unlinkSync(rolled);\n } catch {\n /* best-effort */\n }\n }\n renameSync(logPath, rolled);\n } catch {\n /* never let logging crash the server */\n }\n }\n\n function write(entry: LogEntry): void {\n // console (stderr — stdout is taken by MCP stdio transport)\n const tag = `[triscope-mcp:${entry.level}:${entry.scope}]`;\n if (entry.level === 'error' || entry.level === 'warn') {\n // eslint-disable-next-line no-console\n console.error(tag, entry.msg, entry.meta ?? '');\n } else if (process.env.TRISCOPE_MCP_DEBUG) {\n // eslint-disable-next-line no-console\n console.error(tag, entry.msg, entry.meta ?? '');\n }\n // file\n try {\n rotateIfNeeded();\n appendFileSync(logPath, JSON.stringify(entry) + '\\n');\n } catch {\n /* swallow */\n }\n }\n\n function make(level: LogLevel) {\n return (scope: string, msg: string, meta?: Record<string, unknown>) =>\n write({ ts: new Date().toISOString(), level, scope, msg, meta });\n }\n\n return {\n info: make('info'),\n warn: make('warn'),\n error: make('error'),\n debug: make('debug'),\n logPath,\n };\n}\n"],"mappings":";AAgBA,SAAS,gBAAgB,YAAY,YAAY,UAAU,kBAAkB;AAC7E,SAAS,cAAc;AACvB,SAAS,YAAY;AAoBrB,IAAM,gBAAgB,OAAO;AAEtB,SAAS,aAAa,SAAyB;AACpD,QAAM,UAAU,KAAK,OAAO,GAAG,GAAG,OAAO,UAAU;AAEnD,WAAS,iBAAuB;AAC9B,QAAI;AACF,UAAI,CAAC,WAAW,OAAO,EAAG;AAC1B,YAAM,OAAO,SAAS,OAAO,EAAE;AAC/B,UAAI,OAAO,cAAe;AAC1B,YAAM,SAAS,GAAG,OAAO;AACzB,UAAI,WAAW,MAAM,GAAG;AACtB,YAAI;AACF,qBAAW,MAAM;AAAA,QACnB,QAAQ;AAAA,QAER;AAAA,MACF;AACA,iBAAW,SAAS,MAAM;AAAA,IAC5B,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,WAAS,MAAM,OAAuB;AAEpC,UAAM,MAAM,iBAAiB,MAAM,KAAK,IAAI,MAAM,KAAK;AACvD,QAAI,MAAM,UAAU,WAAW,MAAM,UAAU,QAAQ;AAErD,cAAQ,MAAM,KAAK,MAAM,KAAK,MAAM,QAAQ,EAAE;AAAA,IAChD,WAAW,QAAQ,IAAI,oBAAoB;AAEzC,cAAQ,MAAM,KAAK,MAAM,KAAK,MAAM,QAAQ,EAAE;AAAA,IAChD;AAEA,QAAI;AACF,qBAAe;AACf,qBAAe,SAAS,KAAK,UAAU,KAAK,IAAI,IAAI;AAAA,IACtD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,WAAS,KAAK,OAAiB;AAC7B,WAAO,CAAC,OAAe,KAAa,SAClC,MAAM,EAAE,KAAI,oBAAI,KAAK,GAAE,YAAY,GAAG,OAAO,OAAO,KAAK,KAAK,CAAC;AAAA,EACnE;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,MAAM;AAAA,IACjB,MAAM,KAAK,MAAM;AAAA,IACjB,OAAO,KAAK,OAAO;AAAA,IACnB,OAAO,KAAK,OAAO;AAAA,IACnB;AAAA,EACF;AACF;","names":[]}