@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 +21 -0
- package/README.md +31 -0
- package/bin/triscope-mcp-supervised.mjs +114 -0
- package/bin/triscope-mcp.mjs +11 -0
- package/dist/browser.mjs +348 -0
- package/dist/browser.mjs.map +1 -0
- package/dist/logger.mjs +51 -0
- package/dist/logger.mjs.map +1 -0
- package/dist/refs.mjs +396 -0
- package/dist/refs.mjs.map +1 -0
- package/dist/server.mjs +3125 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +49 -0
- package/src/browser.ts +461 -0
- package/src/logger.ts +94 -0
- package/src/optimize.ts +142 -0
- package/src/refs.ts +468 -0
- package/src/server.ts +2678 -0
- package/src/targets.ts +163 -0
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
|
+
}
|