Skip to content

Commit 7ecf7d0

Browse files
committed
feat: add --marionette-host for remote/tunneled Firefox
Adds a --marionette-host CLI parameter (and MARIONETTE_HOST env var) that controls which host geckodriver connects to for Marionette and where the BiDi WebSocket connection is directed. When connecting to a remote or tunneled Firefox (e.g., via SSH port forwarding), the Marionette and BiDi ports are on a different host than localhost. This parameter lets geckodriver reach the tunnel endpoint while preserving the original Host header for Firefox's WebSocket allowHosts check.
1 parent fcd5855 commit 7ecf7d0

5 files changed

Lines changed: 62 additions & 16 deletions

File tree

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export const cliOptions = {
110110
description: 'Marionette port to connect to when using --connect-existing (default: 2828)',
111111
default: Number(process.env.MARIONETTE_PORT ?? '2828'),
112112
},
113+
marionetteHost: {
114+
type: 'string',
115+
description: 'Marionette host to connect to when using --connect-existing (default: 127.0.0.1). Also used as the BiDi WebSocket connect address when different from 127.0.0.1.',
116+
default: process.env.MARIONETTE_HOST ?? '127.0.0.1',
117+
},
113118
env: {
114119
type: 'array',
115120
description:

src/firefox/core.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class GeckodriverHttpDriver implements IDriver {
140140
this.webSocketUrl = webSocketUrl;
141141
}
142142

143-
static async connect(marionettePort: number): Promise<GeckodriverHttpDriver> {
143+
static async connect(marionettePort: number, marionetteHost = '127.0.0.1'): Promise<GeckodriverHttpDriver> {
144144
// Find geckodriver binary via selenium-manager
145145
const path = await import('node:path');
146146
const { execFileSync } = await import('node:child_process');
@@ -179,7 +179,7 @@ class GeckodriverHttpDriver implements IDriver {
179179
// Use --port=0 to let the OS assign a free port atomically (geckodriver ≥0.34.0)
180180
const gd = spawn(
181181
geckodriverPath,
182-
['--connect-existing', '--marionette-port', String(marionettePort), '--port', '0'],
182+
['--connect-existing', '--marionette-host', marionetteHost, '--marionette-port', String(marionettePort), '--port', '0'],
183183
{ stdio: ['ignore', 'pipe', 'pipe'] }
184184
);
185185

@@ -223,7 +223,14 @@ class GeckodriverHttpDriver implements IDriver {
223223
throw new Error(`Failed to create session: ${JSON.stringify(json)}`);
224224
}
225225

226-
const wsUrl = json.value.capabilities.webSocketUrl as string | undefined;
226+
let wsUrl = json.value.capabilities.webSocketUrl as string | undefined;
227+
logDebug(`Session capabilities webSocketUrl: ${wsUrl ?? 'not present'}, marionetteHost: ${marionetteHost}`);
228+
if (wsUrl && marionetteHost !== '127.0.0.1') {
229+
// Rewrite the URL to connect through the remote host / tunnel.
230+
const parsed = new URL(wsUrl);
231+
parsed.hostname = marionetteHost;
232+
wsUrl = parsed.toString();
233+
}
227234
if (wsUrl) {
228235
logDebug(`BiDi WebSocket URL: ${wsUrl}`);
229236
} else {
@@ -445,12 +452,18 @@ class GeckodriverHttpDriver implements IDriver {
445452
this.gdProcess.kill();
446453
}
447454

448-
/** Kill the geckodriver process without closing Firefox */
449-
kill(): void {
455+
/** Kill the geckodriver process without closing Firefox.
456+
* Deletes the session first so Marionette accepts new connections. */
457+
async kill(): Promise<void> {
450458
if (this.bidiConnection) {
451459
(this.bidiConnection.socket as unknown as WebSocket).close();
452460
this.bidiConnection = null;
453461
}
462+
try {
463+
await this.cmd('DELETE', '');
464+
} catch {
465+
// ignore
466+
}
454467
this.gdProcess.kill();
455468
}
456469

@@ -470,9 +483,11 @@ class GeckodriverHttpDriver implements IDriver {
470483
const ws = new WebSocket(this.webSocketUrl);
471484
await new Promise<void>((resolve, reject) => {
472485
ws.on('open', resolve);
473-
ws.on('error', reject);
486+
ws.on('error', (e: any) => {
487+
const msg = e?.message || e?.error?.message || e?.error || e?.type || JSON.stringify(e) || String(e);
488+
reject(new Error(`BiDi WS to ${this.webSocketUrl}: ${msg}`));
489+
});
474490
});
475-
logDebug('BiDi WebSocket connected');
476491

477492
let cmdId = 0;
478493
const subscribe = async (event: string, contexts?: string[]): Promise<void> => {
@@ -572,7 +587,8 @@ export class FirefoxCore {
572587
// We bypass selenium-webdriver because its BiDi auto-upgrade hangs
573588
// when used with geckodriver's --connect-existing mode.
574589
const port = this.options.marionettePort ?? 2828;
575-
this.driver = await GeckodriverHttpDriver.connect(port);
590+
const host = this.options.marionetteHost ?? '127.0.0.1';
591+
this.driver = await GeckodriverHttpDriver.connect(port, host);
576592
} else {
577593
// Set up output file for capturing Firefox stdout/stderr
578594
if (this.options.logFile) {
@@ -709,7 +725,7 @@ export class FirefoxCore {
709725
*/
710726
reset(): void {
711727
if (this.driver && this.options.connectExisting && 'kill' in this.driver) {
712-
(this.driver as { kill(): void }).kill();
728+
(this.driver as { kill(): Promise<void> }).kill();
713729
}
714730
this.driver = null;
715731
this.currentContextId = null;
@@ -831,7 +847,7 @@ export class FirefoxCore {
831847
async close(): Promise<void> {
832848
if (this.driver) {
833849
if (this.options.connectExisting && 'kill' in this.driver) {
834-
(this.driver as { kill(): void }).kill();
850+
await (this.driver as { kill(): Promise<void> }).kill();
835851
} else if ('quit' in this.driver) {
836852
await (this.driver as { quit(): Promise<void> }).quit();
837853
}

src/firefox/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,20 @@ export class FirefoxClient {
7474
(id: string) => this.core.setCurrentContextId(id)
7575
);
7676

77-
// Subscribe to console and network events for ALL contexts (not just current)
78-
if (this.consoleEvents) {
79-
await this.consoleEvents.subscribe(undefined);
80-
}
81-
if (this.networkEvents) {
82-
await this.networkEvents.subscribe(undefined);
77+
// Subscribe to console and network events for ALL contexts (not just current).
78+
// BiDi may not be available (e.g., tunneled connect-existing without
79+
// Remote Agent). Treat subscription failure as non-fatal.
80+
try {
81+
if (this.consoleEvents) {
82+
await this.consoleEvents.subscribe(undefined);
83+
}
84+
if (this.networkEvents) {
85+
await this.networkEvents.subscribe(undefined);
86+
}
87+
} catch (e) {
88+
console.error(`[firefox-devtools-mcp] BiDi subscription failed (non-fatal): ${e}`);
89+
this.consoleEvents = undefined as any;
90+
this.networkEvents = undefined as any;
8391
}
8492
}
8593

src/firefox/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface FirefoxLaunchOptions {
6060
acceptInsecureCerts?: boolean | undefined;
6161
connectExisting?: boolean | undefined;
6262
marionettePort?: number | undefined;
63+
marionetteHost?: string | undefined;
6364
env?: Record<string, string> | undefined;
6465
logFile?: string | undefined;
6566
/** Firefox preferences to set at startup via moz:firefoxOptions */

src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export async function getFirefox(): Promise<FirefoxDevTools> {
142142
acceptInsecureCerts: args.acceptInsecureCerts,
143143
connectExisting: args.connectExisting,
144144
marionettePort: args.marionettePort,
145+
marionetteHost: args.marionetteHost,
145146
env: envVars,
146147
logFile: args.outputFile ?? undefined,
147148
prefs,
@@ -358,6 +359,21 @@ async function main() {
358359

359360
log('Firefox DevTools MCP server running on stdio');
360361
log('Ready to accept tool requests');
362+
363+
// Graceful shutdown: clean up the Marionette session so Firefox
364+
// accepts new connections. Without this, the session stays locked.
365+
const cleanup = async () => {
366+
if (firefox) {
367+
try {
368+
await firefox.close();
369+
} catch {
370+
// ignore
371+
}
372+
}
373+
process.exit(0);
374+
};
375+
process.on('SIGTERM', cleanup);
376+
process.on('SIGINT', cleanup);
361377
}
362378

363379
// Only run main() if this file is executed directly (not imported)

0 commit comments

Comments
 (0)