From 4e6368b5ffff6dd7c7c5991692492bd7bde00cb3 Mon Sep 17 00:00:00 2001 From: Julian Descottes Date: Thu, 11 Jun 2026 19:54:27 +0200 Subject: [PATCH] Bug 2046888 - feature: Add proper logs to firefox-devtools-mcp --- README.md | 1 + src/cli.ts | 5 +++ src/index.ts | 7 +++- src/utils/logger.ts | 82 ++++++++++++++++++++++++++++++++------ tests/utils/logger.test.ts | 72 ++++++++++++++++++++++++++++++++- 5 files changed, 153 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bf7a73d..7c42dae 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/cli.ts b/src/cli.ts index 46b6b31..fb44bf0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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: diff --git a/src/index.ts b/src/index.ts index 2a57d7e..2bddb08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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 Promise>([ // Pages @@ -395,6 +399,7 @@ export async function run( } } await server.close(); + await flushLogs().catch(() => {}); process.exit(0); }; const onSignal = () => void cleanup(); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 35db5b2..a021445 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -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 { + 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; + }); +} diff --git a/tests/utils/logger.test.ts b/tests/utils/logger.test.ts index 069e0ae..b150c57 100644 --- a/tests/utils/logger.test.ts +++ b/tests/utils/logger.test.ts @@ -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; @@ -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 = {}; + obj.self = obj; + expect(() => log('circular', obj)).not.toThrow(); + }); + + it('should fall back to String() for circular reference', async () => { + const obj: Record = {}; + 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(); + }); + }); });