Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ You can pass flags or environment variables (names on the right):
- `--enable-privileged-context` — enable privileged context tools: list/select privileged contexts, evaluate privileged scripts, get/set Firefox prefs, and list extensions. Requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` (`ENABLE_PRIVILEGED_CONTEXT=true`)
- `--android-device` — enable Firefox for Android mode; value is the ADB device serial (e.g. `emulator-5554`). Run `adb devices` to list connected devices. Omit the value or use `auto` to select the single connected device automatically.
- `--android-package` — Android app package name, default `org.mozilla.firefox`. Other packages: `org.mozilla.firefox_beta` for Firefox Beta, `org.mozilla.fenix` for Firefox Nightly, `org.mozilla.fenix.debug` for Firefox Nightly Debug, `org.mozilla.geckoview_example` for geckoview (`ANDROID_PACKAGE`)
- `--log-file` — write MCP server logs to a file instead of stderr. Useful for debugging sessions with MCP clients that hide server output. Set `DEBUG=*` to also include verbose debug logs. Example: `--log-file /tmp/firefox-mcp.log`

> **Note on `--pref`:** When Firefox runs in automation, it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that modify browser behavior for testing. The `--pref` option allows overriding these defaults when needed.

Expand Down
5 changes: 5 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ export const cliOptions = {
'Android app package name (default: org.mozilla.firefox). Use org.mozilla.fenix for Nightly.',
default: process.env.ANDROID_PACKAGE ?? 'org.mozilla.firefox',
},
logFile: {
type: 'string',
description:
'Path to a file where MCP server logs will be written. Set DEBUG=* to also enable verbose debug logs.',
},
enableScript: {
type: 'boolean',
description:
Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js';

import { SERVER_NAME, SERVER_VERSION } from './config/constants.js';
import { log, logError, logDebug } from './utils/logger.js';
import { log, logError, logDebug, setupLogFile, flushLogs } from './utils/logger.js';
import { parsePrefs } from './cli.js';
import type { parseArguments } from './cli.js';
import { FirefoxDevTools } from './firefox/index.js';
Expand Down Expand Up @@ -173,6 +173,10 @@ export async function run(

args = parseArgsFn(SERVER_VERSION);

if (args.logFile) {
setupLogFile(args.logFile);
}

// Tool handler mapping
const toolHandlers = new Map<string, (input: unknown) => Promise<McpToolResponse>>([
// Pages
Expand Down Expand Up @@ -395,6 +399,7 @@ export async function run(
}
}
await server.close();
await flushLogs().catch(() => {});
process.exit(0);
};
const onSignal = () => void cleanup();
Expand Down
82 changes: 70 additions & 12 deletions src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,82 @@
/**
* Simple logger for Firefox DevTools MCP server
*/
import fs from 'node:fs';

export function log(message: string, ...args: unknown[]): void {
console.error(`[firefox-devtools-mcp] ${message}`, ...args);
let logStream: fs.WriteStream | null = null;

function formatArgs(args: unknown[]): string {
if (args.length === 0) {
return '';
}
return (
' ' +
args
.map((a) => {
if (typeof a === 'string') {
return a;
}
try {
return JSON.stringify(a);
} catch {
return String(a);
}
})
.join(' ')
);
}

export function logError(message: string, error?: unknown): void {
if (error instanceof Error) {
console.error(`[firefox-devtools-mcp] ERROR: ${message}`, error.message);
if (error.stack) {
console.error(error.stack);
// Intended for test cleanup only.
export function closeLogFile(): void {
logStream = null;
}

export function flushLogs(timeoutMs = 2000): Promise<void> {
if (!logStream) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(reject, timeoutMs);
logStream!.end(() => {
clearTimeout(timeout);
resolve();
});
});
}

function write(message: string, args: unknown[], body?: string): void {
if (logStream) {
logStream.write(`${new Date().toISOString()} ${message}${formatArgs(args)}\n`);
if (body) {
logStream.write(`${body}\n`);
}
} else {
console.error(`[firefox-devtools-mcp] ERROR: ${message}`, error);
console.error(message, ...args);
if (body) {
console.error(body);
}
}
}

export function log(message: string, ...args: unknown[]): void {
write(`[firefox-devtools-mcp] ${message}`, args);
}

export function logDebug(message: string, ...args: unknown[]): void {
if (process.env.DEBUG === '*' || process.env.DEBUG?.includes('firefox-devtools')) {
console.error(`[firefox-devtools-mcp] DEBUG: ${message}`, ...args);
write(`[firefox-devtools-mcp] DEBUG: ${message}`, args);
}
}

export function logError(message: string, error?: unknown): void {
if (error instanceof Error) {
write(`[firefox-devtools-mcp] ERROR: ${message}`, [error.message], error.stack);
} else {
write(`[firefox-devtools-mcp] ERROR: ${message}`, [error]);
}
}

export function setupLogFile(filePath: string): void {
logStream = fs.createWriteStream(filePath, { flags: 'a' });
logStream.on('error', (error) => {
console.error(`[firefox-devtools-mcp] Error writing to log file: ${error.message}`);
logStream = null;
});
}
72 changes: 71 additions & 1 deletion tests/utils/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,18 @@
* Unit tests for logger utilities
*/

import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { log, logError, logDebug } from '../../src/utils/logger.js';
import {
log,
logError,
logDebug,
setupLogFile,
flushLogs,
closeLogFile,
} from '../../src/utils/logger.js';

describe('Logger Utilities', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -118,4 +128,64 @@ describe('Logger Utilities', () => {
);
});
});

describe('file logger', () => {
let tmpFile: string;

beforeEach(() => {
tmpFile = join(mkdtempSync(join(tmpdir(), 'logger-test-')), 'out.log');
setupLogFile(tmpFile);
});

afterEach(async () => {
await flushLogs();
closeLogFile();
rmSync(tmpFile, { force: true });
});

it('should write log lines with timestamp prefix', async () => {
log('hello', 'world');
await flushLogs();
const content = readFileSync(tmpFile, 'utf8');
expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T.*\[firefox-devtools-mcp\] hello world\n$/);
});

it('should write error with stack to file', async () => {
const err = new Error('boom');
err.stack = 'Error: boom\n at test';
logError('failed', err);
await flushLogs();
const content = readFileSync(tmpFile, 'utf8');
expect(content).toContain('[firefox-devtools-mcp] ERROR: failed boom');
expect(content).toContain('Error: boom\n at test');
});

it('should not throw on circular reference', () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
expect(() => log('circular', obj)).not.toThrow();
});

it('should fall back to String() for circular reference', async () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
log('circular', obj);
await flushLogs();
const content = readFileSync(tmpFile, 'utf8');
expect(content).toContain('[firefox-devtools-mcp] circular');
});

it('should write debug log to file when DEBUG is set', async () => {
process.env.DEBUG = '*';
logDebug('dbg msg');
await flushLogs();
const content = readFileSync(tmpFile, 'utf8');
expect(content).toContain('[firefox-devtools-mcp] DEBUG: dbg msg');
});

it('should not write to console when file logger is active', () => {
log('file only');
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
});
});