diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc727c3..c8d94ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Merged features from Mozilla's temporary fork (https://github.com/padenot/firefox-devtools-mcp):
+ - Add tool to evaluate JS against the content page
+ - Add tool to install, uninstall and list webextensions
+ - Add tool to restart Firefox
+ - Add tool to read and write preferences
+ - Improved support for reusing existing profile folder
+ - Support for MOZ_LOG
+ - Support privileged context
+ - Support for sending WebDriver BiDi commands
+
+## [0.8.1] - 2026-03-17
+
+### Fixed
+- Increase snapshot test timeout
+
+## [0.8.0] - 2026-03-17
+
+### Added
+- Support --connect-existing to attach to running Firefox
+
## [0.7.1] - 2026-02-13
### Fixed
@@ -32,7 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Enhanced Vue/Livewire/Alpine.js support**: New snapshot options for modern JavaScript frameworks
- `includeAll` parameter: Include all visible elements without relevance filtering
- `selector` parameter: Scope snapshot to specific DOM subtree using CSS selector
- - Fixes [#36](https://github.com/freema/firefox-devtools-mcp/issues/36) - DOM filtering problem with Vue and Livewire applications
+ - Fixes [#36](https://github.com/mozilla/firefox-devtools-mcp/issues/36) - DOM filtering problem with Vue and Livewire applications
- **Test fixtures**: Added new HTML fixtures for testing visibility edge cases (`visibility.html`, `selector.html`)
### Changed
@@ -59,7 +80,7 @@ Released on npm, see GitHub releases for details.
### Added
- Windows-specific integration test runner (`scripts/run-integration-tests-windows.mjs`)
- Runs integration tests directly via Node.js to avoid vitest fork issues on Windows
- - See [#33](https://github.com/freema/firefox-devtools-mcp/issues/33) for details
+ - See [#33](https://github.com/mozilla/firefox-devtools-mcp/issues/33) for details
- Documentation for Windows integration tests in `docs/ci-and-release.md`
- Branch protection enabled on `main` branch
@@ -184,15 +205,15 @@ Released on npm, see GitHub releases for details.
- UID-based element referencing system
- Headless mode support
-[0.7.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.7.0...v0.7.1
-[0.7.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.1...v0.7.0
-[0.6.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.6.0...v0.6.1
-[0.5.3]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.2...v0.5.3
-[0.5.2]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.1...v0.5.2
-[0.5.1]: https://github.com/freema/firefox-devtools-mcp/compare/v0.5.0...v0.5.1
-[0.5.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.4.0...v0.5.0
-[0.4.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.3.0...v0.4.0
-[0.3.0]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.5...v0.3.0
-[0.2.5]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.3...v0.2.5
-[0.2.3]: https://github.com/freema/firefox-devtools-mcp/compare/v0.2.0...v0.2.3
-[0.2.0]: https://github.com/freema/firefox-devtools-mcp/releases/tag/v0.2.0
+[0.7.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.7.0...v0.7.1
+[0.7.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.6.1...v0.7.0
+[0.6.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.6.0...v0.6.1
+[0.5.3]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.2...v0.5.3
+[0.5.2]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.1...v0.5.2
+[0.5.1]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.5.0...v0.5.1
+[0.5.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.4.0...v0.5.0
+[0.4.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.3.0...v0.4.0
+[0.3.0]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.5...v0.3.0
+[0.2.5]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.3...v0.2.5
+[0.2.3]: https://github.com/mozilla/firefox-devtools-mcp/compare/v0.2.0...v0.2.3
+[0.2.0]: https://github.com/mozilla/firefox-devtools-mcp/releases/tag/v0.2.0
diff --git a/README.md b/README.md
index 4196ea6..0b3c0b2 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,15 @@
# Firefox DevTools MCP
[](https://www.npmjs.com/package/firefox-devtools-mcp)
-[](https://github.com/freema/firefox-devtools-mcp/actions/workflows/ci.yml)
+[](https://github.com/mozilla/firefox-devtools-mcp/actions/workflows/ci.yml)
[](https://codecov.io/gh/freema/firefox-devtools-mcp)
[](https://opensource.org/licenses/MIT)
-
+
Model Context Protocol server for automating Firefox via WebDriver BiDi (through Selenium WebDriver). Works with Claude Code, Claude Desktop, Cursor, Cline and other MCP clients.
-Repository: https://github.com/freema/firefox-devtools-mcp
+Repository: https://github.com/mozilla/firefox-devtools-mcp
> **Note**: This MCP server requires a local Firefox browser installation and cannot run on cloud hosting services like glama.ai. Use `npx firefox-devtools-mcp@latest` to run locally, or use Docker with the provided Dockerfile.
@@ -95,6 +95,11 @@ You can pass flags or environment variables (names on the right):
- `--accept-insecure-certs` — ignore TLS errors (`ACCEPT_INSECURE_CERTS=true`)
- `--connect-existing` — attach to an already-running Firefox instead of launching a new one (`CONNECT_EXISTING=true`)
- `--marionette-port` — Marionette port for connect-existing mode, default 2828 (`MARIONETTE_PORT`)
+- `--pref name=value` — set Firefox preference at startup via `moz:firefoxOptions` (repeatable)
+- `--enable-script` — enable the `evaluate_script` tool, which executes arbitrary JavaScript in the page context (`ENABLE_SCRIPT=true`)
+- `--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`)
+
+> **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.
### Connect to existing Firefox
@@ -126,6 +131,10 @@ BiDi-dependent features (console events, network events) are not available in co
- Network: list/get (ID‑first, filters, always‑on capture)
- Console: list/clear
- Screenshot: page/by uid (with optional `saveTo` for CLI environments)
+- Script: evaluate_script
+- Privileged Context: list/select privileged ("chrome") contexts, evaluate_privileged_script (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`)
+- WebExtension: install_extension, uninstall_extension, list_extensions (list requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`)
+- Firefox Management: get_firefox_info, get_firefox_output, restart_firefox, set_firefox_prefs, get_firefox_prefs
- Utilities: accept/dismiss dialog, history back/forward, set viewport
### Screenshot optimization for Claude Code
@@ -195,4 +204,4 @@ npm run inspector:dev
## Author
-Created by [Tomáš Grasl](https://www.tomasgrasl.cz/)
+Created by [Tomáš Grasl](https://www.tomasgrasl.cz/), maintained by [Mozilla](https://www.mozilla.org).
diff --git a/docs/ci-and-release.md b/docs/ci-and-release.md
index 6d6c018..f81dd56 100644
--- a/docs/ci-and-release.md
+++ b/docs/ci-and-release.md
@@ -39,7 +39,7 @@ Release flow
Windows Integration Tests
- On Windows, vitest has known issues with process forking when running integration tests that spawn Firefox.
-- See: https://github.com/freema/firefox-devtools-mcp/issues/33
+- See: https://github.com/mozilla/firefox-devtools-mcp/issues/33
- To work around this, we use a separate test runner (`scripts/run-integration-tests-windows.mjs`) that runs integration tests directly via Node.js without vitest's process isolation.
- The CI workflow detects Windows and automatically uses this runner instead of vitest for integration tests.
- Unit tests still run via vitest on all platforms.
diff --git a/docs/firefox-client.md b/docs/firefox-client.md
index a2c47c0..016e931 100644
--- a/docs/firefox-client.md
+++ b/docs/firefox-client.md
@@ -141,7 +141,9 @@ Selenium automatically manages Firefox through geckodriver:
--firefox-path # Firefox executable path
--headless # Run Firefox headless
--viewport # Set viewport size (e.g., 1280x720)
+--profile-path # Firefox profile path
--start-url # Initial URL to navigate to
+--pref # Set Firefox preference (repeatable, requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1)
```
**Environment Variables:**
@@ -151,9 +153,44 @@ START_URL=https://example.com
```
**Profile Management:**
-- Selenium creates temporary profiles automatically
-- Custom profile support via `firefoxOptions.setProfile()`
-- Automatic cleanup on shutdown
+- Use `--profile-path` to specify a Firefox profile directory
+- Profile is loaded in-place via Firefox's native `--profile` argument (not copied to temp)
+- Runtime profile changes supported via `restart_firefox` tool's `profilePath` parameter
+
+### Firefox Preferences
+
+When Firefox runs in WebDriver BiDi mode (automated testing), it applies [RecommendedPreferences](https://searchfox.org/firefox-main/source/remote/shared/RecommendedPreferences.sys.mjs) that change default behavior for test reliability. The `--pref` option and preference tools allow overriding these when needed.
+
+**Use cases:**
+- Firefox development and debugging
+- Testing scenarios requiring production-like behavior
+- Enabling specific features disabled by RecommendedPreferences
+
+**Example:** The `browser.ml.enable` preference controls Firefox's ML/AI features. RecommendedPreferences disables this by default, making it impossible to use this MCP server to develop or test AI-powered features like Smart Window without explicitly enabling it.
+
+**Setting preferences:**
+
+At startup via CLI:
+```bash
+# Enable ML/AI features like Smart Window
+npx firefox-devtools-mcp --pref "browser.ml.enable=true"
+```
+
+At runtime via tools (requires `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1`):
+```javascript
+// Set preferences (e.g., enable ML features)
+await set_firefox_prefs({ prefs: { "browser.ml.enable": true } });
+
+// Get preference values
+await get_firefox_prefs({ names: ["browser.ml.enable"] });
+
+// Via restart_firefox
+await restart_firefox({ prefs: { "browser.ml.enable": true } });
+```
+
+**Note:** Preference tools require `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` environment variable.
+
+**Preference persistence:** Preferences set via CLI or `restart_firefox` are preserved across restarts. When `restart_firefox` is called without a `prefs` parameter, existing preferences are re-applied automatically.
## Available Tools
@@ -188,6 +225,22 @@ The server provides comprehensive browser automation tools:
| `stop_network_monitoring` | Disable network capture | ✅ Implemented |
| `performance_get_metrics` | Get timing metrics | ✅ Via `performance` API |
+### Firefox Management
+
+| Tool | Description | Parameters |
+|------|-------------|------------|
+| `get_firefox_info` | Get current Firefox configuration | (none) |
+| `get_firefox_output` | Get Firefox stdout/stderr/MOZ_LOG output | `lines`, `grep`, `since` |
+| `restart_firefox` | Restart or configure Firefox | `firefoxPath`, `profilePath`, `env`, `headless`, `startUrl`, `prefs` |
+| `set_firefox_prefs` | Set Firefox preferences at runtime | `prefs` (object) |
+| `get_firefox_prefs` | Get Firefox preference values | `names` (array) |
+
+**Note:** `set_firefox_prefs` and `get_firefox_prefs` require `MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1` environment variable.
+
+**Note:** `restart_firefox` works in two modes:
+- If Firefox is running: closes and restarts with new configuration
+- If Firefox is not running: configures options for next tool call that triggers launch
+
✅ = Fully implemented
## Migration from RDP
diff --git a/docs/future-features.md b/docs/future-features.md
deleted file mode 100644
index 35c8257..0000000
--- a/docs/future-features.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# Future Features
-
-This document tracks features that are planned or considered for future implementation but are currently disabled or not yet implemented.
-
-## Disabled Features
-
-### `evaluate_script` Tool
-
-**Status:** Temporarily Disabled
-**Reason:** Needs further consideration for security and use case validation
-**Implementation:** Fully implemented in `src/tools/script.ts`
-
-#### Description
-
-The `evaluate_script` tool allows executing arbitrary JavaScript functions inside the currently selected browser page. It supports:
-
-- Synchronous and async functions
-- Passing arguments via UIDs from snapshots
-- Timeout protection against infinite loops
-- JSON-serializable return values
-
-#### Example Usage
-
-```json
-{
- "function": "() => document.title",
- "timeout": 5000
-}
-```
-
-```json
-{
- "function": "(el) => el.innerText",
- "args": [{ "uid": "abc123" }]
-}
-```
-
-#### Security Considerations
-
-This tool allows executing arbitrary JavaScript in the browser context, which requires careful consideration:
-
-1. **Sandboxing:** Scripts run in the page context with full DOM access
-2. **Timeout Protection:** Default 5s timeout prevents infinite loops
-3. **Size Limits:** Functions limited to 16KB
-4. **Return Values:** Must be JSON-serializable
-
-#### Future Work
-
-Before re-enabling this tool, consider:
-
-- [ ] Add explicit security warnings in tool description
-- [ ] Document safe usage patterns and anti-patterns
-- [ ] Consider adding a "safe mode" with restricted APIs
-- [ ] Add example use cases to documentation
-- [ ] Evaluate if snapshot + UID-based tools cover most use cases
-
-#### Re-enabling
-
-To re-enable this tool:
-
-1. Uncomment exports in `src/tools/index.ts`
-2. Uncomment handler registration in `src/index.ts`
-3. Uncomment tool definition in `src/index.ts` allTools array
-4. Update documentation with security guidelines
-5. Run tests: `npm test -- script`
-
----
-
-## Planned Features
-
-### BiDi Native Tab Management
-
-**Status:** Planned
-**Priority:** High
-
-Currently, tab management uses Selenium WebDriver's window handles. Future versions should use Firefox BiDi's native `browsingContext` API for:
-
-- Better performance
-- More reliable tab metadata
-- Real-time tab updates
-- Window management
-
-**Implementation Location:** `src/firefox/pages.ts`
-
-### Console Message Filtering by Source
-
-**Status:** Planned
-**Priority:** Medium
-
-Add ability to filter console messages by source (realm/context):
-
-```json
-{
- "level": "error",
- "source": "worker",
- "limit": 10
-}
-```
-
-**Implementation Location:** `src/tools/console.ts`
-
-### Network Request Body Capture
-
-**Status:** Planned
-**Priority:** Medium
-
-Capture and expose request/response bodies for network requests:
-
-- POST/PUT request bodies
-- Response bodies (with size limits)
-- Binary data handling
-
-**Implementation Location:** `src/firefox/events/network.ts`
-
-### Advanced Performance Profiling
-
-**Status:** Planned
-**Priority:** Low
-**Note:** Basic performance metrics are available via Navigation Timing API (not exposed as MCP tools)
-
-Full performance profiling support would include:
-
-- CPU profiling
-- Memory snapshots
-- FPS monitoring
-- Long task detection
-- Custom performance marks and measures
-
-**Reason for deferral:** WebDriver BiDi does not currently provide advanced profiling APIs. Use Firefox DevTools UI Performance panel for advanced profiling.
-
-**Previous implementation:** Performance tools were removed in PERFORMANCE-01 task due to limited BiDi support and complexity for minimal value.
-
----
-
-## Rejected Features
-
-None yet.
-
----
-
-## Contributing
-
-Have an idea for a new feature? Please:
-
-1. Check this document first
-2. Open an issue with the `feature-request` label
-3. Describe the use case and benefits
-4. Consider security and performance implications
diff --git a/package-lock.json b/package-lock.json
index 2bfe1f8..8c43d59 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "firefox-devtools-mcp",
- "version": "0.7.4",
+ "name": "@padenot/firefox-devtools-mcp",
+ "version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "firefox-devtools-mcp",
- "version": "0.7.4",
+ "name": "@padenot/firefox-devtools-mcp",
+ "version": "0.8.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.17.1",
diff --git a/package.json b/package.json
index fd8e69e..c881d1c 100644
--- a/package.json
+++ b/package.json
@@ -95,12 +95,12 @@
],
"repository": {
"type": "git",
- "url": "git+https://github.com/freema/firefox-devtools-mcp.git"
+ "url": "git+https://github.com/mozilla/firefox-devtools-mcp.git"
},
"bugs": {
- "url": "https://github.com/freema/firefox-devtools-mcp/issues"
+ "url": "https://github.com/mozilla/firefox-devtools-mcp/issues"
},
- "homepage": "https://github.com/freema/firefox-devtools-mcp#readme",
+ "homepage": "https://github.com/mozilla/firefox-devtools-mcp#readme",
"publishConfig": {
"access": "public"
}
diff --git a/plugins/claude/firefox-devtools/.claude-plugin/plugin.json b/plugins/claude/firefox-devtools/.claude-plugin/plugin.json
index fc91bdb..e9b838d 100644
--- a/plugins/claude/firefox-devtools/.claude-plugin/plugin.json
+++ b/plugins/claude/firefox-devtools/.claude-plugin/plugin.json
@@ -6,6 +6,6 @@
"name": "Tomáš Grasl",
"url": "https://www.tomasgrasl.cz/"
},
- "repository": "https://github.com/freema/firefox-devtools-mcp",
+ "repository": "https://github.com/mozilla/firefox-devtools-mcp",
"license": "MIT"
}
diff --git a/plugins/claude/firefox-devtools/README.md b/plugins/claude/firefox-devtools/README.md
index 5b89a6b..7919c50 100644
--- a/plugins/claude/firefox-devtools/README.md
+++ b/plugins/claude/firefox-devtools/README.md
@@ -78,5 +78,5 @@ The plugin works automatically when you ask about browser tasks:
## Links
-- [Repository](https://github.com/freema/firefox-devtools-mcp)
+- [Repository](https://github.com/mozilla/firefox-devtools-mcp)
- [npm](https://www.npmjs.com/package/firefox-devtools-mcp)
diff --git a/scripts/run-integration-tests-windows.mjs b/scripts/run-integration-tests-windows.mjs
index 96448ff..41583e5 100644
--- a/scripts/run-integration-tests-windows.mjs
+++ b/scripts/run-integration-tests-windows.mjs
@@ -3,7 +3,7 @@
* Windows Integration Tests Runner
*
* Runs integration tests directly via node to avoid vitest fork issues on Windows.
- * See: https://github.com/freema/firefox-devtools-mcp/issues/33
+ * See: https://github.com/mozilla/firefox-devtools-mcp/issues/33
*/
import { FirefoxDevTools } from '../dist/index.js';
@@ -94,7 +94,7 @@ async function snapshotTests() {
await test('should take snapshot', async () => {
const fixturePath = `file://${fixturesPath}/simple.html`;
await firefox.navigate(fixturePath);
- await new Promise(r => setTimeout(r, 500));
+ await new Promise((r) => setTimeout(r, 500));
const snapshot = await firefox.takeSnapshot();
assert(snapshot, 'snapshot should exist');
diff --git a/src/cli.ts b/src/cli.ts
index f1b4dad..db0b4df 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -6,6 +6,50 @@ import type { Options as YargsOptions } from 'yargs';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
+/**
+ * Parsed preference value (boolean, integer, or string)
+ */
+export type PrefValue = string | number | boolean;
+
+/**
+ * Parse preference strings into typed values
+ * Format: "name=value" where value is auto-typed as boolean/integer/string
+ */
+export function parsePrefs(prefs: string[] | undefined): Record {
+ const result: Record = {};
+
+ if (!prefs || prefs.length === 0) {
+ return result;
+ }
+
+ for (const pref of prefs) {
+ const eqIndex = pref.indexOf('=');
+ if (eqIndex === -1) {
+ // Skip malformed entries (no equals sign)
+ continue;
+ }
+
+ const name = pref.slice(0, eqIndex);
+ const rawValue = pref.slice(eqIndex + 1);
+
+ // Type inference
+ let value: PrefValue;
+ if (rawValue === 'true') {
+ value = true;
+ } else if (rawValue === 'false') {
+ value = false;
+ } else if (/^-?\d+$/.test(rawValue)) {
+ value = parseInt(rawValue, 10);
+ } else {
+ value = rawValue;
+ }
+
+ result[name] = value;
+ }
+
+ return result;
+}
+
export const cliOptions = {
firefoxPath: {
type: 'string',
@@ -66,6 +110,35 @@ export const cliOptions = {
description: 'Marionette port to connect to when using --connect-existing (default: 2828)',
default: Number(process.env.MARIONETTE_PORT ?? '2828'),
},
+ env: {
+ type: 'array',
+ description:
+ 'Environment variables for Firefox in KEY=VALUE format. Can be specified multiple times. Example: --env MOZ_LOG=HTMLMediaElement:4',
+ },
+ outputFile: {
+ type: 'string',
+ description:
+ 'Path to file where Firefox output (stdout/stderr) will be written. If not specified, output is written to ~/.firefox-devtools-mcp/output/',
+ },
+ pref: {
+ type: 'array',
+ string: true,
+ description:
+ 'Set Firefox preference at startup via moz:firefoxOptions (format: name=value). Can be specified multiple times.',
+ alias: 'p',
+ },
+ enableScript: {
+ type: 'boolean',
+ description:
+ 'Enable the evaluate_script tool, which allows executing arbitrary JavaScript in the page context.',
+ default: (process.env.ENABLE_SCRIPT ?? 'false') === 'true',
+ },
+ enablePrivilegedContext: {
+ type: 'boolean',
+ description:
+ '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.',
+ default: (process.env.ENABLE_PRIVILEGED_CONTEXT ?? 'false') === 'true',
+ },
} satisfies Record;
export function parseArguments(version: string, argv = process.argv) {
diff --git a/src/firefox/core.ts b/src/firefox/core.ts
index 0c68d86..0e177ad 100644
--- a/src/firefox/core.ts
+++ b/src/firefox/core.ts
@@ -5,6 +5,9 @@
import { Builder, Browser } from 'selenium-webdriver';
import firefox from 'selenium-webdriver/firefox.js';
import { spawn, type ChildProcess } from 'node:child_process';
+import { mkdirSync, openSync, closeSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
import type { FirefoxLaunchOptions } from './types.js';
import { log, logDebug } from '../utils/logger.js';
@@ -22,6 +25,17 @@ export interface IElement {
takeScreenshot(): Promise;
}
+export interface IBiDiSocket {
+ readyState: number;
+ on(event: string, listener: (data: unknown) => void): void;
+ off(event: string, listener: (data: unknown) => void): void;
+ send(data: string): void;
+}
+
+export interface IBiDi {
+ socket: IBiDiSocket;
+}
+
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface IDriver {
getTitle(): Promise;
@@ -62,6 +76,7 @@ export interface IDriver {
perform(): Promise;
clear(): Promise;
};
+ getBidi(): Promise;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
@@ -419,6 +434,10 @@ class GeckodriverHttpDriver implements IDriver {
kill(): void {
this.gdProcess.kill();
}
+
+ getBidi(): Promise {
+ throw new Error('BiDi not available in connect-existing mode');
+ }
}
// ---------------------------------------------------------------------------
@@ -463,6 +482,9 @@ function findGeckodriverInCache(
export class FirefoxCore {
private driver: IDriver | null = null;
private currentContextId: string | null = null;
+ private originalEnv: Record = {};
+ private logFilePath: string | undefined;
+ private logFileFd: number | undefined;
constructor(private options: FirefoxLaunchOptions) {}
@@ -483,6 +505,31 @@ export class FirefoxCore {
const port = this.options.marionettePort ?? 2828;
this.driver = await GeckodriverHttpDriver.connect(port);
} else {
+ // Set up output file for capturing Firefox stdout/stderr
+ if (this.options.logFile) {
+ this.logFilePath = this.options.logFile;
+ } else if (this.options.env && Object.keys(this.options.env).length > 0) {
+ const outputDir = join(homedir(), '.firefox-devtools-mcp', 'output');
+ mkdirSync(outputDir, { recursive: true });
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ this.logFilePath = join(outputDir, `firefox-${timestamp}.log`);
+ }
+
+ // Set environment variables (will be inherited by geckodriver -> Firefox)
+ if (this.options.env) {
+ for (const [key, value] of Object.entries(this.options.env)) {
+ this.originalEnv[key] = process.env[key];
+ process.env[key] = value;
+ logDebug(`Set env ${key}=${value}`);
+ }
+
+ // Important: Do NOT set MOZ_LOG_FILE - MOZ_LOG writes to stderr by default
+ // We capture stderr directly through file descriptor redirection
+ if (this.options.env.MOZ_LOG_FILE) {
+ logDebug('Note: MOZ_LOG_FILE in env will be used, but may be blocked by sandbox');
+ }
+ }
+
// Standard path: launch a new Firefox via selenium-webdriver
const firefoxOptions = new firefox.Options();
firefoxOptions.enableBidi();
@@ -503,16 +550,41 @@ export class FirefoxCore {
firefoxOptions.addArguments(...this.options.args);
}
if (this.options.profilePath) {
- firefoxOptions.setProfile(this.options.profilePath);
+ // Use Firefox's native --profile argument for reliable profile loading
+ // (Selenium's setProfile() copies to temp dir which can be unreliable)
+ firefoxOptions.addArguments('--profile', this.options.profilePath);
+ log(`📁 Using Firefox profile: ${this.options.profilePath}`);
}
if (this.options.acceptInsecureCerts) {
firefoxOptions.setAcceptInsecureCerts(true);
}
+ if (this.options.prefs) {
+ for (const [name, value] of Object.entries(this.options.prefs)) {
+ firefoxOptions.setPreference(name, value);
+ }
+ }
+
+ // Configure geckodriver service to capture output
+ const serviceBuilder = new firefox.ServiceBuilder();
+
+ // If we have a log file, open it and redirect geckodriver output there
+ // This captures both geckodriver logs and Firefox stderr (including MOZ_LOG)
+ if (this.logFilePath) {
+ // Open file for appending, create if doesn't exist
+ this.logFileFd = openSync(this.logFilePath, 'a');
+
+ // Configure stdio: stdin=ignore, stdout=logfile, stderr=logfile
+ // This redirects all output from geckodriver and Firefox to the log file
+ serviceBuilder.setStdio(['ignore', this.logFileFd, this.logFileFd]);
+
+ log(`📝 Capturing Firefox output to: ${this.logFilePath}`);
+ }
// selenium WebDriver satisfies IDriver structurally at runtime
this.driver = (await new Builder()
.forBrowser(Browser.FIREFOX)
.setFirefoxOptions(firefoxOptions)
+ .setFirefoxService(serviceBuilder)
.build()) as unknown as IDriver;
}
@@ -589,6 +661,99 @@ export class FirefoxCore {
this.currentContextId = contextId;
}
+ /**
+ * Get log file path
+ */
+ getLogFilePath(): string | undefined {
+ return this.logFilePath;
+ }
+
+ /**
+ * Get current launch options
+ */
+ getOptions(): FirefoxLaunchOptions {
+ return this.options;
+ }
+
+ /**
+ * Wait for WebSocket to be in OPEN state
+ */
+ private async waitForWebSocketOpen(ws: any, timeout: number = 5000): Promise {
+ // Already open
+ if (ws.readyState === 1) {
+ return;
+ }
+
+ // Still connecting - wait for open event with timeout
+ if (ws.readyState === 0) {
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ ws.off('open', onOpen);
+ reject(new Error('Timeout waiting for WebSocket to open'));
+ }, timeout);
+
+ const onOpen = () => {
+ clearTimeout(timeoutId);
+ ws.off('open', onOpen);
+ resolve();
+ };
+ ws.on('open', onOpen);
+ });
+ }
+
+ throw new Error(`WebSocket is not open: readyState ${ws.readyState}`);
+ }
+
+ /**
+ * Send raw BiDi command and get response
+ */
+ async sendBiDiCommand(method: string, params: Record = {}): Promise {
+ if (!this.driver) {
+ throw new Error('Driver not connected');
+ }
+
+ const bidi = await this.driver.getBidi();
+ const ws = bidi.socket;
+
+ // Wait for WebSocket to be ready before sending
+ await this.waitForWebSocketOpen(ws);
+
+ const id = Math.floor(Math.random() * 1000000);
+
+ return new Promise((resolve, reject) => {
+ const messageHandler = (data: any) => {
+ try {
+ const payload = JSON.parse(data.toString());
+ if (payload.id === id) {
+ ws.off('message', messageHandler);
+ if (payload.error) {
+ reject(new Error(`BiDi error: ${JSON.stringify(payload.error)}`));
+ } else {
+ resolve(payload.result);
+ }
+ }
+ } catch (err) {
+ // ignore parse errors
+ }
+ };
+
+ ws.on('message', messageHandler);
+
+ const command = {
+ id,
+ method,
+ params,
+ };
+
+ ws.send(JSON.stringify(command));
+
+ setTimeout(() => {
+ ws.off('message', messageHandler);
+ reject(new Error(`BiDi command timeout: ${method}`));
+ }, 10000);
+ });
+ }
+
/**
* Close driver and cleanup.
* When connected to an existing Firefox instance, only kills geckodriver
@@ -603,6 +768,30 @@ export class FirefoxCore {
}
this.driver = null;
}
+
+ // Close log file descriptor if open
+ if (this.logFileFd !== undefined) {
+ try {
+ closeSync(this.logFileFd);
+ logDebug('Log file closed');
+ } catch (error) {
+ logDebug(
+ `Error closing log file: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ this.logFileFd = undefined;
+ }
+
+ // Restore original environment variables
+ for (const [key, value] of Object.entries(this.originalEnv)) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+ this.originalEnv = {};
+
log('✅ Firefox DevTools closed');
}
}
diff --git a/src/firefox/index.ts b/src/firefox/index.ts
index 5df9e1c..d5460be 100644
--- a/src/firefox/index.ts
+++ b/src/firefox/index.ts
@@ -374,6 +374,14 @@ export class FirefoxClient {
// Internal / Advanced
// ============================================================================
+ /**
+ * Send raw BiDi command (for advanced operations)
+ * @internal
+ */
+ async sendBiDiCommand(method: string, params: Record = {}): Promise {
+ return await this.core.sendBiDiCommand(method, params);
+ }
+
/**
* Get WebDriver instance (for advanced operations)
* @internal
@@ -382,6 +390,14 @@ export class FirefoxClient {
return this.core.getDriver();
}
+ /**
+ * Get current browsing context ID (for advanced operations)
+ * @internal
+ */
+ getCurrentContextId(): string | null {
+ return this.core.getCurrentContextId();
+ }
+
/**
* Check if Firefox is still connected and responsive
* Returns false if Firefox was closed or connection is broken
@@ -390,6 +406,20 @@ export class FirefoxClient {
return await this.core.isConnected();
}
+ /**
+ * Get log file path (if logging is enabled)
+ */
+ getLogFilePath(): string | undefined {
+ return this.core.getLogFilePath();
+ }
+
+ /**
+ * Get current launch options
+ */
+ getOptions(): FirefoxLaunchOptions {
+ return this.core.getOptions();
+ }
+
/**
* Reset all internal state (used when Firefox is detected as closed)
*/
diff --git a/src/firefox/pref-utils.ts b/src/firefox/pref-utils.ts
new file mode 100644
index 0000000..6d9f0a8
--- /dev/null
+++ b/src/firefox/pref-utils.ts
@@ -0,0 +1,22 @@
+/**
+ * Firefox preference utilities
+ * Helper functions for working with Services.prefs API
+ */
+
+/**
+ * Generate a Services.prefs.set*Pref script for a given preference name and value
+ * Uses setBoolPref for booleans, setIntPref for numbers, setStringPref for strings
+ */
+export function generatePrefScript(name: string, value: string | number | boolean): string {
+ // Escape quotes in the name
+ const escapedName = JSON.stringify(name);
+
+ if (typeof value === 'boolean') {
+ return `Services.prefs.setBoolPref(${escapedName}, ${value})`;
+ } else if (typeof value === 'number') {
+ return `Services.prefs.setIntPref(${escapedName}, ${value})`;
+ } else {
+ // String value - JSON.stringify handles escaping
+ return `Services.prefs.setStringPref(${escapedName}, ${JSON.stringify(value)})`;
+ }
+}
diff --git a/src/firefox/types.ts b/src/firefox/types.ts
index 688b62d..26e6856 100644
--- a/src/firefox/types.ts
+++ b/src/firefox/types.ts
@@ -60,6 +60,10 @@ export interface FirefoxLaunchOptions {
acceptInsecureCerts?: boolean | undefined;
connectExisting?: boolean | undefined;
marionettePort?: number | undefined;
+ env?: Record | undefined;
+ logFile?: string | undefined;
+ /** Firefox preferences to set at startup via moz:firefoxOptions */
+ prefs?: Record | undefined;
}
/**
diff --git a/src/index.ts b/src/index.ts
index c07225b..0fd6dfb 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -34,7 +34,7 @@ import {
import { SERVER_NAME, SERVER_VERSION } from './config/constants.js';
import { log, logError, logDebug } from './utils/logger.js';
-import { parseArguments } from './cli.js';
+import { parseArguments, parsePrefs } from './cli.js';
import { FirefoxDevTools } from './firefox/index.js';
import type { FirefoxLaunchOptions } from './firefox/types.js';
import * as tools from './tools/index.js';
@@ -57,6 +57,7 @@ export const args = parseArguments(SERVER_VERSION);
// Global context (lazy initialized on first tool call)
let firefox: FirefoxDevTools | null = null;
+let nextLaunchOptions: FirefoxLaunchOptions | null = null;
/**
* Reset Firefox instance (used when disconnection is detected)
@@ -69,6 +70,29 @@ export function resetFirefox(): void {
log('Firefox instance reset - will reconnect on next tool call');
}
+/**
+ * Set options for the next Firefox launch
+ * Used by restart_firefox tool to change configuration
+ */
+export function setNextLaunchOptions(options: FirefoxLaunchOptions): void {
+ nextLaunchOptions = options;
+ log('Next launch options updated');
+}
+
+/**
+ * Check if Firefox is currently running (without auto-starting)
+ */
+export function isFirefoxRunning(): boolean {
+ return firefox !== null;
+}
+
+/**
+ * Get Firefox instance if running, null otherwise (no auto-start)
+ */
+export function getFirefoxIfRunning(): FirefoxDevTools | null {
+ return firefox;
+}
+
export async function getFirefox(): Promise {
// If we have an existing instance, verify it's still connected
if (firefox) {
@@ -84,23 +108,56 @@ export async function getFirefox(): Promise {
// No existing instance - create new connection
log('Initializing Firefox DevTools connection...');
- const options: FirefoxLaunchOptions = {
- firefoxPath: args.firefoxPath ?? undefined,
- headless: args.headless,
- profilePath: args.profilePath ?? undefined,
- viewport: args.viewport ?? undefined,
- args: (args.firefoxArg as string[] | undefined) ?? undefined,
- startUrl: args.startUrl ?? undefined,
- acceptInsecureCerts: args.acceptInsecureCerts,
- connectExisting: args.connectExisting,
- marionettePort: args.marionettePort,
- };
+ let options: FirefoxLaunchOptions;
+
+ // Use nextLaunchOptions if set (from restart_firefox tool)
+ if (nextLaunchOptions) {
+ options = nextLaunchOptions;
+ nextLaunchOptions = null; // Clear after use
+ log('Using custom launch options from restart_firefox');
+ } else {
+ // Parse environment variables from CLI args (format: KEY=VALUE)
+ let envVars: Record | undefined;
+ if (args.env && Array.isArray(args.env) && args.env.length > 0) {
+ envVars = {};
+ for (const envStr of args.env as string[]) {
+ const [key, ...valueParts] = envStr.split('=');
+ if (key && valueParts.length > 0) {
+ envVars[key] = valueParts.join('=');
+ }
+ }
+ }
+
+ // Parse preferences from CLI args
+ const prefValues = parsePrefs(args.pref);
+ const prefs = Object.keys(prefValues).length > 0 ? prefValues : undefined;
+
+ options = {
+ firefoxPath: args.firefoxPath ?? undefined,
+ headless: args.headless,
+ profilePath: args.profilePath ?? undefined,
+ viewport: args.viewport ?? undefined,
+ args: (args.firefoxArg as string[] | undefined) ?? undefined,
+ startUrl: args.startUrl ?? undefined,
+ acceptInsecureCerts: args.acceptInsecureCerts,
+ connectExisting: args.connectExisting,
+ marionettePort: args.marionettePort,
+ env: envVars,
+ logFile: args.outputFile ?? undefined,
+ prefs,
+ };
+ }
firefox = new FirefoxDevTools(options);
- await firefox.connect();
- log('Firefox DevTools connection established');
-
- return firefox;
+ try {
+ await firefox.connect();
+ log('Firefox DevTools connection established');
+ return firefox;
+ } catch (error) {
+ // Connection failed, clean up the failed instance
+ firefox = null;
+ throw error;
+ }
}
// Tool handler mapping
@@ -112,9 +169,6 @@ const toolHandlers = new Map Promise Promise since) {
+ return successResponse(
+ `Output file is ${Math.floor(ageSeconds)}s old, but only output from last ${since}s was requested. File may not have recent entries.`
+ );
+ }
+ }
+
+ // Read output file
+ const content = readFileSync(logFilePath, 'utf-8');
+ let allLines = content.split('\n').filter((line) => line.trim().length > 0);
+
+ // Apply grep filter
+ if (grep) {
+ const grepLower = grep.toLowerCase();
+ allLines = allLines.filter((line) => line.toLowerCase().includes(grepLower));
+ }
+
+ // Get last N lines
+ const maxLines = Math.min(lines, 10000);
+ const recentLines = allLines.slice(-maxLines);
+
+ const result = [
+ `📋 Firefox Output File: ${logFilePath}`,
+ `Total lines in file: ${allLines.length}`,
+ grep ? `Lines matching "${grep}": ${allLines.length}` : '',
+ `Showing last ${recentLines.length} lines:`,
+ '',
+ '─'.repeat(80),
+ recentLines.join('\n'),
+ ]
+ .filter(Boolean)
+ .join('\n');
+
+ return successResponse(result);
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
+
+// ============================================================================
+// Tool: get_firefox_info
+// ============================================================================
+
+export const getFirefoxInfoTool = {
+ name: 'get_firefox_info',
+ description:
+ 'Get information about the current Firefox instance configuration, including binary path, environment variables, and output file location.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ },
+};
+
+export async function handleGetFirefoxInfo(_input: unknown) {
+ try {
+ const firefox = await getFirefox();
+ const options = firefox.getOptions();
+ const logFilePath = firefox.getLogFilePath();
+
+ const info = [];
+ info.push('🦊 Firefox Instance Configuration');
+ info.push('');
+
+ info.push(`Binary: ${options.firefoxPath ?? 'System Firefox (default)'}`);
+ info.push(`Headless: ${options.headless ? 'Yes' : 'No'}`);
+
+ if (options.viewport) {
+ info.push(`Viewport: ${options.viewport.width}x${options.viewport.height}`);
+ }
+
+ if (options.profilePath) {
+ info.push(`Profile: ${options.profilePath}`);
+ }
+
+ if (options.startUrl) {
+ info.push(`Start URL: ${options.startUrl}`);
+ }
+
+ if (options.args && options.args.length > 0) {
+ info.push(`Arguments: ${options.args.join(' ')}`);
+ }
+
+ if (options.env && Object.keys(options.env).length > 0) {
+ info.push('');
+ info.push('Environment Variables:');
+ for (const [key, value] of Object.entries(options.env)) {
+ info.push(` ${key}=${value}`);
+ }
+ }
+
+ if (options.prefs && Object.keys(options.prefs).length > 0) {
+ info.push('');
+ info.push('Preferences:');
+ for (const [key, value] of Object.entries(options.prefs)) {
+ info.push(` ${key} = ${JSON.stringify(value)}`);
+ }
+ }
+
+ if (logFilePath) {
+ info.push('');
+ info.push(`Output File: ${logFilePath}`);
+ if (existsSync(logFilePath)) {
+ const stats = statSync(logFilePath);
+ const sizeMB = (stats.size / 1024 / 1024).toFixed(2);
+ info.push(` Size: ${sizeMB} MB`);
+ info.push(` Last Modified: ${stats.mtime.toISOString()}`);
+ } else {
+ info.push(' (file not created yet)');
+ }
+ }
+
+ return successResponse(info.join('\n'));
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
+
+// ============================================================================
+// Tool: restart_firefox
+// ============================================================================
+
+export const restartFirefoxTool = {
+ name: 'restart_firefox',
+ description:
+ 'Restart Firefox with different configuration. Allows changing binary path, environment variables, and other options. All current tabs will be closed.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ firefoxPath: {
+ type: 'string',
+ description: 'New Firefox binary path (optional, keeps current if not specified)',
+ },
+ profilePath: {
+ type: 'string',
+ description: 'Firefox profile path (optional, keeps current if not specified)',
+ },
+ env: {
+ type: 'array',
+ items: {
+ type: 'string',
+ },
+ description:
+ 'New environment variables in KEY=VALUE format (optional, e.g., ["MOZ_LOG=HTMLMediaElement:5", "MOZ_LOG_FILE=/tmp/ff.log"])',
+ },
+ headless: {
+ type: 'boolean',
+ description: 'Run in headless mode (optional, keeps current if not specified)',
+ },
+ startUrl: {
+ type: 'string',
+ description:
+ 'URL to navigate to after restart (optional, uses about:home if not specified)',
+ },
+ prefs: {
+ type: 'object',
+ description:
+ 'Firefox preferences to set at startup. Values are auto-typed: true/false become booleans, integers become numbers, everything else is a string. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1.',
+ additionalProperties: {
+ oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }],
+ },
+ },
+ },
+ },
+};
+
+export async function handleRestartFirefox(input: unknown) {
+ try {
+ const { firefoxPath, profilePath, env, headless, startUrl, prefs } = input as {
+ firefoxPath?: string;
+ profilePath?: string;
+ env?: string[];
+ headless?: boolean;
+ startUrl?: string;
+ prefs?: Record;
+ };
+
+ // This tool is designed to be robust and never get stuck:
+ // - Handles disconnected Firefox gracefully (resets stale reference)
+ // - Handles close() errors (we're restarting anyway)
+ // - Works both as initial start and restart
+ // - Always leaves system in a clean state for next tool call
+
+ // Parse new environment variables
+ let newEnv: Record | undefined;
+ if (env && Array.isArray(env) && env.length > 0) {
+ newEnv = {};
+ for (const envStr of env) {
+ const [key, ...valueParts] = envStr.split('=');
+ if (key && valueParts.length > 0) {
+ newEnv[key] = valueParts.join('=');
+ }
+ }
+ }
+
+ // Check if Firefox is currently running and connected
+ const currentFirefox = getFirefoxIfRunning();
+ const isConnected = currentFirefox ? await currentFirefox.isConnected() : false;
+
+ if (currentFirefox && isConnected) {
+ // Firefox is running - restart with new config
+ const currentOptions = currentFirefox.getOptions();
+
+ // Merge prefs: combine existing with new, new takes precedence
+ const mergedPrefs =
+ prefs !== undefined ? { ...(currentOptions.prefs || {}), ...prefs } : currentOptions.prefs;
+
+ // Merge with current options, preferring new values
+ const newOptions = {
+ ...currentOptions,
+ firefoxPath: firefoxPath ?? currentOptions.firefoxPath,
+ profilePath: profilePath ?? currentOptions.profilePath,
+ env: newEnv !== undefined ? newEnv : currentOptions.env,
+ headless: headless !== undefined ? headless : currentOptions.headless,
+ startUrl: startUrl ?? currentOptions.startUrl ?? 'about:home',
+ prefs: mergedPrefs,
+ };
+
+ // Set options for next launch
+ setNextLaunchOptions(newOptions);
+
+ // Close current instance (ignore errors - we're restarting anyway)
+ try {
+ await currentFirefox.close();
+ } catch (error) {
+ // Ignore close errors - we'll reset anyway
+ }
+ resetFirefox();
+
+ // Prepare change summary
+ const changes = [];
+ if (firefoxPath && firefoxPath !== currentOptions.firefoxPath) {
+ changes.push(`Binary: ${firefoxPath}`);
+ }
+ if (profilePath && profilePath !== currentOptions.profilePath) {
+ changes.push(`Profile: ${profilePath}`);
+ }
+ if (newEnv !== undefined && JSON.stringify(newEnv) !== JSON.stringify(currentOptions.env)) {
+ changes.push(`Environment variables updated:`);
+ for (const [key, value] of Object.entries(newEnv)) {
+ changes.push(` ${key}=${value}`);
+ }
+ }
+ if (headless !== undefined && headless !== currentOptions.headless) {
+ changes.push(`Headless: ${headless ? 'enabled' : 'disabled'}`);
+ }
+ if (startUrl && startUrl !== currentOptions.startUrl) {
+ changes.push(`Start URL: ${startUrl}`);
+ }
+
+ if (changes.length === 0) {
+ return successResponse(
+ '✅ Firefox closed. Will restart with same configuration on next tool call.'
+ );
+ }
+
+ return successResponse(
+ `✅ Firefox closed. Will restart with new configuration on next tool call:\n${changes.join('\n')}`
+ );
+ } else {
+ // Firefox not running (or disconnected) - configure for first start
+ if (currentFirefox) {
+ // Had a stale disconnected reference, clean it up
+ resetFirefox();
+ }
+
+ // Use provided firefoxPath, or fall back to CLI args if available
+ const resolvedFirefoxPath = firefoxPath ?? args.firefoxPath ?? undefined;
+
+ if (!resolvedFirefoxPath) {
+ return errorResponse(
+ new Error(
+ 'Firefox is not running and no firefoxPath provided. Please specify firefoxPath to start Firefox.'
+ )
+ );
+ }
+
+ const newOptions = {
+ firefoxPath: resolvedFirefoxPath,
+ profilePath: profilePath ?? args.profilePath ?? undefined,
+ env: newEnv,
+ headless: headless ?? false,
+ startUrl: startUrl ?? 'about:home',
+ };
+
+ setNextLaunchOptions(newOptions);
+
+ const config = [`Binary: ${resolvedFirefoxPath}`];
+ const resolvedProfilePath = profilePath ?? args.profilePath;
+ if (resolvedProfilePath) {
+ config.push(`Profile: ${resolvedProfilePath}`);
+ }
+ if (newEnv) {
+ config.push('Environment variables:');
+ for (const [key, value] of Object.entries(newEnv)) {
+ config.push(` ${key}=${value}`);
+ }
+ }
+ if (headless) {
+ config.push('Headless: enabled');
+ }
+ if (startUrl) {
+ config.push(`Start URL: ${startUrl}`);
+ }
+
+ return successResponse(
+ `✅ Firefox configured. Will start on next tool call:\n${config.join('\n')}`
+ );
+ }
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts
new file mode 100644
index 0000000..d7d478e
--- /dev/null
+++ b/src/tools/firefox-prefs.ts
@@ -0,0 +1,245 @@
+/**
+ * Firefox Preferences Tools
+ * Tools for getting and setting Firefox preferences via Services.prefs API
+ * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1
+ */
+
+import { successResponse, errorResponse } from '../utils/response-helpers.js';
+import { generatePrefScript } from '../firefox/pref-utils.js';
+import type { McpToolResponse } from '../types/common.js';
+
+// ============================================================================
+// Tool: set_firefox_prefs
+// ============================================================================
+
+export const setFirefoxPrefsTool = {
+ name: 'set_firefox_prefs',
+ description:
+ 'Set Firefox preferences at runtime a privileged API. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ prefs: {
+ type: 'object',
+ description:
+ 'Object mapping preference names to values. Values are auto-typed: true/false become booleans, integers become numbers, everything else is a string.',
+ additionalProperties: {
+ oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }],
+ },
+ },
+ },
+ required: ['prefs'],
+ },
+};
+
+export async function handleSetFirefoxPrefs(args: unknown): Promise {
+ try {
+ const { prefs } = args as { prefs: Record };
+
+ if (!prefs || typeof prefs !== 'object') {
+ throw new Error('prefs parameter is required and must be an object');
+ }
+
+ const prefEntries = Object.entries(prefs);
+ if (prefEntries.length === 0) {
+ return successResponse('No preferences to set');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ // Get privileged ("chrome") contexts
+ const result = await firefox.sendBiDiCommand('browsingContext.getTree', {
+ 'moz:scope': 'chrome',
+ });
+
+ const contexts = result.contexts || [];
+ if (contexts.length === 0) {
+ throw new Error(
+ 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.'
+ );
+ }
+
+ const driver = firefox.getDriver();
+ const chromeContextId = contexts[0].context;
+
+ // Remember current context
+ const originalContextId = firefox.getCurrentContextId();
+
+ try {
+ // Switch to chrome context
+ await driver.switchTo().window(chromeContextId);
+ await driver.setContext('chrome');
+
+ const results: string[] = [];
+ const errors: string[] = [];
+
+ // Set each preference
+ for (const [name, value] of prefEntries) {
+ try {
+ const script = generatePrefScript(name, value);
+ await driver.executeScript(script);
+ results.push(` ${name} = ${JSON.stringify(value)}`);
+ } catch (error) {
+ errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ const output: string[] = [];
+ if (results.length > 0) {
+ output.push(`✅ Set ${results.length} preference(s):`);
+ output.push(...results);
+ }
+ if (errors.length > 0) {
+ output.push(`\n⚠️ Failed to set ${errors.length} preference(s):`);
+ output.push(...errors);
+ }
+
+ return successResponse(output.join('\n'));
+ } finally {
+ // Restore content context
+ try {
+ await driver.setContext('content');
+ if (originalContextId) {
+ await driver.switchTo().window(originalContextId);
+ }
+ } catch {
+ // Ignore errors restoring context
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('UnsupportedOperationError')) {
+ return errorResponse(
+ new Error(
+ 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.'
+ )
+ );
+ }
+ return errorResponse(error as Error);
+ }
+}
+
+// ============================================================================
+// Tool: get_firefox_prefs
+// ============================================================================
+
+export const getFirefoxPrefsTool = {
+ name: 'get_firefox_prefs',
+ description:
+ 'Get Firefox preference values via a privileged API. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ names: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Array of preference names to read',
+ },
+ },
+ required: ['names'],
+ },
+};
+
+export async function handleGetFirefoxPrefs(args: unknown): Promise {
+ try {
+ const { names } = args as { names: string[] };
+
+ if (!names || !Array.isArray(names) || names.length === 0) {
+ throw new Error('names parameter is required and must be a non-empty array');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ // Get privileged ("chrome") contexts
+ const result = await firefox.sendBiDiCommand('browsingContext.getTree', {
+ 'moz:scope': 'chrome',
+ });
+
+ const contexts = result.contexts || [];
+ if (contexts.length === 0) {
+ throw new Error(
+ 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.'
+ );
+ }
+
+ const driver = firefox.getDriver();
+ const chromeContextId = contexts[0].context;
+
+ // Remember current context
+ const originalContextId = firefox.getCurrentContextId();
+
+ try {
+ // Switch to chrome context
+ await driver.switchTo().window(chromeContextId);
+ await driver.setContext('chrome');
+
+ const results: string[] = [];
+ const errors: string[] = [];
+
+ // Read each preference
+ for (const name of names) {
+ try {
+ // Use getPrefType to determine how to read the pref
+ const script = `
+ (function() {
+ const type = Services.prefs.getPrefType(${JSON.stringify(name)});
+ if (type === Services.prefs.PREF_INVALID) {
+ return { exists: false };
+ } else if (type === Services.prefs.PREF_BOOL) {
+ return { exists: true, value: Services.prefs.getBoolPref(${JSON.stringify(name)}) };
+ } else if (type === Services.prefs.PREF_INT) {
+ return { exists: true, value: Services.prefs.getIntPref(${JSON.stringify(name)}) };
+ } else {
+ return { exists: true, value: Services.prefs.getStringPref(${JSON.stringify(name)}) };
+ }
+ })()
+ `;
+ const prefResult = (await driver.executeScript(`return ${script}`)) as {
+ exists: boolean;
+ value?: unknown;
+ };
+
+ if (prefResult.exists) {
+ results.push(` ${name} = ${JSON.stringify(prefResult.value)}`);
+ } else {
+ results.push(` ${name} = (not set)`);
+ }
+ } catch (error) {
+ errors.push(` ${name}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
+ const output: string[] = [];
+ if (results.length > 0) {
+ output.push(`📋 Firefox Preferences:`);
+ output.push(...results);
+ }
+ if (errors.length > 0) {
+ output.push(`\n⚠️ Failed to read ${errors.length} preference(s):`);
+ output.push(...errors);
+ }
+
+ return successResponse(output.join('\n'));
+ } finally {
+ // Restore content context
+ try {
+ await driver.setContext('content');
+ if (originalContextId) {
+ await driver.switchTo().window(originalContextId);
+ }
+ } catch {
+ // Ignore errors restoring context
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('UnsupportedOperationError')) {
+ return errorResponse(
+ new Error(
+ 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.'
+ )
+ );
+ }
+ return errorResponse(error as Error);
+ }
+}
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 09ce596..bf85871 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -16,8 +16,8 @@ export {
handleClosePage,
} from './pages.js';
-// Script evaluation tools - DISABLED (see docs/future-features.md)
-// export { evaluateScriptTool, handleEvaluateScript } from './script.js';
+// Script evaluation tools
+export { evaluateScriptTool, handleEvaluateScript } from './script.js';
// Console tools
export {
@@ -80,3 +80,41 @@ export {
handleNavigateHistory,
handleSetViewportSize,
} from './utilities.js';
+
+// Firefox management tools (logs, restart, info)
+export {
+ getFirefoxLogsTool,
+ getFirefoxInfoTool,
+ restartFirefoxTool,
+ handleGetFirefoxLogs,
+ handleGetFirefoxInfo,
+ handleRestartFirefox,
+} from './firefox-management.js';
+
+// Privileged ("chrome") context tools
+export {
+ listPrivilegedContextsTool,
+ selectPrivilegedContextTool,
+ evaluatePrivilegedScriptTool,
+ handleListPrivilegedContexts,
+ handleSelectPrivilegedContext,
+ handleEvaluatePrivilegedScript,
+} from './privileged-context.js';
+
+// Firefox preferences tools
+export {
+ setFirefoxPrefsTool,
+ getFirefoxPrefsTool,
+ handleSetFirefoxPrefs,
+ handleGetFirefoxPrefs,
+} from './firefox-prefs.js';
+
+// WebExtension tools (install, uninstall, and list extensions)
+export {
+ installExtensionTool,
+ uninstallExtensionTool,
+ listExtensionsTool,
+ handleInstallExtension,
+ handleUninstallExtension,
+ handleListExtensions,
+} from './webextension.js';
diff --git a/src/tools/privileged-context.ts b/src/tools/privileged-context.ts
new file mode 100644
index 0000000..e68b5c3
--- /dev/null
+++ b/src/tools/privileged-context.ts
@@ -0,0 +1,157 @@
+/**
+ * Privileged context management tools for MCP
+ * Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1
+ */
+
+import { successResponse, errorResponse } from '../utils/response-helpers.js';
+import type { McpToolResponse } from '../types/common.js';
+
+export const listPrivilegedContextsTool = {
+ name: 'list_privileged_contexts',
+ description:
+ 'List privileged (privileged) browsing contexts. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Use restart_firefox with env parameter to enable.',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ },
+};
+
+export const selectPrivilegedContextTool = {
+ name: 'select_privileged_context',
+ description:
+ 'Select a privileged browsing context by ID and set WebDriver Classic context to "chrome" . Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ contextId: {
+ type: 'string',
+ description: 'Privileged browsing context ID from list_privileged_contexts',
+ },
+ },
+ required: ['contextId'],
+ },
+};
+
+export const evaluatePrivilegedScriptTool = {
+ name: 'evaluate_privileged_script',
+ description:
+ 'Evaluate JavaScript in the current privileged context. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var. Returns the result of the expression.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ expression: {
+ type: 'string',
+ description: 'JavaScript expression to evaluate in the privileged context',
+ },
+ },
+ required: ['expression'],
+ },
+};
+
+function formatContextList(contexts: any[]): string {
+ if (contexts.length === 0) {
+ return '🔧 No privileged contexts found';
+ }
+
+ const lines: string[] = [`🔧 ${contexts.length} privileged contexts`];
+ for (const ctx of contexts) {
+ const id = ctx.context;
+ const url = ctx.url || '(no url)';
+ const children = ctx.children ? ` [${ctx.children.length} children]` : '';
+ lines.push(` ${id}: ${url}${children}`);
+ }
+ return lines.join('\n');
+}
+
+export async function handleListPrivilegedContexts(_args: unknown): Promise {
+ try {
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ const result = await firefox.sendBiDiCommand('browsingContext.getTree', {
+ 'moz:scope': 'chrome',
+ });
+
+ const contexts = result.contexts || [];
+
+ return successResponse(formatContextList(contexts));
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('UnsupportedOperationError')) {
+ return errorResponse(
+ new Error(
+ 'Privileged context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.'
+ )
+ );
+ }
+ return errorResponse(error as Error);
+ }
+}
+
+export async function handleSelectPrivilegedContext(args: unknown): Promise {
+ try {
+ const { contextId } = args as { contextId: string };
+
+ if (!contextId || typeof contextId !== 'string') {
+ throw new Error('contextId parameter is required and must be a string');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ const driver = firefox.getDriver();
+ await driver.switchTo().window(contextId);
+
+ try {
+ await driver.setContext('chrome');
+ } catch (contextError) {
+ return errorResponse(
+ new Error(
+ `Switched to context ${contextId} but failed to set Marionette privileged context. Your Firefox build may not support privileged context or MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is not set.`
+ )
+ );
+ }
+
+ return successResponse(
+ `✅ Switched to privileged context: ${contextId} (Marionette context set to privileged)`
+ );
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
+
+export async function handleEvaluatePrivilegedScript(args: unknown): Promise {
+ try {
+ const { expression } = args as { expression: string };
+
+ if (!expression || typeof expression !== 'string') {
+ throw new Error('expression parameter is required and must be a string');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ const driver = firefox.getDriver();
+
+ try {
+ const result = await driver.executeScript(`return (${expression});`);
+ const resultText =
+ typeof result === 'string'
+ ? result
+ : result === null
+ ? 'null'
+ : result === undefined
+ ? 'undefined'
+ : JSON.stringify(result, null, 2);
+
+ return successResponse(`🔧 Result:\n${resultText}`);
+ } catch (executeError) {
+ return errorResponse(
+ new Error(
+ `Script execution failed: ${executeError instanceof Error ? executeError.message : String(executeError)}`
+ )
+ );
+ }
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
diff --git a/src/tools/webextension.ts b/src/tools/webextension.ts
new file mode 100644
index 0000000..168ee0d
--- /dev/null
+++ b/src/tools/webextension.ts
@@ -0,0 +1,344 @@
+/**
+ * WebExtension tools for MCP
+ * Tools for installing, managing and inspecting Firefox extensions
+ *
+ * install/uninstall: Uses Firefox's native WebDriver BiDi webExtension module
+ * list_extensions: Uses chrome-privileged AddonManager API as workaround for
+ * missing webExtension.getExtensions BiDi command
+ *
+ * Note: list_extensions requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1
+ */
+
+import { successResponse, errorResponse } from '../utils/response-helpers.js';
+import type { McpToolResponse } from '../types/common.js';
+
+// ============================================================================
+// Tool: install_extension
+// ============================================================================
+
+export const installExtensionTool = {
+ name: 'install_extension',
+ description:
+ 'Install a Firefox extension using WebDriver BiDi webExtension.install command. Supports installing from archive (.xpi/.zip), base64-encoded data, or unpacked directory.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ type: {
+ type: 'string',
+ enum: ['archivePath', 'base64', 'path'],
+ description:
+ 'Extension data type: "archivePath" for .xpi/.zip, "base64" for encoded data, "path" for unpacked directory',
+ },
+ path: {
+ type: 'string',
+ description: 'File path (for archivePath or path types)',
+ },
+ value: {
+ type: 'string',
+ description: 'Base64-encoded extension data (for base64 type)',
+ },
+ permanent: {
+ type: 'boolean',
+ description:
+ 'Firefox-specific: Install permanently (requires signed extension). Default: false (temporary install)',
+ },
+ },
+ required: ['type'],
+ },
+};
+
+export async function handleInstallExtension(args: unknown): Promise {
+ try {
+ const { type, path, value, permanent } = args as {
+ type: 'archivePath' | 'base64' | 'path';
+ path?: string;
+ value?: string;
+ permanent?: boolean;
+ };
+
+ if (!type) {
+ throw new Error('type parameter is required');
+ }
+
+ // Validate required fields based on type
+ if ((type === 'archivePath' || type === 'path') && !path) {
+ throw new Error(`path parameter is required for type "${type}"`);
+ }
+ if (type === 'base64' && !value) {
+ throw new Error('value parameter is required for type "base64"');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ // Build extensionData parameter
+ const extensionData: Record = { type };
+ if (path) {
+ extensionData.path = path;
+ }
+ if (value) {
+ extensionData.value = value;
+ }
+
+ // Build BiDi command parameters
+ const params: Record = { extensionData };
+ if (permanent !== undefined) {
+ params['moz:permanent'] = permanent;
+ }
+
+ const result = await firefox.sendBiDiCommand('webExtension.install', params);
+
+ const extensionId = result?.extension || 'unknown';
+ const installType = permanent ? 'permanent' : 'temporary';
+
+ return successResponse(
+ `✅ Extension installed (${installType}):\n ID: ${extensionId}\n Type: ${type}${path ? `\n Path: ${path}` : ''}`
+ );
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
+
+// ============================================================================
+// Tool: uninstall_extension
+// ============================================================================
+
+export const uninstallExtensionTool = {
+ name: 'uninstall_extension',
+ description:
+ 'Uninstall a Firefox extension using WebDriver BiDi webExtension.uninstall command. Requires the extension ID returned by install_extension or obtained from list_extensions.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ description: 'Extension ID (e.g., "addon@example.com")',
+ },
+ },
+ required: ['id'],
+ },
+};
+
+export async function handleUninstallExtension(args: unknown): Promise {
+ try {
+ const { id } = args as { id: string };
+
+ if (!id || typeof id !== 'string') {
+ throw new Error('id parameter is required and must be a string');
+ }
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ await firefox.sendBiDiCommand('webExtension.uninstall', { extension: id });
+
+ return successResponse(`✅ Extension uninstalled:\n ID: ${id}`);
+ } catch (error) {
+ return errorResponse(error as Error);
+ }
+}
+
+// ============================================================================
+// Tool: list_extensions
+// ============================================================================
+
+export const listExtensionsTool = {
+ name: 'list_extensions',
+ description:
+ // MOZ_REMOTE_ALLOW_SYSTEM_ACCESS is required because the tool relies on the
+ // privileged AddonManager API as a workaround for the currently missing
+ // webExtension.getExtensions WebDriver BiDi command.
+ 'List installed Firefox extensions with UUIDs and background scripts. Requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ ids: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Optional: Filter by exact extension IDs (e.g., ["addon@example.com"])',
+ },
+ name: {
+ type: 'string',
+ description: 'Optional: Filter by partial name match (case-insensitive, e.g., "shopify")',
+ },
+ isActive: {
+ type: 'boolean',
+ description: 'Optional: Filter by enabled (true) or disabled (false) status',
+ },
+ isSystem: {
+ type: 'boolean',
+ description:
+ 'Optional: Filter by system/built-in (true) or user-installed (false) extensions',
+ },
+ },
+ },
+};
+
+interface ExtensionInfo {
+ id: string;
+ name: string;
+ version: string;
+ isActive: boolean;
+ isSystem: boolean;
+ uuid: string;
+ baseURL: string;
+ backgroundScripts: string[];
+ manifestVersion: number | null;
+}
+
+function formatExtensionList(extensions: ExtensionInfo[], filterId?: string): string {
+ if (extensions.length === 0) {
+ return filterId ? `🔍 Extension not found: ${filterId}` : '📦 No extensions installed';
+ }
+
+ const lines: string[] = [
+ `📦 ${extensions.length} extension(s)${filterId ? ` (filtered by: ${filterId})` : ''}`,
+ ];
+
+ for (const ext of extensions) {
+ lines.push('');
+ lines.push(` 📌 ${ext.name} (v${ext.version})`);
+ lines.push(` ID: ${ext.id}`);
+ lines.push(` Type: ${ext.isSystem ? '🔧 System/Built-in' : '👤 User-installed'}`);
+ lines.push(` UUID: ${ext.uuid}`);
+ lines.push(` Base URL: ${ext.baseURL}`);
+ lines.push(` Manifest: v${ext.manifestVersion || 'unknown'}`);
+ lines.push(` Active: ${ext.isActive ? '✅' : '❌'}`);
+
+ if (ext.backgroundScripts.length > 0) {
+ lines.push(` Background scripts:`);
+ for (const script of ext.backgroundScripts) {
+ const scriptName = script.split('/').pop();
+ lines.push(` • ${scriptName}`);
+ }
+ } else {
+ lines.push(` Background scripts: (none)`);
+ }
+ }
+
+ return lines.join('\n');
+}
+
+export async function handleListExtensions(args: unknown): Promise {
+ try {
+ const { ids, name, isActive, isSystem } =
+ (args as {
+ ids?: string[];
+ name?: string;
+ isActive?: boolean;
+ isSystem?: boolean;
+ }) || {};
+
+ const { getFirefox } = await import('../index.js');
+ const firefox = await getFirefox();
+
+ // Get privileged ("chrome") contexts
+ const result = await firefox.sendBiDiCommand('browsingContext.getTree', {
+ 'moz:scope': 'chrome',
+ });
+
+ const contexts = result.contexts || [];
+ if (contexts.length === 0) {
+ throw new Error(
+ 'No privileged contexts available. Ensure MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 is set.'
+ );
+ }
+
+ const driver = firefox.getDriver();
+ const chromeContextId = contexts[0].context;
+ const originalContextId = firefox.getCurrentContextId();
+
+ try {
+ // Switch to chrome context
+ await driver.switchTo().window(chromeContextId);
+ await driver.setContext('chrome');
+
+ // Execute chrome-privileged script to get extensions
+ // Use executeAsyncScript for async operations
+ const filterParams = { ids, name, isActive, isSystem };
+ const script = `
+ const callback = arguments[arguments.length - 1];
+ const filter = ${JSON.stringify(filterParams)};
+ (async () => {
+ try {
+ const { AddonManager } = ChromeUtils.importESModule("resource://gre/modules/AddonManager.sys.mjs");
+ let addons = await AddonManager.getAllAddons();
+
+ // Filter to only extensions (not themes, plugins, etc.)
+ addons = addons.filter(addon => addon.type === "extension");
+
+ // Apply filters
+ if (filter.ids && filter.ids.length > 0) {
+ addons = addons.filter(addon => filter.ids.includes(addon.id));
+ }
+ if (filter.name) {
+ const search = filter.name.toLowerCase();
+ addons = addons.filter(addon => addon.name.toLowerCase().includes(search));
+ }
+ if (typeof filter.isActive === 'boolean') {
+ addons = addons.filter(addon => addon.isActive === filter.isActive);
+ }
+ if (typeof filter.isSystem === 'boolean') {
+ addons = addons.filter(addon => addon.isSystem === filter.isSystem);
+ }
+
+ const extensions = [];
+ for (const addon of addons) {
+ const policy = WebExtensionPolicy.getByID(addon.id);
+ if (!policy) continue; // Skip if no policy (addon not loaded)
+
+ extensions.push({
+ id: addon.id,
+ name: addon.name,
+ version: addon.version,
+ isActive: addon.isActive,
+ isSystem: addon.isSystem,
+ uuid: policy.mozExtensionHostname,
+ baseURL: policy.baseURL,
+ backgroundScripts: policy.extension?.backgroundScripts || [],
+ manifestVersion: policy.extension?.manifest?.manifest_version || null
+ });
+ }
+
+ callback(extensions);
+ } catch (error) {
+ callback([]);
+ }
+ })();
+ `;
+
+ const extensions = (await driver.executeAsyncScript(script)) as ExtensionInfo[];
+
+ // Build filter description for output
+ const filterDesc = [
+ ids && ids.length > 0 ? `ids: [${ids.join(', ')}]` : null,
+ name ? `name: "${name}"` : null,
+ typeof isActive === 'boolean' ? `active: ${isActive}` : null,
+ typeof isSystem === 'boolean' ? `system: ${isSystem}` : null,
+ ]
+ .filter(Boolean)
+ .join(', ');
+
+ return successResponse(formatExtensionList(extensions, filterDesc || undefined));
+ } finally {
+ // Restore content context
+ try {
+ await driver.setContext('content');
+ if (originalContextId) {
+ await driver.switchTo().window(originalContextId);
+ }
+ } catch {
+ // Ignore errors restoring context
+ }
+ }
+ } catch (error) {
+ if (error instanceof Error && error.message.includes('UnsupportedOperationError')) {
+ return errorResponse(
+ new Error(
+ 'Chrome context access not enabled. Set MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 environment variable and restart Firefox.'
+ )
+ );
+ }
+ return errorResponse(error as Error);
+ }
+}
diff --git a/src/utils/errors.ts b/src/utils/errors.ts
index c0add89..2728db0 100644
--- a/src/utils/errors.ts
+++ b/src/utils/errors.ts
@@ -11,10 +11,9 @@ export class FirefoxDisconnectedError extends Error {
constructor(reason?: string) {
const baseMessage = 'Firefox browser is not connected';
const instruction =
- 'The Firefox browser window was closed by the user. ' +
- 'To continue browser automation, ask the user to restart the firefox-devtools-mcp server ' +
- '(they need to restart Claude Code or the MCP connection). ' +
- 'This will launch a new Firefox instance.';
+ 'The Firefox browser window was closed. ' +
+ 'Use the restart_firefox tool with firefoxPath parameter to start a new Firefox instance. ' +
+ 'Example: restart_firefox with firefoxPath="/usr/bin/firefox"';
const fullMessage = reason
? `${baseMessage}: ${reason}. ${instruction}`
diff --git a/tasks/99-specification.md b/tasks/99-specification.md
deleted file mode 100644
index 245bd22..0000000
--- a/tasks/99-specification.md
+++ /dev/null
@@ -1,660 +0,0 @@
-# Firefox DevTools MCP Server - Aktuální Specifikace (2025)
-
-## Přehled projektu
-
-Model Context Protocol server pro ovládání a inspekci Firefox browseru přes **WebDriver BiDi protokol**. Umožňuje AI asistentům automatizovat Firefox s moderní architekturou založenou na W3C standardech.
-
-**Status:** ✅ Produkční implementace s kompletní funkcionalitou
-
-## Technologie
-
-- **Jazyk:** TypeScript/Node.js
-- **Runtime:** Node.js 20.19.0+
-- **Browser automatizace:** Selenium WebDriver 4.36+ s BiDi protokolem
-- **Protocol:** W3C WebDriver BiDi (nástupce RDP)
-- **MCP SDK:** @modelcontextprotocol/sdk ^1.17.1
-- **Build:** tsup (esbuild wrapper)
-- **Testing:** Vitest + manuální test skripty
-
-## Aktuální Architektura
-
-### Modulární struktura `src/firefox/`
-
-```
-src/firefox/
-├── index.ts # FirefoxClient - Public API facade
-├── core.ts # FirefoxCore - WebDriver + BiDi connection
-├── types.ts # TypeScript type definitions
-├── events.ts # ConsoleEvents + NetworkEvents
-├── dom.ts # DomInteractions - DOM manipulation
-├── pages.ts # PageManagement - tabs, navigation, dialogs
-└── snapshot/ # DOM snapshot system with UID mapping
- ├── index.ts # Snapshot public interface
- ├── manager.ts # SnapshotManager - caching & resolution
- ├── formatter.ts # Text formatter for LLM consumption
- ├── types.ts # Snapshot type definitions
- └── injected/ # Browser-side injected scripts
- ├── snapshot.injected.ts # Main injected entry point
- ├── treeWalker.ts # DOM tree traversal
- ├── elementCollector.ts # Element filtering & relevance
- ├── attributeCollector.ts # ARIA & accessibility
- └── selectorGenerator.ts # CSS & XPath generation
-```
-
-### Komponenty
-
-1. **FirefoxCore** (`core.ts`)
- - Správa Selenium WebDriver instance
- - BiDi protokol enablement
- - Lifecycle management (launch, quit)
- - Browsing context tracking
-
-2. **ConsoleEvents** (`events.ts`)
- - BiDi `log.entryAdded` subscription
- - Console message collection (debug, info, warn, error)
- - Real-time WebSocket message parsing
-
-3. **NetworkEvents** (`events.ts`)
- - BiDi network event subscriptions:
- - `network.beforeRequestSent`
- - `network.responseStarted`
- - `network.responseCompleted`
- - Request/response pairing
- - Timing calculation
- - Resource type detection
- - Start/stop monitoring control
-
-4. **DomInteractions** (`dom.ts`)
- - JavaScript evaluation via WebDriver
- - Page content extraction
- - Selector-based interactions (click, hover, fill, drag, upload)
- - UID-based interactions (resolves snapshot UIDs to elements)
- - Screenshot capture (full page + element)
-
-5. **PageManagement** (`pages.ts`)
- - Navigation (URL, back, forward)
- - Tab/window management
- - Viewport resizing
- - Dialog handling (alert, confirm, prompt)
-
-6. **SnapshotManager** (`snapshot/`)
- - DOM tree snapshot with UIDs
- - CSS & XPath selector generation
- - Element caching with staleness detection
- - ARIA attributes & accessibility info
- - LLM-optimized text format
- - Iframe support (same-origin only)
- - Incremental snapshot IDs
-
-### Komunikace
-
-```
-Claude/AI Agent
- ↓ (MCP Protocol - stdio)
-MCP Server (src/index.ts)
- ↓ (FirefoxClient API)
-Selenium WebDriver
- ↓ (WebDriver BiDi Protocol - WebSocket)
-Firefox Browser (BiDi enabled)
-```
-
-## Implementované Funkce
-
-### 1. Browser Lifecycle ✅
-
-**`FirefoxClient.connect()`**
-- Spustí Firefox přes Selenium WebDriver
-- Nastaví BiDi protokol
-- Vrací: Promise
-- Options:
- - `firefoxPath?: string` - Cesta k Firefox binary
- - `headless: boolean` - Headless mode
- - `profilePath?: string` - Custom Firefox profile
- - `viewport?: {width, height}` - Velikost okna
- - `args?: string[]` - Další Firefox argumenty
- - `startUrl?: string` - Počáteční URL
-
-**`FirefoxClient.close()`**
-- Ukončí Firefox instanci
-- Cleanup všech resources
-- Vrací: Promise
-
-### 2. Navigace a Správa Stránek ✅
-
-**`navigate(url: string)`**
-- Naviguje na URL
-- Automaticky čistí console a snapshot cache
-- Vrací: Promise
-
-**`navigateBack()` / `navigateForward()`**
-- Historie navigace
-- Vrací: Promise
-
-**`getTabs()` / `selectTab(index)` / `createNewPage(url)` / `closeTab(index)`**
-- Tab management přes window handles
-- Vrací: tab info nebo Promise
-
-**`refreshTabs()` / `getSelectedTabIdx()`**
-- Tab metadata operations
-
-### 3. Viewport & Dialogs ✅
-
-**`setViewportSize(width: number, height: number)`**
-- Změní velikost viewport
-- Vrací: Promise
-
-**`acceptDialog(promptText?: string)`**
-- Přijme alert/confirm/prompt dialog
-- Optional text input pro prompt
-- Vrací: Promise
-
-**`dismissDialog()`**
-- Zavře/zamítne dialog
-- Vrací: Promise
-
-### 4. JavaScript Execution ✅
-
-**`evaluate(script: string)`**
-- Vykoná JavaScript v page context
-- Automatické `return` wrapping
-- Vrací: Promise (JSON-serializable result)
-
-**`getContent()`**
-- Získá `document.documentElement.outerHTML`
-- Vrací: Promise
-
-### 5. DOM Snapshot System ✅
-
-**`takeSnapshot()`**
-- Kompletní DOM snapshot s UIDs
-- Vrací: `Promise`
- - `json: SnapshotJson` - Strukturovaný DOM tree
- - `text: string` - LLM-optimized textová reprezentace
-
-**SnapshotNode structure:**
-```typescript
-{
- uid: string, // Unikátní ID (snapshotId_nodeId)
- tag: string, // HTML tag name
- role?: string, // ARIA role nebo semantická role
- name?: string, // Accessible name
- value?: string, // Input/textarea value
- href?: string, // Link href
- src?: string, // Image/iframe src
- text?: string, // Text content
- aria?: AriaAttributes, // ARIA properties
- computed?: { // Computed properties
- focusable?: boolean,
- interactive?: boolean,
- visible?: boolean
- },
- children: SnapshotNode[] // Nested elements
-}
-```
-
-**`resolveUidToSelector(uid: string)`**
-- Převede UID na CSS selector
-- Validuje staleness (snapshot ID)
-- Vrací: string
-
-**`resolveUidToElement(uid: string)`**
-- Převede UID na WebElement
-- Caching s staleness detection
-- Fallback na XPath při selhání CSS
-- Vrací: Promise
-
-**`clearSnapshot()`**
-- Vyčistí snapshot cache
-
-### 6. User Interaction (Selector-based) ✅
-
-**`clickBySelector(selector: string)`**
-- Klikne na element
-- Vrací: Promise
-
-**`hoverBySelector(selector: string)`**
-- Hover nad element
-- Vrací: Promise
-
-**`fillBySelector(selector: string, text: string)`**
-- Vyplní input/textarea
-- Clear + sendKeys
-- Vrací: Promise
-
-**`dragAndDropBySelectors(source: string, target: string)`**
-- Drag & drop mezi elementy
-- JS fallback (HTML5 DnD API)
-- Vrací: Promise
-
-**`uploadFileBySelector(selector: string, filePath: string)`**
-- Upload souboru
-- JS unhide + sendKeys
-- Vrací: Promise
-
-### 7. User Interaction (UID-based) ✅
-
-**`clickByUid(uid: string, dblClick = false)`**
-- Klikne na element podle UID
-- Optional double-click
-- Vrací: Promise
-
-**`hoverByUid(uid: string)`**
-- Hover podle UID
-- Vrací: Promise
-
-**`fillByUid(uid: string, value: string)`**
-- Vyplní input podle UID
-- Vrací: Promise
-
-**`dragByUidToUid(fromUid: string, toUid: string)`**
-- Drag & drop mezi UIDs
-- Vrací: Promise
-
-**`fillFormByUid(elements: Array<{uid, value}>)`**
-- Batch form filling
-- Vrací: Promise
-
-**`uploadFileByUid(uid: string, filePath: string)`**
-- Upload podle UID
-- Vrací: Promise
-
-### 8. Screenshots ✅
-
-**`takeScreenshotPage()`**
-- Full page screenshot
-- Vrací: Promise (base64 PNG)
-
-**`takeScreenshotByUid(uid: string)`**
-- Screenshot konkrétního elementu
-- Automatický scrollIntoView
-- Element cropping (native Selenium)
-- Vrací: Promise (base64 PNG)
-
-### 9. Console Monitoring ✅
-
-**`getConsoleMessages()`**
-- Získá všechny console logy
-- Vrací: Promise
- - `level: 'debug' | 'info' | 'warn' | 'error'`
- - `text: string`
- - `timestamp: number`
- - `source?: string`
- - `args?: unknown[]`
-
-**`clearConsoleMessages()`**
-- Vyčistí console buffer
-- Vrací: void
-
-### 10. Network Monitoring ✅
-
-Aktuální přístup: Always‑on capture (návrh změny) – sběr síťových událostí běží trvale po `connect()`, relevanci dat řídíme přes nástroj `list_network_requests` (filtry `sinceMs`, `limit`, `sortBy`, …). Jednotlivé requesty mají stabilní `id` (BiDi request id), které lze použít v `get_network_request` pro stažení detailu.
-
-API (klientská vrstva):
-
-**`getNetworkRequests()`**
-- Vrátí zachycené requesty (od posledního čistění bufferu při navigaci, pokud je auto‑clear zapnut)
-- Vrací: Promise
- - `id: string`
- - `url: string`
- - `method: string`
- - `status?: number`
- - `resourceType?: string`
- - `requestHeaders?: Record`
- - `responseHeaders?: Record`
- - `timings?: {requestTime, responseTime, duration}`
-
-Pozn.: Dřívější start/stop/clear nástroje budou odstraněny z MCP vrstvy (viz tasks/NETWORK-03-...).
-
-## MCP Tools (Budoucí implementace)
-
-Následující MCP tools budou vystaveny přes `src/index.ts` MCP server:
-
-Poznámka k `inputSchema`:
-- Všechny MCP nástroje musí používat čisté JSON Schema (serializovatelné), ne přímo Zod instance. Validaci lze interně ponechat, ale schema publikovat v JSON podobě (viz tasks/SCHEMA-01-json-schema-unification.md).
-
-### Plánované Tools
-
-1. **Browser Management**
- - `firefox_launch` - Spustí Firefox (wrapper nad connect)
- - `firefox_close` - Ukončí Firefox
- - `firefox_get_status` - Status info
-
-2. **Navigation**
- - `navigate_to` - Navigace na URL
- - `navigate_back` / `navigate_forward` - Historie
- - `list_tabs` - Seznam tabů
- - `select_tab` - Přepnutí tabu
- - `create_tab` - Nový tab
- - `close_tab` - Zavřít tab
-
-3. **DOM Inspection**
- - `take_snapshot` - DOM snapshot s UIDs
- - `get_page_content` - HTML content
- - `find_elements` - Najít elementy (future)
- - `resolve_uid` - UID → selector/element
-
-4. **User Interaction**
- - `click_element` - Klik (selector nebo UID)
- - `type_text` - Psaní textu
- - `hover_element` - Hover
- - `drag_and_drop` - Drag & drop
- - `upload_file` - Upload souboru
- - `fill_form` - Batch form filling
-
-5. **JavaScript**
- - `evaluate_javascript` - JS eval
- - `get_console_logs` - Console messages
-
-6. **Network & Performance**
- - `list_network_requests` - Vylistovat requesty (filtry, stabilní `id`, možnost detailního výstupu)
- - `get_network_request` - Detail požadavku podle `id`
- - (odstranit) `start_network_monitor` / `stop_network_monitor` / `clear_network_requests`
- - (odstranit) `get_performance_metrics`, `performance_start_trace`, `performance_stop_trace` (viz tasks/PERFORMANCE-01-...)
-
-7. **Screenshots**
- - `take_screenshot` - Page nebo element screenshot
-
-8. **Dialogs & Viewport**
- - `handle_dialog` - Accept/dismiss dialog
- - `resize_viewport` - Změna velikosti
-
----
-
-## Release and Versioning (RELEASE-01)
-
-- Use semver in the 0.x range until the public API is stable.
-- Injected snapshot bundle includes a simple version marker that is logged on load.
-- Align Node.js runtime requirement with `package.json engines` (>=20).
-
-## Google Actions (ACTIONS-01/02)
-
-- Prepare Google Actions mapping for our Firefox tools. Use `old/mcp_gsheet` as inspiration only (style and structure), do not integrate Google Sheets.
-- Keep action surface minimal and English‑only; inputs use plain JSON Schema.
-
-
-9. **Storage (future)**
- - `get_cookies` - Získat cookies
- - `set_cookie` - Nastavit cookie
- - `get_local_storage` - LocalStorage data
- - `get_session_storage` - SessionStorage data
-
-## Testování
-
-### Implementované Test Skripty
-
-1. **`scripts/test-bidi-devtools.js`**
- - Kompletní E2E test suite (18 testů)
- - Coverage všech funkcí:
- - Browser launch & connect
- - Navigation & tabs
- - Console monitoring
- - Network monitoring
- - JavaScript evaluation
- - Snapshot system
- - History navigation
- - Screenshot capture
- - Dialog handling
-
-2. **`scripts/test-input-tools.js`**
- - Test všech input interakcí
- - Selector-based i UID-based metody
- - Click, hover, fill, drag, upload
-
-3. **`scripts/test-screenshot.js`**
- - Full page screenshots
- - Element screenshots
- - Custom HTML testy
- - Output do `/temp` složky
-
-4. **`scripts/test-dialog.js`**
- - Alert dialogs
- - Confirm dialogs (accept/dismiss)
- - Prompt dialogs s text inputem
- - Error handling
-
-### NPM Test Scripts
-
-```bash
-npm run test:tools # Hlavní E2E testy
-npm run test:input # Input tools testy
-npm run test:screenshot # Screenshot testy
-npm run test:dialog # Dialog handling testy
-```
-
-### Quality Checks
-
-```bash
-npm run check # ESLint fix + TypeScript typecheck
-npm run check:all # check + vitest + build
-npm run build # tsup build
-```
-
-## Konfigurace
-
-### FirefoxLaunchOptions
-
-```typescript
-{
- firefoxPath?: string; // Auto-detect pokud není uvedeno
- headless: boolean; // true/false
- profilePath?: string; // Custom profile
- viewport?: {
- width: number;
- height: number;
- };
- args?: string[]; // Extra Firefox args
- startUrl?: string; // Počáteční URL
-}
-```
-
-### Claude Desktop Config (MCP)
-
-```json
-{
- "mcpServers": {
- "firefox-devtools": {
- "command": "node",
- "args": ["/path/to/firefox-devtools-mcp/dist/index.js"],
- "env": {
- "FIREFOX_PATH": "/Applications/Firefox.app/Contents/MacOS/firefox"
- }
- }
- }
-}
-```
-
-### Environment Variables
-
-- `FIREFOX_PATH` - Cesta k Firefox binary (optional, auto-detect)
-- `DEBUG` - Debug logging (např. `DEBUG=firefox-devtools-mcp`)
-- `NODE_ENV` - development/production
-
-## Firefox Setup
-
-### Požadavky
-
-- **Firefox:** Stable (latest), ESR, Developer Edition, nebo Nightly
-- **Geckodriver:** Auto-instalováno přes npm (geckodriver package)
-- **Node.js:** 20.19.0+
-
-### BiDi Protocol
-
-WebDriver BiDi je automaticky aktivován přes Selenium:
-
-```typescript
-const firefoxOptions = new firefox.Options();
-firefoxOptions.enableBidi();
-```
-
-**Žádná manuální konfigurace Firefox profilu není potřeba!**
-
-## Omezení a Známé Issues
-
-### BiDi Coverage
-
-✅ **Plně podporováno:**
-- JavaScript evaluation
-- Navigation & history
-- Console monitoring
-- Network monitoring (beforeRequestSent, responseStarted, responseCompleted)
-- Screenshot (full page + element)
-- Dialog handling
-- Tab management
-
-⚠️ **Částečně podporováno:**
-- Iframe support (pouze same-origin)
-- Network timing (ne tak přesné jako Chrome DevTools)
-
-❌ **Není podporováno:**
-- WebSocket monitoring (BiDi spec in progress)
-- Service Worker debugging
-- Cross-origin iframe inspection
-- HAR export (není v BiDi)
-- Video recording (není v BiDi)
-- Performance profiling (pouze Performance API přes JS)
-
-### Known Issues
-
-1. **Data URL parsing:** Firefox má problém s komplexními data: URLs
- - **Fix:** Použít `about:blank` + innerHTML injection
-
-2. **Staleness detection:** UIDs jsou vázány na snapshot ID
- - Po navigaci automaticky invalidovány
- - Cache se čistí při `navigate()`
-
-3. **Drag & Drop:** Native WebDriver DnD je nestabilní
- - **Fix:** JS fallback s HTML5 DnD API
-
-4. **File Upload:** Input může být `display: none`
- - **Fix:** JS unhide před sendKeys
-
-## Performance & Optimalizace
-
-### Implementované optimalizace
-
-1. **Element caching** - UID → WebElement cache
-2. **Staleness detection** - Snapshot ID validation
-3. **Lazy event subscription** - BiDi events pouze při connect
-4. **Always‑on network capture** - Filtry (`sinceMs`, `limit`) místo start/stop
-5. **Efficient selectors** - CSS primary, XPath fallback
-
-### Resource Cleanup
-
-- Automatické cleanup při `close()`
-- Console/Network buffer clearing
-- Snapshot cache invalidation na navigation
-
-## Development
-
-### Struktura projektu
-
-```
-firefox-devtools-mcp/
-├── src/
-│ ├── index.ts # MCP server entry point
-│ ├── firefox/ # Firefox client library
-│ ├── tools/ # MCP tool definitions (future)
-│ └── utils/ # Shared utilities
-├── scripts/ # Test & setup scripts
-├── tasks/ # Task specifications
-├── old/ # Reference implementations
-├── temp/ # Test artifacts
-└── dist/ # Build output
-```
-
-### Build System
-
-- **Builder:** tsup (esbuild wrapper)
-- **Target:** Node 20 ESM
-- **Output:** Single-file bundle + type definitions
-- **Watch mode:** `npm run dev`
-
-### Code Quality
-
-- **Linter:** ESLint + TypeScript plugin
-- **Formatter:** Prettier
-- **Types:** Strict TypeScript (`exactOptionalPropertyTypes: true`)
-- **Testing:** Vitest + manual E2E scripts
- - **Comment Style:** English only; concise, accurate, and durable (no internal task numbers). User‑facing caveats belong in docs, not tool descriptions. See tasks/CODE-COMMENTS-01-review-and-cleanup.md.
-
-## Roadmap
-
-### ✅ Completed (Q1 2025)
-
-- [x] Project scaffold & TypeScript setup
-- [x] BiDi connection & WebDriver integration
-- [x] Modular architecture (core, events, dom, pages, snapshot)
-- [x] Console & Network monitoring
-- [x] Snapshot system s UID mapping
-- [x] Selector-based input tools
-- [x] UID-based input tools
-- [x] Screenshot tools (page + element)
-- [x] Dialog handling
-- [x] Comprehensive test coverage
-
-### 🚧 In Progress (Q2 2025)
-
-- [ ] MCP Tools implementation (`src/tools/`)
-- [ ] MCP Server integration (`src/index.ts`)
-- [ ] Resource & Prompt definitions
-- [ ] Error handling standardization
-- [ ] Tool documentation & examples
-
-### 📋 Future Features
-
-#### Short-term
-- [ ] Cookie management
-- [ ] LocalStorage/SessionStorage access
-- [ ] Element visibility checks
-- [ ] Wait conditions (element present, visible, etc.)
-- [ ] Keyboard shortcuts simulation
-- [ ] Mouse wheel scroll
- - [ ] Overhaul síťových nástrojů (NETWORK-01/02/03)
- - [ ] Sjednocení `inputSchema` na čisté JSON Schema (SCHEMA-01)
- - [ ] Odstranění performance nástrojů z MCP (PERFORMANCE-01)
- - [ ] Vylepšit `take_snapshot` (SNAPSHOT-01)
-
-#### Medium-term
-- [ ] Performance metrics (Performance API wrapper)
-- [ ] Advanced selector strategies (text content, label)
-- [ ] Accessibility tree snapshot
-- [ ] Cross-origin iframe support (if BiDi adds)
-- [ ] WebSocket monitoring (when BiDi supports)
-
-#### Long-term
-- [ ] Multi-profile support
-- [ ] Remote Firefox connection
-- [ ] HAR export (custom implementation)
-- [ ] Screenshot comparison
-- [ ] Video recording (screencast)
-- [ ] Firefox Developer Edition specifics
-- [ ] WebExtension debugging support
-
-## Kompatibilita
-
-### Firefox Verze
-- ✅ Firefox Stable (latest) - Primary target
-- ✅ Firefox ESR - Supported
-- ✅ Firefox Developer Edition - Supported
-- ✅ Firefox Nightly - Supported (ale může mít BiDi breaking changes)
-
-### OS Support
-- ✅ macOS (tested: macOS Sequoia 15.6)
-- ✅ Linux (via Selenium WebDriver)
-- ✅ Windows (via Selenium WebDriver)
-
-### Node.js
-- ✅ Node 20.19.0+ (required)
-- ❌ Node 18.x (není testováno)
-
-## Závěr
-
-Firefox DevTools MCP je kompletní automation library postavená na moderním WebDriver BiDi protokolu. Poskytuje:
-
-- **Čistou TypeScript API** s type safety
-- **Modular architecture** s jasnou separation of concerns
-- **UID-based interaction** pro AI-friendly DOM targeting
-- **Comprehensive testing** s E2E coverage
-- **Production-ready** s error handling a resource cleanup
-
-**Ready for MCP integration!** Další krok je implementace MCP Tools vrstvy a připojení na MCP SDK.
diff --git a/tasks/README.md b/tasks/README.md
deleted file mode 100644
index 9cc2c26..0000000
--- a/tasks/README.md
+++ /dev/null
@@ -1,113 +0,0 @@
-**Firefox DevTools MCP – TODO Roadmap**
-
-This folder contains work items for the Firefox DevTools MCP server. Implementation follows the structure and practices used in `old/mcp_gsheet` and (where it makes sense) aims for parity with `old/mcp_dev_tool_chrome`.
-
-- Kompletní specifikace (původní návrh) byla přesunuta do: `tasks/99-specification.md`
- - Aktuální analýza MCP nástrojů a návrhy zjednodušení: `tasks/tools-analysis.md`
-
-Stav práce budeme řídit přes checklist níže. Každý bod odkazuje na samostatný úkol s detaily, akceptačními kritérii, referencemi a ukázkovými snippetami (ilustrační, ne finální kód).
-
-Roadmap
-
-- [x] 00 – Výzkum a architektura: přístup k Firefoxu, parity s Chrome, struktura projektu (`tasks/00-architecture-research.md`)
-- [x] 01 – Projektový scaffold (TypeScript, tsup, eslint, prettier, vitest, scripts) (`tasks/01-project-scaffold.md`)
-- [x] 02 – Struktura `src/` a skelet MCP serveru (`tasks/02-structure-and-boilerplate.md`)
-- [x] 03 – Konfigurace, `.env`, setup skript pro MCP klienty, Inspector (`tasks/03-config-env-and-scripts.md`)
-- [x] 04 – Taskfile.yaml a Dockerfile pro lokální testování a Inspector (`tasks/04-taskfile-and-dockerfile.md`)
-- [x] 05 – Vrstva prohlížeče (McpContext/browser wrapper) (`tasks/05-browser-abstraction.md`)
-- [x] 06 – Tools: Navigace a správa stránek (MVP) (`tasks/06-tools-pages-and-navigation.md`)
-- [x] 07 – Tools: Debug a screenshoty (MVP) (`tasks/07-tools-debug-and-screenshot.md`)
-- [x] 08 – Tools: Console a evaluate (MVP) (`tasks/08-tools-console-and-script.md`)
-- [x] 09 – Tools: Síť a výkon (iterace, omezení Firefoxu) (`tasks/09-tools-network-and-performance.md`)
-- [x] 10 – Testování (unit/integration) a Inspector workflow (`tasks/10-testing-and-inspector.md`)
-- [x] 11 – Balíčkování, metadata, `server.json`, publikace (`tasks/11-ci-and-packaging.md`)
-- [x] 12 – Dokumentace: README, Tool reference, Troubleshooting (`tasks/12-docs-and-readme.md`)
- - [x] 13 – Launcher: RDP přepínače a readiness (`tasks/13-launcher-rdp-flags-and-readiness.md`)
- - [x] 14 – Launcher: Detekce binárky + edice (`tasks/14-launcher-executable-detection-and-editions.md`)
- - [x] 15 – BiDi port a screenshot (volitelné) (`tasks/15-bidi-port-and-screenshot.md`)
-- [x] 16 – Docs: Vlastní Firefox klient (EN) (`tasks/16-docs-firefox-client.md`)
-- [x] 17 – BiDi coverage vs. Chrome tools (`tasks/17-bidi-coverage-vs-chrome-tools.md`)
-- [x] 18 – Refaktor architektury `src/firefox/` (modularizace) (`tasks/18-firefox-client-architecture-refactor.md`)
-- [x] 19 – Network backend (BiDi events) (`tasks/19-network-backend-bidi-events.md`)
-- [x] 20 – Snapshot + UID mapping (`tasks/20-snapshot-and-uid-mapping.md`)
-- [x] 21 – Input tools (click/hover/fill/drag/upload) (`tasks/21-input-tools-bidi.md`)
-- [x] 22 – Screenshot tool (page/element) (`tasks/22-screenshot-tool.md`)
-- [x] 23 – Page utilities (history/resize/dialog) (`tasks/23-page-utilities-history-resize-dialog.md`)
-- [x] 24 – Remove legacy RDP options & wording (`tasks/24-remove-legacy-rdp.md`)
-- [x] 25 – Snapshot: Finalization and Extensions (`tasks/25-snapshot-finalization-and-extensions.md`)
-- [x] 26 – Firefox modules cleanup & lifecycle hooks (`tasks/26-firefox-modules-cleanup-and-lifecycle.md`)
-- [x] 27 – Offline test harness & scripts refactor (`tasks/27-offline-test-harness-and-scripts-refactor.md`)
-- [x] 28 – Snapshot bundling integration & de‑dup (`tasks/28-snapshot-bundling-integration-and-dedup.md`)
- - [x] 29 – MCP tools: Snapshot integration (`tasks/29-mcp-tools-snapshot-integration.md`)
- - [x] 30 – MCP tools: Input akce podle UID (`tasks/30-mcp-tools-input-uid-actions.md`)
- - [x] 31 – MCP tools: Screenshot a Utility akce stránky (`tasks/31-mcp-tools-screenshot-and-page-utilities.md`)
- - [x] 32 – MCP tools: Refaktor evaluate_script (`tasks/32-mcp-tools-evaluate-refactor.md`)
- - [x] 33 – MCP tools: Síťové nástroje – filtry + čištění (`tasks/33-mcp-tools-network-refactor.md`)
-- [x] 34 – MCP tools: Console + Pages drobný refaktor (`tasks/34-mcp-tools-console-and-pages-refactor.md`)
-
-New priority items (overhaul)
-
-- [x] NETWORK-01 – Přepracovat `list_network_requests` (čisté JSON Schema, stabilní `id`, detailní JSON výstup) — tasks/NETWORK-01-overhaul-list_network_requests.md
-- [x] NETWORK-02 – Redesign `get_network_request` (primárně podle `id`, strukturovaný detail) — tasks/NETWORK-02-redesign-get_network_request.md
-- [x] NETWORK-03 – Always‑on network capture; odstranit `start/stop/clear` nástroje — tasks/NETWORK-03-always-on-network-capture-and-remove-start-stop-clear.md
-- [x] SCHEMA-01 – Sjednotit `inputSchema` na čisté JSON Schema (zvl. network/performance) — tasks/SCHEMA-01-json-schema-unification.md
-- [x] PERFORMANCE-01 – Odstranit performance nástroje (`performance_*`) z veřejné sady — tasks/PERFORMANCE-01-remove-performance-tools.md
-- [x] SNAPSHOT-01 – Vyčistit výstup `take_snapshot` + doplnit návod „co dál" + parametrizace — tasks/SNAPSHOT-01-clean-snapshot-output-and-guidance.md
-- [x] PAGES – Odstranit `refresh_pages` (duplicitní s `list_pages`) — viz tasks/tools-analysis.md
-- [x] CODE-COMMENTS-01 – Review and cleanup of code comments (English only, accurate, no internal task refs) — tasks/CODE-COMMENTS-01-review-and-cleanup.md
-- [x] TOOLS-PROMPTS-01 – Improve MCP tool descriptions for better agent-friendliness and consistency — tasks/TOOLS-PROMPTS-01-improvements.md
-
-Release 0.2.0 (2025-10-19)
-
-- [x] RELEASE-01 – Node.js version guard (>=20) + SERVER_VERSION 0.2.0 + bundle logging — tasks/CR-0.2.0-release-enhancements.md
-- [x] SNAPSHOT-01 – Parameters (includeText, maxDepth) + clean output (no empty strings) — tasks/CR-0.2.0-release-enhancements.md
-- [x] NETWORK-01/02 – Add format parameter (text/json) to network tools — tasks/CR-0.2.0-release-enhancements.md
-- [x] CONSOLE-01 – Add filters (textContains, source) + format parameter — tasks/CR-0.2.0-release-enhancements.md
-- [x] EVENTS-01 – Memory protection (TTL 5min + max 1000 items) for console/network buffers — tasks/CR-0.2.0-release-enhancements.md
-- [x] ACTIONS-01 – GitHub Actions CI/CD outline — tasks/ACTIONS-01-github-actions-outline.md
-- [x] ACTIONS-02 – GitHub Actions workflows implementation (ci, pr-check, publish, release) — tasks/ACTIONS-02-github-actions-implementation.md
-- [x] VERSION-01 – Version synchronization (npm version lifecycle hook + version-check.yml + docs/release-process.md) — scripts/sync-version.js, .github/workflows/version-check.yml
-- [x] CODECOV-01 – Codecov integration (coverage tracking + PR comments + badges) — .codecov.yml, vitest.config.ts, README.md
-- [x] TESTS-01 – Smoke tests (fix CI "no test files" error) — tests/smoke.test.ts
-
-Upcoming work
-
-- [ ] TASKS-01 – Tasks folder English migration — tasks/TASKS-01-english-migration.md
-
-Notes
-
-- Code snippets reference existing implementations in `old/`. They are illustrative, not final.
-- Structure, tooling, and scripts should follow `old/mcp_gsheet` where possible.
-- For MCP tool naming/semantics, keep alignment with `old/mcp_dev_tool_chrome` where it improves prompt reuse. Document deviations explicitly where Firefox differs.
-
-Process and quality control
-
-- Work through tasks in sequence unless agreed otherwise.
-- After each task, run `task check`.
- - `task check` performs ESLint fixes and TypeScript typecheck (see Taskfile in `old/mcp_gsheet` for the pattern).
-- After each task, add a code review note in `tasks/CR-.md`.
- - Example: `tasks/CR-06.md` or `tasks/CR-06-tools-pages-and-navigation.md`.
- - Recommended CR outline:
-
-```md
-# Code Review –
-
-Date: YYYY-MM-DD
-
-What was done
-- Short summary of changes and reasoning
-- Impacted areas (files/modules)
-
-Decisions and impact
-- Important decisions (naming, CLI behavior, defaults)
-- Known limitations or tech debt
-
-References
-- Links to `old/mcp_gsheet` and `old/mcp_dev_tool_chrome` (specific files)
-
-Next steps
-- Follow‑up tasks/tests
-```
-
-- Rozšiřuj `.gitignore` podle potřeby (např. `dist/`, `node_modules/`, `coverage/`, `*.log`, `.env`, dočasné soubory, artefakty Dockeru, atd.). Vycházej ze vzoru v `old/mcp_gsheet/.gitignore`.
-- Po každém dokončeném úkolu aktualizuj dokumentaci (README, případně `docs/*`, tool reference, poznámky k konfiguraci a omezením). Uveď změny i do CR záznamu.
diff --git a/test-mozlog.js b/test-mozlog.js
new file mode 100755
index 0000000..e94c985
--- /dev/null
+++ b/test-mozlog.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+
+import { FirefoxDevTools } from './dist/index.js';
+import { readFileSync, existsSync } from 'fs';
+
+async function test() {
+ console.log('=== Test: MOZ_LOG and Script Injection (headless) ===\n');
+
+ const logFile = '/tmp/firefox-mozlog-test.log';
+
+ const firefox = new FirefoxDevTools({
+ headless: true,
+ firefoxPath: process.env.HOME + '/firefox/firefox',
+ env: {
+ MOZ_LOG: 'timestamp,sync,nsHttp:5',
+ },
+ logFile,
+ });
+
+ await firefox.connect();
+ console.log('✓ Firefox started in headless mode with MOZ_LOG');
+ console.log(` Log file: ${logFile}`);
+
+ // Test content script evaluation
+ console.log('\n--- Testing content script evaluation ---');
+ await firefox.navigate('https://example.com');
+ console.log('✓ Navigated to example.com');
+
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const title = await firefox.evaluate('return document.title');
+ console.log(`✓ Content script: document.title = "${title}"`);
+ } catch (err) {
+ console.log(`✗ Content script evaluation failed: ${err.message}`);
+ }
+
+ try {
+ const url = await firefox.evaluate('return window.location.href');
+ console.log(`✓ Content script: window.location.href = "${url}"`);
+ } catch (err) {
+ console.log(`✗ Failed to access window.location: ${err.message}`);
+ }
+
+ try {
+ const headings = await firefox.evaluate('return document.querySelectorAll("h1").length');
+ console.log(`✓ Content script: found ${headings} h1 elements`);
+ } catch (err) {
+ console.log(`✗ Failed to query DOM: ${err.message}`);
+ }
+
+ // Navigate to another page
+ await firefox.navigate('https://mozilla.org');
+ console.log('✓ Navigated to mozilla.org');
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const title2 = await firefox.evaluate('return document.title');
+ console.log(`✓ Content script: document.title = "${title2}"`);
+ } catch (err) {
+ console.log(`✗ Content script evaluation failed: ${err.message}`);
+ }
+
+ // Check log file
+ console.log('\n--- Checking MOZ_LOG output ---');
+
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ await firefox.close();
+ console.log('✓ Firefox closed');
+
+ // Give a moment for log file to be flushed
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ if (existsSync(logFile)) {
+ try {
+ const logContent = readFileSync(logFile, 'utf8');
+ const lines = logContent.split('\n').filter((l) => l.trim());
+ console.log(`✓ Log file exists with ${lines.length} lines`);
+
+ // Check for HTTP logging
+ const httpLines = lines.filter((l) => l.includes('nsHttp'));
+ console.log(` Found ${httpLines.length} nsHttp log lines`);
+
+ if (httpLines.length > 0) {
+ console.log(' Sample HTTP log lines:');
+ httpLines.slice(0, 3).forEach((line) => {
+ console.log(` ${line.substring(0, 100)}`);
+ });
+ }
+
+ // Check for timestamps
+ const timestampLines = lines.filter((l) => /^\d{4}-\d{2}-\d{2}/.test(l));
+ console.log(` Found ${timestampLines.length} timestamped lines`);
+ } catch (err) {
+ console.log(`✗ Could not read log file: ${err.message}`);
+ }
+ } else {
+ console.log(`✗ Log file does not exist: ${logFile}`);
+ }
+
+ console.log('\n✓ All feature tests completed!');
+ console.log('\nNote: Privileged context evaluation requires:');
+ console.log(' - MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 env var');
+ console.log(' - Using restart_firefox tool or npm run inspector');
+}
+
+test().catch((err) => {
+ console.error('\nTest failed:', err);
+ console.error(err.stack);
+ process.exit(1);
+});
diff --git a/test-navigation.js b/test-navigation.js
new file mode 100755
index 0000000..28b6c5a
--- /dev/null
+++ b/test-navigation.js
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+
+import { FirefoxDevTools } from './dist/index.js';
+
+async function test() {
+ console.log('=== Test: Start Firefox and navigate ===\n');
+
+ const firefox = new FirefoxDevTools({
+ headless: false,
+ firefoxPath: process.env.HOME + '/firefox/firefox',
+ });
+
+ await firefox.connect();
+ console.log('✓ Firefox started');
+
+ await firefox.navigate('https://example.com');
+ console.log('✓ Navigated to example.com');
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ await firefox.navigate('https://mozilla.org');
+ console.log('✓ Navigated to mozilla.org');
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ await firefox.refreshTabs();
+ const tabs = firefox.getTabs();
+ console.log(`✓ Listed tabs: ${tabs.length} tab(s)`);
+ if (tabs.length > 0) {
+ console.log(` Current URL: ${tabs[0].url}`);
+ console.log(` Current title: ${tabs[0].title || 'N/A'}`);
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ await firefox.navigate('https://www.w3.org');
+ console.log('✓ Navigated to w3.org');
+
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ await firefox.refreshTabs();
+ const tabsAfter = firefox.getTabs();
+ console.log(`✓ Listed tabs again: ${tabsAfter.length} tab(s)`);
+ if (tabsAfter.length > 0) {
+ console.log(` Current URL: ${tabsAfter[0].url}`);
+ }
+
+ await firefox.close();
+ console.log('\n✓ Basic navigation tests passed!');
+ console.log('\nNote: To test restart_firefox with logs, use the MCP inspector:');
+ console.log(' npm run inspector');
+}
+
+test().catch(err => {
+ console.error('\nTest failed:', err);
+ process.exit(1);
+});
diff --git a/test-privileged-context.js b/test-privileged-context.js
new file mode 100755
index 0000000..e26a014
--- /dev/null
+++ b/test-privileged-context.js
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+
+import { FirefoxDevTools } from './dist/index.js';
+
+async function test() {
+ console.log('=== Test: Privileged Context Script Evaluation (headless) ===\n');
+
+ const firefox = new FirefoxDevTools({
+ headless: true,
+ firefoxPath: process.env.HOME + '/firefox/firefox',
+ env: {
+ MOZ_REMOTE_ALLOW_SYSTEM_ACCESS: '1',
+ },
+ });
+
+ await firefox.connect();
+ console.log('✓ Firefox started with MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1');
+
+ // Test content script first
+ console.log('\n--- Testing content script (default context) ---');
+ await firefox.navigate('https://example.com');
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ try {
+ const title = await firefox.evaluate('return document.title');
+ console.log(`✓ Content context: document.title = "${title}"`);
+ } catch (err) {
+ console.log(`✗ Content script failed: ${err.message}`);
+ }
+
+ // Now test privileged context via BiDi with moz:scope
+ console.log('\n--- Testing privileged context listing ---');
+
+ try {
+ // Use BiDi to list privileged contexts with moz:scope
+ const result = await firefox.sendBiDiCommand('browsingContext.getTree', {
+ 'moz:scope': 'chrome',
+ });
+
+ const contexts = result.contexts || [];
+ console.log(`✓ Listed ${contexts.length} privileged context(s) via BiDi`);
+
+ if (contexts.length > 0) {
+ console.log(' Sample privileged contexts:');
+ contexts.slice(0, 3).forEach((ctx) => {
+ console.log(` ${ctx.context}: ${ctx.url || '(no url)'}`);
+ });
+
+ // Try to evaluate in privileged context
+ console.log('\n--- Testing privileged script execution ---');
+
+ const driver = firefox.getDriver();
+ const firstContext = contexts[0];
+
+ // Switch to chrome browsing context
+ await driver.switchTo().window(firstContext.context);
+ console.log(`✓ Switched to privileged context: ${firstContext.context}`);
+
+ // Set Marionette context to chrome
+ try {
+ await driver.setContext('chrome');
+ console.log('✓ Set Marionette context to "chrome"');
+
+ // Now try to evaluate chrome-privileged script
+ const appName = await driver.executeScript('return Services.appinfo.name;');
+ console.log(`✓ Privileged script: Services.appinfo.name = "${appName}"`);
+
+ const version = await driver.executeScript('return Services.appinfo.version;');
+ console.log(`✓ Privileged script: Services.appinfo.version = "${version}"`);
+
+ const buildID = await driver.executeScript('return Services.appinfo.appBuildID;');
+ console.log(`✓ Privileged script: Services.appinfo.appBuildID = "${buildID}"`);
+
+ console.log('\n✅ Privileged context evaluation WORKS!');
+ } catch (err) {
+ console.log(`✗ Failed to set privileged context: ${err.message}`);
+ console.log(' Your Firefox build may not support privileged context');
+ }
+ } else {
+ console.log(' No privileged contexts found (requires dev/nightly build)');
+ }
+ } catch (err) {
+ if (err.message && err.message.includes('UnsupportedOperationError')) {
+ console.log('✗ Privileged context not supported by this Firefox build');
+ console.log(' Requires Firefox Nightly or custom build');
+ } else {
+ console.log(`✗ Privileged context test failed: ${err.message}`);
+ }
+ }
+
+ await firefox.close();
+ console.log('\n✓ Test completed');
+}
+
+test().catch((err) => {
+ console.error('\nTest failed:', err);
+ console.error(err.stack);
+ process.exit(1);
+});
diff --git a/tests/cli/prefs-parsing.test.ts b/tests/cli/prefs-parsing.test.ts
new file mode 100644
index 0000000..eb00c93
--- /dev/null
+++ b/tests/cli/prefs-parsing.test.ts
@@ -0,0 +1,120 @@
+/**
+ * Tests for preference parsing (parsePrefs function) and CLI --pref option
+ */
+
+import { describe, it, expect } from 'vitest';
+import { parsePrefs, parseArguments } from '../../src/cli.js';
+
+describe('parsePrefs', () => {
+ it('should return empty object for undefined input', () => {
+ expect(parsePrefs(undefined)).toEqual({});
+ });
+
+ it('should return empty object for empty array', () => {
+ expect(parsePrefs([])).toEqual({});
+ });
+
+ it('should parse simple string preference', () => {
+ expect(parsePrefs(['some.pref=value'])).toEqual({ 'some.pref': 'value' });
+ });
+
+ it('should parse boolean true', () => {
+ expect(parsePrefs(['some.pref=true'])).toEqual({ 'some.pref': true });
+ });
+
+ it('should parse boolean false', () => {
+ expect(parsePrefs(['some.pref=false'])).toEqual({ 'some.pref': false });
+ });
+
+ it('should parse integer', () => {
+ expect(parsePrefs(['some.pref=42'])).toEqual({ 'some.pref': 42 });
+ });
+
+ it('should parse negative integer', () => {
+ expect(parsePrefs(['some.pref=-5'])).toEqual({ 'some.pref': -5 });
+ });
+
+ it('should keep float as string (Firefox has no float pref)', () => {
+ expect(parsePrefs(['some.pref=3.14'])).toEqual({ 'some.pref': '3.14' });
+ });
+
+ it('should handle value containing equals sign', () => {
+ expect(parsePrefs(['url=https://x.com?a=b'])).toEqual({
+ url: 'https://x.com?a=b',
+ });
+ });
+
+ it('should skip malformed entries', () => {
+ expect(parsePrefs(['malformed', 'valid=value'])).toEqual({ valid: 'value' });
+ });
+
+ it('should handle empty value as empty string', () => {
+ expect(parsePrefs(['some.pref='])).toEqual({ 'some.pref': '' });
+ });
+
+ // Multiple prefs
+ it('should parse multiple preferences', () => {
+ expect(
+ parsePrefs([
+ 'bool.pref=true',
+ 'int.pref=42',
+ 'string.pref=hello',
+ ])
+ ).toEqual({
+ 'bool.pref': true,
+ 'int.pref': 42,
+ 'string.pref': 'hello',
+ });
+ });
+});
+
+describe('CLI --enable-script flag', () => {
+ it('should default to false', () => {
+ const args = parseArguments('1.0.0', ['node', 'script']);
+ expect(args.enableScript).toBe(false);
+ });
+
+ it('should be true when --enable-script is passed', () => {
+ const args = parseArguments('1.0.0', ['node', 'script', '--enable-script']);
+ expect(args.enableScript).toBe(true);
+ });
+});
+
+describe('CLI --enable-privileged-context flag', () => {
+ it('should default to false', () => {
+ const args = parseArguments('1.0.0', ['node', 'script']);
+ expect(args.enablePrivilegedContext).toBe(false);
+ });
+
+ it('should be true when --enable-privileged-context is passed', () => {
+ const args = parseArguments('1.0.0', ['node', 'script', '--enable-privileged-context']);
+ expect(args.enablePrivilegedContext).toBe(true);
+ });
+});
+
+describe('CLI --pref option', () => {
+ it('should accept --pref argument', () => {
+ const args = parseArguments('1.0.0', ['node', 'script', '--pref', 'test=value']);
+ expect(args.pref).toContain('test=value');
+ });
+
+ it('should accept -p alias', () => {
+ const args = parseArguments('1.0.0', ['node', 'script', '-p', 'test=value']);
+ expect(args.pref).toBeDefined();
+ expect(args.pref).toContain('test=value');
+ });
+
+ // Multiple prefs via CLI
+ it('should accept multiple --pref arguments', () => {
+ const args = parseArguments('1.0.0', [
+ 'node',
+ 'script',
+ '--pref',
+ 'pref1=value1',
+ '--pref',
+ 'pref2=value2',
+ ]);
+ expect(args.pref).toContain('pref1=value1');
+ expect(args.pref).toContain('pref2=value2');
+ });
+});
diff --git a/tests/firefox/core-prefs.test.ts b/tests/firefox/core-prefs.test.ts
new file mode 100644
index 0000000..030217f
--- /dev/null
+++ b/tests/firefox/core-prefs.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Tests for FirefoxCore --pref handling via moz:firefoxOptions
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock the index module to prevent actual Firefox connection
+const mockGetFirefox = vi.hoisted(() => vi.fn());
+
+vi.mock('../../src/index.js', () => ({
+ getFirefox: mockGetFirefox,
+}));
+
+describe('FirefoxCore prefs via firefoxOptions', () => {
+ const mockSetPreference = vi.fn();
+ const mockGetWindowHandle = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+ mockGetWindowHandle.mockResolvedValue('content-context-id');
+ });
+
+ function mockSelenium(extraOptions: Record = {}) {
+ vi.doMock('selenium-webdriver/firefox.js', () => ({
+ default: {
+ Options: vi.fn(() => ({
+ enableBidi: vi.fn(),
+ addArguments: vi.fn(),
+ setBinary: vi.fn(),
+ setAcceptInsecureCerts: vi.fn(),
+ setPreference: mockSetPreference,
+ ...extraOptions,
+ })),
+ ServiceBuilder: vi.fn(() => ({ setStdio: vi.fn() })),
+ },
+ }));
+
+ vi.doMock('selenium-webdriver', () => ({
+ Builder: vi.fn(() => ({
+ forBrowser: vi.fn().mockReturnThis(),
+ setFirefoxOptions: vi.fn().mockReturnThis(),
+ setFirefoxService: vi.fn().mockReturnThis(),
+ build: vi.fn().mockResolvedValue({
+ getWindowHandle: mockGetWindowHandle,
+ get: vi.fn().mockResolvedValue(undefined),
+ }),
+ })),
+ Browser: { FIREFOX: 'firefox' },
+ }));
+ }
+
+ it('should not call setPreference when no prefs are provided', async () => {
+ mockSelenium();
+ const { FirefoxCore } = await import('../../src/firefox/core.js');
+ const core = new FirefoxCore({ headless: true });
+ await core.connect();
+ expect(mockSetPreference).not.toHaveBeenCalled();
+ });
+
+ it('should call setPreference for each pref at startup', async () => {
+ mockSelenium();
+ const { FirefoxCore } = await import('../../src/firefox/core.js');
+ const core = new FirefoxCore({
+ headless: true,
+ prefs: {
+ 'bool.pref': true,
+ 'int.pref': 42,
+ 'string.pref': 'hello',
+ },
+ });
+ await core.connect();
+ expect(mockSetPreference).toHaveBeenCalledTimes(3);
+ expect(mockSetPreference).toHaveBeenCalledWith('bool.pref', true);
+ expect(mockSetPreference).toHaveBeenCalledWith('int.pref', 42);
+ expect(mockSetPreference).toHaveBeenCalledWith('string.pref', 'hello');
+ });
+
+ it('should not require MOZ_REMOTE_ALLOW_SYSTEM_ACCESS', async () => {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+ mockSelenium();
+ const { FirefoxCore } = await import('../../src/firefox/core.js');
+ const core = new FirefoxCore({
+ headless: true,
+ prefs: { 'test.pref': 'value' },
+ });
+ await expect(core.connect()).resolves.not.toThrow();
+ expect(mockSetPreference).toHaveBeenCalledWith('test.pref', 'value');
+ });
+});
diff --git a/tests/firefox/core.test.ts b/tests/firefox/core.test.ts
index 523c495..aece07c 100644
--- a/tests/firefox/core.test.ts
+++ b/tests/firefox/core.test.ts
@@ -2,7 +2,7 @@
* Unit tests for FirefoxCore module
*/
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FirefoxCore } from '@/firefox/core.js';
import type { FirefoxLaunchOptions } from '@/firefox/types.js';
@@ -74,3 +74,235 @@ describe('FirefoxCore', () => {
});
});
});
+
+// Tests for connect() behavior with mocked Selenium
+describe('FirefoxCore connect() profile handling', () => {
+ // Mock selenium-webdriver/firefox.js at module level
+ const mockAddArguments = vi.fn();
+ const mockSetProfile = vi.fn();
+ const mockEnableBidi = vi.fn();
+ const mockSetBinary = vi.fn();
+ const mockWindowSize = vi.fn();
+ const mockSetAcceptInsecureCerts = vi.fn();
+ const mockSetStdio = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.resetModules();
+
+ vi.doMock('selenium-webdriver/firefox.js', () => ({
+ default: {
+ Options: vi.fn(() => ({
+ enableBidi: mockEnableBidi,
+ addArguments: mockAddArguments,
+ setProfile: mockSetProfile,
+ setBinary: mockSetBinary,
+ windowSize: mockWindowSize,
+ setAcceptInsecureCerts: mockSetAcceptInsecureCerts,
+ })),
+ ServiceBuilder: vi.fn(() => ({
+ setStdio: mockSetStdio,
+ })),
+ },
+ }));
+
+ vi.doMock('selenium-webdriver', () => ({
+ Builder: vi.fn(() => ({
+ forBrowser: vi.fn().mockReturnThis(),
+ setFirefoxOptions: vi.fn().mockReturnThis(),
+ setFirefoxService: vi.fn().mockReturnThis(),
+ build: vi.fn().mockResolvedValue({
+ getWindowHandle: vi.fn().mockResolvedValue('mock-context-id'),
+ get: vi.fn().mockResolvedValue(undefined),
+ }),
+ })),
+ Browser: { FIREFOX: 'firefox' },
+ }));
+ });
+
+ it('should pass profile path via --profile argument instead of setProfile', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const profilePath = '/path/to/test/profile';
+ const core = new FirefoxCore({
+ headless: true,
+ profilePath,
+ });
+
+ await core.connect();
+
+ // Assert: setProfile should NOT be called (it copies to temp dir)
+ expect(mockSetProfile).not.toHaveBeenCalled();
+
+ expect(mockAddArguments).toHaveBeenCalledWith('--profile', profilePath);
+ });
+});
+
+// Tests for sendBiDiCommand WebSocket handling
+describe('FirefoxCore sendBiDiCommand WebSocket readiness', () => {
+ it('should wait for WebSocket to open when in CONNECTING state', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const core = new FirefoxCore({ headless: true });
+
+ // Track event listeners and send calls
+ const eventListeners: Record = {};
+ const mockSend = vi.fn();
+
+ // Mock WebSocket in CONNECTING state (readyState 0)
+ const mockWs = {
+ readyState: 0, // CONNECTING
+ send: mockSend,
+ on: vi.fn((event: string, handler: Function) => {
+ if (!eventListeners[event]) eventListeners[event] = [];
+ eventListeners[event].push(handler);
+ }),
+ off: vi.fn(),
+ };
+
+ // Mock driver with BiDi socket
+ (core as any).driver = {
+ getBidi: vi.fn().mockResolvedValue({
+ socket: mockWs,
+ }),
+ };
+
+ // Start the command (don't await yet)
+ const commandPromise = core.sendBiDiCommand('test.method', { foo: 'bar' });
+
+ // Give the async code a tick to execute
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // ASSERT: send() should NOT have been called while still CONNECTING
+ expect(mockSend).not.toHaveBeenCalled();
+
+ // ASSERT: should have registered an 'open' event listener
+ expect(mockWs.on).toHaveBeenCalledWith('open', expect.any(Function));
+
+ // Now simulate WebSocket becoming OPEN
+ mockWs.readyState = 1; // OPEN
+ if (eventListeners['open']) {
+ eventListeners['open'].forEach((handler) => handler());
+ }
+
+ // Give another tick for send to be called
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // ASSERT: send() should now have been called
+ expect(mockSend).toHaveBeenCalledTimes(1);
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.stringContaining('"method":"test.method"')
+ );
+
+ // Simulate response to complete the promise
+ if (eventListeners['message']) {
+ const sentCommand = JSON.parse(mockSend.mock.calls[0][0]);
+ eventListeners['message'].forEach((handler) =>
+ handler(JSON.stringify({ id: sentCommand.id, result: { success: true } }))
+ );
+ }
+
+ const result = await commandPromise;
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should timeout if WebSocket never opens', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const core = new FirefoxCore({ headless: true });
+
+ // Track event listeners
+ const eventListeners: Record = {};
+
+ // Mock WebSocket stuck in CONNECTING state (never opens)
+ const mockWs = {
+ readyState: 0, // CONNECTING - stays this way
+ send: vi.fn(),
+ on: vi.fn((event: string, handler: Function) => {
+ if (!eventListeners[event]) eventListeners[event] = [];
+ eventListeners[event].push(handler);
+ }),
+ off: vi.fn(),
+ };
+
+ // Mock driver with BiDi socket
+ (core as any).driver = {
+ getBidi: vi.fn().mockResolvedValue({
+ socket: mockWs,
+ }),
+ };
+
+ // Access the private method directly to test with a short timeout
+ const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core);
+
+ // ASSERT: should reject with timeout error (using 50ms timeout for fast test)
+ await expect(waitForWebSocketOpen(mockWs, 50)).rejects.toThrow(
+ /timeout.*websocket/i
+ );
+ });
+
+ it('should throw error when WebSocket is CLOSING', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const core = new FirefoxCore({ headless: true });
+
+ // Mock WebSocket in CLOSING state (readyState 2)
+ const mockWs = {
+ readyState: 2, // CLOSING
+ send: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ };
+
+ // Access the private method directly
+ const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core);
+
+ // ASSERT: should throw immediately with descriptive error
+ await expect(waitForWebSocketOpen(mockWs)).rejects.toThrow(
+ /websocket is not open.*readystate 2/i
+ );
+ });
+
+ it('should throw error when WebSocket is CLOSED', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const core = new FirefoxCore({ headless: true });
+
+ // Mock WebSocket in CLOSED state (readyState 3)
+ const mockWs = {
+ readyState: 3, // CLOSED
+ send: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ };
+
+ // Access the private method directly
+ const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core);
+
+ // ASSERT: should throw immediately with descriptive error
+ await expect(waitForWebSocketOpen(mockWs)).rejects.toThrow(
+ /websocket is not open.*readystate 3/i
+ );
+ });
+
+ it('should proceed immediately when WebSocket is already OPEN', async () => {
+ const { FirefoxCore } = await import('@/firefox/core.js');
+
+ const core = new FirefoxCore({ headless: true });
+
+ // Mock WebSocket already in OPEN state (readyState 1)
+ const mockWs = {
+ readyState: 1, // OPEN
+ send: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ };
+
+ // Access the private method directly
+ const waitForWebSocketOpen = (core as any).waitForWebSocketOpen.bind(core);
+
+ // ASSERT: should resolve immediately without registering any listeners
+ await expect(waitForWebSocketOpen(mockWs)).resolves.toBeUndefined();
+ expect(mockWs.on).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/firefox/pref-utils.test.ts b/tests/firefox/pref-utils.test.ts
new file mode 100644
index 0000000..5a55879
--- /dev/null
+++ b/tests/firefox/pref-utils.test.ts
@@ -0,0 +1,40 @@
+/**
+ * Tests for Firefox preference utilities
+ */
+
+import { describe, it, expect } from 'vitest';
+import { generatePrefScript } from '../../src/firefox/pref-utils.js';
+
+describe('generatePrefScript', () => {
+ it('should generate setBoolPref for true', () => {
+ expect(generatePrefScript('p', true)).toBe('Services.prefs.setBoolPref("p", true)');
+ });
+
+ it('should generate setBoolPref for false', () => {
+ expect(generatePrefScript('p', false)).toBe('Services.prefs.setBoolPref("p", false)');
+ });
+
+ it('should generate setIntPref for number', () => {
+ expect(generatePrefScript('p', 42)).toBe('Services.prefs.setIntPref("p", 42)');
+ });
+
+ it('should generate setStringPref for string', () => {
+ expect(generatePrefScript('p', 'v')).toBe('Services.prefs.setStringPref("p", "v")');
+ });
+
+ it('should escape quotes in values', () => {
+ expect(generatePrefScript('p', 'a"b')).toBe('Services.prefs.setStringPref("p", "a\\"b")');
+ });
+
+ it('should escape quotes in preference names', () => {
+ expect(generatePrefScript('p"ref', 'v')).toBe('Services.prefs.setStringPref("p\\"ref", "v")');
+ });
+
+ it('should handle negative numbers', () => {
+ expect(generatePrefScript('p', -10)).toBe('Services.prefs.setIntPref("p", -10)');
+ });
+
+ it('should handle empty string value', () => {
+ expect(generatePrefScript('p', '')).toBe('Services.prefs.setStringPref("p", "")');
+ });
+});
diff --git a/tests/firefox/types.test.ts b/tests/firefox/types.test.ts
new file mode 100644
index 0000000..4a1cb37
--- /dev/null
+++ b/tests/firefox/types.test.ts
@@ -0,0 +1,32 @@
+/**
+ * Tests for Firefox type definitions
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { FirefoxLaunchOptions } from '../../src/firefox/types.js';
+
+describe('FirefoxLaunchOptions', () => {
+ it('should accept prefs field', () => {
+ const options: FirefoxLaunchOptions = {
+ headless: true,
+ prefs: { 'a': 'b', 'num': 42, 'bool': true },
+ };
+ expect(options.prefs).toBeDefined();
+ expect(options.prefs).toEqual({ 'a': 'b', 'num': 42, 'bool': true });
+ });
+
+ it('should accept empty prefs object', () => {
+ const options: FirefoxLaunchOptions = {
+ headless: true,
+ prefs: {},
+ };
+ expect(options.prefs).toEqual({});
+ });
+
+ it('should allow prefs to be undefined', () => {
+ const options: FirefoxLaunchOptions = {
+ headless: true,
+ };
+ expect(options.prefs).toBeUndefined();
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
index 1ecccea..c89651f 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -27,18 +27,30 @@ function cleanup() {
isCleaningUp = true;
try {
- // Kill all Firefox processes started with --marionette flag (test instances)
- execSync('pkill -9 -f "firefox.*marionette" 2>/dev/null || true', {
- stdio: 'ignore',
- });
+ // Find Firefox processes started with --marionette (test instances)
+ const firefoxPids = execSync('pgrep -f "firefox.*marionette" || true', {
+ encoding: 'utf-8',
+ })
+ .trim()
+ .split('\n')
+ .filter(Boolean);
- // Kill all geckodriver processes
- execSync('pkill -9 -f geckodriver 2>/dev/null || true', {
- stdio: 'ignore',
- });
+ // Kill children of each Firefox test process, then kill the parent
+ for (const pid of firefoxPids) {
+ try {
+ execSync(`pkill -9 -P ${pid} 2>/dev/null || true`, { stdio: 'ignore' });
+ } catch {
+ // Ignore errors - child processes might already be dead
+ }
+ try {
+ execSync(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'ignore' });
+ } catch {
+ // Ignore errors - process might already be dead
+ }
+ }
- // Kill plugin containers (child processes of Firefox)
- execSync('pkill -9 -f "plugin-container" 2>/dev/null || true', {
+ // Kill all geckodriver processes
+ execSync('pkill -9 -f geckodriver || true', {
stdio: 'ignore',
});
diff --git a/tests/tools/firefox-management.test.ts b/tests/tools/firefox-management.test.ts
new file mode 100644
index 0000000..f8467ca
--- /dev/null
+++ b/tests/tools/firefox-management.test.ts
@@ -0,0 +1,259 @@
+/**
+ * Unit tests for Firefox management tools (restart_firefox, get_firefox_info, get_firefox_output)
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { restartFirefoxTool, getFirefoxInfoTool } from '../../src/tools/firefox-management.js';
+
+// Create mock functions that will be used in the hoisted mock
+const mockSetNextLaunchOptions = vi.hoisted(() => vi.fn());
+const mockResetFirefox = vi.hoisted(() => vi.fn());
+const mockGetFirefoxIfRunning = vi.hoisted(() => vi.fn());
+const mockArgs = vi.hoisted(() => ({
+ firefoxPath: undefined as string | undefined,
+ profilePath: undefined as string | undefined,
+}));
+
+const mockGetFirefox = vi.hoisted(() => vi.fn());
+
+vi.mock('../../src/index.js', () => ({
+ args: mockArgs,
+ getFirefoxIfRunning: () => mockGetFirefoxIfRunning(),
+ setNextLaunchOptions: (opts: unknown) => mockSetNextLaunchOptions(opts),
+ resetFirefox: () => mockResetFirefox(),
+ getFirefox: () => mockGetFirefox(),
+}));
+
+describe('Firefox Management Tools', () => {
+ describe('restartFirefoxTool schema', () => {
+ it('should have profilePath in input schema properties', () => {
+ const { properties } = restartFirefoxTool.inputSchema as {
+ properties: Record;
+ };
+ expect(properties.profilePath).toBeDefined();
+ expect(properties.profilePath.type).toBe('string');
+ expect(properties.profilePath.description).toContain('profile');
+ });
+
+ it('should have prefs in input schema properties', () => {
+ const { properties } = restartFirefoxTool.inputSchema as {
+ properties: Record;
+ };
+ expect(properties.prefs).toBeDefined();
+ expect(properties.prefs.type).toBe('object');
+ });
+ });
+
+ describe('handleRestartFirefox', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockArgs.firefoxPath = undefined;
+ mockArgs.profilePath = undefined;
+ });
+
+ describe('when Firefox is NOT running', () => {
+ beforeEach(() => {
+ mockGetFirefoxIfRunning.mockReturnValue(null);
+ mockArgs.firefoxPath = '/path/to/firefox';
+ });
+
+ it('should use provided profilePath in launch options', async () => {
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({ profilePath: '/custom/profile' });
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: '/custom/profile',
+ })
+ );
+ });
+
+ it('should fall back to args.profilePath when profilePath not specified', async () => {
+ mockArgs.profilePath = '/cli/profile';
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({});
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: '/cli/profile',
+ })
+ );
+ });
+
+ it('should use provided profilePath over args.profilePath', async () => {
+ mockArgs.profilePath = '/cli/profile';
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({ profilePath: '/override/profile' });
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: '/override/profile',
+ })
+ );
+ });
+
+ it('should set profilePath to undefined when neither provided nor in CLI args', async () => {
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({});
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: undefined,
+ })
+ );
+ });
+ });
+
+ describe('when Firefox IS running', () => {
+ const mockFirefoxInstance = {
+ getOptions: vi.fn(),
+ isConnected: vi.fn(),
+ close: vi.fn(),
+ };
+
+ beforeEach(() => {
+ mockGetFirefoxIfRunning.mockReturnValue(mockFirefoxInstance);
+ mockFirefoxInstance.isConnected.mockResolvedValue(true);
+ mockFirefoxInstance.close.mockResolvedValue(undefined);
+ mockFirefoxInstance.getOptions.mockReturnValue({
+ firefoxPath: '/current/firefox',
+ profilePath: '/current/profile',
+ headless: false,
+ env: {},
+ });
+ });
+
+ it('should preserve currentOptions.profilePath when not specified', async () => {
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({});
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: '/current/profile',
+ })
+ );
+ });
+
+ it('should use provided profilePath when specified', async () => {
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({ profilePath: '/new/profile' });
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ profilePath: '/new/profile',
+ })
+ );
+ });
+
+ it('should include profilePath in change summary when changed', async () => {
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ const result = await handleRestartFirefox({ profilePath: '/new/profile' });
+
+ const text = result.content[0].text;
+ expect(text).toContain('Profile');
+ expect(text).toContain('/new/profile');
+ });
+
+ it('should merge prefs into launch options', async () => {
+ mockFirefoxInstance.getOptions.mockReturnValue({
+ firefoxPath: '/current/firefox',
+ profilePath: '/current/profile',
+ headless: false,
+ env: {},
+ prefs: { 'existing.pref': 'old' },
+ });
+
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({
+ prefs: { 'new.pref': 'value', 'existing.pref': 'new' },
+ });
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prefs: { 'existing.pref': 'new', 'new.pref': 'value' },
+ })
+ );
+ });
+
+ it('should preserve existing prefs when none provided', async () => {
+ mockFirefoxInstance.getOptions.mockReturnValue({
+ firefoxPath: '/current/firefox',
+ profilePath: '/current/profile',
+ headless: false,
+ env: {},
+ prefs: { 'existing.pref': 'value' },
+ });
+
+ const { handleRestartFirefox } = await import('../../src/tools/firefox-management.js');
+
+ await handleRestartFirefox({});
+
+ expect(mockSetNextLaunchOptions).toHaveBeenCalledWith(
+ expect.objectContaining({
+ prefs: { 'existing.pref': 'value' },
+ })
+ );
+ });
+ });
+ });
+
+ describe('handleGetFirefoxInfo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should include prefs in output when configured', async () => {
+ const mockFirefoxWithPrefs = {
+ getOptions: vi.fn().mockReturnValue({
+ firefoxPath: '/path/to/firefox',
+ headless: true,
+ prefs: {
+ 'bool.pref': true,
+ 'int.pref': 42,
+ 'string.pref': 'hello',
+ },
+ }),
+ getLogFilePath: vi.fn().mockReturnValue(undefined),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefoxWithPrefs);
+
+ const { handleGetFirefoxInfo } = await import('../../src/tools/firefox-management.js');
+
+ const result = await handleGetFirefoxInfo({});
+
+ const text = result.content[0].text;
+ expect(text).toContain('Preferences:');
+ expect(text).toContain('bool.pref = true');
+ expect(text).toContain('int.pref = 42');
+ expect(text).toContain('string.pref = "hello"');
+ });
+
+ it('should not show preferences section when none configured', async () => {
+ const mockFirefoxNoPrefs = {
+ getOptions: vi.fn().mockReturnValue({
+ firefoxPath: '/path/to/firefox',
+ headless: true,
+ }),
+ getLogFilePath: vi.fn().mockReturnValue(undefined),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefoxNoPrefs);
+
+ const { handleGetFirefoxInfo } = await import('../../src/tools/firefox-management.js');
+
+ const result = await handleGetFirefoxInfo({});
+
+ const text = result.content[0].text;
+ expect(text).not.toContain('Preferences:');
+ });
+ });
+});
diff --git a/tests/tools/firefox-prefs.test.ts b/tests/tools/firefox-prefs.test.ts
new file mode 100644
index 0000000..cfc2d60
--- /dev/null
+++ b/tests/tools/firefox-prefs.test.ts
@@ -0,0 +1,372 @@
+/**
+ * Tests for Firefox preferences tools
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ setFirefoxPrefsTool,
+ getFirefoxPrefsTool,
+ handleSetFirefoxPrefs,
+ handleGetFirefoxPrefs,
+} from '../../src/tools/firefox-prefs.js';
+
+// Mock the index module
+const mockGetFirefox = vi.hoisted(() => vi.fn());
+
+vi.mock('../../src/index.js', () => ({
+ getFirefox: () => mockGetFirefox(),
+}));
+
+describe('Firefox Prefs Tool Definitions', () => {
+ describe('setFirefoxPrefsTool', () => {
+ it('should have correct name', () => {
+ expect(setFirefoxPrefsTool.name).toBe('set_firefox_prefs');
+ });
+
+ it('should require prefs parameter', () => {
+ const schema = setFirefoxPrefsTool.inputSchema as {
+ required?: string[];
+ };
+ expect(schema.required).toContain('prefs');
+ });
+
+ it('should have description', () => {
+ expect(setFirefoxPrefsTool.description).toBeDefined();
+ expect(setFirefoxPrefsTool.description.length).toBeGreaterThan(0);
+ });
+
+ it('should define prefs as object type', () => {
+ const schema = setFirefoxPrefsTool.inputSchema as {
+ properties?: Record;
+ };
+ expect(schema.properties?.prefs?.type).toBe('object');
+ });
+ });
+
+ describe('getFirefoxPrefsTool', () => {
+ it('should have correct name', () => {
+ expect(getFirefoxPrefsTool.name).toBe('get_firefox_prefs');
+ });
+
+ it('should require names parameter', () => {
+ const schema = getFirefoxPrefsTool.inputSchema as {
+ required?: string[];
+ };
+ expect(schema.required).toContain('names');
+ });
+
+ it('should have description', () => {
+ expect(getFirefoxPrefsTool.description).toBeDefined();
+ expect(getFirefoxPrefsTool.description.length).toBeGreaterThan(0);
+ });
+
+ it('should define names as array type', () => {
+ const schema = getFirefoxPrefsTool.inputSchema as {
+ properties?: Record;
+ };
+ expect(schema.properties?.names?.type).toBe('array');
+ });
+ });
+});
+
+describe('Firefox Prefs Tool Handlers', () => {
+ const mockExecuteScript = vi.fn();
+ const mockSetContext = vi.fn();
+ const mockSwitchToWindow = vi.fn();
+ const mockSendBiDiCommand = vi.fn();
+
+ let originalEnv: string | undefined;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ originalEnv = process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+ });
+
+ afterEach(() => {
+ if (originalEnv !== undefined) {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = originalEnv;
+ } else {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+ }
+ });
+
+ describe('handleSetFirefoxPrefs', () => {
+ it('should return error when prefs parameter is missing', async () => {
+ const result = await handleSetFirefoxPrefs({});
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('prefs parameter is required');
+ });
+
+ it('should return success when prefs is empty', async () => {
+ const result = await handleSetFirefoxPrefs({ prefs: {} });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('No preferences to set');
+ });
+
+ it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no privileged contexts', async () => {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+
+ // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no privileged contexts are available
+ mockSendBiDiCommand.mockResolvedValue({ contexts: [] });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn(),
+ getCurrentContextId: vi.fn(),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS');
+ });
+
+ it('should set preferences successfully', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleSetFirefoxPrefs({
+ prefs: { 'test.bool': true, 'test.int': 42, 'test.string': 'hello' },
+ });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Set 3 preference(s)');
+ expect(mockExecuteScript).toHaveBeenCalledTimes(3);
+ expect(mockExecuteScript).toHaveBeenCalledWith(
+ 'Services.prefs.setBoolPref("test.bool", true)'
+ );
+ expect(mockExecuteScript).toHaveBeenCalledWith('Services.prefs.setIntPref("test.int", 42)');
+ expect(mockExecuteScript).toHaveBeenCalledWith(
+ 'Services.prefs.setStringPref("test.string", "hello")'
+ );
+ });
+
+ it('should handle partial failures gracefully', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+
+ mockExecuteScript
+ .mockResolvedValueOnce(undefined) // first pref succeeds
+ .mockRejectedValueOnce(new Error('Pref error')); // second fails
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleSetFirefoxPrefs({
+ prefs: { 'good.pref': 'value', 'bad.pref': 'value' },
+ });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Set 1 preference(s)');
+ expect(result.content[0].text).toContain('Failed to set 1 preference(s)');
+ });
+
+ it('should return error when no privileged contexts available', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({ contexts: [] });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn(),
+ getCurrentContextId: vi.fn(),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('No privileged contexts');
+ });
+
+ it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleSetFirefoxPrefs({ prefs: { 'test.pref': 'value' } });
+
+ expect(mockGetFirefox).toHaveBeenCalled();
+ expect(result.isError).toBeUndefined();
+ });
+ });
+
+ describe('handleGetFirefoxPrefs', () => {
+ it('should return error when names parameter is missing', async () => {
+ const result = await handleGetFirefoxPrefs({});
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('names parameter is required');
+ });
+
+ it('should return error when names is empty array', async () => {
+ const result = await handleGetFirefoxPrefs({ names: [] });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('names parameter is required');
+ });
+
+ it('should return helpful error when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS results in no privileged contexts', async () => {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+
+ // Without MOZ_REMOTE_ALLOW_SYSTEM_ACCESS, no privileged contexts are available
+ mockSendBiDiCommand.mockResolvedValue({ contexts: [] });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn(),
+ getCurrentContextId: vi.fn(),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleGetFirefoxPrefs({ names: ['test.pref'] });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('MOZ_REMOTE_ALLOW_SYSTEM_ACCESS');
+ });
+
+ it('should get preferences successfully', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+
+ mockExecuteScript.mockResolvedValue({ exists: true, value: 'test-value' });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleGetFirefoxPrefs({ names: ['test.pref'] });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('Firefox Preferences');
+ expect(result.content[0].text).toContain('test.pref');
+ expect(result.content[0].text).toContain('"test-value"');
+ });
+
+ it('should handle non-existent preferences', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+
+ mockExecuteScript.mockResolvedValue({ exists: false });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleGetFirefoxPrefs({ names: ['nonexistent.pref'] });
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content[0].text).toContain('(not set)');
+ });
+
+ it('should return error when no privileged contexts available', async () => {
+ process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS = '1';
+
+ mockSendBiDiCommand.mockResolvedValue({ contexts: [] });
+
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn(),
+ getCurrentContextId: vi.fn(),
+ };
+
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleGetFirefoxPrefs({ names: ['test.pref'] });
+
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('No privileged contexts');
+ });
+
+ it('should call getFirefox even when MOZ_REMOTE_ALLOW_SYSTEM_ACCESS not in process.env', async () => {
+ delete process.env.MOZ_REMOTE_ALLOW_SYSTEM_ACCESS;
+
+ mockSendBiDiCommand.mockResolvedValue({
+ contexts: [{ context: 'chrome-context-id' }],
+ });
+ mockExecuteScript.mockResolvedValue({ exists: true, value: 'test-value' });
+ const mockFirefox = {
+ sendBiDiCommand: mockSendBiDiCommand,
+ getDriver: vi.fn().mockReturnValue({
+ switchTo: () => ({ window: mockSwitchToWindow }),
+ setContext: mockSetContext,
+ executeScript: mockExecuteScript,
+ }),
+ getCurrentContextId: vi.fn().mockReturnValue('content-context-id'),
+ };
+ mockGetFirefox.mockResolvedValue(mockFirefox);
+
+ const result = await handleGetFirefoxPrefs({ names: ['test.pref'] });
+
+ expect(mockGetFirefox).toHaveBeenCalled();
+ expect(result.isError).toBeUndefined();
+ });
+ });
+});
diff --git a/tests/utils/errors.test.ts b/tests/utils/errors.test.ts
index a421c7a..60a3942 100644
--- a/tests/utils/errors.test.ts
+++ b/tests/utils/errors.test.ts
@@ -6,15 +6,15 @@ describe('FirefoxDisconnectedError', () => {
const error = new FirefoxDisconnectedError();
expect(error.name).toBe('FirefoxDisconnectedError');
expect(error.message).toContain('Firefox browser is not connected');
- expect(error.message).toContain('ask the user to restart');
- expect(error.message).toContain('firefox-devtools-mcp server');
+ expect(error.message).toContain('restart_firefox tool');
+ expect(error.message).toContain('firefoxPath parameter');
});
it('should create error with custom reason', () => {
const error = new FirefoxDisconnectedError('Browser was closed');
expect(error.message).toContain('Browser was closed');
expect(error.message).toContain('Firefox browser is not connected');
- expect(error.message).toContain('restart Claude Code or the MCP connection');
+ expect(error.message).toContain('restart_firefox tool');
});
it('should be instanceof Error', () => {