@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/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@triscope/mcp",
3
+ "version": "0.4.0",
4
+ "description": "MCP server for Triscope: live read_telemetry, set_knob, list_elements, capture_views, run_smoke. Lets Claude Code drive a running triscope dev server.",
5
+ "type": "module",
6
+ "bin": {
7
+ "triscope-mcp": "./bin/triscope-mcp.mjs",
8
+ "triscope-mcp-supervised": "./bin/triscope-mcp-supervised.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
21
+ "pngjs": "^7.0.0",
22
+ "zod": "^3.23.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.19.41",
26
+ "@types/pngjs": "^6.0.5",
27
+ "@vitest/coverage-v8": "^2.1.9",
28
+ "tsup": "^8.5.1",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^2.1.9"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "prepublishOnly": "npm run build",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "test:e2e": "node test/e2e/mcp-tools.e2e.mjs",
39
+ "test:e2e:all": "node test/e2e/mcp-all.e2e.mjs",
40
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html"
41
+ },
42
+ "keywords": [
43
+ "triscope",
44
+ "mcp",
45
+ "model-context-protocol",
46
+ "claude-code",
47
+ "three.js"
48
+ ]
49
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,461 @@
1
+ // Persistent Chromium pool. The MCP server keeps one Chromium child +
2
+ // one CDP websocket alive across capture_views / diff_reference calls so
3
+ // subsequent captures skip the 3-5 s cold start. First call lazy-spawns;
4
+ // later calls navigate the existing page if the URL changed, otherwise reuse.
5
+ //
6
+ // One pool per MCP process. Disposed on process exit (kill -9 also clears
7
+ // the user-data-dir via the OS, since /tmp is volatile).
8
+
9
+ import type { ChildProcessWithoutNullStreams } from 'node:child_process';
10
+ import { spawn } from 'node:child_process';
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import type { Logger } from './logger.js';
15
+
16
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
17
+
18
+ const DEFAULT_CHROME_ARGS = [
19
+ '--enable-unsafe-webgpu',
20
+ '--ignore-gpu-blocklist',
21
+ // Suppress the first-run wizard and the default-browser banner: both
22
+ // can block the new window from rendering or trigger a different code
23
+ // path that ignores --remote-debugging-port until dismissed. Both have
24
+ // been seen to leave Chromium "running" but with no CDP endpoint open,
25
+ // which manifests as a "DevTools endpoint did not become ready" error.
26
+ '--no-first-run',
27
+ '--no-default-browser-check',
28
+ ];
29
+
30
+ export function parseExtraChromeArgs(): string[] {
31
+ const raw = process.env.TRISCOPE_CHROME_ARGS ?? '';
32
+ if (!raw.trim()) return [];
33
+ // Keep this intentionally simple: env-configured args are whitespace split.
34
+ // Flags containing spaces should be wrapped in a tiny launcher script and
35
+ // provided via CHROME_BIN instead.
36
+ return raw.trim().split(/\s+/).filter(Boolean);
37
+ }
38
+
39
+ export function tailLines(text: string, maxLines = 24): string {
40
+ const lines = text.trim().split(/\r?\n/).filter(Boolean);
41
+ return lines.slice(-maxLines).join('\n');
42
+ }
43
+
44
+ async function readDevtoolsPages(port: number): Promise<any[] | null> {
45
+ try {
46
+ const pages = await fetch(`http://127.0.0.1:${port}/json`, {
47
+ signal: AbortSignal.timeout(1000),
48
+ }).then((r) => r.json());
49
+ return Array.isArray(pages) && pages.length > 0 ? pages : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export function cdpClient(ws) {
56
+ const pending = new Map();
57
+ let nextId = 0;
58
+ ws.onmessage = (event) => {
59
+ const msg = JSON.parse(event.data);
60
+ if (msg.id && pending.has(msg.id)) {
61
+ pending.get(msg.id)(msg);
62
+ pending.delete(msg.id);
63
+ }
64
+ };
65
+ // When the socket closes or errors mid-call, every pending request would
66
+ // otherwise sit until its 20s timeout, blocking the next tool call for up to
67
+ // 20s × N. Drain them immediately with a synthetic error so the failing tool
68
+ // call rejects fast (and health() can report a sick pool).
69
+ const drain = (reason: string) => {
70
+ for (const cb of pending.values()) cb({ error: { message: reason } });
71
+ pending.clear();
72
+ };
73
+ ws.onclose = (event) =>
74
+ drain(`CDP WebSocket closed (code ${event?.code ?? '?'}) before the response arrived`);
75
+ ws.onerror = (event) =>
76
+ drain(`CDP WebSocket error (${event?.message ?? 'unknown'}) before the response arrived`);
77
+ const call = (method, params = {}) =>
78
+ new Promise((resolve, reject) => {
79
+ const id = ++nextId;
80
+ ws.send(JSON.stringify({ id, method, params }));
81
+ const t = setTimeout(() => {
82
+ pending.delete(id);
83
+ reject(new Error(`CDP timeout for ${method}`));
84
+ }, 20000);
85
+ pending.set(id, (msg) => {
86
+ clearTimeout(t);
87
+ if (msg.error) reject(new Error(`${method}: ${msg.error.message}`));
88
+ else resolve(msg);
89
+ });
90
+ });
91
+ return call;
92
+ }
93
+
94
+ /**
95
+ * Locate a Chromium-family browser on disk. Resolution order matches
96
+ * puppeteer/playwright so users who already set one of these for their
97
+ * existing tooling don't have to duplicate config:
98
+ * 1. explicit arg
99
+ * 2. CHROME_BIN
100
+ * 3. PUPPETEER_EXECUTABLE_PATH
101
+ * 4. OS-typical defaults (Windows: Program Files\Google\Chrome; macOS:
102
+ * /Applications/Google Chrome.app; Linux: PATH-relative `chromium`)
103
+ */
104
+
105
+ export function inferGraphicalEnv(): NodeJS.ProcessEnv {
106
+ if (process.platform !== 'linux') return process.env;
107
+
108
+ const env: NodeJS.ProcessEnv = { ...process.env };
109
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
110
+ const runtimeDir = env.XDG_RUNTIME_DIR || (uid !== undefined ? `/run/user/${uid}` : undefined);
111
+
112
+ if (runtimeDir && existsSync(runtimeDir)) {
113
+ env.XDG_RUNTIME_DIR = runtimeDir;
114
+ if (!env.WAYLAND_DISPLAY) {
115
+ try {
116
+ const waylandSocket = readdirSync(runtimeDir).find((name) => /^wayland-\d+$/.test(name));
117
+ if (waylandSocket) env.WAYLAND_DISPLAY = waylandSocket;
118
+ } catch {
119
+ /* best-effort */
120
+ }
121
+ }
122
+ }
123
+
124
+ if (!env.DISPLAY && existsSync('/tmp/.X11-unix/X0')) env.DISPLAY = ':0';
125
+ return env;
126
+ }
127
+
128
+ export function defaultChromeBinary(): string {
129
+ if (process.platform === 'win32') {
130
+ // Windows users normally install Chrome under Program Files. We don't
131
+ // touch the filesystem to verify — Chrome's own startup will surface
132
+ // an ENOENT clearly enough — but we pick the typical 64-bit path so
133
+ // most installs Just Work without setting CHROME_BIN.
134
+ return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
135
+ }
136
+ if (process.platform === 'darwin') {
137
+ return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
138
+ }
139
+ return 'chromium';
140
+ }
141
+
142
+ export function createBrowserPool({
143
+ chromeBin = process.env.CHROME_BIN ??
144
+ process.env.PUPPETEER_EXECUTABLE_PATH ??
145
+ defaultChromeBinary(),
146
+ port = Number(process.env.TRISCOPE_DEBUG_PORT ?? 9230),
147
+ logger = undefined as Logger | undefined,
148
+ } = {}) {
149
+ let chrome: ChildProcessWithoutNullStreams | null = null;
150
+ let chromeExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
151
+ let chromeStderr = '';
152
+ let ws = null;
153
+ let call = null;
154
+ let currentUrl = null;
155
+ // URL last confirmed to have rendered ≥1 real frame. Lets getPage skip the
156
+ // warm-frame wait for an already-warm page (so e.g. auto_tune's capture loop
157
+ // isn't slowed); reset whenever we navigate or force a reload.
158
+ let lastWarmUrl: string | null = null;
159
+
160
+ async function connectToPage(initialUrl: string, pages: any[]) {
161
+ const page =
162
+ pages.find((p) => p.url === initialUrl || p.url?.startsWith(initialUrl)) ?? pages[0];
163
+ ws = new WebSocket(page.webSocketDebuggerUrl);
164
+ await new Promise((res, rej) => {
165
+ ws.onopen = res;
166
+ ws.onerror = (e) => rej(new Error(`ws error: ${e?.message ?? 'unknown'}`));
167
+ });
168
+ call = cdpClient(ws);
169
+ await call('Runtime.enable');
170
+ await call('Page.enable');
171
+ currentUrl = page.url ?? initialUrl;
172
+ if (currentUrl !== initialUrl) await navigateIfNeeded(initialUrl);
173
+ }
174
+
175
+ function chromeLaunchArgs(profile: string, initialUrl: string): string[] {
176
+ return [
177
+ ...DEFAULT_CHROME_ARGS,
178
+ ...parseExtraChromeArgs(),
179
+ `--user-data-dir=${profile}`,
180
+ `--remote-debugging-port=${port}`,
181
+ '--window-size=1600,900',
182
+ initialUrl,
183
+ ];
184
+ }
185
+
186
+ async function ensureBrowser(initialUrl: string) {
187
+ if (chrome && !chrome.killed && ws && ws.readyState === 1) return;
188
+
189
+ // First attach to an already-running browser on the configured port. This
190
+ // is the reliable path in sandboxed agents where opening a headed GUI may
191
+ // require a user-approved launcher outside the MCP process.
192
+ const existingPages = await readDevtoolsPages(port);
193
+ if (existingPages) {
194
+ logger?.info('browser', 'attaching to existing DevTools endpoint', {
195
+ port,
196
+ pages: existingPages.length,
197
+ });
198
+ await connectToPage(initialUrl, existingPages);
199
+ return;
200
+ }
201
+
202
+ // Profile dir: pid + monotonic timestamp + random suffix → unique per
203
+ // spawn so two concurrent MCP servers (or one that respawned after a
204
+ // crash, leaving SingletonLock pointing at a dead PID) can never share
205
+ // the same dir. Chromium's singleton check is path-based — same dir =
206
+ // forwards URL to the "running" instance and exits without binding
207
+ // --remote-debugging-port, which is the silent-fail mode that's hard
208
+ // to diagnose.
209
+ const profile = join(
210
+ tmpdir(),
211
+ `triscope-mcp-profile-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
212
+ );
213
+ const args = chromeLaunchArgs(profile, initialUrl);
214
+ chromeExit = null;
215
+ chromeStderr = '';
216
+ const browserEnv = inferGraphicalEnv();
217
+ logger?.info('browser', 'spawning chromium', {
218
+ chromeBin,
219
+ port,
220
+ profile,
221
+ args,
222
+ env: {
223
+ DISPLAY: browserEnv.DISPLAY,
224
+ WAYLAND_DISPLAY: browserEnv.WAYLAND_DISPLAY,
225
+ XDG_RUNTIME_DIR: browserEnv.XDG_RUNTIME_DIR,
226
+ },
227
+ });
228
+ chrome = spawn(chromeBin, args, { stdio: ['ignore', 'ignore', 'pipe'], env: browserEnv });
229
+ chrome.stderr.on('data', (d) => {
230
+ chromeStderr += String(d);
231
+ if (chromeStderr.length > 12000) chromeStderr = chromeStderr.slice(-12000);
232
+ });
233
+ chrome.on('exit', (code, signal) => {
234
+ chromeExit = { code, signal };
235
+ logger?.warn('browser', 'chromium exited', { code, signal, stderr: tailLines(chromeStderr) });
236
+ });
237
+ chrome.on('error', (err) => {
238
+ chromeExit = { code: 1, signal: null };
239
+ chromeStderr += `\nspawn error: ${(err as any)?.message ?? String(err)}`;
240
+ logger?.error('browser', 'chromium spawn error', {
241
+ message: (err as any)?.message ?? String(err),
242
+ });
243
+ });
244
+
245
+ let pages: any[] | null = null;
246
+ const start = Date.now();
247
+ while (Date.now() - start < 10000) {
248
+ pages = await readDevtoolsPages(port);
249
+ if (pages) break;
250
+ if (chromeExit) break;
251
+ await wait(250);
252
+ }
253
+ if (!pages) {
254
+ const stderr = tailLines(chromeStderr);
255
+ const exit = chromeExit ? ` chromiumExit=${JSON.stringify(chromeExit)}` : '';
256
+ // Silent-exit pattern: chromiumExit is null (process didn't die) but
257
+ // port stayed closed. Almost always: sandboxed Chromium can't bind
258
+ // network ports, or the singleton check forwarded the URL to a
259
+ // (non-debuggable) sibling instance.
260
+ const silentExit = !chromeExit && !pages;
261
+ const hint = silentExit
262
+ ? '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=' +
263
+ port +
264
+ ' --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.'
265
+ : 'If this runs under Codex/Claude and headed launch still fails, pre-launch Chrome with --remote-debugging-port=' +
266
+ port +
267
+ ' or set TRISCOPE_CHROME_ARGS=--headless=new for non-interactive capture.';
268
+ throw new Error(
269
+ `DevTools endpoint did not become ready on 127.0.0.1:${port}.${exit}${stderr ? `\nstderr:\n${stderr}` : ''}\n${hint}`,
270
+ );
271
+ }
272
+ await connectToPage(initialUrl, pages);
273
+ }
274
+
275
+ function harnessNotMountedError(url: string) {
276
+ return new Error(
277
+ `window.__TRISCOPE__ did not mount within 10s on ${url}. ` +
278
+ `Common causes: ` +
279
+ `(1) the page never loaded — confirm the dev server is up and the URL is right (open it in a real browser tab); ` +
280
+ `(2) WebGPU init failed — Linux Chrome needs --enable-unsafe-webgpu and either xvfb or a real display; ` +
281
+ `(3) runLab() threw before mounting — check the #boot overlay text or the page console; ` +
282
+ `(4) the lab page doesn't call runLab() at all — verify its entry script imports @triscope/core.`,
283
+ );
284
+ }
285
+
286
+ // When the harness never mounts, check whether runLab() recorded a precise
287
+ // mount failure (bad element / mount() threw) and surface THAT instead of the
288
+ // generic timeout — turns "not mounted within 10s" into "element X failed to
289
+ // mount: <real error at line N>".
290
+ async function mountErrorOrTimeout(url: string): Promise<Error> {
291
+ try {
292
+ const r = await call('Runtime.evaluate', {
293
+ expression: 'JSON.stringify(window.__TRISCOPE_MOUNT_ERROR__ ?? null)',
294
+ returnByValue: true,
295
+ });
296
+ const me = JSON.parse(r.result.result.value);
297
+ if (me?.message) {
298
+ const probs = me.problems?.length
299
+ ? `\nContract problems:\n - ${me.problems.join('\n - ')}`
300
+ : '';
301
+ return new Error(`element "${me.element ?? '?'}" failed to mount: ${me.message}${probs}`);
302
+ }
303
+ } catch {
304
+ /* fall through to the generic message */
305
+ }
306
+ return harnessNotMountedError(url);
307
+ }
308
+
309
+ async function navigateIfNeeded(url) {
310
+ if (url === currentUrl) return;
311
+ await call('Page.navigate', { url });
312
+ lastWarmUrl = null; // new document → not warm until a frame renders
313
+ // Wait for the harness to remount on the new page.
314
+ const start = Date.now();
315
+ while (Date.now() - start < 10000) {
316
+ try {
317
+ const probe = await call('Runtime.evaluate', {
318
+ expression:
319
+ '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
320
+ returnByValue: true,
321
+ });
322
+ if (probe.result.result.value) {
323
+ currentUrl = url;
324
+ return;
325
+ }
326
+ } catch {}
327
+ await wait(200);
328
+ }
329
+ throw await mountErrorOrTimeout(url);
330
+ }
331
+
332
+ async function waitForHarness() {
333
+ for (let i = 0; i < 40; i++) {
334
+ try {
335
+ const probe = await call('Runtime.evaluate', {
336
+ expression:
337
+ '!!window.__TRISCOPE__ && (Object.keys(window.__TRISCOPE__.cameras || {}).length > 0 || typeof window.__TRISCOPE__.availableElements === "function")',
338
+ returnByValue: true,
339
+ });
340
+ if (probe.result.result.value) return;
341
+ } catch {
342
+ // Transient "execution context destroyed" while a reload tears down the
343
+ // old document — retry (mirrors navigateIfNeeded). Without this a probe
344
+ // landing mid-teardown throws raw out of a fresh:true call.
345
+ }
346
+ await wait(250);
347
+ }
348
+ throw await mountErrorOrTimeout(currentUrl ?? '(initial page)');
349
+ }
350
+
351
+ // Wait for the harness to render a REAL frame before a capture. The mount
352
+ // probe (waitForHarness/navigateIfNeeded) only proves window.__TRISCOPE__
353
+ // exists — not that anything was drawn. Capturing in that gap yields a black
354
+ // pre-render frame (observed: diff_reference came back black right after a
355
+ // fresh navigate while a warmed capture_views rendered fine). Polls the
356
+ // harness's monotonic framesRendered() (true on the first RAF), falling back
357
+ // to fps>0 for older labs. Skips entirely when the page is already warm.
358
+ async function waitForWarmFrame() {
359
+ if (currentUrl && currentUrl === lastWarmUrl) return;
360
+ for (let i = 0; i < 20; i++) {
361
+ try {
362
+ const probe = await call('Runtime.evaluate', {
363
+ expression:
364
+ '(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;}})()',
365
+ returnByValue: true,
366
+ });
367
+ if ((probe.result.result.value ?? 0) >= 2) {
368
+ lastWarmUrl = currentUrl;
369
+ return;
370
+ }
371
+ } catch {
372
+ /* transient eval failure — retry */
373
+ }
374
+ await wait(100);
375
+ }
376
+ // Timed out without a confirmed frame — proceed anyway; per-camera
377
+ // blackFrames detection in capture_views is the backstop. Cache as "warm"
378
+ // so a perpetually-slow page doesn't re-block every capture.
379
+ lastWarmUrl = currentUrl;
380
+ }
381
+
382
+ async function isAlive() {
383
+ if (!chrome || chrome.killed || !ws || ws.readyState !== 1) return false;
384
+ // Even with WS open, the page may be hung. Probe with a fast no-op
385
+ // CDP call and short timeout so a stalled Chromium is detected here
386
+ // rather than at the much heavier Page.navigate that follows.
387
+ try {
388
+ await Promise.race([
389
+ call('Runtime.evaluate', { expression: '1', returnByValue: true }),
390
+ new Promise((_, rej) => setTimeout(() => rej(new Error('alive probe timeout')), 1500)),
391
+ ]);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ function disposeQuiet() {
399
+ try {
400
+ ws?.close();
401
+ } catch {}
402
+ try {
403
+ if (chrome && !chrome.killed) chrome.kill();
404
+ } catch {}
405
+ ws = null;
406
+ chrome = null;
407
+ call = null;
408
+ currentUrl = null;
409
+ lastWarmUrl = null;
410
+ chromeExit = null;
411
+ chromeStderr = '';
412
+ }
413
+
414
+ return {
415
+ /** Lazy-spawn Chromium (first call) or reuse it (subsequent). Always
416
+ * guarantees the page is sitting on `url` with the harness mounted.
417
+ * Self-heals: if the previous Chromium died externally (manual kill,
418
+ * crash) the next call disposes the stale state and respawns. */
419
+ async getPage(url, opts: { reload?: boolean } = {}) {
420
+ const reload = opts?.reload === true;
421
+ if (chrome && !(await isAlive())) {
422
+ disposeQuiet();
423
+ }
424
+ if (!chrome) {
425
+ await ensureBrowser(url);
426
+ await waitForHarness();
427
+ lastWarmUrl = null;
428
+ } else if (reload && url === currentUrl) {
429
+ // Force a fresh load of the SAME url to pick up source edits the pool's
430
+ // cached page would otherwise miss (e.g. a *.lab.ts edit that doesn't
431
+ // match the dev plugin's forceReloadOn regex).
432
+ //
433
+ // Page.reload resolves on *initiation*, not load — the old execution
434
+ // context stays scriptable briefly, so waitForHarness/waitForWarmFrame
435
+ // would otherwise confirm the STALE page (its __TRISCOPE__ + monotonic
436
+ // framesRendered both still satisfy the probes) and we'd capture the
437
+ // pre-edit content. Invalidate the old global first so the probes only
438
+ // pass once the NEW document has mounted a fresh harness.
439
+ try {
440
+ await call('Runtime.evaluate', {
441
+ expression: 'try { window.__TRISCOPE__ = undefined; } catch (e) {}',
442
+ returnByValue: true,
443
+ });
444
+ } catch {
445
+ /* old context already gone — fine */
446
+ }
447
+ lastWarmUrl = null;
448
+ await call('Page.reload', { ignoreCache: true });
449
+ await waitForHarness();
450
+ } else {
451
+ await navigateIfNeeded(url); // navigates only when url changed
452
+ }
453
+ await waitForWarmFrame();
454
+ return { call };
455
+ },
456
+ /** Synchronous teardown. Safe to call multiple times. */
457
+ dispose() {
458
+ disposeQuiet();
459
+ },
460
+ };
461
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Structured logger for the MCP server.
3
+ *
4
+ * Two output sinks:
5
+ * - console.error (the existing convention — readable when run under
6
+ * Claude Code, doesn't pollute stdout which is the MCP transport)
7
+ * - /tmp/<project>-mcp.log with naive 1 MB rotation (rename to .1 then
8
+ * truncate), so a long-running server keeps a persistent error trail
9
+ * without leaking disk.
10
+ *
11
+ * Each entry is one JSON line:
12
+ * {"ts":"2026-05-17T15:00:00.000Z","level":"error","scope":"capture",
13
+ * "msg":"…","meta":{…}}
14
+ *
15
+ * Designed for grep / jq, not for fancy log libraries.
16
+ */
17
+ import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
22
+
23
+ export interface LogEntry {
24
+ ts: string;
25
+ level: LogLevel;
26
+ scope: string;
27
+ msg: string;
28
+ meta?: Record<string, unknown>;
29
+ }
30
+
31
+ export interface Logger {
32
+ info(scope: string, msg: string, meta?: Record<string, unknown>): void;
33
+ warn(scope: string, msg: string, meta?: Record<string, unknown>): void;
34
+ error(scope: string, msg: string, meta?: Record<string, unknown>): void;
35
+ debug(scope: string, msg: string, meta?: Record<string, unknown>): void;
36
+ readonly logPath: string;
37
+ }
38
+
39
+ const MAX_LOG_BYTES = 1024 * 1024; // 1 MB
40
+
41
+ export function createLogger(project: string): Logger {
42
+ const logPath = join(tmpdir(), `${project}-mcp.log`);
43
+
44
+ function rotateIfNeeded(): void {
45
+ try {
46
+ if (!existsSync(logPath)) return;
47
+ const size = statSync(logPath).size;
48
+ if (size < MAX_LOG_BYTES) return;
49
+ const rolled = `${logPath}.1`;
50
+ if (existsSync(rolled)) {
51
+ try {
52
+ unlinkSync(rolled);
53
+ } catch {
54
+ /* best-effort */
55
+ }
56
+ }
57
+ renameSync(logPath, rolled);
58
+ } catch {
59
+ /* never let logging crash the server */
60
+ }
61
+ }
62
+
63
+ function write(entry: LogEntry): void {
64
+ // console (stderr — stdout is taken by MCP stdio transport)
65
+ const tag = `[triscope-mcp:${entry.level}:${entry.scope}]`;
66
+ if (entry.level === 'error' || entry.level === 'warn') {
67
+ // eslint-disable-next-line no-console
68
+ console.error(tag, entry.msg, entry.meta ?? '');
69
+ } else if (process.env.TRISCOPE_MCP_DEBUG) {
70
+ // eslint-disable-next-line no-console
71
+ console.error(tag, entry.msg, entry.meta ?? '');
72
+ }
73
+ // file
74
+ try {
75
+ rotateIfNeeded();
76
+ appendFileSync(logPath, JSON.stringify(entry) + '\n');
77
+ } catch {
78
+ /* swallow */
79
+ }
80
+ }
81
+
82
+ function make(level: LogLevel) {
83
+ return (scope: string, msg: string, meta?: Record<string, unknown>) =>
84
+ write({ ts: new Date().toISOString(), level, scope, msg, meta });
85
+ }
86
+
87
+ return {
88
+ info: make('info'),
89
+ warn: make('warn'),
90
+ error: make('error'),
91
+ debug: make('debug'),
92
+ logPath,
93
+ };
94
+ }