browser-debug-mcp-bridge 1.4.0 → 1.6.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/README.md +258 -52
- package/apps/mcp-server/dist/db/events-repository.js +61 -23
- package/apps/mcp-server/dist/db/events-repository.js.map +1 -1
- package/apps/mcp-server/dist/db/migrations.js +104 -1
- package/apps/mcp-server/dist/db/migrations.js.map +1 -1
- package/apps/mcp-server/dist/db/schema.js +1 -1
- package/apps/mcp-server/dist/main.js +3 -2
- package/apps/mcp-server/dist/main.js.map +1 -1
- package/apps/mcp-server/dist/mcp/server.js +259 -51
- package/apps/mcp-server/dist/mcp/server.js.map +1 -1
- package/apps/mcp-server/dist/mcp-bridge.js +37 -3
- package/apps/mcp-server/dist/mcp-bridge.js.map +1 -1
- package/apps/mcp-server/dist/retention.js +34 -8
- package/apps/mcp-server/dist/retention.js.map +1 -1
- package/apps/mcp-server/dist/websocket/messages.js +5 -0
- package/apps/mcp-server/dist/websocket/messages.js.map +1 -1
- package/apps/mcp-server/dist/websocket/websocket-server.js +59 -0
- package/apps/mcp-server/dist/websocket/websocket-server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/mcp-start.cjs +504 -26
package/scripts/mcp-start.cjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const { spawn } = require('node:child_process');
|
|
3
|
-
const { existsSync } = require('node:fs');
|
|
2
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
3
|
+
const { existsSync, mkdirSync, openSync, writeFileSync, closeSync, readFileSync, unlinkSync } = require('node:fs');
|
|
4
4
|
const { dirname, join, resolve } = require('node:path');
|
|
5
5
|
const { createRequire } = require('node:module');
|
|
6
6
|
const net = require('node:net');
|
|
7
|
+
const http = require('node:http');
|
|
7
8
|
|
|
8
9
|
const repoRoot = resolve(__dirname, '..');
|
|
9
10
|
const packageJson = join(repoRoot, 'package.json');
|
|
@@ -16,10 +17,14 @@ const useTsx = args.includes('--mode=tsx');
|
|
|
16
17
|
const useDist = args.includes('--mode=dist');
|
|
17
18
|
const dryRun = args.includes('--dry-run');
|
|
18
19
|
const standalone = args.includes('--standalone');
|
|
20
|
+
const stopRequested = args.includes('--stop');
|
|
19
21
|
const localRequire = createRequire(join(repoRoot, 'package.json'));
|
|
20
22
|
const supportsColor = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
|
|
21
23
|
const greenBackground = '\x1b[42m\x1b[30m';
|
|
22
24
|
const ansiReset = '\x1b[0m';
|
|
25
|
+
const launchLockPath = join(process.env.DATA_DIR ? resolve(process.env.DATA_DIR) : join(repoRoot, 'data'), '.mcp-start.lock');
|
|
26
|
+
|
|
27
|
+
let launchLockHeld = false;
|
|
23
28
|
|
|
24
29
|
function resolveRuntimePath(specifier) {
|
|
25
30
|
try {
|
|
@@ -69,12 +74,409 @@ function isPortInUse(port, host = '127.0.0.1') {
|
|
|
69
74
|
});
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
function delay(ms) {
|
|
78
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isProcessAlive(pid) {
|
|
82
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return Boolean(error && error.code === 'EPERM');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseLockPayload(raw) {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(raw);
|
|
97
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readLaunchLock(lockPath) {
|
|
104
|
+
try {
|
|
105
|
+
const raw = readFileSync(lockPath, 'utf8');
|
|
106
|
+
return parseLockPayload(raw);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function acquireLaunchLock(lockPath) {
|
|
113
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
114
|
+
const payload = JSON.stringify({
|
|
115
|
+
pid: process.pid,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
command: process.argv.join(' '),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
121
|
+
try {
|
|
122
|
+
const fd = openSync(lockPath, 'wx');
|
|
123
|
+
try {
|
|
124
|
+
writeFileSync(fd, payload, 'utf8');
|
|
125
|
+
} finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
128
|
+
launchLockHeld = true;
|
|
129
|
+
return;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!error || error.code !== 'EEXIST') {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const existing = readLaunchLock(lockPath);
|
|
136
|
+
const lockPid = Number(existing && existing.pid);
|
|
137
|
+
if (Number.isInteger(lockPid) && lockPid > 0 && lockPid !== process.pid && isProcessAlive(lockPid)) {
|
|
138
|
+
process.stderr.write(
|
|
139
|
+
`[mcp-start] MCP_STARTUP_LOCKED: another launcher instance (pid ${lockPid}) is already running.\n`,
|
|
140
|
+
);
|
|
141
|
+
process.stderr.write('[mcp-start] If startup appears stuck, run: node scripts/mcp-start.cjs --stop\n');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
unlinkSync(lockPath);
|
|
147
|
+
} catch (unlinkError) {
|
|
148
|
+
if (!unlinkError || unlinkError.code !== 'ENOENT') {
|
|
149
|
+
process.stderr.write(
|
|
150
|
+
`[mcp-start] MCP_STARTUP_LOCK_FAILED: unable to clear stale lock at ${lockPath}.\n`,
|
|
151
|
+
);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
process.stderr.write(`[mcp-start] MCP_STARTUP_LOCK_FAILED: unable to acquire startup lock at ${lockPath}.\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function releaseLaunchLock(lockPath) {
|
|
163
|
+
if (!launchLockHeld) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const existing = readLaunchLock(lockPath);
|
|
169
|
+
const lockPid = Number(existing && existing.pid);
|
|
170
|
+
if (Number.isInteger(lockPid) && lockPid > 0 && lockPid !== process.pid) {
|
|
171
|
+
launchLockHeld = false;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
unlinkSync(lockPath);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error && error.code !== 'ENOENT') {
|
|
177
|
+
process.stderr.write(`[mcp-start] Warning: failed to release startup lock ${lockPath}.\n`);
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
launchLockHeld = false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getStartupTimeoutMs() {
|
|
185
|
+
const timeoutMs = Number(process.env.MCP_STARTUP_TIMEOUT_MS || '15000');
|
|
186
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1000) {
|
|
187
|
+
return 15000;
|
|
188
|
+
}
|
|
189
|
+
return Math.floor(timeoutMs);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function fetchJson(pathname, port, timeoutMs = 1000) {
|
|
193
|
+
return new Promise((resolveJson) => {
|
|
194
|
+
const request = http.request(
|
|
195
|
+
{
|
|
196
|
+
host: '127.0.0.1',
|
|
197
|
+
port,
|
|
198
|
+
path: pathname,
|
|
199
|
+
method: 'GET',
|
|
200
|
+
timeout: timeoutMs,
|
|
201
|
+
},
|
|
202
|
+
(response) => {
|
|
203
|
+
const chunks = [];
|
|
204
|
+
response.on('data', (chunk) => chunks.push(chunk));
|
|
205
|
+
response.on('end', () => {
|
|
206
|
+
try {
|
|
207
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
208
|
+
resolveJson(payload);
|
|
209
|
+
} catch {
|
|
210
|
+
resolveJson(null);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
request.on('error', () => resolveJson(null));
|
|
216
|
+
request.on('timeout', () => {
|
|
217
|
+
request.destroy();
|
|
218
|
+
resolveJson(null);
|
|
219
|
+
});
|
|
220
|
+
request.end();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function isBridgeHttpEndpoint(port) {
|
|
225
|
+
const root = await fetchJson('/', port);
|
|
226
|
+
if (root && typeof root === 'object' && typeof root.name === 'string' && root.name.includes('Browser Debug MCP Bridge')) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const health = await fetchJson('/health', port);
|
|
231
|
+
return Boolean(health && typeof health === 'object' && health.status === 'ok' && health.websocket);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getWindowsListeningPids(port) {
|
|
235
|
+
const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
|
|
236
|
+
if (result.status !== 0) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const targetSuffix = `:${port}`;
|
|
241
|
+
const pids = new Set();
|
|
242
|
+
const lines = String(result.stdout || '').split(/\r?\n/u);
|
|
243
|
+
|
|
244
|
+
for (const line of lines) {
|
|
245
|
+
const parts = line.trim().split(/\s+/u);
|
|
246
|
+
if (parts.length < 5) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const proto = String(parts[0] || '').toUpperCase();
|
|
251
|
+
const localAddress = String(parts[1] || '');
|
|
252
|
+
const state = String(parts[3] || '').toUpperCase();
|
|
253
|
+
const pid = Number(parts[4]);
|
|
254
|
+
|
|
255
|
+
if (proto === 'TCP' && state === 'LISTENING' && localAddress.endsWith(targetSuffix) && Number.isInteger(pid) && pid > 0) {
|
|
256
|
+
pids.add(pid);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return Array.from(pids);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getWindowsProcessCommandLine(pid) {
|
|
264
|
+
const result = spawnSync(
|
|
265
|
+
'powershell.exe',
|
|
266
|
+
['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`],
|
|
267
|
+
{ encoding: 'utf8' },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (result.status !== 0) {
|
|
271
|
+
return '';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return String(result.stdout || '').trim();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isLikelyBridgeCommandLine(commandLine) {
|
|
278
|
+
const normalized = String(commandLine || '').toLowerCase();
|
|
279
|
+
return normalized.includes('scripts\\mcp-start.cjs')
|
|
280
|
+
|| normalized.includes('scripts/mcp-start.cjs')
|
|
281
|
+
|| normalized.includes('mcp-bridge.js')
|
|
282
|
+
|| normalized.includes('mcp-bridge.ts')
|
|
283
|
+
|| normalized.includes('mcp-server:serve-mcp')
|
|
284
|
+
|| normalized.includes('browser-debug-mcp-bridge.cmd');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function killWindowsProcess(pid) {
|
|
288
|
+
const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' });
|
|
289
|
+
return result.status === 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function getPosixListeningPids(port) {
|
|
293
|
+
const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
|
|
294
|
+
if (result.error || result.status !== 0) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return String(result.stdout || '')
|
|
299
|
+
.split(/\r?\n/u)
|
|
300
|
+
.map((value) => Number(value.trim()))
|
|
301
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getListeningPids(port) {
|
|
305
|
+
return process.platform === 'win32' ? getWindowsListeningPids(port) : getPosixListeningPids(port);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function getPosixProcessCommandLine(pid) {
|
|
309
|
+
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
|
|
310
|
+
if (result.error || result.status !== 0) {
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
return String(result.stdout || '').trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function getProcessCommandLine(pid) {
|
|
317
|
+
return process.platform === 'win32' ? getWindowsProcessCommandLine(pid) : getPosixProcessCommandLine(pid);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function terminateProcess(pid) {
|
|
321
|
+
if (process.platform === 'win32') {
|
|
322
|
+
return killWindowsProcess(pid);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
process.kill(pid, 'SIGTERM');
|
|
327
|
+
return true;
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function tryRecoverStaleBridgeOnWindowsPort(port) {
|
|
334
|
+
if (process.platform !== 'win32') {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const endpointLooksLikeBridge = await isBridgeHttpEndpoint(port);
|
|
339
|
+
const listenerPids = getWindowsListeningPids(port).filter((pid) => pid !== process.pid);
|
|
340
|
+
if (listenerPids.length === 0) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let attemptedRestart = false;
|
|
345
|
+
for (const pid of listenerPids) {
|
|
346
|
+
const commandLine = getWindowsProcessCommandLine(pid);
|
|
347
|
+
const looksLikeBridge = endpointLooksLikeBridge || isLikelyBridgeCommandLine(commandLine);
|
|
348
|
+
if (!looksLikeBridge) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
attemptedRestart = true;
|
|
353
|
+
process.stderr.write(
|
|
354
|
+
`[mcp-start] Port ${port} is occupied by stale bridge process (pid ${pid}). Restarting automatically.\n`,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
if (!killWindowsProcess(pid)) {
|
|
358
|
+
process.stderr.write(`[mcp-start] Failed to terminate stale process ${pid}.\n`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!attemptedRestart) {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
367
|
+
await delay(200);
|
|
368
|
+
const stillInUse = await isPortInUse(port);
|
|
369
|
+
if (!stillInUse) {
|
|
370
|
+
process.stderr.write(`[mcp-start] Recovered port ${port} from stale bridge instance.\n`);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function waitForBridgeReady(port, child) {
|
|
379
|
+
const timeoutMs = getStartupTimeoutMs();
|
|
380
|
+
const deadline = Date.now() + timeoutMs;
|
|
381
|
+
|
|
382
|
+
while (Date.now() < deadline) {
|
|
383
|
+
if (child.exitCode !== null) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
reason: `MCP bridge exited during startup with code ${child.exitCode}.`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const health = await fetchJson('/health', port, 1000);
|
|
391
|
+
if (health && typeof health === 'object' && health.status === 'ok' && health.websocket) {
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await delay(200);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
reason: `Timed out after ${timeoutMs}ms waiting for /health on 127.0.0.1:${port}.`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function stopBridgeOnPort(port) {
|
|
405
|
+
const listenerPids = getListeningPids(port).filter((pid) => pid !== process.pid);
|
|
406
|
+
if (listenerPids.length === 0) {
|
|
407
|
+
const inUse = await isPortInUse(port);
|
|
408
|
+
if (!inUse) {
|
|
409
|
+
process.stderr.write(`[mcp-start] MCP_STOP_NO_ACTIVE_PROCESS: no listener found on port ${port}.\n`);
|
|
410
|
+
process.exit(0);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
process.stderr.write(
|
|
414
|
+
`[mcp-start] MCP_STOP_FAILED: port ${port} is in use but listener process could not be resolved on ${process.platform}.\n`,
|
|
415
|
+
);
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const endpointLooksLikeBridge = await isBridgeHttpEndpoint(port);
|
|
420
|
+
const bridgePids = [];
|
|
421
|
+
const nonBridgePids = [];
|
|
422
|
+
|
|
423
|
+
for (const pid of listenerPids) {
|
|
424
|
+
const commandLine = getProcessCommandLine(pid);
|
|
425
|
+
const looksLikeBridge = endpointLooksLikeBridge || isLikelyBridgeCommandLine(commandLine);
|
|
426
|
+
if (looksLikeBridge) {
|
|
427
|
+
bridgePids.push({ pid, commandLine });
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
nonBridgePids.push({ pid, commandLine });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (bridgePids.length === 0) {
|
|
434
|
+
process.stderr.write(
|
|
435
|
+
`[mcp-start] MCP_STOP_PORT_OCCUPIED_BY_OTHER_APP: port ${port} is not owned by Browser Debug MCP Bridge.\n`,
|
|
436
|
+
);
|
|
437
|
+
for (const proc of nonBridgePids) {
|
|
438
|
+
process.stderr.write(
|
|
439
|
+
`[mcp-start] Occupant pid=${proc.pid}${proc.commandLine ? ` cmd=${proc.commandLine}` : ''}\n`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let stopFailed = false;
|
|
446
|
+
for (const proc of bridgePids) {
|
|
447
|
+
process.stderr.write(`[mcp-start] Stopping Browser Debug MCP Bridge process ${proc.pid} on port ${port}.\n`);
|
|
448
|
+
if (!terminateProcess(proc.pid)) {
|
|
449
|
+
process.stderr.write(`[mcp-start] Failed to terminate process ${proc.pid}.\n`);
|
|
450
|
+
stopFailed = true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (stopFailed) {
|
|
455
|
+
process.stderr.write('[mcp-start] MCP_STOP_FAILED: one or more processes could not be terminated.\n');
|
|
456
|
+
process.exit(1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
460
|
+
await delay(200);
|
|
461
|
+
const remainingListeners = getListeningPids(port).filter((pid) => pid !== process.pid);
|
|
462
|
+
if (remainingListeners.length === 0) {
|
|
463
|
+
process.stderr.write(`[mcp-start] MCP_STOP_SUCCESS: Browser Debug MCP Bridge stopped on port ${port}.\n`);
|
|
464
|
+
process.exit(0);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
process.stderr.write(
|
|
469
|
+
`[mcp-start] MCP_STOP_FAILED: process termination requested but port ${port} is still in use.\n`,
|
|
470
|
+
);
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
72
474
|
if (!existsSync(packageJson)) {
|
|
73
475
|
process.stderr.write(`[mcp-start] Invalid repository root: ${repoRoot}\n`);
|
|
74
476
|
process.exit(1);
|
|
75
477
|
}
|
|
76
478
|
|
|
77
|
-
function spawnRuntime(runtime) {
|
|
479
|
+
async function spawnRuntime(runtime, port) {
|
|
78
480
|
const nxTarget = standalone ? 'mcp-server:serve' : 'mcp-server:serve-mcp';
|
|
79
481
|
const entryScript =
|
|
80
482
|
runtime === 'dist'
|
|
@@ -97,17 +499,6 @@ function spawnRuntime(runtime) {
|
|
|
97
499
|
process.exit(0);
|
|
98
500
|
}
|
|
99
501
|
|
|
100
|
-
const startedMessage = standalone
|
|
101
|
-
? `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: standalone). Keep this terminal open.`
|
|
102
|
-
: `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: mcp-stdio).`;
|
|
103
|
-
process.stderr.write(`${supportsColor ? `${greenBackground}${startedMessage}${ansiReset}` : startedMessage}\n`);
|
|
104
|
-
if (!standalone && process.stdin.isTTY) {
|
|
105
|
-
process.stderr.write(
|
|
106
|
-
'[mcp-start] Running from interactive terminal without MCP host. ' +
|
|
107
|
-
'Use --standalone for manual keep-alive testing.\n',
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
502
|
const child =
|
|
112
503
|
runtime === 'nx'
|
|
113
504
|
? spawn(
|
|
@@ -135,11 +526,57 @@ function spawnRuntime(runtime) {
|
|
|
135
526
|
},
|
|
136
527
|
);
|
|
137
528
|
|
|
529
|
+
let startupFinished = false;
|
|
530
|
+
|
|
531
|
+
const forwardSignalToChild = (signal) => {
|
|
532
|
+
if (child.exitCode !== null || child.killed) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
child.kill(signal);
|
|
538
|
+
} catch {
|
|
539
|
+
// Ignore signal forwarding failures when child is already terminating.
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
process.on('SIGINT', () => forwardSignalToChild('SIGINT'));
|
|
544
|
+
process.on('SIGTERM', () => forwardSignalToChild('SIGTERM'));
|
|
545
|
+
if (process.platform !== 'win32') {
|
|
546
|
+
process.on('SIGHUP', () => forwardSignalToChild('SIGHUP'));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
process.on('exit', () => {
|
|
550
|
+
if (child.exitCode === null && !child.killed) {
|
|
551
|
+
try {
|
|
552
|
+
child.kill('SIGTERM');
|
|
553
|
+
} catch {
|
|
554
|
+
// Ignore failures during process exit.
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
if (!standalone) {
|
|
560
|
+
const shutdownFromHostDisconnect = () => {
|
|
561
|
+
process.stderr.write('[mcp-start] MCP host disconnected; stopping MCP bridge child process.\n');
|
|
562
|
+
forwardSignalToChild('SIGTERM');
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
process.stdin.on('end', shutdownFromHostDisconnect);
|
|
566
|
+
process.stdin.on('close', shutdownFromHostDisconnect);
|
|
567
|
+
process.on('disconnect', shutdownFromHostDisconnect);
|
|
568
|
+
}
|
|
569
|
+
|
|
138
570
|
child.on('exit', (code, signal) => {
|
|
139
571
|
if (signal) {
|
|
140
572
|
process.kill(process.pid, signal);
|
|
141
573
|
return;
|
|
142
574
|
}
|
|
575
|
+
if (!startupFinished) {
|
|
576
|
+
process.stderr.write(
|
|
577
|
+
`[mcp-start] MCP_STARTUP_FAILED: MCP bridge exited before readiness check completed (code ${code ?? 0}).\n`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
143
580
|
if (!standalone && process.stdin.isTTY && (code ?? 0) === 0) {
|
|
144
581
|
process.stderr.write(
|
|
145
582
|
'[mcp-start] MCP stdio process exited (no MCP host attached). ' +
|
|
@@ -156,27 +593,68 @@ function spawnRuntime(runtime) {
|
|
|
156
593
|
);
|
|
157
594
|
process.exit(1);
|
|
158
595
|
});
|
|
596
|
+
|
|
597
|
+
const readiness = await waitForBridgeReady(port, child);
|
|
598
|
+
if (!readiness.ok) {
|
|
599
|
+
process.stderr.write(`[mcp-start] MCP_STARTUP_FAILED: ${readiness.reason}\n`);
|
|
600
|
+
forwardSignalToChild('SIGTERM');
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
startupFinished = true;
|
|
605
|
+
const startedMessage = standalone
|
|
606
|
+
? `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: standalone). Keep this terminal open.`
|
|
607
|
+
: `[mcp-start] Started Browser Debug MCP Bridge (runtime: ${runtime}, mode: mcp-stdio).`;
|
|
608
|
+
process.stderr.write(`${supportsColor ? `${greenBackground}${startedMessage}${ansiReset}` : startedMessage}\n`);
|
|
609
|
+
if (!standalone && process.stdin.isTTY) {
|
|
610
|
+
process.stderr.write(
|
|
611
|
+
'[mcp-start] Running from interactive terminal without MCP host. ' +
|
|
612
|
+
'Use --standalone for manual keep-alive testing.\n',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
159
615
|
}
|
|
160
616
|
|
|
161
617
|
async function main() {
|
|
162
618
|
const port = Number(process.env.PORT || '8065');
|
|
619
|
+
if (!Number.isFinite(port) || port <= 0) {
|
|
620
|
+
process.stderr.write(`[mcp-start] Invalid PORT value: ${String(process.env.PORT || '')}\n`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (stopRequested) {
|
|
625
|
+
await stopBridgeOnPort(port);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (!dryRun) {
|
|
630
|
+
acquireLaunchLock(launchLockPath);
|
|
631
|
+
process.on('exit', () => releaseLaunchLock(launchLockPath));
|
|
632
|
+
}
|
|
633
|
+
|
|
163
634
|
if (Number.isFinite(port)) {
|
|
164
|
-
|
|
635
|
+
let inUse = await isPortInUse(port);
|
|
165
636
|
if (inUse) {
|
|
637
|
+
const recovered = await tryRecoverStaleBridgeOnWindowsPort(port);
|
|
638
|
+
if (recovered) {
|
|
639
|
+
inUse = await isPortInUse(port);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (inUse) {
|
|
644
|
+
process.stderr.write(
|
|
645
|
+
`[mcp-start] MCP_STARTUP_PORT_IN_USE: required MCP port ${port} is already in use.\n`,
|
|
646
|
+
);
|
|
166
647
|
process.stderr.write(
|
|
167
|
-
`[mcp-start]
|
|
648
|
+
`[mcp-start] Reserve port ${port} for Browser Debug MCP Bridge: stop the process currently using it, then start the bridge again.\n`,
|
|
168
649
|
);
|
|
169
650
|
process.stderr.write(
|
|
170
|
-
'[mcp-start]
|
|
651
|
+
'[mcp-start] The bridge cannot start until the configured MCP port is free.\n',
|
|
171
652
|
);
|
|
172
653
|
if (process.platform === 'win32') {
|
|
173
654
|
process.stderr.write(
|
|
174
|
-
|
|
655
|
+
`[mcp-start] Windows help: netstat -ano | findstr :${port}\n`,
|
|
175
656
|
);
|
|
176
657
|
}
|
|
177
|
-
process.stderr.write(
|
|
178
|
-
'[mcp-start] Example with different port: PORT=8070 browser-debug-mcp-bridge --standalone\n',
|
|
179
|
-
);
|
|
180
658
|
process.exit(1);
|
|
181
659
|
}
|
|
182
660
|
}
|
|
@@ -188,7 +666,7 @@ async function main() {
|
|
|
188
666
|
);
|
|
189
667
|
process.exit(1);
|
|
190
668
|
}
|
|
191
|
-
spawnRuntime('dist');
|
|
669
|
+
await spawnRuntime('dist', port);
|
|
192
670
|
return;
|
|
193
671
|
}
|
|
194
672
|
|
|
@@ -197,17 +675,17 @@ async function main() {
|
|
|
197
675
|
process.stderr.write('[mcp-start] Missing tsx runtime. Run npm install/pnpm install first.\n');
|
|
198
676
|
process.exit(1);
|
|
199
677
|
}
|
|
200
|
-
spawnRuntime('tsx');
|
|
678
|
+
await spawnRuntime('tsx', port);
|
|
201
679
|
return;
|
|
202
680
|
}
|
|
203
681
|
|
|
204
682
|
if (existsSync(mcpBridgeDistEntry) && existsSync(mainServerDistEntry)) {
|
|
205
|
-
spawnRuntime('dist');
|
|
683
|
+
await spawnRuntime('dist', port);
|
|
206
684
|
return;
|
|
207
685
|
}
|
|
208
686
|
|
|
209
687
|
if (existsSync(nxBin)) {
|
|
210
|
-
spawnRuntime('nx');
|
|
688
|
+
await spawnRuntime('nx', port);
|
|
211
689
|
return;
|
|
212
690
|
}
|
|
213
691
|
|
|
@@ -220,7 +698,7 @@ async function main() {
|
|
|
220
698
|
}
|
|
221
699
|
|
|
222
700
|
process.stderr.write('[mcp-start] nx runtime not found, using tsx fallback runtime.\n');
|
|
223
|
-
spawnRuntime('tsx');
|
|
701
|
+
await spawnRuntime('tsx', port);
|
|
224
702
|
}
|
|
225
703
|
|
|
226
704
|
main().catch((error) => {
|