Skip to content

Commit c8aa858

Browse files
committed
feat: detect port conflicts with foreign MCP servers
Change default port from 9224 to 9225 to avoid collision with chrome-devtools-axi, which also defaults to 9224. The bridge /health response now includes server: "opera-browser-cli" on both 200 and 503, so the CLI can distinguish its own bridge from any other process on the port. ensureBridge() checks before spawning and throws a clear error if a foreign server is occupying the port.
1 parent 634313a commit c8aa858

2 files changed

Lines changed: 45 additions & 11 deletions

File tree

src/bridge.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { dirname, join, resolve } from "node:path";
2828
import { homedir } from "node:os";
2929

3030
const DEFAULT_PORT = Number.parseInt(
31-
process.env.OPERA_CLI_PORT ?? "9224",
31+
process.env.OPERA_CLI_PORT ?? "9225",
3232
10,
3333
);
3434
const STATE_DIR = join(homedir(), ".opera-browser-cli");
@@ -258,9 +258,9 @@ export async function handleBridgeRequest(
258258

259259
if (req.method === "GET" && req.url === "/health") {
260260
if (await isBridgeClientConnected(client)) {
261-
writeJson(res, 200, { status: "ok" });
261+
writeJson(res, 200, { status: "ok", server: "opera-browser-cli" });
262262
} else {
263-
writeJson(res, 503, { error: "Not connected" });
263+
writeJson(res, 503, { status: "not-connected", server: "opera-browser-cli" });
264264
}
265265
return;
266266
}

src/client.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const STATE_DIR = join(homedir(), ".opera-browser-cli");
1414
const PID_FILE = join(STATE_DIR, "bridge.pid");
1515
const CONFIG_FILE = join(STATE_DIR, "config");
1616
const LOG_FILE = join(STATE_DIR, "bridge.log");
17-
const DEFAULT_PORT = 9224;
17+
const DEFAULT_PORT = 9225;
1818

1919
export function getLogFile(): string {
2020
return LOG_FILE;
@@ -202,16 +202,34 @@ function httpPost(
202202
});
203203
}
204204

205-
async function checkBridgeHealth(port: number): Promise<boolean> {
205+
async function isBridgeHealthy(port: number): Promise<boolean> {
206206
try {
207-
const resp = await httpGet(port, "/health");
207+
const resp = await httpGet(port, "/health", 2000);
208208
const data = JSON.parse(resp);
209209
return data.status === "ok";
210210
} catch {
211211
return false;
212212
}
213213
}
214214

215+
/**
216+
* Check what is listening on a port.
217+
* Returns "ok" if it is our bridge, "conflict" if something else responded,
218+
* or "free" if nothing is listening.
219+
*/
220+
async function checkPortStatus(port: number): Promise<"ok" | "conflict" | "free"> {
221+
try {
222+
const resp = await httpGet(port, "/health", 2000);
223+
const data = JSON.parse(resp);
224+
if (data.server === "opera-browser-cli") {
225+
return data.status === "ok" ? "ok" : "free";
226+
}
227+
return "conflict";
228+
} catch {
229+
return "free";
230+
}
231+
}
232+
215233
function sleep(ms: number): Promise<void> {
216234
return new Promise((r) => setTimeout(r, ms));
217235
}
@@ -225,10 +243,10 @@ export async function ensureBridge(): Promise<number> {
225243
10,
226244
);
227245

228-
// Check existing bridge via PID file
246+
// Check existing bridge via PID file (lenient: we trust our own PID file).
229247
const pidInfo = readPidFile();
230248
if (pidInfo && isProcessAlive(pidInfo.pid)) {
231-
if (await checkBridgeHealth(pidInfo.port)) {
249+
if (await isBridgeHealthy(pidInfo.port)) {
232250
return pidInfo.port;
233251
}
234252
try {
@@ -238,6 +256,22 @@ export async function ensureBridge(): Promise<number> {
238256
}
239257
}
240258

259+
// Check for a foreign server already occupying the target port before spawning.
260+
const portStatus = await checkPortStatus(port);
261+
if (portStatus === "ok") {
262+
// A healthy bridge is already running (no PID file or stale PID).
263+
return port;
264+
}
265+
if (portStatus === "conflict") {
266+
throw new CdpError(
267+
`Port ${port} is in use by a different server (not opera-devtools-mcp). Stop it or choose a different port.`,
268+
"BRIDGE_NOT_READY",
269+
[
270+
`Stop the process on port ${port} and try again, or set OPERA_CLI_PORT to a different port number`,
271+
],
272+
);
273+
}
274+
241275
// Start a new bridge
242276

243277
const bridgeScript = resolveBridgeScript(import.meta.dirname);
@@ -272,7 +306,7 @@ export async function ensureBridge(): Promise<number> {
272306
// Poll for health (max 30s — Chrome launch can be slow)
273307
const deadline = Date.now() + 30_000;
274308
while (Date.now() < deadline) {
275-
if (await checkBridgeHealth(port)) {
309+
if (await isBridgeHealthy(port)) {
276310
return port;
277311
}
278312
await sleep(500);
@@ -395,7 +429,7 @@ export async function getBridgeStatus(): Promise<BridgeStatus> {
395429
};
396430
}
397431
const alive = isProcessAlive(pidInfo.pid);
398-
const healthy = alive ? await checkBridgeHealth(pidInfo.port) : false;
432+
const healthy = alive ? await isBridgeHealthy(pidInfo.port) : false;
399433
return {
400434
pidFileExists: true,
401435
processAlive: alive,
@@ -414,7 +448,7 @@ export async function getSessionSnapshotIfRunning(): Promise<string | null> {
414448
if (!pidInfo || !isProcessAlive(pidInfo.pid)) {
415449
return null;
416450
}
417-
if (!(await checkBridgeHealth(pidInfo.port))) {
451+
if (!(await isBridgeHealthy(pidInfo.port))) {
418452
return null;
419453
}
420454
try {

0 commit comments

Comments
 (0)