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.
- package/README.md +159 -49
- package/apps/mcp-server/dist/mcp/server.js +76 -18
- 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/websocket/websocket-server.js +57 -0
- package/apps/mcp-server/dist/websocket/websocket-server.js.map +1 -1
- package/package.json +1 -1
- package/scripts/mcp-start.cjs +326 -8
package/scripts/mcp-start.cjs
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|