browser-debug-mcp-bridge 1.4.0 → 1.5.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.
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- const { spawn } = require('node:child_process');
2
+ const { spawn, spawnSync } = require('node:child_process');
3
3
  const { existsSync } = 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,6 +17,7 @@ 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';
@@ -69,6 +71,266 @@ function isPortInUse(port, host = '127.0.0.1') {
69
71
  });
70
72
  }
71
73
 
74
+ function delay(ms) {
75
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
76
+ }
77
+
78
+ function fetchJson(pathname, port, timeoutMs = 1000) {
79
+ return new Promise((resolveJson) => {
80
+ const request = http.request(
81
+ {
82
+ host: '127.0.0.1',
83
+ port,
84
+ path: pathname,
85
+ method: 'GET',
86
+ timeout: timeoutMs,
87
+ },
88
+ (response) => {
89
+ const chunks = [];
90
+ response.on('data', (chunk) => chunks.push(chunk));
91
+ response.on('end', () => {
92
+ try {
93
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
94
+ resolveJson(payload);
95
+ } catch {
96
+ resolveJson(null);
97
+ }
98
+ });
99
+ },
100
+ );
101
+ request.on('error', () => resolveJson(null));
102
+ request.on('timeout', () => {
103
+ request.destroy();
104
+ resolveJson(null);
105
+ });
106
+ request.end();
107
+ });
108
+ }
109
+
110
+ async function isBridgeHttpEndpoint(port) {
111
+ const root = await fetchJson('/', port);
112
+ if (root && typeof root === 'object' && typeof root.name === 'string' && root.name.includes('Browser Debug MCP Bridge')) {
113
+ return true;
114
+ }
115
+
116
+ const health = await fetchJson('/health', port);
117
+ return Boolean(health && typeof health === 'object' && health.status === 'ok' && health.websocket);
118
+ }
119
+
120
+ function getWindowsListeningPids(port) {
121
+ const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
122
+ if (result.status !== 0) {
123
+ return [];
124
+ }
125
+
126
+ const targetSuffix = `:${port}`;
127
+ const pids = new Set();
128
+ const lines = String(result.stdout || '').split(/\r?\n/u);
129
+
130
+ for (const line of lines) {
131
+ const parts = line.trim().split(/\s+/u);
132
+ if (parts.length < 5) {
133
+ continue;
134
+ }
135
+
136
+ const proto = String(parts[0] || '').toUpperCase();
137
+ const localAddress = String(parts[1] || '');
138
+ const state = String(parts[3] || '').toUpperCase();
139
+ const pid = Number(parts[4]);
140
+
141
+ if (proto === 'TCP' && state === 'LISTENING' && localAddress.endsWith(targetSuffix) && Number.isInteger(pid) && pid > 0) {
142
+ pids.add(pid);
143
+ }
144
+ }
145
+
146
+ return Array.from(pids);
147
+ }
148
+
149
+ function getWindowsProcessCommandLine(pid) {
150
+ const result = spawnSync(
151
+ 'powershell.exe',
152
+ ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`],
153
+ { encoding: 'utf8' },
154
+ );
155
+
156
+ if (result.status !== 0) {
157
+ return '';
158
+ }
159
+
160
+ return String(result.stdout || '').trim();
161
+ }
162
+
163
+ function isLikelyBridgeCommandLine(commandLine) {
164
+ const normalized = String(commandLine || '').toLowerCase();
165
+ return normalized.includes('scripts\\mcp-start.cjs')
166
+ || normalized.includes('scripts/mcp-start.cjs')
167
+ || normalized.includes('mcp-bridge.js')
168
+ || normalized.includes('mcp-bridge.ts')
169
+ || normalized.includes('mcp-server:serve-mcp')
170
+ || normalized.includes('browser-debug-mcp-bridge.cmd');
171
+ }
172
+
173
+ function killWindowsProcess(pid) {
174
+ const result = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { encoding: 'utf8' });
175
+ return result.status === 0;
176
+ }
177
+
178
+ function getPosixListeningPids(port) {
179
+ const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { encoding: 'utf8' });
180
+ if (result.error || result.status !== 0) {
181
+ return [];
182
+ }
183
+
184
+ return String(result.stdout || '')
185
+ .split(/\r?\n/u)
186
+ .map((value) => Number(value.trim()))
187
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
188
+ }
189
+
190
+ function getListeningPids(port) {
191
+ return process.platform === 'win32' ? getWindowsListeningPids(port) : getPosixListeningPids(port);
192
+ }
193
+
194
+ function getPosixProcessCommandLine(pid) {
195
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' });
196
+ if (result.error || result.status !== 0) {
197
+ return '';
198
+ }
199
+ return String(result.stdout || '').trim();
200
+ }
201
+
202
+ function getProcessCommandLine(pid) {
203
+ return process.platform === 'win32' ? getWindowsProcessCommandLine(pid) : getPosixProcessCommandLine(pid);
204
+ }
205
+
206
+ function terminateProcess(pid) {
207
+ if (process.platform === 'win32') {
208
+ return killWindowsProcess(pid);
209
+ }
210
+
211
+ try {
212
+ process.kill(pid, 'SIGTERM');
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ async function tryRecoverStaleBridgeOnWindowsPort(port) {
220
+ if (process.platform !== 'win32') {
221
+ return false;
222
+ }
223
+
224
+ const endpointLooksLikeBridge = await isBridgeHttpEndpoint(port);
225
+ const listenerPids = getWindowsListeningPids(port).filter((pid) => pid !== process.pid);
226
+ if (listenerPids.length === 0) {
227
+ return false;
228
+ }
229
+
230
+ let attemptedRestart = false;
231
+ for (const pid of listenerPids) {
232
+ const commandLine = getWindowsProcessCommandLine(pid);
233
+ const looksLikeBridge = endpointLooksLikeBridge || isLikelyBridgeCommandLine(commandLine);
234
+ if (!looksLikeBridge) {
235
+ continue;
236
+ }
237
+
238
+ attemptedRestart = true;
239
+ process.stderr.write(
240
+ `[mcp-start] Port ${port} is occupied by stale bridge process (pid ${pid}). Restarting automatically.\n`,
241
+ );
242
+
243
+ if (!killWindowsProcess(pid)) {
244
+ process.stderr.write(`[mcp-start] Failed to terminate stale process ${pid}.\n`);
245
+ }
246
+ }
247
+
248
+ if (!attemptedRestart) {
249
+ return false;
250
+ }
251
+
252
+ for (let attempt = 0; attempt < 12; attempt++) {
253
+ await delay(200);
254
+ const stillInUse = await isPortInUse(port);
255
+ if (!stillInUse) {
256
+ process.stderr.write(`[mcp-start] Recovered port ${port} from stale bridge instance.\n`);
257
+ return true;
258
+ }
259
+ }
260
+
261
+ return false;
262
+ }
263
+
264
+ async function stopBridgeOnPort(port) {
265
+ const listenerPids = getListeningPids(port).filter((pid) => pid !== process.pid);
266
+ if (listenerPids.length === 0) {
267
+ const inUse = await isPortInUse(port);
268
+ if (!inUse) {
269
+ process.stderr.write(`[mcp-start] MCP_STOP_NO_ACTIVE_PROCESS: no listener found on port ${port}.\n`);
270
+ process.exit(0);
271
+ }
272
+
273
+ process.stderr.write(
274
+ `[mcp-start] MCP_STOP_FAILED: port ${port} is in use but listener process could not be resolved on ${process.platform}.\n`,
275
+ );
276
+ process.exit(1);
277
+ }
278
+
279
+ const endpointLooksLikeBridge = await isBridgeHttpEndpoint(port);
280
+ const bridgePids = [];
281
+ const nonBridgePids = [];
282
+
283
+ for (const pid of listenerPids) {
284
+ const commandLine = getProcessCommandLine(pid);
285
+ const looksLikeBridge = endpointLooksLikeBridge || isLikelyBridgeCommandLine(commandLine);
286
+ if (looksLikeBridge) {
287
+ bridgePids.push({ pid, commandLine });
288
+ continue;
289
+ }
290
+ nonBridgePids.push({ pid, commandLine });
291
+ }
292
+
293
+ if (bridgePids.length === 0) {
294
+ process.stderr.write(
295
+ `[mcp-start] MCP_STOP_PORT_OCCUPIED_BY_OTHER_APP: port ${port} is not owned by Browser Debug MCP Bridge.\n`,
296
+ );
297
+ for (const proc of nonBridgePids) {
298
+ process.stderr.write(
299
+ `[mcp-start] Occupant pid=${proc.pid}${proc.commandLine ? ` cmd=${proc.commandLine}` : ''}\n`,
300
+ );
301
+ }
302
+ process.exit(1);
303
+ }
304
+
305
+ let stopFailed = false;
306
+ for (const proc of bridgePids) {
307
+ process.stderr.write(`[mcp-start] Stopping Browser Debug MCP Bridge process ${proc.pid} on port ${port}.\n`);
308
+ if (!terminateProcess(proc.pid)) {
309
+ process.stderr.write(`[mcp-start] Failed to terminate process ${proc.pid}.\n`);
310
+ stopFailed = true;
311
+ }
312
+ }
313
+
314
+ if (stopFailed) {
315
+ process.stderr.write('[mcp-start] MCP_STOP_FAILED: one or more processes could not be terminated.\n');
316
+ process.exit(1);
317
+ }
318
+
319
+ for (let attempt = 0; attempt < 15; attempt++) {
320
+ await delay(200);
321
+ const remainingListeners = getListeningPids(port).filter((pid) => pid !== process.pid);
322
+ if (remainingListeners.length === 0) {
323
+ process.stderr.write(`[mcp-start] MCP_STOP_SUCCESS: Browser Debug MCP Bridge stopped on port ${port}.\n`);
324
+ process.exit(0);
325
+ }
326
+ }
327
+
328
+ process.stderr.write(
329
+ `[mcp-start] MCP_STOP_FAILED: process termination requested but port ${port} is still in use.\n`,
330
+ );
331
+ process.exit(1);
332
+ }
333
+
72
334
  if (!existsSync(packageJson)) {
73
335
  process.stderr.write(`[mcp-start] Invalid repository root: ${repoRoot}\n`);
74
336
  process.exit(1);
@@ -135,6 +397,45 @@ function spawnRuntime(runtime) {
135
397
  },
136
398
  );
137
399
 
400
+ const forwardSignalToChild = (signal) => {
401
+ if (child.exitCode !== null || child.killed) {
402
+ return;
403
+ }
404
+
405
+ try {
406
+ child.kill(signal);
407
+ } catch {
408
+ // Ignore signal forwarding failures when child is already terminating.
409
+ }
410
+ };
411
+
412
+ process.on('SIGINT', () => forwardSignalToChild('SIGINT'));
413
+ process.on('SIGTERM', () => forwardSignalToChild('SIGTERM'));
414
+ if (process.platform !== 'win32') {
415
+ process.on('SIGHUP', () => forwardSignalToChild('SIGHUP'));
416
+ }
417
+
418
+ process.on('exit', () => {
419
+ if (child.exitCode === null && !child.killed) {
420
+ try {
421
+ child.kill('SIGTERM');
422
+ } catch {
423
+ // Ignore failures during process exit.
424
+ }
425
+ }
426
+ });
427
+
428
+ if (!standalone) {
429
+ const shutdownFromHostDisconnect = () => {
430
+ process.stderr.write('[mcp-start] MCP host disconnected; stopping MCP bridge child process.\n');
431
+ forwardSignalToChild('SIGTERM');
432
+ };
433
+
434
+ process.stdin.on('end', shutdownFromHostDisconnect);
435
+ process.stdin.on('close', shutdownFromHostDisconnect);
436
+ process.on('disconnect', shutdownFromHostDisconnect);
437
+ }
438
+
138
439
  child.on('exit', (code, signal) => {
139
440
  if (signal) {
140
441
  process.kill(process.pid, signal);
@@ -160,23 +461,40 @@ function spawnRuntime(runtime) {
160
461
 
161
462
  async function main() {
162
463
  const port = Number(process.env.PORT || '8065');
464
+ if (!Number.isFinite(port) || port <= 0) {
465
+ process.stderr.write(`[mcp-start] Invalid PORT value: ${String(process.env.PORT || '')}\n`);
466
+ process.exit(1);
467
+ }
468
+
469
+ if (stopRequested) {
470
+ await stopBridgeOnPort(port);
471
+ return;
472
+ }
473
+
163
474
  if (Number.isFinite(port)) {
164
- const inUse = await isPortInUse(port);
475
+ let inUse = await isPortInUse(port);
476
+ if (inUse) {
477
+ const recovered = await tryRecoverStaleBridgeOnWindowsPort(port);
478
+ if (recovered) {
479
+ inUse = await isPortInUse(port);
480
+ }
481
+ }
482
+
165
483
  if (inUse) {
166
484
  process.stderr.write(
167
- `[mcp-start] Port ${port} is already in use. Another Browser Debug MCP Bridge instance is likely running.\n`,
485
+ `[mcp-start] MCP_STARTUP_PORT_IN_USE: required MCP port ${port} is already in use.\n`,
168
486
  );
169
487
  process.stderr.write(
170
- '[mcp-start] Stop the existing process, or run this instance on a different port.\n',
488
+ `[mcp-start] Reserve port ${port} for Browser Debug MCP Bridge: stop the process currently using it, then start the bridge again.\n`,
489
+ );
490
+ process.stderr.write(
491
+ '[mcp-start] The bridge cannot start until the configured MCP port is free.\n',
171
492
  );
172
493
  if (process.platform === 'win32') {
173
494
  process.stderr.write(
174
- '[mcp-start] Windows help: netstat -ano | findstr :8065\n',
495
+ `[mcp-start] Windows help: netstat -ano | findstr :${port}\n`,
175
496
  );
176
497
  }
177
- process.stderr.write(
178
- '[mcp-start] Example with different port: PORT=8070 browser-debug-mcp-bridge --standalone\n',
179
- );
180
498
  process.exit(1);
181
499
  }
182
500
  }