# Directory Structure
```
├── .github
│ ├── addon_badge.svg
│ └── workflows
│ └── release.yml
├── .gitignore
├── CLAUDE.md
├── common
│ ├── extension-messages.ts
│ ├── index.ts
│ └── server-messages.ts
├── CONTRIBUTING.md
├── Dockerfile
├── firefox-extension
│ ├── __tests__
│ │ ├── message-handler.test.ts
│ │ └── setup.ts
│ ├── assets
│ │ └── caret.svg
│ ├── auth.ts
│ ├── background.ts
│ ├── client.ts
│ ├── extension-config.ts
│ ├── jest.config.js
│ ├── manifest.json
│ ├── message-handler.ts
│ ├── nx.json
│ ├── options.html
│ ├── options.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── tsconfig.json
│ └── types
│ └── browserTabGroups.d.ts
├── LICENSE
├── mcp-server
│ ├── browser-api.ts
│ ├── manifest.json
│ ├── package-lock.json
│ ├── package.json
│ ├── server.ts
│ ├── tsconfig.json
│ └── util.ts
├── nx.json
├── package-lock.json
├── package.json
├── pnpm-workspace.yaml
└── README.md
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Dependency directories
11 | node_modules/
12 | jspm_packages/
13 |
14 | # TypeScript cache
15 | *.tsbuildinfo
16 |
17 | # Optional npm cache directory
18 | .npm
19 |
20 | # Optional eslint cache
21 | .eslintcache
22 |
23 | # Optional stylelint cache
24 | .stylelintcache
25 |
26 | # dotenv environment variable files and config files
27 | .env
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 | .env.local
32 |
33 | dist
34 |
35 | .temp
36 | .cache
37 |
38 | # Stores VSCode versions used for testing VSCode extensions
39 | .vscode
40 | .vscode-test
41 |
42 | # yarn v2
43 | .yarn/cache
44 | .yarn/unplugged
45 | .yarn/build-state.yml
46 | .yarn/install-state.gz
47 | .pnp.*
48 |
49 | .nx
50 |
51 | # DXT
52 | *.dxt
53 |
54 | # Web extension dist
55 | web-ext-artifacts/
56 |
57 | .DS_Store
58 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Browser Control MCP
2 |
3 | [](https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/)
4 |
5 | An MCP server paired with a Firefox browser extension that provides AI assistants with access to tab management, browsing history, and webpage text content.
6 |
7 | ## Features
8 |
9 | The MCP server supports the following tools:
10 | - Open or close tabs
11 | - Get the list of opened tabs
12 | - Create tab groups with name and color
13 | - Reorder opened tabs
14 | - Read and search the browser's history
15 | - Read a webpage's text content and links (requires user consent)
16 | - Find and highlight text in a browser tab (requires user consent)
17 |
18 | ## Example use-cases:
19 |
20 | ### Tab management
21 | - *"Close all non-work-related tabs in my browser."*
22 | - *"Group all development related tabs in my browser into a new group called 'Development'."*
23 | - *"Rearrange tabs in my browser in an order that makes sense."*
24 | - *"Close all tabs in my browser that haven't been accessed within the past 24 hours"*
25 |
26 | ### Browser history search
27 | - *"Help me find an article in my browser history about the Milford track in NZ."*
28 | - *"Open all the articles about AI that I visited during the last week, up to 10 articles, avoid duplications."*
29 |
30 | ### Browsing and research
31 | - *"Open hackernews in my browser, then open the top story, read it, also read the comments. Do the comments agree with the story?"*
32 | - *"In my browser, use Google Scholar to search for papers about L-theanine in the last 3 years. Open the 3 most cited papers. Read them and summarize them for me."*
33 | - *"Use Google search in my browser to look for flower shops. Open the 10 most relevant results. Show me a table of each flower shop with location and opening hours."*
34 |
35 | ## Comparison to web automation MCP servers
36 |
37 | The MCP server and Firefox extension combo is designed to be more secure than web automation MCP servers, enabling safer use with the user's personal browser.
38 |
39 | * It does not support web page modification, page interactions, or arbitrary scripting.
40 | * Reading webpage content requires the user's explicit consent in the browser for each domain. This is enforced at the extension's manifest level.
41 | * It uses a local-only connection with a shared secret between the MCP server and extension.
42 | * No remote data collection or tracking.
43 | * It provides an extension-side audit log for tool calls and tool enable/disable configuration.
44 | * The extension includes no runtime third-party dependencies.
45 |
46 | **Important note**: Browser Control MCP is still experimental. Use at your own risk. You should practice caution as with any other MCP server and authorize/monitor tool calls carefully.
47 |
48 | ## Installation
49 |
50 | Update: Due to [an issue with MCP startup](https://github.com/modelcontextprotocol/servers/issues/812), the MCP server does not currently work with **Claude Desktop**.
51 |
52 | ### Option 1: Install the Firefox and Claude Desktop extensions
53 |
54 | The Firefox extension / add-on is [available on addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/). You can also download and open the latest pre-built version from this GitHub repository: [browser-control-mcp-1.5.0.xpi](https://github.com/eyalzh/browser-control-mcp/releases/download/v1.5.0/browser-control-1.5.0.xpi). Complete the installation based on the instructions in the "Manage extension" page, which will open automatically after installation.
55 |
56 | The add-on's "Manage extension" page will include a link to the Claude Desktop DXT file. You can also download it here: [mcp-server-v1.5.1.dxt](
57 | https://github.com/eyalzh/browser-control-mcp/releases/download/v1.5.1/mcp-server-v1.5.1.dxt). After downloading the file, open it or drag it into Claude Desktop's settings window. Make sure to enable the DXT extension after installing it. This will only work with the latest versions of Claude Desktop. If you wish to install the MCP server locally, see the MCP configuration below.
58 |
59 | ### Option 2: Build from code
60 |
61 | To build from code, clone this repository, then run the following commands in the main repository directory to build both the MCP server and the browser extension.
62 | ```
63 | npm install
64 | npm run build
65 | ```
66 |
67 | #### Installing a Firefox Temporary Add-on
68 |
69 | To install the extension on Firefox as a Temporary Add-on:
70 |
71 | 1. Type `about:debugging` in the Firefox URL bar
72 | 2. Click on "This Firefox"
73 | 3. click on "Load Temporary Add-on..."
74 | 4. Select the `manifest.json` file under the `firefox-extension` folder in this project
75 | 5. The extension's preferences page will open. Copy the secret key to your clipboard. It will be used to configure the MCP server.
76 |
77 | Alternatively, to install a permanent add-on, you can install the [Browser Control MCP on addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/) and then configure the MCP Server as detailed below.
78 |
79 | If you prefer not to run the extension on your personal Firefox browser, an alternative is to download a separate Firefox instance (such as Firefox Developer Edition, available at https://www.mozilla.org/en-US/firefox/developer/).
80 |
81 |
82 | #### MCP Server configuration
83 |
84 | After installing the browser extension, add the following configuration to your mcpServers configuration (e.g. `claude_desktop_config.json` for Claude Desktop):
85 | ```json
86 | {
87 | "mcpServers": {
88 | "browser-control": {
89 | "command": "node",
90 | "args": [
91 | "/path/to/repo/mcp-server/dist/server.js"
92 | ],
93 | "env": {
94 | "EXTENSION_SECRET": "<secret_on_firefox_extension_options_page>",
95 | "EXTENSION_PORT": "8089"
96 | }
97 | }
98 | }
99 | }
100 | ```
101 | Replace `/path/to/repo` with the correct path.
102 |
103 | Set the EXTENSION_SECRET to the value shown on the extension's preferences page in Firefox (you can access it at `about:addons`). You can also set the EXTENSION_PORT environment variable to specify the port that the MCP server will use to communicate with the extension (default is 8089).
104 |
105 | It might take a few seconds for the MCP server to connect to the extension.
106 |
107 | ##### Configure the MCP server with Docker
108 |
109 | Alternatively, you can use a Docker-based configuration. To do so, build the mcp-server Docker image:
110 | ```
111 | docker build -t browser-control-mcp .
112 | ```
113 |
114 | and use the following mcpServers configuration:
115 |
116 | ```json
117 | {
118 | "mcpServers": {
119 | "browser-control": {
120 | "command": "docker",
121 | "args": [
122 | "run",
123 | "--rm",
124 | "-i",
125 | "-p", "127.0.0.1:8089:8089",
126 | "-e", "EXTENSION_SECRET=<secret_from_extension>",
127 | "-e", "CONTAINERIZED=true",
128 | "browser-control-mcp"
129 | ]
130 | }
131 | }
132 | }
133 | ```
134 |
135 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Contributing to Browser Control MCP
2 |
3 | We welcome pull requests for adding new features and tools to the extension, as well as for bug fixes.
4 |
5 | ## Development Guidelines
6 |
7 | ### Testing Requirements
8 | - Make sure to update the Firefox extension unit tests when making changes
9 | - Test the MCP server integration with Claude Desktop
10 | - Test the Firefox extension on Firefox Developer Edition
11 |
12 | ### Compatibility
13 | - Keep backwards and forward compatibility in mind when making changes
14 | - Ensure changes work across different versions of Firefox and Claude Desktop
15 |
16 | ### Security and Privacy
17 | Security and privacy are the core design principles of this solution. Please ensure that:
18 | - All browser interactions require explicit user consent
19 | - No sensitive data is logged or transmitted unnecessarily
20 | - Extension permissions are minimal and justified
21 | - WebSocket communication uses proper authentication
22 |
23 | ## Getting Started
24 |
25 | See the main README.md for setup instructions and the CLAUDE.md file for development commands.
26 |
27 | ## Pull Request Process
28 |
29 | 1. Fork the repository
30 | 2. Create a feature branch
31 | 3. Make your changes with appropriate tests
32 | 4. Run the test suite: `cd firefox-extension && npm test`
33 | 5. Build all projects: `npm run build`
34 | 6. Test manually with Claude Desktop and Firefox Developer Edition
35 | 7. Submit a pull request with a clear description of changes
```
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
```markdown
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Commands
6 |
7 | ### Installation
8 | ```bash
9 | npm install # Install all dependencies (includes subproject dependencies)
10 | ```
11 |
12 | ### Build
13 | ```bash
14 | npm run build # Build all projects using nx
15 | ```
16 |
17 | ### Individual project builds
18 | ```bash
19 | # MCP Server
20 | cd mcp-server && npm run build
21 |
22 | # Firefox Extension
23 | cd firefox-extension && npm run build
24 | ```
25 |
26 | ### Test
27 | ```bash
28 | cd firefox-extension && npm test
29 | ```
30 |
31 | ### Start MCP Server
32 | ```bash
33 | cd mcp-server && npm start
34 | ```
35 |
36 | ### Package DXT
37 | ```bash
38 | cd mcp-server && npm run pack-dxt
39 | ```
40 |
41 | ## Architecture
42 |
43 | This is a monorepo with three main components:
44 |
45 | 1. **mcp-server**: Node.js MCP server that communicates with Claude Desktop and the browser extension via WebSocket
46 | 2. **firefox-extension**: Firefox browser extension that executes browser actions
47 | 3. **common**: Shared TypeScript interfaces for message passing between server and extension
48 |
49 | ### Communication Flow
50 | - MCP Server ↔ Claude Desktop: MCP protocol over stdio
51 | - MCP Server ↔ Firefox Extension: WebSocket with authentication via shared secret
52 | - Extension uses Firefox WebExtensions API for browser control
53 |
54 | ### Key Files
55 | - `mcp-server/server.ts`: Main MCP server with tool definitions
56 | - `mcp-server/browser-api.ts`: WebSocket client for extension communication
57 | - `firefox-extension/background.ts`: Extension background script
58 | - `firefox-extension/message-handler.ts`: Handles server messages and executes browser actions
59 | - `common/server-messages.ts`: Messages sent from server to extension
60 | - `common/extension-messages.ts`: Messages sent from extension to server
61 |
62 | ### Authentication
63 | The extension generates a random secret key that must be configured in the MCP server's environment as `EXTENSION_SECRET`. The server connects to the extension on port 8089 (configurable via `EXTENSION_PORT`).
64 |
65 | ### Development Notes
66 | - Uses esbuild for extension bundling
67 | - TypeScript throughout with shared interfaces
68 | - Jest for testing (extension only)
69 | - Nx for monorepo management
70 | - Extension requires user consent for accessing webpage content by default
```
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
```yaml
1 | packages:
2 | - 'firefox-extension'
3 | - 'mcp-server'
4 |
5 |
```
--------------------------------------------------------------------------------
/firefox-extension/nx.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "./node_modules/nx/schemas/nx-schema.json"
3 | }
```
--------------------------------------------------------------------------------
/nx.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "./node_modules/nx/schemas/nx-schema.json"
3 | }
```
--------------------------------------------------------------------------------
/common/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export * from "./server-messages";
2 | export * from "./extension-messages";
3 |
```
--------------------------------------------------------------------------------
/firefox-extension/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "commonjs",
5 | "outDir": "dist/",
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "skipLibCheck": true
10 | }
11 | }
12 |
```
--------------------------------------------------------------------------------
/mcp-server/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "commonjs",
5 | "outDir": "dist/",
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "skipLibCheck": true
10 | }
11 | }
12 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "browser-control-mcp",
3 | "version": "1.5.0",
4 | "private": true,
5 | "scripts": {
6 | "postinstall": "npm install --prefix mcp-server && npm install --prefix firefox-extension",
7 | "build": "nx run-many --target=build --all --parallel"
8 | },
9 | "devDependencies": {
10 | "nx": "20.6.0"
11 | }
12 | }
```
--------------------------------------------------------------------------------
/firefox-extension/assets/caret.svg:
--------------------------------------------------------------------------------
```
1 | <?xml version="1.0" encoding="utf-8"?>
2 | <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
3 | <svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M106.666667 659.2L172.8 725.333333 512 386.133333 851.2 725.333333l66.133333-66.133333L512 256z" fill="#2196F3" /></svg>
```
--------------------------------------------------------------------------------
/firefox-extension/jest.config.js:
--------------------------------------------------------------------------------
```javascript
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'jsdom',
5 | setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
6 | moduleNameMapper: {
7 | '^@browser-control-mcp/common$': '<rootDir>/../common'
8 | },
9 | transform: {
10 | '^.+\\.tsx?$': ['ts-jest', {
11 | tsconfig: 'tsconfig.json',
12 | }],
13 | },
14 | testMatch: ['**/__tests__/**/*.test.ts'],
15 | };
16 |
```
--------------------------------------------------------------------------------
/firefox-extension/manifest.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "manifest_version": 2,
3 | "name": "Browser Control MCP",
4 | "version": "1.5.0",
5 | "description": "An extension that allows a local MCP server to perform actions on the browser.",
6 | "permissions": [
7 | "tabs",
8 | "tabGroups",
9 | "history",
10 | "storage"
11 | ],
12 | "optional_permissions": [
13 | "*://*/*",
14 | "find"
15 | ],
16 | "background": {
17 | "scripts": ["dist/background.js"]
18 | },
19 | "options_ui": {
20 | "page": "options.html"
21 | }
22 | }
23 |
```
--------------------------------------------------------------------------------
/mcp-server/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-server",
3 | "version": "1.5.1",
4 | "main": "dist/server.js",
5 | "engines": {
6 | "node": ">=22.0.0"
7 | },
8 | "scripts": {
9 | "build": "tsc",
10 | "start": "node dist/server.js",
11 | "pack-dxt": "npx @anthropic-ai/dxt pack"
12 | },
13 | "license": "MIT",
14 | "description": "Browser Control MCP Server",
15 | "dependencies": {
16 | "@browser-control-mcp/common": "../common",
17 | "@modelcontextprotocol/sdk": "^1.7.0",
18 | "dayjs": "^1.11.13",
19 | "readline": "^1.3.0",
20 | "ws": "^8.18.1"
21 | },
22 | "devDependencies": {
23 | "@types/ws": "^8.18.0",
24 | "typescript": "^5.8.2"
25 | }
26 | }
27 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM node:22-alpine
2 |
3 | # Set working directory
4 | WORKDIR /app
5 |
6 | # Copy package files for dependency installation
7 | COPY mcp-server/package*.json ./mcp-server/
8 |
9 | # Copy common directory (shared dependency)
10 | COPY common/ ./common/
11 |
12 | # Set working directory to mcp-server for installation
13 | WORKDIR /app/mcp-server
14 |
15 | # Install dependencies
16 | RUN npm install
17 |
18 | # Copy mcp-server source code
19 | COPY mcp-server/ ./
20 |
21 | # Build mcp-server
22 | RUN npm run build
23 |
24 | # Set default port (EXTENSION_SECRET should be provided at runtime)
25 | ENV EXTENSION_PORT=8089
26 |
27 | # Expose port (default WebSocket port for extension communication)
28 | EXPOSE 8089
29 |
30 | # Start the MCP server
31 | CMD ["npm", "start"]
```
--------------------------------------------------------------------------------
/firefox-extension/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "firefox-extension",
3 | "version": "1.5.0",
4 | "main": "dist/background.js",
5 | "scripts": {
6 | "build": "esbuild background.ts --bundle --outfile=dist/background.js && esbuild options.ts --bundle --outfile=dist/options.js",
7 | "test": "jest"
8 | },
9 | "license": "MIT",
10 | "description": "Browser Control MCP Firefox Extension / Add-on",
11 | "devDependencies": {
12 | "@types/jest": "^29.5.14",
13 | "esbuild": "0.25.1",
14 | "jest": "^30.0.4",
15 | "jest-environment-jsdom": "^30.0.4",
16 | "nx": "20.6.0",
17 | "ts-jest": "^29.4.0",
18 | "typescript": "^5.8.2",
19 | "@types/firefox-webext-browser": "^120.0.4",
20 | "@browser-control-mcp/common": "../common"
21 | },
22 | "nx": {}
23 | }
24 |
```
--------------------------------------------------------------------------------
/firefox-extension/auth.ts:
--------------------------------------------------------------------------------
```typescript
1 | function buf2hex(buffer: ArrayBuffer) {
2 | return Array.from(new Uint8Array(buffer))
3 | .map((x) => x.toString(16).padStart(2, "0"))
4 | .join("");
5 | }
6 |
7 | export async function getMessageSignature(
8 | message: string,
9 | secretKey: string
10 | ): Promise<string> {
11 | if (secretKey.length === 0) {
12 | throw new Error("Secret key is empty");
13 | }
14 |
15 | const encoder = new TextEncoder();
16 | const keyData = encoder.encode(secretKey);
17 | const messageData = encoder.encode(message);
18 |
19 | const key = await crypto.subtle.importKey(
20 | "raw",
21 | keyData,
22 | { name: "HMAC", hash: "SHA-256" },
23 | false,
24 | ["sign"]
25 | );
26 |
27 | const rawSignature = await crypto.subtle.sign(
28 | { name: "HMAC" },
29 | key,
30 | messageData
31 | );
32 |
33 | return buf2hex(rawSignature);
34 | }
35 |
```
--------------------------------------------------------------------------------
/firefox-extension/types/browserTabGroups.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | // See: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabGroups/update
2 | // This is a partial type representation of the browser.tabGroups API.
3 |
4 | declare namespace browser.tabGroups {
5 | type Color =
6 | | "blue"
7 | | "cyan"
8 | | "grey"
9 | | "green"
10 | | "orange"
11 | | "pink"
12 | | "purple"
13 | | "red"
14 | | "yellow";
15 |
16 | interface TabGroup {
17 | id: number;
18 | }
19 |
20 | interface GroupUpdateProperties {
21 | collapsed?: boolean;
22 | color?: Color;
23 | title?: string;
24 | }
25 |
26 | function update(
27 | groupId: number,
28 | updateProperties: GroupUpdateProperties
29 | ): Promise<TabGroup>;
30 | }
31 |
32 | declare namespace browser.tabs {
33 | interface GroupOptions {
34 | tabIds: number[];
35 | }
36 |
37 | function group(options: GroupOptions): Promise<number>;
38 | }
39 |
```
--------------------------------------------------------------------------------
/firefox-extension/__tests__/setup.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Jest setup file for browser API mocking
2 |
3 | // Mock the browser API completely
4 | const mockBrowser = {
5 | tabs: {
6 | create: jest.fn(),
7 | remove: jest.fn(),
8 | query: jest.fn(),
9 | get: jest.fn(),
10 | executeScript: jest.fn(),
11 | move: jest.fn(),
12 | update: jest.fn(),
13 | group: jest.fn(),
14 | },
15 | tabGroups: {
16 | update: jest.fn(),
17 | },
18 | history: {
19 | search: jest.fn(),
20 | },
21 | find: {
22 | find: jest.fn(),
23 | highlightResults: jest.fn(),
24 | },
25 | storage: {
26 | local: {
27 | get: jest.fn(),
28 | set: jest.fn(),
29 | },
30 | },
31 | permissions: {
32 | contains: jest.fn(),
33 | },
34 | runtime: {
35 | getURL: jest.fn(),
36 | },
37 | };
38 |
39 | // Override the global browser object
40 | Object.defineProperty(global, 'browser', {
41 | value: mockBrowser,
42 | writable: true,
43 | configurable: true,
44 | });
45 |
46 | // Export for use in tests
47 | export { mockBrowser };
48 |
```
--------------------------------------------------------------------------------
/mcp-server/util.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as net from 'net';
2 |
3 | export function isPortInUse(port: number) {
4 | return new Promise((resolve) => {
5 | const server = net.createServer();
6 |
7 | server.once('error', (err: NodeJS.ErrnoException) => {
8 | // If the error is because the port is already in use
9 | if (err.code === 'EADDRINUSE') {
10 | resolve(true);
11 | } else {
12 | // Some other error occurred
13 | console.error('Error checking port:', err);
14 | resolve(false);
15 | }
16 | });
17 |
18 | server.once('listening', () => {
19 | // If we get here, the port is free
20 | // Close the server and resolve with false (port not in use)
21 | server.close(() => {
22 | resolve(false);
23 | });
24 | });
25 |
26 | // Try to listen on the port (bind to localhost)
27 | server.listen(port, 'localhost');
28 | });
29 | }
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Build and Release MCP Server as DXT
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | build-and-upload:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v4
14 |
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v4
17 | with:
18 | node-version: 22
19 | cache: 'npm'
20 |
21 | - name: Install root dependencies
22 | run: npm install
23 |
24 | - name: Install MCP server dependencies
25 | run: npm install --prefix mcp-server
26 |
27 | - name: Build MCP server package
28 | run: cd mcp-server && npm run build && npm run pack-dxt
29 |
30 | - name: Rename package file with version
31 | run: |
32 | cd mcp-server
33 | mv mcp-server.dxt mcp-server-${{ github.event.release.tag_name }}.dxt
34 |
35 | - name: Upload package to release
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | run: |
39 | gh release upload ${{ github.event.release.tag_name }} \
40 | ./mcp-server/mcp-server-${{ github.event.release.tag_name }}.dxt \
41 | --clobber
42 |
43 |
```
--------------------------------------------------------------------------------
/.github/addon_badge.svg:
--------------------------------------------------------------------------------
```
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="140" height="20" role="img" aria-label="Firefox Add-on: v1.5.0">
2 | <title>Firefox Add-on: v1.5.0</title>
3 | <a href="https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/" target="_blank">
4 | <linearGradient id="s" x2="0" y2="100%">
5 | <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
6 | <stop offset="1" stop-opacity=".1"/>
7 | </linearGradient>
8 | <clipPath id="r">
9 | <rect width="140" height="20" rx="3" fill="#fff"/>
10 | </clipPath>
11 | <g clip-path="url(#r)">
12 | <rect width="89" height="20" fill="#555"/>
13 | <rect x="89" width="51" height="20" fill="#6a7d8a"/>
14 | <rect width="140" height="20" fill="url(#s)"/>
15 | </g>
16 | <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
17 | <text aria-hidden="true" x="455" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="790">Firefox Add-on</text>
18 | <text x="455" y="140" transform="scale(.1)" fill="#fff" textLength="790">Firefox Add-on</text>
19 | <text aria-hidden="true" x="1135" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="410">v1.5.0</text>
20 | <text x="1135" y="140" transform="scale(.1)" fill="#fff" textLength="410">v1.5.0</text>
21 | </g>
22 | </a>
23 | </svg>
24 |
```
--------------------------------------------------------------------------------
/common/server-messages.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface ServerMessageBase {
2 | cmd: string;
3 | }
4 |
5 | export interface OpenTabServerMessage extends ServerMessageBase {
6 | cmd: "open-tab";
7 | url: string;
8 | }
9 |
10 | export interface CloseTabsServerMessage extends ServerMessageBase {
11 | cmd: "close-tabs";
12 | tabIds: number[];
13 | }
14 |
15 | export interface GetTabListServerMessage extends ServerMessageBase {
16 | cmd: "get-tab-list";
17 | }
18 |
19 | export interface GetBrowserRecentHistoryServerMessage extends ServerMessageBase {
20 | cmd: "get-browser-recent-history";
21 | searchQuery?: string;
22 | }
23 |
24 | export interface GetTabContentServerMessage extends ServerMessageBase {
25 | cmd: "get-tab-content";
26 | tabId: number;
27 | offset?: number;
28 | }
29 |
30 | export interface ReorderTabsServerMessage extends ServerMessageBase {
31 | cmd: "reorder-tabs";
32 | tabOrder: number[];
33 | }
34 |
35 | export interface FindHighlightServerMessage extends ServerMessageBase {
36 | cmd: "find-highlight";
37 | tabId: number;
38 | queryPhrase: string;
39 | }
40 |
41 | export interface GroupTabsServerMessage extends ServerMessageBase {
42 | cmd: "group-tabs";
43 | tabIds: number[];
44 | isCollapsed: boolean;
45 | groupColor: string;
46 | groupTitle: string;
47 | }
48 |
49 | export type ServerMessage =
50 | | OpenTabServerMessage
51 | | CloseTabsServerMessage
52 | | GetTabListServerMessage
53 | | GetBrowserRecentHistoryServerMessage
54 | | GetTabContentServerMessage
55 | | ReorderTabsServerMessage
56 | | FindHighlightServerMessage
57 | | GroupTabsServerMessage;
58 |
59 | export type ServerMessageRequest = ServerMessage & { correlationId: string };
60 |
```
--------------------------------------------------------------------------------
/firefox-extension/background.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebsocketClient } from "./client";
2 | import { MessageHandler } from "./message-handler";
3 | import { getConfig, generateSecret } from "./extension-config";
4 |
5 | function initClient(port: number, secret: string) {
6 | const wsClient = new WebsocketClient(port, secret);
7 | const messageHandler = new MessageHandler(wsClient);
8 |
9 | wsClient.connect();
10 |
11 | wsClient.addMessageListener(async (message) => {
12 | console.log("Message from server:", message);
13 |
14 | try {
15 | await messageHandler.handleDecodedMessage(message);
16 | } catch (error) {
17 | console.error("Error handling message:", error);
18 | if (error instanceof Error) {
19 | await wsClient.sendErrorToServer(message.correlationId, error.message);
20 | }
21 | }
22 | });
23 | }
24 |
25 | async function initExtension() {
26 | let config = await getConfig();
27 | if (!config.secret) {
28 | console.log("No secret found, generating new one");
29 | await generateSecret();
30 | // Open the options page to allow the user to view the config:
31 | await browser.runtime.openOptionsPage();
32 | config = await getConfig();
33 | }
34 | return config;
35 | }
36 |
37 | initExtension()
38 | .then((config) => {
39 | const secret = config.secret;
40 |
41 | if (!secret) {
42 | console.error("Secret not found in storage - reinstall extension");
43 | return;
44 | }
45 | const portList = config.ports;
46 | if (portList.length === 0) {
47 | console.error("No ports configured in extension config");
48 | return;
49 | }
50 | for (const port of portList) {
51 | initClient(port, secret);
52 | }
53 | console.log("Browser extension initialized");
54 | })
55 | .catch((error) => {
56 | console.error("Error initializing extension:", error);
57 | });
58 |
```
--------------------------------------------------------------------------------
/common/extension-messages.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface ExtensionMessageBase {
2 | resource: string;
3 | correlationId: string;
4 | }
5 |
6 | export interface TabContentExtensionMessage extends ExtensionMessageBase {
7 | resource: "tab-content";
8 | tabId: number;
9 | fullText: string;
10 | isTruncated: boolean;
11 | totalLength: number;
12 | links: { url: string; text: string }[];
13 | }
14 |
15 | export interface BrowserTab {
16 | id?: number;
17 | url?: string;
18 | title?: string;
19 | lastAccessed?: number;
20 | }
21 |
22 | export interface TabsExtensionMessage extends ExtensionMessageBase {
23 | resource: "tabs";
24 | tabs: BrowserTab[];
25 | }
26 |
27 | export interface OpenedTabIdExtensionMessage extends ExtensionMessageBase {
28 | resource: "opened-tab-id";
29 | tabId: number | undefined;
30 | }
31 |
32 | export interface BrowserHistoryItem {
33 | url?: string;
34 | title?: string;
35 | lastVisitTime?: number;
36 | }
37 |
38 | export interface BrowserHistoryExtensionMessage extends ExtensionMessageBase {
39 | resource: "history";
40 |
41 | historyItems: BrowserHistoryItem[];
42 | }
43 |
44 | export interface ReorderedTabsExtensionMessage extends ExtensionMessageBase {
45 | resource: "tabs-reordered";
46 | tabOrder: number[];
47 | }
48 |
49 | export interface FindHighlightExtensionMessage extends ExtensionMessageBase {
50 | resource: "find-highlight-result";
51 | noOfResults: number;
52 | }
53 |
54 | export interface TabsClosedExtensionMessage extends ExtensionMessageBase {
55 | resource: "tabs-closed";
56 | }
57 |
58 | export interface TabGroupCreatedExtensionMessage extends ExtensionMessageBase {
59 | resource: "new-tab-group";
60 | groupId: number;
61 | }
62 |
63 | export type ExtensionMessage =
64 | | TabContentExtensionMessage
65 | | TabsExtensionMessage
66 | | OpenedTabIdExtensionMessage
67 | | BrowserHistoryExtensionMessage
68 | | ReorderedTabsExtensionMessage
69 | | FindHighlightExtensionMessage
70 | | TabsClosedExtensionMessage
71 | | TabGroupCreatedExtensionMessage;
72 |
73 | export interface ExtensionError {
74 | correlationId: string;
75 | errorMessage: string;
76 | }
```
--------------------------------------------------------------------------------
/mcp-server/manifest.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "dxt_version": "0.1",
3 | "name": "browser-control-firefox",
4 | "version": "1.5.1",
5 | "display_name": "Firefox Control",
6 | "description": "Control Mozilla Firefox: tabs, history and web content (privacy aware)",
7 | "long_description": "This MCP server provides AI assistants with access to the browser's tab management, browsing history, page search, and webpage text content through a local MCP (Model Context Protocol) connection.\n\nBefore accessing any webpage content, explicit user consent is required for each domain through browser-side permissions.\n\n**Note:** This extension requires a separate Firefox add-on component, which is available at [https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/](https://addons.mozilla.org/en-US/firefox/addon/browser-control-mcp/). Start by installing the Firefox add-on, then copy the secret key from the add-on settings page (it will open automatically) and paste it into this extension's configuration.",
8 | "author": {
9 | "name": "eyalzh",
10 | "url": "https://github.com/eyalzh"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "https://github.com/eyalzh/browser-control-mcp"
15 | },
16 | "support": "https://github.com/eyalzh/browser-control-mcp/issues",
17 | "documentation": "https://github.com/eyalzh/browser-control-mcp",
18 | "license": "MIT",
19 | "keywords": [
20 | "browser",
21 | "firefox"
22 | ],
23 | "server": {
24 | "type": "node",
25 | "entry_point": "dist/server.js",
26 | "mcp_config": {
27 | "command": "node",
28 | "args": [
29 | "${__dirname}/dist/server.js"
30 | ],
31 | "env": {
32 | "EXTENSION_SECRET": "${user_config.extension_secret}",
33 | "EXTENSION_PORT": "${user_config.port}"
34 | }
35 | }
36 | },
37 | "user_config": {
38 | "extension_secret": {
39 | "type": "string",
40 | "title": "Firefox Extension Secret",
41 | "description": "The secret key provided by the Firefox extension",
42 | "sensitive": true,
43 | "required": true
44 | },
45 | "port": {
46 | "type": "string",
47 | "title": "Extension Port",
48 | "description": "The port that the MCP server will use to communicate with the Firefox extension (default is 8089)",
49 | "default": "8089",
50 | "required": false
51 | }
52 | },
53 | "tools": [
54 | {
55 | "name": "open-browser-tab",
56 | "description": "Open a new tab in the browser"
57 | },
58 | {
59 | "name": "close-browser-tabs",
60 | "description": "Close tabs in the browser"
61 | },
62 | {
63 | "name": "get-list-of-open-tabs",
64 | "description": "Get the list of open tabs in the browser"
65 | },
66 | {
67 | "name": "get-recent-browser-history",
68 | "description": "Get the list of recent browser history"
69 | },
70 | {
71 | "name": "get-tab-web-content",
72 | "description": "Get the full text content of the webpage and the list of links in the webpage (requires user consent)"
73 | },
74 | {
75 | "name": "reorder-browser-tabs",
76 | "description": "Change the order of open browser tabs"
77 | },
78 | {
79 | "name": "group-browser-tabs",
80 | "description": "Group browser tabs into a new tab group"
81 | },
82 | {
83 | "name": "find-highlight-in-browser-tab",
84 | "description": "Find and highlight text in a browser tab"
85 | }
86 | ]
87 | }
88 |
```
--------------------------------------------------------------------------------
/firefox-extension/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type {
2 | ExtensionMessage,
3 | ExtensionError,
4 | ServerMessageRequest,
5 | } from "@browser-control-mcp/common";
6 | import { getMessageSignature } from "./auth";
7 |
8 | const RECONNECT_INTERVAL = 2000; // 2 seconds
9 |
10 | export class WebsocketClient {
11 | private socket: WebSocket | null = null;
12 | private readonly port: number;
13 | private readonly secret: string;
14 | private reconnectTimer: number | null = null;
15 | private connectionAttempts: number = 0;
16 | private messageCallback: ((data: ServerMessageRequest) => void) | null = null;
17 |
18 | constructor(port: number, secret: string) {
19 | this.port = port;
20 | this.secret = secret;
21 | }
22 |
23 | public connect(): void {
24 | console.log("Connecting to WebSocket server at port", this.port);
25 |
26 | this.socket = new WebSocket(`ws://localhost:${this.port}`);
27 |
28 | this.socket.addEventListener("open", () => {
29 | console.log("Connected to WebSocket server at port", this.port);
30 | this.connectionAttempts = 0;
31 | });
32 |
33 | this.socket.addEventListener("close", () => {
34 | console.log("WebSocket connection closed event at port", this.port);
35 | this.connectionAttempts = 0;
36 | });
37 |
38 | this.socket.addEventListener("error", (event) => {
39 | console.error("WebSocket error:", event);
40 | });
41 |
42 | this.socket.addEventListener("message", async (event) => {
43 | if (this.messageCallback === null) {
44 | return;
45 | }
46 | try {
47 | const signedMessage = JSON.parse(event.data);
48 | const messageSig = await getMessageSignature(
49 | JSON.stringify(signedMessage.payload),
50 | this.secret
51 | );
52 | if (messageSig.length === 0 || messageSig !== signedMessage.signature) {
53 | console.error("Invalid message signature");
54 | await this.sendErrorToServer(
55 | signedMessage.payload.correlationId,
56 | "Invalid message signature - extension and server not in sync"
57 | );
58 | return;
59 | }
60 | this.messageCallback(signedMessage.payload);
61 | } catch (error) {
62 | console.error("Failed to parse message:", error);
63 | }
64 | });
65 |
66 | // Start reconnection timer if not already running
67 | if (this.reconnectTimer === null) {
68 | this.startReconnectTimer();
69 | }
70 | }
71 |
72 | public addMessageListener(
73 | callback: (data: ServerMessageRequest) => void
74 | ): void {
75 | this.messageCallback = callback;
76 | }
77 |
78 | private startReconnectTimer(): void {
79 | this.reconnectTimer = window.setInterval(() => {
80 | if (this.socket && this.socket.readyState === WebSocket.CONNECTING) {
81 | this.connectionAttempts++;
82 |
83 | if (this.connectionAttempts > 2) {
84 | // Avoid long retry backoff periods by resetting the connection
85 | this.socket.close();
86 | }
87 | }
88 |
89 | if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
90 | this.connect();
91 | }
92 | }, RECONNECT_INTERVAL);
93 | }
94 |
95 | public async sendResourceToServer(resource: ExtensionMessage): Promise<void> {
96 | if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
97 | console.error("Socket is not open");
98 | return;
99 | }
100 | const signedMessage = {
101 | payload: resource,
102 | signature: await getMessageSignature(
103 | JSON.stringify(resource),
104 | this.secret
105 | ),
106 | };
107 | this.socket.send(JSON.stringify(signedMessage));
108 | }
109 |
110 | public async sendErrorToServer(
111 | correlationId: string,
112 | errorMessage: string
113 | ): Promise<void> {
114 | if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
115 | console.error("Socket is not open", this.socket);
116 | return;
117 | }
118 | const extensionError: ExtensionError = {
119 | correlationId,
120 | errorMessage: errorMessage,
121 | };
122 | this.socket.send(JSON.stringify(extensionError));
123 | }
124 |
125 | public disconnect(): void {
126 | if (this.reconnectTimer !== null) {
127 | window.clearInterval(this.reconnectTimer);
128 | this.reconnectTimer = null;
129 | }
130 |
131 | if (this.socket) {
132 | this.socket.close();
133 | this.socket = null;
134 | }
135 | }
136 | }
137 |
```
--------------------------------------------------------------------------------
/mcp-server/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import { z } from "zod";
4 | import { BrowserAPI } from "./browser-api";
5 | import dayjs from "dayjs";
6 | import relativeTime from "dayjs/plugin/relativeTime";
7 |
8 | dayjs.extend(relativeTime);
9 |
10 | const mcpServer = new McpServer({
11 | name: "BrowserControl",
12 | version: "1.5.1",
13 | });
14 |
15 | mcpServer.tool(
16 | "open-browser-tab",
17 | "Open a new tab in the user's browser (useful when the user asks to open a website)",
18 | { url: z.string() },
19 | async ({ url }) => {
20 | const openedTabId = await browserApi.openTab(url);
21 | if (openedTabId !== undefined) {
22 | return {
23 | content: [
24 | {
25 | type: "text",
26 | text: `${url} opened in tab id ${openedTabId}`,
27 | },
28 | ],
29 | };
30 | } else {
31 | return {
32 | content: [{ type: "text", text: "Failed to open tab", isError: true }],
33 | };
34 | }
35 | }
36 | );
37 |
38 | mcpServer.tool(
39 | "close-browser-tabs",
40 | "Close tabs in the user's browser by tab IDs",
41 | { tabIds: z.array(z.number()) },
42 | async ({ tabIds }) => {
43 | await browserApi.closeTabs(tabIds);
44 | return {
45 | content: [{ type: "text", text: "Closed tabs" }],
46 | };
47 | }
48 | );
49 |
50 | mcpServer.tool(
51 | "get-list-of-open-tabs",
52 | "Get the list of open tabs in the user's browser",
53 | {},
54 | async () => {
55 | const openTabs = await browserApi.getTabList();
56 | return {
57 | content: openTabs.map((tab) => {
58 | let lastAccessed = "unknown";
59 | if (tab.lastAccessed) {
60 | lastAccessed = dayjs(tab.lastAccessed).fromNow(); // LLM-friendly time ago
61 | }
62 | return {
63 | type: "text",
64 | text: `tab id=${tab.id}, tab url=${tab.url}, tab title=${tab.title}, last accessed=${lastAccessed}`,
65 | };
66 | }),
67 | };
68 | }
69 | );
70 |
71 | mcpServer.tool(
72 | "get-recent-browser-history",
73 | "Get the list of recent browser history (to get all, don't use searchQuery)",
74 | { searchQuery: z.string().optional() },
75 | async ({ searchQuery }) => {
76 | const browserHistory = await browserApi.getBrowserRecentHistory(
77 | searchQuery
78 | );
79 | if (browserHistory.length > 0) {
80 | return {
81 | content: browserHistory.map((item) => {
82 | let lastVisited = "unknown";
83 | if (item.lastVisitTime) {
84 | lastVisited = dayjs(item.lastVisitTime).fromNow(); // LLM-friendly time ago
85 | }
86 | return {
87 | type: "text",
88 | text: `url=${item.url}, title="${item.title}", lastVisitTime=${lastVisited}`,
89 | };
90 | }),
91 | };
92 | } else {
93 | // If nothing was found for the search query, hint the AI to list
94 | // all the recent history items instead.
95 | const hint = searchQuery ? "Try without a searchQuery" : "";
96 | return { content: [{ type: "text", text: `No history found. ${hint}` }] };
97 | }
98 | }
99 | );
100 |
101 | mcpServer.tool(
102 | "get-tab-web-content",
103 | `
104 | Get the full text content of the webpage and the list of links in the webpage, by tab ID.
105 | Use "offset" only for larger documents when the first call was truncated and if you require more content in order to assist the user.
106 | `,
107 | { tabId: z.number(), offset: z.number().default(0) },
108 | async ({ tabId, offset }) => {
109 | const content = await browserApi.getTabContent(tabId, offset);
110 | let links: { type: "text"; text: string }[] = [];
111 | if (offset === 0) {
112 | // Only include the links if offset is 0 (default value). Otherwise, we can
113 | // assume this is not the first call. Adding the links again would be redundant.
114 | links = content.links.map((link: { text: string; url: string }) => {
115 | return {
116 | type: "text",
117 |
118 | text: `Link text: ${link.text}, Link URL: ${link.url}`,
119 | };
120 | });
121 | }
122 |
123 | let text = content.fullText;
124 | let hint: { type: "text"; text: string }[] = [];
125 | if (content.isTruncated || offset > 0) {
126 | // If the content is truncated, add a "tip" suggesting
127 | // that another tool, search in page, can be used to
128 | // discover additional data.
129 | const rangeString = `${offset}-${offset + text.length}`;
130 | hint = [
131 | {
132 | type: "text",
133 | text:
134 | `The following text content is truncated due to size (includes character range ${rangeString} out of ${content.totalLength}). ` +
135 | "If you want to read characters beyond this range, please use the 'get-tab-web-content' tool with an offset. ",
136 | },
137 | ];
138 | }
139 |
140 | return {
141 | content: [...hint, { type: "text", text }, ...links],
142 | };
143 | }
144 | );
145 |
146 | mcpServer.tool(
147 | "reorder-browser-tabs",
148 | "Change the order of open browser tabs",
149 | { tabOrder: z.array(z.number()) },
150 | async ({ tabOrder }) => {
151 | const newOrder = await browserApi.reorderTabs(tabOrder);
152 | return {
153 | content: [
154 | { type: "text", text: `Tabs reordered: ${newOrder.join(", ")}` },
155 | ],
156 | };
157 | }
158 | );
159 |
160 | mcpServer.tool(
161 | "find-highlight-in-browser-tab",
162 | "Find and highlight text in a browser tab (use a query phrase that exists in the web content)",
163 | { tabId: z.number(), queryPhrase: z.string() },
164 | async ({ tabId, queryPhrase }) => {
165 | const noOfResults = await browserApi.findHighlight(tabId, queryPhrase);
166 | return {
167 | content: [
168 | {
169 | type: "text",
170 | text: `Number of results found and highlighted in the tab: ${noOfResults}`,
171 | },
172 | ],
173 | };
174 | }
175 | );
176 |
177 | mcpServer.tool(
178 | "group-browser-tabs",
179 | "Organize opened browser tabs in a new tab group",
180 | {
181 | tabIds: z.array(z.number()),
182 | isCollapsed: z.boolean().default(false),
183 | groupColor: z
184 | .enum([
185 | "grey",
186 | "blue",
187 | "red",
188 | "yellow",
189 | "green",
190 | "pink",
191 | "purple",
192 | "cyan",
193 | "orange",
194 | ])
195 | .default("grey"),
196 | groupTitle: z.string().default("New Group"),
197 | },
198 | async ({ tabIds, isCollapsed, groupColor, groupTitle }) => {
199 | const groupId = await browserApi.groupTabs(
200 | tabIds,
201 | isCollapsed,
202 | groupColor,
203 | groupTitle
204 | );
205 | return {
206 | content: [
207 | {
208 | type: "text",
209 | text: `Created tab group "${groupTitle}" with ${tabIds.length} tabs (group ID: ${groupId})`,
210 | },
211 | ],
212 | };
213 | }
214 | );
215 |
216 | const browserApi = new BrowserAPI();
217 | browserApi.init().catch((err) => {
218 | console.error("Browser API init error", err);
219 | process.exit(1);
220 | });
221 |
222 | const transport = new StdioServerTransport();
223 | mcpServer.connect(transport).catch((err) => {
224 | console.error("MCP Server connection error", err);
225 | process.exit(1);
226 | });
227 |
228 | process.stdin.on("close", () => {
229 | browserApi.close();
230 | mcpServer.close();
231 | process.exit(0);
232 | });
233 |
```
--------------------------------------------------------------------------------
/mcp-server/browser-api.ts:
--------------------------------------------------------------------------------
```typescript
1 | import WebSocket from "ws";
2 | import type {
3 | ExtensionMessage,
4 | BrowserTab,
5 | BrowserHistoryItem,
6 | ServerMessage,
7 | TabContentExtensionMessage,
8 | ServerMessageRequest,
9 | ExtensionError,
10 | } from "@browser-control-mcp/common";
11 | import { isPortInUse } from "./util";
12 | import * as crypto from "crypto";
13 |
14 | const WS_DEFAULT_PORT = 8089;
15 | const EXTENSION_RESPONSE_TIMEOUT_MS = 1000;
16 |
17 | interface ExtensionRequestResolver<T extends ExtensionMessage["resource"]> {
18 | resource: T;
19 | resolve: (value: Extract<ExtensionMessage, { resource: T }>) => void;
20 | reject: (reason?: string) => void;
21 | }
22 |
23 | export class BrowserAPI {
24 | private ws: WebSocket | null = null;
25 | private wsServer: WebSocket.Server | null = null;
26 | private sharedSecret: string | null = null;
27 |
28 | // Map to persist the request to the extension. It maps the request correlationId
29 | // to a resolver, fulfulling a promise created when sending a message to the extension.
30 | private extensionRequestMap: Map<
31 | string,
32 | ExtensionRequestResolver<ExtensionMessage["resource"]>
33 | > = new Map();
34 |
35 | async init() {
36 | const { secret, port } = readConfig();
37 | if (!secret) {
38 | throw new Error(
39 | "EXTENSION_SECRET env var missing. See the extension's options page."
40 | );
41 | }
42 | this.sharedSecret = secret;
43 |
44 | if (await isPortInUse(port)) {
45 | throw new Error(
46 | `Configured port ${port} is already in use. Please configure a different port.`
47 | );
48 | }
49 |
50 | // Unless running in a container, bind to localhost only
51 | const host = process.env.CONTAINERIZED ? "0.0.0.0" : "localhost";
52 |
53 | this.wsServer = new WebSocket.Server({
54 | host,
55 | port,
56 | });
57 |
58 | console.error(`Starting WebSocket server on ${host}:${port}`);
59 | this.wsServer.on("connection", async (connection) => {
60 | this.ws = connection;
61 |
62 | console.error("WebSocket connection established on port", port);
63 |
64 | this.ws.on("message", (message) => {
65 | const decoded = JSON.parse(message.toString());
66 | if (isErrorMessage(decoded)) {
67 | this.handleExtensionError(decoded);
68 | return;
69 | }
70 | const signature = this.createSignature(JSON.stringify(decoded.payload));
71 | if (signature !== decoded.signature) {
72 | console.error("Invalid message signature");
73 | return;
74 | }
75 | this.handleDecodedExtensionMessage(decoded.payload);
76 | });
77 | });
78 | this.wsServer.on("error", (error) => {
79 | console.error("WebSocket server error:", error);
80 | });
81 | }
82 |
83 | close() {
84 | this.wsServer?.close();
85 | }
86 |
87 | getSelectedPort() {
88 | return this.wsServer?.options.port;
89 | }
90 |
91 | async openTab(url: string): Promise<number | undefined> {
92 | const correlationId = this.sendMessageToExtension({
93 | cmd: "open-tab",
94 | url,
95 | });
96 | const message = await this.waitForResponse(correlationId, "opened-tab-id");
97 | return message.tabId;
98 | }
99 |
100 | async closeTabs(tabIds: number[]) {
101 | const correlationId = this.sendMessageToExtension({
102 | cmd: "close-tabs",
103 | tabIds,
104 | });
105 | await this.waitForResponse(correlationId, "tabs-closed");
106 | }
107 |
108 | async getTabList(): Promise<BrowserTab[]> {
109 | const correlationId = this.sendMessageToExtension({
110 | cmd: "get-tab-list",
111 | });
112 | const message = await this.waitForResponse(correlationId, "tabs");
113 | return message.tabs;
114 | }
115 |
116 | async getBrowserRecentHistory(
117 | searchQuery?: string
118 | ): Promise<BrowserHistoryItem[]> {
119 | const correlationId = this.sendMessageToExtension({
120 | cmd: "get-browser-recent-history",
121 | searchQuery,
122 | });
123 | const message = await this.waitForResponse(correlationId, "history");
124 | return message.historyItems;
125 | }
126 |
127 | async getTabContent(
128 | tabId: number,
129 | offset: number
130 | ): Promise<TabContentExtensionMessage> {
131 | const correlationId = this.sendMessageToExtension({
132 | cmd: "get-tab-content",
133 | tabId,
134 | offset,
135 | });
136 | return await this.waitForResponse(correlationId, "tab-content");
137 | }
138 |
139 | async reorderTabs(tabOrder: number[]): Promise<number[]> {
140 | const correlationId = this.sendMessageToExtension({
141 | cmd: "reorder-tabs",
142 | tabOrder,
143 | });
144 | const message = await this.waitForResponse(correlationId, "tabs-reordered");
145 | return message.tabOrder;
146 | }
147 |
148 | async findHighlight(tabId: number, queryPhrase: string): Promise<number> {
149 | const correlationId = this.sendMessageToExtension({
150 | cmd: "find-highlight",
151 | tabId,
152 | queryPhrase,
153 | });
154 | const message = await this.waitForResponse(
155 | correlationId,
156 | "find-highlight-result"
157 | );
158 | return message.noOfResults;
159 | }
160 |
161 | async groupTabs(
162 | tabIds: number[],
163 | isCollapsed: boolean,
164 | groupColor: string,
165 | groupTitle: string
166 | ): Promise<number> {
167 | const correlationId = this.sendMessageToExtension({
168 | cmd: "group-tabs",
169 | tabIds,
170 | isCollapsed,
171 | groupColor,
172 | groupTitle,
173 | });
174 | const message = await this.waitForResponse(correlationId, "new-tab-group");
175 | return message.groupId;
176 | }
177 |
178 | private createSignature(payload: string): string {
179 | if (!this.sharedSecret) {
180 | throw new Error("Shared secret not initialized");
181 | }
182 | const hmac = crypto.createHmac("sha256", this.sharedSecret);
183 | hmac.update(payload);
184 | return hmac.digest("hex");
185 | }
186 |
187 | private sendMessageToExtension(message: ServerMessage): string {
188 | if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
189 | throw new Error("WebSocket is not open");
190 | }
191 |
192 | const correlationId = Math.random().toString(36).substring(2);
193 | const req: ServerMessageRequest = { ...message, correlationId };
194 | const payload = JSON.stringify(req);
195 | const signature = this.createSignature(payload);
196 | const signedMessage = {
197 | payload: req,
198 | signature: signature,
199 | };
200 |
201 | // Send the signed message to the extension
202 | this.ws.send(JSON.stringify(signedMessage));
203 |
204 | return correlationId;
205 | }
206 |
207 | private handleDecodedExtensionMessage(decoded: ExtensionMessage) {
208 | const { correlationId } = decoded;
209 | const { resolve, resource } = this.extensionRequestMap.get(correlationId)!;
210 | if (resource !== decoded.resource) {
211 | console.error("Resource mismatch:", resource, decoded.resource);
212 | return;
213 | }
214 | this.extensionRequestMap.delete(correlationId);
215 | resolve(decoded);
216 | }
217 |
218 | private handleExtensionError(decoded: ExtensionError) {
219 | const { correlationId, errorMessage } = decoded;
220 | const { reject } = this.extensionRequestMap.get(correlationId)!;
221 | this.extensionRequestMap.delete(correlationId);
222 | reject(errorMessage);
223 | }
224 |
225 | private async waitForResponse<T extends ExtensionMessage["resource"]>(
226 | correlationId: string,
227 | resource: T
228 | ): Promise<Extract<ExtensionMessage, { resource: T }>> {
229 | return new Promise<Extract<ExtensionMessage, { resource: T }>>(
230 | (resolve, reject) => {
231 | this.extensionRequestMap.set(correlationId, {
232 | resolve: resolve as (value: ExtensionMessage) => void,
233 | resource,
234 | reject,
235 | });
236 | setTimeout(() => {
237 | this.extensionRequestMap.delete(correlationId);
238 | reject("Timed out waiting for response");
239 | }, EXTENSION_RESPONSE_TIMEOUT_MS);
240 | }
241 | );
242 | }
243 | }
244 |
245 | function readConfig() {
246 | return {
247 | secret: process.env.EXTENSION_SECRET,
248 | port: process.env.EXTENSION_PORT
249 | ? parseInt(process.env.EXTENSION_PORT, 10)
250 | : WS_DEFAULT_PORT,
251 | };
252 | }
253 |
254 | export function isErrorMessage(message: any): message is ExtensionError {
255 | return (
256 | message.errorMessage !== undefined && message.correlationId !== undefined
257 | );
258 | }
259 |
```
--------------------------------------------------------------------------------
/firefox-extension/extension-config.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Configuration management for Browser Control MCP extension
3 | */
4 |
5 | import { ServerMessageRequest } from "@browser-control-mcp/common/server-messages";
6 |
7 | const DEFAULT_WS_PORT = 8089;
8 | const AUDIT_LOG_SIZE_LIMIT = 100; // Maximum number of audit log entries to keep
9 |
10 | // Define all available tools with their IDs and descriptions
11 | export interface ToolInfo {
12 | id: string;
13 | name: string;
14 | description: string;
15 | }
16 |
17 | export const AVAILABLE_TOOLS: ToolInfo[] = [
18 | {
19 | id: "open-browser-tab",
20 | name: "Open Browser Tab",
21 | description: "Allows the MCP server to open new browser tabs"
22 | },
23 | {
24 | id: "close-browser-tabs",
25 | name: "Close Browser Tabs",
26 | description: "Allows the MCP server to close browser tabs"
27 | },
28 | {
29 | id: "get-list-of-open-tabs",
30 | name: "Get List of Open Tabs",
31 | description: "Allows the MCP server to get a list of all open tabs"
32 | },
33 | {
34 | id: "get-recent-browser-history",
35 | name: "Get Recent Browser History",
36 | description: "Allows the MCP server to access your recent browsing history"
37 | },
38 | {
39 | id: "get-tab-web-content",
40 | name: "Get Tab Web Content",
41 | description: "Allows the MCP server to read the content of web pages"
42 | },
43 | {
44 | id: "reorder-browser-tabs",
45 | name: "Reorder/Group Browser Tabs",
46 | description: "Allows the MCP server to reorder/group your browser tabs"
47 | },
48 | {
49 | id: "find-highlight-in-browser-tab",
50 | name: "Find and Highlight in Browser Tab",
51 | description: "Allows the MCP server to search for and highlight text in web pages"
52 | }
53 | ];
54 |
55 | // Map command names to tool IDs
56 | export const COMMAND_TO_TOOL_ID: Record<ServerMessageRequest["cmd"], string> = {
57 | "open-tab": "open-browser-tab",
58 | "close-tabs": "close-browser-tabs",
59 | "get-tab-list": "get-list-of-open-tabs",
60 | "get-browser-recent-history": "get-recent-browser-history",
61 | "get-tab-content": "get-tab-web-content",
62 | "reorder-tabs": "reorder-browser-tabs",
63 | "find-highlight": "find-highlight-in-browser-tab",
64 | "group-tabs": "reorder-browser-tabs",
65 | };
66 |
67 | // Storage schema for tool settings
68 | export interface ToolSettings {
69 | [toolId: string]: boolean;
70 | }
71 |
72 | // Audit log entry interface
73 | export interface AuditLogEntry {
74 | toolId: string;
75 | command: string;
76 | timestamp: number;
77 | url?: string;
78 | }
79 |
80 | // Extended config interface
81 | export interface ExtensionConfig {
82 | secret: string;
83 | toolSettings?: ToolSettings;
84 | domainDenyList?: string[];
85 | ports: number[];
86 | auditLog?: AuditLogEntry[];
87 | }
88 |
89 | /**
90 | * Gets the default tool settings (all enabled)
91 | */
92 | export function getDefaultToolSettings(): ToolSettings {
93 | const settings: ToolSettings = {};
94 | AVAILABLE_TOOLS.forEach(tool => {
95 | settings[tool.id] = true;
96 | });
97 | return settings;
98 | }
99 |
100 | /**
101 | * Gets the extension configuration from storage
102 | * @returns A Promise that resolves with the extension configuration
103 | */
104 | export async function getConfig(): Promise<ExtensionConfig> {
105 | const configObj = await browser.storage.local.get("config");
106 | const config: ExtensionConfig = configObj.config || { secret: "" };
107 |
108 | // Initialize toolSettings if it doesn't exist
109 | if (!config.toolSettings) {
110 | config.toolSettings = getDefaultToolSettings();
111 | }
112 |
113 | if (!config.ports) {
114 | config.ports = [DEFAULT_WS_PORT];
115 | }
116 |
117 | return config;
118 | }
119 |
120 | /**
121 | * Saves the extension configuration to storage
122 | * @param config The configuration to save
123 | * @returns A Promise that resolves when the configuration is saved
124 | */
125 | export async function saveConfig(config: ExtensionConfig): Promise<void> {
126 | await browser.storage.local.set({ config });
127 | }
128 |
129 | /**
130 | * Gets the secret from storage
131 | * @returns A Promise that resolves with the secret
132 | */
133 | export async function getSecret(): Promise<string> {
134 | const config = await getConfig();
135 | return config.secret;
136 | }
137 |
138 | /**
139 | * Generates a new secret and saves it to storage
140 | * @returns A Promise that resolves with the new secret
141 | */
142 | export async function generateSecret(): Promise<string> {
143 | const config = await getConfig();
144 | config.secret = crypto.randomUUID();
145 | await saveConfig(config);
146 | return config.secret;
147 | }
148 |
149 | /**
150 | * Checks if a tool is enabled
151 | * @param toolId The ID of the tool to check
152 | * @returns A Promise that resolves with true if the tool is enabled, false otherwise
153 | */
154 | export async function isToolEnabled(toolId: string): Promise<boolean> {
155 | const config = await getConfig();
156 | // Default to true if not explicitly set to false
157 | return config.toolSettings?.[toolId] !== false;
158 | }
159 |
160 | /**
161 | * Checks if a command is allowed based on the tool permissions
162 | * @param command The command to check
163 | * @returns A Promise that resolves with true if the command is allowed, false otherwise
164 | */
165 | export async function isCommandAllowed(command: ServerMessageRequest["cmd"]): Promise<boolean> {
166 | const toolId = COMMAND_TO_TOOL_ID[command];
167 | if (!toolId) {
168 | console.error(`Unknown command: ${command}`);
169 | return false;
170 | }
171 | return isToolEnabled(toolId);
172 | }
173 |
174 | /**
175 | * Sets the enabled status of a tool
176 | * @param toolId The ID of the tool to update
177 | * @param enabled Whether the tool should be enabled
178 | * @returns A Promise that resolves when the setting is saved
179 | */
180 | export async function setToolEnabled(toolId: string, enabled: boolean): Promise<void> {
181 | const config = await getConfig();
182 |
183 | // Update the setting
184 | if (!config.toolSettings) {
185 | config.toolSettings = getDefaultToolSettings();
186 | }
187 | config.toolSettings[toolId] = enabled;
188 |
189 | // Save back to storage
190 | await saveConfig(config);
191 | }
192 |
193 | /**
194 | * Gets all tool settings
195 | * @returns A Promise that resolves with the current tool settings
196 | */
197 | export async function getAllToolSettings(): Promise<ToolSettings> {
198 | const config = await getConfig();
199 | return config.toolSettings || getDefaultToolSettings();
200 | }
201 |
202 | /**
203 | * Gets the domain deny list
204 | * @returns A Promise that resolves with the domain deny list
205 | */
206 | export async function getDomainDenyList(): Promise<string[]> {
207 | const config = await getConfig();
208 | return config.domainDenyList || [];
209 | }
210 |
211 | /**
212 | * Sets the domain deny list
213 | * @param domains Array of domains to deny
214 | * @returns A Promise that resolves when the setting is saved
215 | */
216 | export async function setDomainDenyList(domains: string[]): Promise<void> {
217 | const config = await getConfig();
218 | config.domainDenyList = domains;
219 | await saveConfig(config);
220 | }
221 |
222 | /**
223 | * Checks if a domain is in the deny list
224 | * @param url The URL to check
225 | * @returns A Promise that resolves with true if the domain is in the deny list, false otherwise
226 | */
227 | export async function isDomainInDenyList(url: string): Promise<boolean> {
228 | try {
229 | // Extract the domain from the URL
230 | const urlObj = new URL(url);
231 | const domain = urlObj.hostname;
232 |
233 | // Get the deny list
234 | const denyList = await getDomainDenyList();
235 |
236 | // Check if the domain is in the deny list
237 | return denyList.some(deniedDomain =>
238 | domain.toLowerCase() === deniedDomain.toLowerCase() ||
239 | domain.toLowerCase().endsWith(`.${deniedDomain.toLowerCase()}`)
240 | );
241 | } catch (error) {
242 | console.error(`Error checking domain in deny list: ${error}`);
243 | // If there's an error parsing the URL, return false
244 | return false;
245 | }
246 | }
247 |
248 | /**
249 | * Gets the WebSocket ports list
250 | * @returns A Promise that resolves with the ports list
251 | */
252 | export async function getPorts(): Promise<number[]> {
253 | const config = await getConfig();
254 | return config.ports || [DEFAULT_WS_PORT];
255 | }
256 |
257 | /**
258 | * Sets the WebSocket ports list
259 | * @param ports Array of port numbers
260 | * @returns A Promise that resolves when the setting is saved
261 | */
262 | export async function setPorts(ports: number[]): Promise<void> {
263 | const config = await getConfig();
264 | config.ports = ports;
265 | await saveConfig(config);
266 | }
267 |
268 | /**
269 | * Adds an entry to the audit log
270 | * @param entry The audit log entry to add
271 | * @returns A Promise that resolves when the entry is saved
272 | */
273 | export async function addAuditLogEntry(entry: AuditLogEntry): Promise<void> {
274 | const config = await getConfig();
275 |
276 | if (!config.auditLog) {
277 | config.auditLog = [];
278 | }
279 |
280 | // Add the new entry at the beginning
281 | config.auditLog.unshift(entry);
282 |
283 | // Keep only the last AUDIT_LOG_SIZE_LIMIT entries
284 | if (config.auditLog.length > AUDIT_LOG_SIZE_LIMIT) {
285 | config.auditLog = config.auditLog.slice(0, AUDIT_LOG_SIZE_LIMIT);
286 | }
287 |
288 | await saveConfig(config);
289 | }
290 |
291 | /**
292 | * Gets the audit log entries
293 | * @returns A Promise that resolves with the audit log entries
294 | */
295 | export async function getAuditLog(): Promise<AuditLogEntry[]> {
296 | const config = await getConfig();
297 | return config.auditLog || [];
298 | }
299 |
300 | /**
301 | * Clears the audit log
302 | * @returns A Promise that resolves when the audit log is cleared
303 | */
304 | export async function clearAuditLog(): Promise<void> {
305 | const config = await getConfig();
306 | config.auditLog = [];
307 | await saveConfig(config);
308 | }
309 |
310 | /**
311 | * Gets the tool name by tool ID
312 | * @param toolId The tool ID to look up
313 | * @returns The tool name or the tool ID if not found
314 | */
315 | export function getToolNameById(toolId: string): string {
316 | const tool = AVAILABLE_TOOLS.find(t => t.id === toolId);
317 | return tool ? tool.name : toolId;
318 | }
319 |
```
--------------------------------------------------------------------------------
/firefox-extension/message-handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import type { ServerMessageRequest } from "@browser-control-mcp/common";
2 | import { WebsocketClient } from "./client";
3 | import { isCommandAllowed, isDomainInDenyList, COMMAND_TO_TOOL_ID, addAuditLogEntry } from "./extension-config";
4 |
5 | export class MessageHandler {
6 | private client: WebsocketClient;
7 |
8 | constructor(client: WebsocketClient) {
9 | this.client = client;
10 | }
11 |
12 | public async handleDecodedMessage(req: ServerMessageRequest): Promise<void> {
13 | const isAllowed = await isCommandAllowed(req.cmd);
14 | if (!isAllowed) {
15 | throw new Error(`Command '${req.cmd}' is disabled in extension settings`);
16 | }
17 |
18 | this.addAuditLogForReq(req).catch((error) => {
19 | console.error("Failed to add audit log entry:", error);
20 | });
21 |
22 | switch (req.cmd) {
23 | case "open-tab":
24 | await this.openUrl(req.correlationId, req.url);
25 | break;
26 | case "close-tabs":
27 | await this.closeTabs(req.correlationId, req.tabIds);
28 | break;
29 | case "get-tab-list":
30 | await this.sendTabs(req.correlationId);
31 | break;
32 | case "get-browser-recent-history":
33 | await this.sendRecentHistory(req.correlationId, req.searchQuery);
34 | break;
35 | case "get-tab-content":
36 | await this.sendTabsContent(req.correlationId, req.tabId, req.offset);
37 | break;
38 | case "reorder-tabs":
39 | await this.reorderTabs(req.correlationId, req.tabOrder);
40 | break;
41 | case "find-highlight":
42 | await this.findAndHighlightText(
43 | req.correlationId,
44 | req.tabId,
45 | req.queryPhrase
46 | );
47 | break;
48 | case "group-tabs":
49 | await this.groupTabs(
50 | req.correlationId,
51 | req.tabIds,
52 | req.isCollapsed,
53 | req.groupColor as browser.tabGroups.Color,
54 | req.groupTitle
55 | );
56 | break;
57 | default:
58 | const _exhaustiveCheck: never = req;
59 | console.error("Invalid message received:", req);
60 | }
61 | }
62 |
63 | private async addAuditLogForReq(req: ServerMessageRequest) {
64 | // Get the URL in context (either from param or from the tab)
65 | let contextUrl: string | undefined;
66 | if ("url" in req && req.url) {
67 | contextUrl = req.url;
68 | }
69 | if ("tabId" in req) {
70 | try {
71 | const tab = await browser.tabs.get(req.tabId);
72 | contextUrl = tab.url;
73 | } catch (error) {
74 | console.error("Failed to get tab URL for audit log:", error);
75 | }
76 | }
77 |
78 | const toolId = COMMAND_TO_TOOL_ID[req.cmd];
79 | const auditEntry = {
80 | toolId,
81 | command: req.cmd,
82 | timestamp: Date.now(),
83 | url: contextUrl
84 | };
85 |
86 | await addAuditLogEntry(auditEntry);
87 | }
88 |
89 | private async openUrl(correlationId: string, url: string): Promise<void> {
90 | if (!url.startsWith("https://")) {
91 | console.error("Invalid URL:", url);
92 | throw new Error("Invalid URL");
93 | }
94 |
95 | if (await isDomainInDenyList(url)) {
96 | throw new Error("Domain in user defined deny list");
97 | }
98 |
99 | const tab = await browser.tabs.create({
100 | url,
101 | });
102 |
103 | await this.client.sendResourceToServer({
104 | resource: "opened-tab-id",
105 | correlationId,
106 | tabId: tab.id,
107 | });
108 | }
109 |
110 | private async closeTabs(
111 | correlationId: string,
112 | tabIds: number[]
113 | ): Promise<void> {
114 | await browser.tabs.remove(tabIds);
115 | await this.client.sendResourceToServer({
116 | resource: "tabs-closed",
117 | correlationId,
118 | });
119 | }
120 |
121 | private async sendTabs(correlationId: string): Promise<void> {
122 | const tabs = await browser.tabs.query({});
123 | await this.client.sendResourceToServer({
124 | resource: "tabs",
125 | correlationId,
126 | tabs,
127 | });
128 | }
129 |
130 | private async sendRecentHistory(
131 | correlationId: string,
132 | searchQuery: string | null = null
133 | ): Promise<void> {
134 | const historyItems = await browser.history.search({
135 | text: searchQuery ?? "", // Search for all URLs (empty string matches everything)
136 | maxResults: 200, // Limit to 200 results
137 | startTime: 0, // Search from the beginning of time
138 | });
139 | const filteredHistoryItems = historyItems.filter((item) => {
140 | return !!item.url;
141 | });
142 | await this.client.sendResourceToServer({
143 | resource: "history",
144 | correlationId,
145 | historyItems: filteredHistoryItems,
146 | });
147 | }
148 |
149 | // Check that the user has granted permission to access the URL's domain.
150 | // This will open the options page with a URL parameter to request permission
151 | // and throw an error to indicate that the request cannot proceed until permission is granted.
152 | private async checkForUrlPermission(url: string | undefined): Promise<void> {
153 | if (url) {
154 | const origin = new URL(url).origin;
155 | const granted = await browser.permissions.contains({
156 | origins: [`${origin}/*`],
157 | });
158 |
159 | if (!granted) {
160 | // Open the options page with a URL parameter to request permission:
161 | const optionsUrl = browser.runtime.getURL("options.html");
162 | const urlWithParams = `${optionsUrl}?requestUrl=${encodeURIComponent(
163 | url
164 | )}`;
165 |
166 | await browser.tabs.create({ url: urlWithParams });
167 | throw new Error(
168 | `The user has not yet granted permission to access the domain "${origin}". A dialog is now being opened to request permission. If the user grants permission, you can try the request again.`
169 | );
170 | }
171 | }
172 | }
173 |
174 | private async checkForGlobalPermission(permissions: string[]): Promise<void> {
175 | const granted = await browser.permissions.contains({
176 | permissions,
177 | });
178 |
179 | if (!granted) {
180 | // Open the options page with a URL parameter to request permission:
181 | const optionsUrl = browser.runtime.getURL("options.html");
182 | const urlWithParams = `${optionsUrl}?requestPermissions=${encodeURIComponent(
183 | JSON.stringify(permissions)
184 | )}`;
185 |
186 | await browser.tabs.create({ url: urlWithParams });
187 | throw new Error(
188 | `The user has not yet granted permission for the following operations: ${permissions.join(
189 | ", "
190 | )}. A dialog is now being opened to request permission. If the user grants permission, you can try the request again.`
191 | );
192 | }
193 | }
194 |
195 | private async sendTabsContent(
196 | correlationId: string,
197 | tabId: number,
198 | offset?: number
199 | ): Promise<void> {
200 | const tab = await browser.tabs.get(tabId);
201 | if (tab.url && (await isDomainInDenyList(tab.url))) {
202 | throw new Error(`Domain in tab URL is in the deny list`);
203 | }
204 |
205 | await this.checkForUrlPermission(tab.url);
206 |
207 | const MAX_CONTENT_LENGTH = 50_000;
208 | const results = await browser.tabs.executeScript(tabId, {
209 | code: `
210 | (function () {
211 | function getLinks() {
212 | const linkElements = document.querySelectorAll('a[href]');
213 | return Array.from(linkElements).map(el => ({
214 | url: el.href,
215 | text: el.innerText.trim() || el.getAttribute('aria-label') || el.getAttribute('title') || ''
216 | })).filter(link => link.text !== '' && link.url.startsWith('https://') && !link.url.includes('#'));
217 | }
218 |
219 | function getTextContent() {
220 | let isTruncated = false;
221 | let text = document.body.innerText.substring(${Number(offset) || 0});
222 | if (text.length > ${MAX_CONTENT_LENGTH}) {
223 | text = text.substring(0, ${MAX_CONTENT_LENGTH});
224 | isTruncated = true;
225 | }
226 | return {
227 | text, isTruncated
228 | }
229 | }
230 |
231 | const textContent = getTextContent();
232 |
233 | return {
234 | links: getLinks(),
235 | fullText: textContent.text,
236 | isTruncated: textContent.isTruncated,
237 | totalLength: document.body.innerText.length
238 | };
239 | })();
240 | `,
241 | });
242 | const { isTruncated, fullText, links, totalLength } = results[0];
243 | await this.client.sendResourceToServer({
244 | resource: "tab-content",
245 | tabId,
246 | correlationId,
247 | isTruncated,
248 | fullText,
249 | links,
250 | totalLength,
251 | });
252 | }
253 |
254 | private async reorderTabs(
255 | correlationId: string,
256 | tabOrder: number[]
257 | ): Promise<void> {
258 | // Reorder the tabs sequentially
259 | for (let newIndex = 0; newIndex < tabOrder.length; newIndex++) {
260 | const tabId = tabOrder[newIndex];
261 | await browser.tabs.move(tabId, { index: newIndex });
262 | }
263 | await this.client.sendResourceToServer({
264 | resource: "tabs-reordered",
265 | correlationId,
266 | tabOrder,
267 | });
268 | }
269 |
270 | private async findAndHighlightText(
271 | correlationId: string,
272 | tabId: number,
273 | queryPhrase: string
274 | ): Promise<void> {
275 | const tab = await browser.tabs.get(tabId);
276 |
277 | if (tab.url && (await isDomainInDenyList(tab.url))) {
278 | throw new Error(`Domain in tab URL is in the deny list`);
279 | }
280 |
281 | await this.checkForGlobalPermission(["find"]);
282 |
283 | const findResults = await browser.find.find(queryPhrase, {
284 | tabId,
285 | caseSensitive: true,
286 | });
287 |
288 | // If there are results, highlight them
289 | if (findResults.count > 0) {
290 | // But first, activate the tab. In firefox, this would also enable
291 | // auto-scrolling to the highlighted result.
292 | await browser.tabs.update(tabId, { active: true });
293 | browser.find.highlightResults({
294 | tabId,
295 | });
296 | }
297 |
298 | await this.client.sendResourceToServer({
299 | resource: "find-highlight-result",
300 | correlationId,
301 | noOfResults: findResults.count,
302 | });
303 | }
304 |
305 | private async groupTabs(
306 | correlationId: string,
307 | tabIds: number[],
308 | isCollapsed: boolean,
309 | groupColor: browser.tabGroups.Color,
310 | groupTitle: string
311 | ): Promise<void> {
312 | const groupId = await browser.tabs.group({
313 | tabIds,
314 | });
315 |
316 | let tabGroup = await browser.tabGroups.update(groupId, {
317 | collapsed: isCollapsed,
318 | color: groupColor,
319 | title: groupTitle,
320 | });
321 |
322 | await this.client.sendResourceToServer({
323 | resource: "new-tab-group",
324 | correlationId,
325 | groupId: tabGroup.id,
326 | });
327 | }
328 | }
329 |
```
--------------------------------------------------------------------------------
/firefox-extension/options.html:
--------------------------------------------------------------------------------
```html
1 | <!DOCTYPE html>
2 | <html>
3 |
4 | <head>
5 | <meta charset="utf-8">
6 | <title>Browser Control MCP Options</title>
7 | <style>
8 | body {
9 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
10 | padding: 20px;
11 | max-width: 800px;
12 | margin: 0 auto;
13 | }
14 |
15 | .container {
16 | background-color: #f9f9f9;
17 | border-radius: 8px;
18 | padding: 20px;
19 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
20 | }
21 |
22 | h1 {
23 | color: #333;
24 | margin-top: 0;
25 | }
26 |
27 | h2 {
28 | margin-top: 12px;
29 | margin-bottom: 8px;
30 | color: #333;
31 | cursor: pointer;
32 | display: flex;
33 | align-items: center;
34 | justify-content: space-between;
35 | }
36 |
37 | h2::after {
38 | content: "";
39 | display: inline-block;
40 | width: 16px;
41 | height: 16px;
42 | background-image: url("assets/caret.svg");
43 | background-size: contain;
44 | background-repeat: no-repeat;
45 | }
46 |
47 | h2.collapsed::after {
48 | transform: rotate(180deg);
49 | }
50 |
51 | .section-container {
52 | margin-top: 20px;
53 | background-color: #fff;
54 | border: 1px solid #ddd;
55 | border-radius: 4px;
56 | padding: 15px;
57 | }
58 |
59 | .section-content {
60 | transition: max-height 0.3s ease, opacity 0.3s ease;
61 | max-height: 2000px;
62 | opacity: 1;
63 | overflow: hidden;
64 | }
65 |
66 | .section-content.collapsed {
67 | max-height: 0;
68 | opacity: 0;
69 | padding-top: 0;
70 | padding-bottom: 0;
71 | }
72 |
73 | .secret-value {
74 | font-family: monospace;
75 | word-break: break-all;
76 | background-color: #f5f5f5;
77 | padding: 10px;
78 | border-radius: 4px;
79 | border: 1px solid #e0e0e0;
80 | }
81 |
82 | .copy-button {
83 | margin-top: 10px;
84 | background-color: #4285f4;
85 | color: white;
86 | border: none;
87 | padding: 8px 16px;
88 | border-radius: 4px;
89 | cursor: pointer;
90 | font-size: 14px;
91 | }
92 |
93 | .copy-button:hover {
94 | background-color: #3367d6;
95 | }
96 |
97 | .status {
98 | margin-top: 10px;
99 | color: #4caf50;
100 | font-size: 14px;
101 | height: 20px;
102 | }
103 |
104 | .tool-row {
105 | display: flex;
106 | justify-content: space-between;
107 | align-items: center;
108 | padding: 10px 0;
109 | border-bottom: 1px solid #eee;
110 | }
111 |
112 | .tool-row:last-child {
113 | border-bottom: none;
114 | }
115 |
116 | .tool-label-container {
117 | flex: 1;
118 | padding-right: 20px;
119 | }
120 |
121 | .tool-name {
122 | font-weight: bold;
123 | margin-bottom: 5px;
124 | }
125 |
126 | .tool-description {
127 | font-size: 14px;
128 | color: #666;
129 | }
130 |
131 | /* Toggle switch styles */
132 | .toggle-switch {
133 | position: relative;
134 | display: inline-block;
135 | width: 50px;
136 | height: 24px;
137 | }
138 |
139 | .toggle-switch input {
140 | opacity: 0;
141 | width: 0;
142 | height: 0;
143 | }
144 |
145 | .slider {
146 | position: absolute;
147 | cursor: pointer;
148 | top: 0;
149 | left: 0;
150 | right: 0;
151 | bottom: 0;
152 | background-color: #ccc;
153 | transition: .4s;
154 | border-radius: 24px;
155 | }
156 |
157 | .slider:before {
158 | position: absolute;
159 | content: "";
160 | height: 16px;
161 | width: 16px;
162 | left: 4px;
163 | bottom: 4px;
164 | background-color: white;
165 | transition: .4s;
166 | border-radius: 50%;
167 | }
168 |
169 | input:checked+.slider {
170 | background-color: #4285f4;
171 | }
172 |
173 | input:focus+.slider {
174 | box-shadow: 0 0 1px #4285f4;
175 | }
176 |
177 | input:checked+.slider:before {
178 | transform: translateX(26px);
179 | }
180 |
181 | /* Permission modal styles */
182 | .permission-modal {
183 | position: fixed;
184 | top: 0;
185 | left: 0;
186 | width: 100%;
187 | height: 100%;
188 | background-color: rgba(0, 0, 0, 0.5);
189 | display: flex;
190 | justify-content: center;
191 | align-items: center;
192 | z-index: 1000;
193 | }
194 |
195 | .permission-modal.hidden {
196 | display: none;
197 | }
198 |
199 | .permission-modal-content {
200 | background-color: white;
201 | padding: 30px;
202 | border-radius: 8px;
203 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
204 | max-width: 500px;
205 | width: 90%;
206 | text-align: center;
207 | }
208 |
209 | .permission-modal h2 {
210 | margin-top: 0;
211 | margin-bottom: 20px;
212 | color: #333;
213 | cursor: default;
214 | }
215 |
216 | .permission-modal h2::after {
217 | display: none;
218 | }
219 |
220 | .permission-modal p {
221 | margin-bottom: 20px;
222 | color: #666;
223 | line-height: 1.5;
224 | }
225 |
226 | .permission-domain {
227 | font-family: monospace;
228 | background-color: #f5f5f5;
229 | padding: 8px 12px;
230 | border-radius: 4px;
231 | border: 1px solid #e0e0e0;
232 | display: inline-block;
233 | margin: 0 4px;
234 | font-weight: bold;
235 | }
236 |
237 | .permission-buttons {
238 | display: flex;
239 | gap: 10px;
240 | justify-content: center;
241 | margin-top: 25px;
242 | }
243 |
244 | .grant-button {
245 | background-color: #4285f4;
246 | color: white;
247 | border: none;
248 | padding: 10px 20px;
249 | border-radius: 4px;
250 | cursor: pointer;
251 | font-size: 14px;
252 | font-weight: bold;
253 | }
254 |
255 | .grant-button:hover {
256 | background-color: #3367d6;
257 | }
258 |
259 | .cancel-button {
260 | background-color: #f1f3f4;
261 | color: #333;
262 | border: 1px solid #dadce0;
263 | padding: 10px 20px;
264 | border-radius: 4px;
265 | cursor: pointer;
266 | font-size: 14px;
267 | }
268 |
269 | .cancel-button:hover {
270 | background-color: #e8eaed;
271 | }
272 |
273 | .main-content.modal-open {
274 | filter: blur(2px);
275 | pointer-events: none;
276 | }
277 |
278 | /* Audit log styles */
279 | .audit-log-table {
280 | width: 100%;
281 | border-collapse: collapse;
282 | margin-top: 10px;
283 | font-size: 14px;
284 | }
285 |
286 | .audit-log-table th,
287 | .audit-log-table td {
288 | padding: 8px 12px;
289 | text-align: left;
290 | border-bottom: 1px solid #eee;
291 | }
292 |
293 | .audit-log-table th {
294 | background-color: #f5f5f5;
295 | font-weight: bold;
296 | color: #333;
297 | }
298 |
299 | .audit-log-table td {
300 | color: #666;
301 | }
302 |
303 | .audit-log-url {
304 | font-family: monospace;
305 | font-size: 12px;
306 | max-width: 200px;
307 | overflow: hidden;
308 | text-overflow: ellipsis;
309 | white-space: nowrap;
310 | }
311 |
312 | .audit-log-timestamp {
313 | white-space: nowrap;
314 | font-size: 12px;
315 | }
316 |
317 | .audit-log-empty {
318 | text-align: center;
319 | color: #999;
320 | font-style: italic;
321 | padding: 20px;
322 | }
323 | </style>
324 | </head>
325 |
326 | <body>
327 | <!-- Permission Request Modal -->
328 | <div id="permission-modal" class="permission-modal hidden">
329 | <div class="permission-modal-content">
330 | <h2>Permission Required</h2>
331 | <p>The Browser Control MCP extension needs permission to access:</p>
332 | <div class="permission-domain" id="permission-domain"></div>
333 | <p id="permission-text">This will allow the extension to interact with pages on this domain as requested by the MCP server.</p>
334 | <div class="permission-buttons">
335 | <button id="grant-btn" class="grant-button">Grant Permission</button>
336 | <button id="cancel-btn" class="cancel-button">Cancel</button>
337 | </div>
338 | </div>
339 | </div>
340 |
341 | <div class="container main-content" id="main-content">
342 | <h1>Browser Control MCP Options</h1>
343 | <p>Configure settings for the Browser Control MCP extension.</p>
344 |
345 | <div class="section-container">
346 | <h2>MCP Server Installation</h2>
347 | <div class="section-content">
348 | <p>To use this extension, you need to install the local MCP server. Choose one of the following options:</p>
349 |
350 | <div style="margin: 15px 0;">
351 | <h3 style="margin: 10px 0; font-size: 16px; color: #333;">Option 1: Download DXT Package (Claude Desktop only)</h3>
352 | <p>Download and open the pre-built DXT package:</p>
353 | <a href="https://github.com/eyalzh/browser-control-mcp/releases/download/v1.5.0/mcp-server-v1.5.0.dxt"
354 | style="display: inline-block; background-color: #4285f4; color: white; padding: 8px 16px;
355 | text-decoration: none; border-radius: 4px; margin: 5px 0; font-size: 14px;">
356 | Download DXT Package
357 | </a>
358 | </div>
359 |
360 | <div style="margin: 15px 0;">
361 | <h3 style="margin: 10px 0; font-size: 16px; color: #333;">Option 2: Manual MCP Installation</h3>
362 | <p>Follow the detailed setup instructions in the github repository:</p>
363 | <a href="https://github.com/eyalzh/browser-control-mcp?tab=readme-ov-file#installation"
364 | target="_blank" rel="noopener noreferrer"
365 | style="display: inline-block; background-color: #f1f3f4; color: #333; padding: 8px 16px;
366 | text-decoration: none; border-radius: 4px; border: 1px solid #dadce0; margin: 5px 0; font-size: 14px;">
367 | View Installation Guide
368 | </a>
369 | </div>
370 |
371 | <p style="margin-top: 15px; color: #666; font-size: 14px;">
372 | After installing the MCP server, you'll need to configure it with the secret key shown below.
373 | </p>
374 | </div>
375 | </div>
376 |
377 | <div class="section-container">
378 | <h2>Secret Key</h2>
379 | <div class="section-content">
380 | <p>This secret key is automatically generated when the extension is installed and is used to authenticate
381 | connections to the MCP server:</p>
382 | <div class="secret-value" id="secret-display">Loading...</div>
383 | <button class="copy-button" id="copy-button">Copy to Clipboard</button>
384 | <div class="status" id="status"></div>
385 | </div>
386 | </div>
387 |
388 | <div class="section-container">
389 | <h2>WebSocket Ports</h2>
390 | <div class="section-content">
391 | <p>Configure the WebSocket ports for MCP server connections (comma-separated list). Note: Changing this value will reload the extension.</p>
392 | <input type="text" id="ports-input" placeholder="8089, 8090, 8091" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-family: monospace; margin-bottom: 10px;" />
393 | <button class="copy-button" id="save-ports">Save Ports</button>
394 | <div class="status" id="ports-status"></div>
395 | </div>
396 | </div>
397 |
398 | <div class="section-container">
399 | <h2>Tool Permissions</h2>
400 | <div class="section-content">
401 | <p>Enable or disable specific MCP tools that can interact with your browser:</p>
402 | <div id="tool-settings-container">
403 | <!-- Tool settings will be dynamically inserted here -->
404 | <div class="loading">Loading tool settings...</div>
405 | </div>
406 | </div>
407 | </div>
408 |
409 | <div class="section-container">
410 | <h2 class="collapsed" role="button" aria-expanded="false" tabindex="0" id="domain-filtering-header">Domain Filtering</h2>
411 | <div class="section-content collapsed" aria-labelledby="domain-filtering-header">
412 | <p>Configure which domains the extension can interact with:</p>
413 |
414 | <div class="domain-list-container" style="margin-top: 20px;">
415 | <h3>Domain Deny List</h3>
416 | <p>The extension will not open tabs or get content from tabs with these domains (one domain per line):</p>
417 | <textarea id="domain-deny-list" rows="6" style="width: 100%; font-family: monospace;"></textarea>
418 | </div>
419 |
420 | <button id="save-domain-lists" class="copy-button" style="margin-top: 15px;">Save Domain Lists</button>
421 | <div class="status" id="domain-status"></div>
422 | </div>
423 | </div>
424 |
425 | <div class="section-container">
426 | <h2 class="collapsed" role="button" aria-expanded="false" tabindex="0" id="audit-log-header">Audit Log</h2>
427 | <div class="section-content collapsed" aria-labelledby="audit-log-header">
428 | <p>View recent tool usage history:</p>
429 |
430 | <div id="audit-log-container" style="margin-top: 20px;">
431 | <div class="loading">Loading audit log...</div>
432 | </div>
433 |
434 | <div style="margin-top: 15px;">
435 | <button id="clear-audit-log" class="copy-button">Clear Log</button>
436 | <div class="status" id="audit-log-status"></div>
437 | </div>
438 | </div>
439 | </div>
440 | </div>
441 |
442 | <script src="dist/options.js"></script>
443 | </body>
444 |
445 | </html>
446 |
```
--------------------------------------------------------------------------------
/firefox-extension/__tests__/message-handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { MessageHandler } from "../message-handler";
2 | import { WebsocketClient } from "../client";
3 | import type { ServerMessageRequest } from "@browser-control-mcp/common";
4 | import { ExtensionConfig } from "../extension-config";
5 |
6 | // Mock the WebsocketClient
7 | jest.mock("../client", () => {
8 | return {
9 | WebsocketClient: jest.fn().mockImplementation(() => {
10 | return {
11 | sendResourceToServer: jest.fn().mockResolvedValue(undefined),
12 | sendErrorToServer: jest.fn().mockResolvedValue(undefined),
13 | };
14 | }),
15 | };
16 | });
17 |
18 | describe("MessageHandler", () => {
19 | let messageHandler: MessageHandler;
20 | let mockClient: jest.Mocked<WebsocketClient>;
21 |
22 | beforeEach(() => {
23 | // Clear all mocks before each test
24 | jest.clearAllMocks();
25 |
26 | // Create a new instance of WebsocketClient and MessageHandler
27 | mockClient = new WebsocketClient(
28 | 8080,
29 | "test-secret"
30 | ) as jest.Mocked<WebsocketClient>;
31 | messageHandler = new MessageHandler(mockClient);
32 |
33 | // Mock browser.storage.local.get to return default config
34 | const defaultConfig: ExtensionConfig = {
35 | secret: "test-secret",
36 | toolSettings: {
37 | "open-browser-tab": true,
38 | "close-browser-tabs": true,
39 | "get-list-of-open-tabs": true,
40 | "get-recent-browser-history": true,
41 | "get-tab-web-content": true,
42 | "reorder-browser-tabs": true,
43 | "find-highlight-in-browser-tab": true,
44 | },
45 | domainDenyList: [],
46 | ports: [8089],
47 | auditLog: [],
48 | };
49 |
50 | (browser.storage.local.get as jest.Mock).mockResolvedValue({
51 | config: defaultConfig,
52 | });
53 | });
54 |
55 | describe("handleDecodedMessage", () => {
56 | it("should throw an error if command is not allowed", async () => {
57 | // Arrange
58 | const configWithDisabledOpenTab: ExtensionConfig = {
59 | secret: "test-secret",
60 | toolSettings: {
61 | "open-browser-tab": false, // Disable open-tab command
62 | "close-browser-tabs": true,
63 | "get-list-of-open-tabs": true,
64 | "get-recent-browser-history": true,
65 | "get-tab-web-content": true,
66 | "reorder-browser-tabs": true,
67 | "find-highlight-in-browser-tab": true,
68 | },
69 | domainDenyList: [],
70 | ports: [8089],
71 | auditLog: [],
72 | };
73 | (browser.storage.local.get as jest.Mock).mockResolvedValue({
74 | config: configWithDisabledOpenTab,
75 | });
76 |
77 | const request: ServerMessageRequest = {
78 | cmd: "open-tab",
79 | url: "https://example.com",
80 | correlationId: "test-correlation-id",
81 | };
82 |
83 | // Act & Assert
84 | await expect(
85 | messageHandler.handleDecodedMessage(request)
86 | ).rejects.toThrow("Command 'open-tab' is disabled in extension settings");
87 | });
88 |
89 | describe("open-tab command", () => {
90 | it("should open a new tab and send the tab ID to the server", async () => {
91 | // Arrange
92 | const request: ServerMessageRequest = {
93 | cmd: "open-tab",
94 | url: "https://example.com",
95 | correlationId: "test-correlation-id",
96 | };
97 |
98 | const mockTab = { id: 123 };
99 | (browser.tabs.create as jest.Mock).mockResolvedValue(mockTab);
100 |
101 | // Act
102 | await messageHandler.handleDecodedMessage(request);
103 |
104 | // Assert
105 | expect(browser.tabs.create).toHaveBeenCalledWith({
106 | url: "https://example.com",
107 | });
108 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
109 | resource: "opened-tab-id",
110 | correlationId: "test-correlation-id",
111 | tabId: 123,
112 | });
113 | });
114 |
115 | it("should throw an error if URL does not start with https://", async () => {
116 | // Arrange
117 | const request: ServerMessageRequest = {
118 | cmd: "open-tab",
119 | url: "http://example.com",
120 | correlationId: "test-correlation-id",
121 | };
122 |
123 | // Act & Assert
124 | await expect(
125 | messageHandler.handleDecodedMessage(request)
126 | ).rejects.toThrow("Invalid URL");
127 | expect(browser.tabs.create).not.toHaveBeenCalled();
128 | });
129 |
130 | it("should throw an error if domain is in deny list", async () => {
131 | // Arrange
132 | const configWithDenyList: ExtensionConfig = {
133 | secret: "test-secret",
134 | toolSettings: {
135 | "open-browser-tab": true,
136 | "close-browser-tabs": true,
137 | "get-list-of-open-tabs": true,
138 | "get-recent-browser-history": true,
139 | "get-tab-web-content": true,
140 | "reorder-browser-tabs": true,
141 | "find-highlight-in-browser-tab": true,
142 | },
143 | domainDenyList: ["example.com", "another.com"],
144 | ports: [8089],
145 | auditLog: [],
146 | };
147 | (browser.storage.local.get as jest.Mock).mockResolvedValue({
148 | config: configWithDenyList,
149 | });
150 |
151 | const request: ServerMessageRequest = {
152 | cmd: "open-tab",
153 | url: "https://example.com",
154 | correlationId: "test-correlation-id",
155 | };
156 |
157 | // Act & Assert
158 | await expect(
159 | messageHandler.handleDecodedMessage(request)
160 | ).rejects.toThrow("Domain in user defined deny list");
161 | expect(browser.tabs.create).not.toHaveBeenCalled();
162 | });
163 |
164 | it("should open a new tab in the domain is not in the deny list", async () => {
165 | // Arrange
166 | const configWithDenyList: ExtensionConfig = {
167 | secret: "test-secret",
168 | toolSettings: {
169 | "open-browser-tab": true,
170 | "close-browser-tabs": true,
171 | "get-list-of-open-tabs": true,
172 | "get-recent-browser-history": true,
173 | "get-tab-web-content": true,
174 | "reorder-browser-tabs": true,
175 | "find-highlight-in-browser-tab": true,
176 | },
177 | domainDenyList: ["example.com", "another.com"],
178 | ports: [8089],
179 | auditLog: [],
180 | };
181 | (browser.storage.local.get as jest.Mock).mockResolvedValue({
182 | config: configWithDenyList,
183 | });
184 |
185 | const request: ServerMessageRequest = {
186 | cmd: "open-tab",
187 | url: "https://allowed.com",
188 | correlationId: "test-correlation-id",
189 | };
190 |
191 | const mockTab = { id: 123 };
192 | (browser.tabs.create as jest.Mock).mockResolvedValue(mockTab);
193 |
194 | // Act
195 | await messageHandler.handleDecodedMessage(request);
196 |
197 | // Assert
198 | expect(browser.tabs.create).toHaveBeenCalledWith({
199 | url: "https://allowed.com",
200 | });
201 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
202 | resource: "opened-tab-id",
203 | correlationId: "test-correlation-id",
204 | tabId: 123,
205 | });
206 | });
207 | });
208 |
209 | describe("close-tabs command", () => {
210 | it("should close tabs and send confirmation to the server", async () => {
211 | // Arrange
212 | const request: ServerMessageRequest = {
213 | cmd: "close-tabs",
214 | tabIds: [123, 456],
215 | correlationId: "test-correlation-id",
216 | };
217 |
218 | (browser.tabs.remove as jest.Mock).mockResolvedValue(undefined);
219 |
220 | // Act
221 | await messageHandler.handleDecodedMessage(request);
222 |
223 | // Assert
224 | expect(browser.tabs.remove).toHaveBeenCalledWith([123, 456]);
225 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
226 | resource: "tabs-closed",
227 | correlationId: "test-correlation-id",
228 | });
229 | });
230 | });
231 |
232 | describe("get-tab-list command", () => {
233 | it("should get tabs and send them to the server", async () => {
234 | // Arrange
235 | const request: ServerMessageRequest = {
236 | cmd: "get-tab-list",
237 | correlationId: "test-correlation-id",
238 | };
239 |
240 | const mockTabs = [{ id: 123, url: "https://example.com" }];
241 | (browser.tabs.query as jest.Mock).mockResolvedValue(mockTabs);
242 |
243 | // Act
244 | await messageHandler.handleDecodedMessage(request);
245 |
246 | // Assert
247 | expect(browser.tabs.query).toHaveBeenCalledWith({});
248 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
249 | resource: "tabs",
250 | correlationId: "test-correlation-id",
251 | tabs: mockTabs,
252 | });
253 | });
254 | });
255 |
256 | describe("get-browser-recent-history command", () => {
257 | it("should get history items and send them to the server", async () => {
258 | // Arrange
259 | const request: ServerMessageRequest = {
260 | cmd: "get-browser-recent-history",
261 | searchQuery: "test",
262 | correlationId: "test-correlation-id",
263 | };
264 |
265 | const mockHistoryItems = [
266 | { url: "https://example.com", title: "Example" },
267 | { url: "https://test.com", title: "Test" },
268 | ];
269 | (browser.history.search as jest.Mock).mockResolvedValue(
270 | mockHistoryItems
271 | );
272 |
273 | // Act
274 | await messageHandler.handleDecodedMessage(request);
275 |
276 | // Assert
277 | expect(browser.history.search).toHaveBeenCalledWith({
278 | text: "test",
279 | maxResults: 200,
280 | startTime: 0,
281 | });
282 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
283 | resource: "history",
284 | correlationId: "test-correlation-id",
285 | historyItems: mockHistoryItems,
286 | });
287 | });
288 |
289 | it("should use empty string for search query if not provided", async () => {
290 | // Arrange
291 | const request: ServerMessageRequest = {
292 | cmd: "get-browser-recent-history",
293 | correlationId: "test-correlation-id",
294 | };
295 |
296 | const mockHistoryItems = [
297 | { url: "https://example.com", title: "Example" },
298 | ];
299 | (browser.history.search as jest.Mock).mockResolvedValue(
300 | mockHistoryItems
301 | );
302 |
303 | // Act
304 | await messageHandler.handleDecodedMessage(request);
305 |
306 | // Assert
307 | expect(browser.history.search).toHaveBeenCalledWith({
308 | text: "",
309 | maxResults: 200,
310 | startTime: 0,
311 | });
312 | });
313 |
314 | it("should filter out history items without URLs", async () => {
315 | // Arrange
316 | const request: ServerMessageRequest = {
317 | cmd: "get-browser-recent-history",
318 | correlationId: "test-correlation-id",
319 | };
320 |
321 | const mockHistoryItems = [
322 | { url: "https://example.com", title: "Example" },
323 | { title: "No URL" }, // This should be filtered out
324 | ];
325 | (browser.history.search as jest.Mock).mockResolvedValue(
326 | mockHistoryItems
327 | );
328 |
329 | // Act
330 | await messageHandler.handleDecodedMessage(request);
331 |
332 | // Assert
333 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
334 | resource: "history",
335 | correlationId: "test-correlation-id",
336 | historyItems: [{ url: "https://example.com", title: "Example" }],
337 | });
338 | });
339 | });
340 |
341 | describe("get-tab-content command", () => {
342 | it("should get tab content and send it to the server", async () => {
343 | // Arrange
344 | const request: ServerMessageRequest = {
345 | cmd: "get-tab-content",
346 | tabId: 123,
347 | correlationId: "test-correlation-id",
348 | };
349 |
350 | const mockTab = { id: 123, url: "https://example.com" };
351 | (browser.tabs.get as jest.Mock).mockResolvedValue(mockTab);
352 | (browser.permissions.contains as jest.Mock).mockResolvedValue(true);
353 |
354 | const mockScriptResult = [
355 | {
356 | links: [{ url: "https://example.com/page", text: "Page" }],
357 | fullText: "Page content",
358 | isTruncated: false,
359 | totalLength: 12,
360 | },
361 | ];
362 | (browser.tabs.executeScript as jest.Mock).mockResolvedValue(
363 | mockScriptResult
364 | );
365 |
366 | // Act
367 | await messageHandler.handleDecodedMessage(request);
368 |
369 | // Assert
370 | expect(browser.tabs.get).toHaveBeenCalledWith(123);
371 | expect(browser.permissions.contains).toHaveBeenCalledWith({
372 | origins: ["https://example.com/*"],
373 | });
374 | expect(browser.tabs.executeScript).toHaveBeenCalled();
375 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
376 | resource: "tab-content",
377 | tabId: 123,
378 | correlationId: "test-correlation-id",
379 | isTruncated: false,
380 | fullText: "Page content",
381 | links: [{ url: "https://example.com/page", text: "Page" }],
382 | totalLength: 12,
383 | });
384 | });
385 |
386 | it("should throw an error if tab URL domain is in deny list", async () => {
387 | // Arrange
388 | const configWithDenyList: ExtensionConfig = {
389 | secret: "test-secret",
390 | toolSettings: {
391 | "open-browser-tab": true,
392 | "close-browser-tabs": true,
393 | "get-list-of-open-tabs": true,
394 | "get-recent-browser-history": true,
395 | "get-tab-web-content": true,
396 | "reorder-browser-tabs": true,
397 | "find-highlight-in-browser-tab": true,
398 | },
399 | domainDenyList: ["example.com"], // Add example.com to deny list
400 | ports: [8089],
401 | auditLog: [],
402 | };
403 | (browser.storage.local.get as jest.Mock).mockResolvedValue({
404 | config: configWithDenyList,
405 | });
406 |
407 | const request: ServerMessageRequest = {
408 | cmd: "get-tab-content",
409 | tabId: 123,
410 | correlationId: "test-correlation-id",
411 | };
412 |
413 | const mockTab = { id: 123, url: "https://example.com" };
414 | (browser.tabs.get as jest.Mock).mockResolvedValue(mockTab);
415 |
416 | // Act & Assert
417 | await expect(
418 | messageHandler.handleDecodedMessage(request)
419 | ).rejects.toThrow("Domain in tab URL is in the deny list");
420 | expect(browser.tabs.executeScript).not.toHaveBeenCalled();
421 | });
422 |
423 | it("should throw an error if permissions are denied", async () => {
424 | // Arrange
425 | const request: ServerMessageRequest = {
426 | cmd: "get-tab-content",
427 | tabId: 123,
428 | correlationId: "test-correlation-id",
429 | };
430 |
431 | const mockTab = { id: 123, url: "https://example.com" };
432 | (browser.tabs.get as jest.Mock).mockResolvedValue(mockTab);
433 | (browser.permissions.contains as jest.Mock).mockResolvedValue(false);
434 |
435 | // Act & Assert
436 | await expect(
437 | messageHandler.handleDecodedMessage(request)
438 | ).rejects.toThrow();
439 | expect(browser.tabs.executeScript).not.toHaveBeenCalled();
440 | });
441 | });
442 |
443 | describe("reorder-tabs command", () => {
444 | it("should reorder tabs and send confirmation to the server", async () => {
445 | // Arrange
446 | const request: ServerMessageRequest = {
447 | cmd: "reorder-tabs",
448 | tabOrder: [123, 456, 789],
449 | correlationId: "test-correlation-id",
450 | };
451 |
452 | (browser.tabs.move as jest.Mock).mockResolvedValue(undefined);
453 |
454 | // Act
455 | await messageHandler.handleDecodedMessage(request);
456 |
457 | // Assert
458 | expect(browser.tabs.move).toHaveBeenCalledTimes(3);
459 | expect(browser.tabs.move).toHaveBeenNthCalledWith(1, 123, { index: 0 });
460 | expect(browser.tabs.move).toHaveBeenNthCalledWith(2, 456, { index: 1 });
461 | expect(browser.tabs.move).toHaveBeenNthCalledWith(3, 789, { index: 2 });
462 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
463 | resource: "tabs-reordered",
464 | correlationId: "test-correlation-id",
465 | tabOrder: [123, 456, 789],
466 | });
467 | });
468 | });
469 |
470 | describe("find-highlight command", () => {
471 | it("should find and highlight text in a tab", async () => {
472 | // Arrange
473 | const request: ServerMessageRequest = {
474 | cmd: "find-highlight",
475 | tabId: 123,
476 | queryPhrase: "test",
477 | correlationId: "test-correlation-id",
478 | };
479 |
480 | const mockFindResults = { count: 5 };
481 | (browser.find.find as jest.Mock).mockResolvedValue(mockFindResults);
482 | (browser.tabs.update as jest.Mock).mockResolvedValue(undefined);
483 | (browser.permissions.contains as jest.Mock).mockResolvedValue(true);
484 |
485 | // Act
486 | await messageHandler.handleDecodedMessage(request);
487 |
488 | // Assert
489 | expect(browser.find.find).toHaveBeenCalledWith("test", {
490 | tabId: 123,
491 | caseSensitive: true,
492 | });
493 | expect(browser.tabs.update).toHaveBeenCalledWith(123, { active: true });
494 | expect(browser.find.highlightResults).toHaveBeenCalledWith({
495 | tabId: 123,
496 | });
497 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
498 | resource: "find-highlight-result",
499 | correlationId: "test-correlation-id",
500 | noOfResults: 5,
501 | });
502 | });
503 |
504 | it("should not highlight or activate tab if no results found", async () => {
505 | // Arrange
506 | const request: ServerMessageRequest = {
507 | cmd: "find-highlight",
508 | tabId: 123,
509 | queryPhrase: "test",
510 | correlationId: "test-correlation-id",
511 | };
512 |
513 | const mockFindResults = { count: 0 };
514 | const mockTab = { id: 123, url: "https://example.com" };
515 | (browser.tabs.get as jest.Mock).mockResolvedValue(mockTab);
516 | (browser.find.find as jest.Mock).mockResolvedValue(mockFindResults);
517 | (browser.permissions.contains as jest.Mock).mockResolvedValue(true);
518 |
519 | // Act
520 | await messageHandler.handleDecodedMessage(request);
521 |
522 | // Assert
523 | expect(browser.tabs.update).not.toHaveBeenCalled();
524 | expect(browser.find.highlightResults).not.toHaveBeenCalled();
525 | expect(mockClient.sendResourceToServer).toHaveBeenCalledWith({
526 | resource: "find-highlight-result",
527 | correlationId: "test-correlation-id",
528 | noOfResults: 0,
529 | });
530 | });
531 |
532 | it("should throw an error if permissions are denied", async () => {
533 | // Arrange
534 | const request: ServerMessageRequest = {
535 | cmd: "find-highlight",
536 | tabId: 123,
537 | queryPhrase: "test",
538 | correlationId: "test-correlation-id",
539 | };
540 |
541 | const mockTab = { id: 123, url: "https://example.com" };
542 | (browser.tabs.get as jest.Mock).mockResolvedValue(mockTab);
543 | (browser.permissions.contains as jest.Mock).mockResolvedValue(false);
544 |
545 | // Act & Assert
546 | await expect(
547 | messageHandler.handleDecodedMessage(request)
548 | ).rejects.toThrow();
549 | expect(browser.find.find).not.toHaveBeenCalled();
550 | });
551 | });
552 | });
553 | });
554 |
```
--------------------------------------------------------------------------------
/firefox-extension/options.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Options page script for Browser Control MCP extension
3 | */
4 | import {
5 | getSecret,
6 | AVAILABLE_TOOLS,
7 | getAllToolSettings,
8 | setToolEnabled,
9 | getDomainDenyList,
10 | setDomainDenyList,
11 | getPorts,
12 | setPorts,
13 | getAuditLog,
14 | clearAuditLog,
15 | getToolNameById,
16 | } from "./extension-config";
17 |
18 | const secretDisplay = document.getElementById(
19 | "secret-display"
20 | ) as HTMLDivElement;
21 | const copyButton = document.getElementById("copy-button") as HTMLButtonElement;
22 | const statusElement = document.getElementById("status") as HTMLDivElement;
23 | const toolSettingsContainer = document.getElementById(
24 | "tool-settings-container"
25 | ) as HTMLDivElement;
26 | const domainDenyListTextarea = document.getElementById(
27 | "domain-deny-list"
28 | ) as HTMLTextAreaElement;
29 | const saveDomainListsButton = document.getElementById(
30 | "save-domain-lists"
31 | ) as HTMLButtonElement;
32 | const domainStatusElement = document.getElementById(
33 | "domain-status"
34 | ) as HTMLDivElement;
35 | const portsInput = document.getElementById("ports-input") as HTMLInputElement;
36 | const savePortsButton = document.getElementById("save-ports") as HTMLButtonElement;
37 | const portsStatusElement = document.getElementById("ports-status") as HTMLDivElement;
38 | const auditLogContainer = document.getElementById("audit-log-container") as HTMLDivElement;
39 | const clearAuditLogButton = document.getElementById("clear-audit-log") as HTMLButtonElement;
40 | const auditLogStatusElement = document.getElementById("audit-log-status") as HTMLDivElement;
41 |
42 | /**
43 | * Loads the secret from storage and displays it
44 | */
45 | async function loadSecret() {
46 | try {
47 | const secret = await getSecret();
48 |
49 | // Check if secret exists
50 | if (secret) {
51 | secretDisplay.textContent = secret;
52 | } else {
53 | secretDisplay.textContent =
54 | "No secret found. Please reinstall the extension.";
55 | secretDisplay.style.color = "red";
56 | copyButton.disabled = true;
57 | }
58 | } catch (error) {
59 | console.error("Error loading secret:", error);
60 | secretDisplay.textContent =
61 | "Error loading secret. Please check console for details.";
62 | secretDisplay.style.color = "red";
63 | copyButton.disabled = true;
64 | }
65 | }
66 |
67 | /**
68 | * Copies the secret to clipboard
69 | */
70 | async function copyToClipboard(event: MouseEvent) {
71 | if (!event.isTrusted) {
72 | return;
73 | }
74 | try {
75 | const secret = secretDisplay.textContent;
76 | if (
77 | !secret ||
78 | secret === "Loading..." ||
79 | secret.includes("No secret found") ||
80 | secret.includes("Error loading")
81 | ) {
82 | return;
83 | }
84 |
85 | await navigator.clipboard.writeText(secret);
86 |
87 | // Show success message
88 | statusElement.textContent = "Secret copied to clipboard!";
89 | setTimeout(() => {
90 | statusElement.textContent = "";
91 | }, 3000);
92 | } catch (error) {
93 | console.error("Error copying to clipboard:", error);
94 | statusElement.textContent = "Failed to copy to clipboard";
95 | statusElement.style.color = "red";
96 | setTimeout(() => {
97 | statusElement.textContent = "";
98 | statusElement.style.color = "";
99 | }, 3000);
100 | }
101 | }
102 |
103 | /**
104 | * Creates the tool settings UI
105 | */
106 | async function createToolSettingsUI() {
107 | const toolSettings = await getAllToolSettings();
108 |
109 | // Clear existing content
110 | toolSettingsContainer.innerHTML = "";
111 |
112 | // Create a toggle switch for each tool
113 | AVAILABLE_TOOLS.forEach((tool) => {
114 | const isEnabled = toolSettings[tool.id] !== false; // Default to true if not set
115 |
116 | const toolRow = document.createElement("div");
117 | toolRow.className = "tool-row";
118 |
119 | const labelContainer = document.createElement("div");
120 | labelContainer.className = "tool-label-container";
121 |
122 | const toolName = document.createElement("div");
123 | toolName.className = "tool-name";
124 | toolName.textContent = tool.name;
125 |
126 | const toolDescription = document.createElement("div");
127 | toolDescription.className = "tool-description";
128 | toolDescription.textContent = tool.description;
129 |
130 | labelContainer.appendChild(toolName);
131 | labelContainer.appendChild(toolDescription);
132 |
133 | const toggleContainer = document.createElement("label");
134 | toggleContainer.className = "toggle-switch";
135 |
136 | const checkbox = document.createElement("input");
137 | checkbox.type = "checkbox";
138 | checkbox.checked = isEnabled;
139 | checkbox.dataset.toolId = tool.id;
140 | checkbox.addEventListener("change", handleToolToggle);
141 |
142 | const slider = document.createElement("span");
143 | slider.className = "slider";
144 |
145 | toggleContainer.appendChild(checkbox);
146 | toggleContainer.appendChild(slider);
147 |
148 | toolRow.appendChild(labelContainer);
149 | toolRow.appendChild(toggleContainer);
150 |
151 | toolSettingsContainer.appendChild(toolRow);
152 | });
153 | }
154 |
155 | /**
156 | * Handles toggling a tool on/off
157 | */
158 | async function handleToolToggle(event: Event) {
159 | const checkbox = event.target as HTMLInputElement;
160 | const toolId = checkbox.dataset.toolId;
161 | const isEnabled = checkbox.checked;
162 |
163 | if (!toolId) {
164 | console.error("Tool ID not found");
165 | return;
166 | }
167 |
168 | try {
169 | await setToolEnabled(toolId, isEnabled);
170 | // No status message displayed
171 | } catch (error) {
172 | console.error("Error saving tool setting:", error);
173 |
174 | // Revert the checkbox state
175 | checkbox.checked = !isEnabled;
176 | }
177 | }
178 |
179 | /**
180 | * Loads the domain lists from storage and displays them
181 | */
182 | async function loadDomainLists() {
183 | try {
184 | // Load deny list
185 | const denyList = await getDomainDenyList();
186 | domainDenyListTextarea.value = denyList.join("\n");
187 | } catch (error) {
188 | console.error("Error loading domain lists:", error);
189 | domainStatusElement.textContent =
190 | "Error loading domain lists. Please check console for details.";
191 | domainStatusElement.style.color = "red";
192 | setTimeout(() => {
193 | domainStatusElement.textContent = "";
194 | domainStatusElement.style.color = "";
195 | }, 3000);
196 | }
197 | }
198 |
199 | /**
200 | * Saves the domain lists to storage
201 | */
202 | async function saveDomainLists(event: MouseEvent) {
203 | if (!event.isTrusted) {
204 | return;
205 | }
206 |
207 | try {
208 | // Parse deny list (split by newlines and filter out empty lines)
209 | const denyListText = domainDenyListTextarea.value.trim();
210 | const denyList = denyListText
211 | ? denyListText
212 | .split("\n")
213 | .map((domain) => domain.trim())
214 | .filter(Boolean)
215 | : [];
216 |
217 | // Save to storage
218 | await setDomainDenyList(denyList);
219 |
220 | // Show success message
221 | domainStatusElement.textContent = "Domain deny list saved successfully!";
222 | domainStatusElement.style.color = "#4caf50";
223 | setTimeout(() => {
224 | domainStatusElement.textContent = "";
225 | domainStatusElement.style.color = "";
226 | }, 3000);
227 | } catch (error) {
228 | console.error("Error saving domain lists:", error);
229 | domainStatusElement.textContent = "Failed to save domain lists";
230 | domainStatusElement.style.color = "red";
231 | setTimeout(() => {
232 | domainStatusElement.textContent = "";
233 | domainStatusElement.style.color = "";
234 | }, 3000);
235 | }
236 | }
237 |
238 | /**
239 | * Loads the ports from storage and displays them
240 | */
241 | async function loadPorts() {
242 | try {
243 | const ports = await getPorts();
244 | portsInput.value = ports.join(", ");
245 | } catch (error) {
246 | console.error("Error loading ports:", error);
247 | portsStatusElement.textContent =
248 | "Error loading ports. Please check console for details.";
249 | portsStatusElement.style.color = "red";
250 | setTimeout(() => {
251 | portsStatusElement.textContent = "";
252 | portsStatusElement.style.color = "";
253 | }, 3000);
254 | }
255 | }
256 |
257 | /**
258 | * Saves the ports to storage
259 | */
260 | async function savePorts(event: MouseEvent) {
261 | if (!event.isTrusted) {
262 | return;
263 | }
264 |
265 | try {
266 | // Parse ports (split by commas and filter out empty values)
267 | const portsText = portsInput.value.trim();
268 | const portStrings = portsText
269 | ? portsText
270 | .split(",")
271 | .map((port) => port.trim())
272 | .filter(Boolean)
273 | : [];
274 |
275 | // Validate and convert to numbers
276 | const ports: number[] = [];
277 | for (const portStr of portStrings) {
278 | const port = parseInt(portStr, 10);
279 | if (isNaN(port) || port < 1 || port > 65535) {
280 | throw new Error(`Invalid port number: ${portStr}. Ports must be between 1 and 65535.`);
281 | }
282 | ports.push(port);
283 | }
284 |
285 | // Ensure at least one port is provided
286 | if (ports.length === 0) {
287 | throw new Error("At least one port must be specified.");
288 | }
289 |
290 | // Save to storage
291 | await setPorts(ports);
292 |
293 | // Reload the extension:
294 | browser.runtime.reload();
295 | } catch (error) {
296 | console.error("Error saving ports:", error);
297 | portsStatusElement.textContent = error instanceof Error ? error.message : "Failed to save ports";
298 | portsStatusElement.style.color = "red";
299 | setTimeout(() => {
300 | portsStatusElement.textContent = "";
301 | portsStatusElement.style.color = "";
302 | }, 3000);
303 | }
304 | }
305 |
306 | /**
307 | * Loads the audit log from storage and displays it
308 | */
309 | async function loadAuditLog() {
310 | try {
311 | const auditLog = await getAuditLog();
312 |
313 | // Clear existing content
314 | auditLogContainer.innerHTML = "";
315 |
316 | if (auditLog.length === 0) {
317 | // Show empty state
318 | const emptyDiv = document.createElement("div");
319 | emptyDiv.className = "audit-log-empty";
320 | emptyDiv.textContent = "No tool usage recorded yet.";
321 | auditLogContainer.appendChild(emptyDiv);
322 | return;
323 | }
324 |
325 | // Create table
326 | const table = document.createElement("table");
327 | table.className = "audit-log-table";
328 |
329 | // Create header
330 | const thead = document.createElement("thead");
331 | const headerRow = document.createElement("tr");
332 |
333 | const headers = ["Tool", "Timestamp", "Domain"];
334 | headers.forEach(headerText => {
335 | const th = document.createElement("th");
336 | th.textContent = headerText;
337 | headerRow.appendChild(th);
338 | });
339 |
340 | thead.appendChild(headerRow);
341 | table.appendChild(thead);
342 |
343 | // Create body
344 | const tbody = document.createElement("tbody");
345 |
346 | auditLog.forEach(entry => {
347 | const row = document.createElement("tr");
348 |
349 | // Tool name
350 | const toolCell = document.createElement("td");
351 | toolCell.textContent = getToolNameById(entry.toolId);
352 | row.appendChild(toolCell);
353 |
354 | // Timestamp
355 | const timestampCell = document.createElement("td");
356 | timestampCell.className = "audit-log-timestamp";
357 | const date = new Date(entry.timestamp);
358 | timestampCell.textContent = date.toLocaleString();
359 | row.appendChild(timestampCell);
360 |
361 | // URL Domain
362 | const urlCell = document.createElement("td");
363 | urlCell.className = "audit-log-url";
364 | if (entry.url) {
365 | // Show only the domain part of the URL
366 | try {
367 | const urlObj = new URL(entry.url);
368 | urlCell.textContent = urlObj.hostname;
369 | } catch (e) {
370 | console.error("Invalid URL in audit log entry:", e);
371 | urlCell.textContent = "Invalid URL";
372 | }
373 | } else {
374 | urlCell.textContent = "-";
375 | }
376 | row.appendChild(urlCell);
377 |
378 | tbody.appendChild(row);
379 | });
380 |
381 | table.appendChild(tbody);
382 | auditLogContainer.appendChild(table);
383 |
384 | } catch (error) {
385 | console.error("Error loading audit log:", error);
386 | auditLogContainer.innerHTML = '<div class="audit-log-empty">Error loading audit log. Please check console for details.</div>';
387 | }
388 | }
389 |
390 | /**
391 | * Clears the audit log
392 | */
393 | async function handleClearAuditLog(event: MouseEvent) {
394 | if (!event.isTrusted) {
395 | return;
396 | }
397 |
398 | try {
399 | await clearAuditLog();
400 |
401 | // Reload the audit log display
402 | await loadAuditLog();
403 |
404 | // Show success message
405 | auditLogStatusElement.textContent = "Audit log cleared successfully!";
406 | auditLogStatusElement.style.color = "#4caf50";
407 | setTimeout(() => {
408 | auditLogStatusElement.textContent = "";
409 | auditLogStatusElement.style.color = "";
410 | }, 3000);
411 | } catch (error) {
412 | console.error("Error clearing audit log:", error);
413 | auditLogStatusElement.textContent = "Failed to clear audit log";
414 | auditLogStatusElement.style.color = "red";
415 | setTimeout(() => {
416 | auditLogStatusElement.textContent = "";
417 | auditLogStatusElement.style.color = "";
418 | }, 3000);
419 | }
420 | }
421 |
422 | /**
423 | * Initializes the collapsible sections
424 | */
425 | function initializeCollapsibleSections() {
426 | const sectionHeaders = document.querySelectorAll(".section-container > h2");
427 |
428 | sectionHeaders.forEach((header) => {
429 | // Add click event listener to toggle section visibility
430 | header.addEventListener("click", (event) => {
431 | event.preventDefault();
432 |
433 | // Toggle the collapsed class on the header
434 | header.classList.toggle("collapsed");
435 |
436 | // Toggle the collapsed class on the section content
437 | const sectionContent = header.nextElementSibling as HTMLElement;
438 | sectionContent.classList.toggle("collapsed");
439 | });
440 | });
441 | }
442 |
443 | function showPermissionRequest(url: string) {
444 | const domain = new URL(url).hostname;
445 | const origin = new URL(url).origin;
446 |
447 | // Show the modal and hide the main content
448 | const modal = document.getElementById("permission-modal") as HTMLDivElement;
449 | const mainContent = document.getElementById("main-content") as HTMLDivElement;
450 | const domainElement = document.getElementById("permission-domain") as HTMLDivElement;
451 | const grantBtn = document.getElementById("grant-btn") as HTMLButtonElement;
452 | const cancelBtn = document.getElementById("cancel-btn") as HTMLButtonElement;
453 | const permissionText = document.getElementById("permission-text") as HTMLParagraphElement;
454 |
455 | // Set the domain in the modal
456 | domainElement.textContent = domain;
457 |
458 | // Update permission text for URL permission
459 | permissionText.textContent = "This will allow the extension to interact with pages on this domain as requested by the MCP server.";
460 |
461 | // Show modal and blur main content
462 | modal.classList.remove("hidden");
463 | mainContent.classList.add("modal-open");
464 |
465 | // Handle grant permission button click
466 | const handleGrant = async () => {
467 | try {
468 | const granted = await browser.permissions.request({
469 | origins: [`${origin}/*`],
470 | });
471 |
472 | if (granted) {
473 | // Permission granted, close the window or redirect back
474 | window.close();
475 | } else {
476 | // Permission denied, hide modal and show main content
477 | hidePermissionModal();
478 | }
479 | } catch (error) {
480 | console.error("Error requesting permission:", error);
481 | hidePermissionModal();
482 | }
483 | };
484 |
485 | // Handle cancel button click
486 | const handleCancel = () => {
487 | hidePermissionModal();
488 | };
489 |
490 | // Add event listeners
491 | grantBtn.addEventListener("click", handleGrant);
492 | cancelBtn.addEventListener("click", handleCancel);
493 |
494 | // Store references to remove listeners later
495 | (window as any).permissionHandlers = {
496 | handleGrant,
497 | handleCancel,
498 | grantBtn,
499 | cancelBtn
500 | };
501 | }
502 |
503 | function showGlobalPermissionRequest(permissions: string[]) {
504 | // Show the modal and hide the main content
505 | const modal = document.getElementById("permission-modal") as HTMLDivElement;
506 | const mainContent = document.getElementById("main-content") as HTMLDivElement;
507 | const domainElement = document.getElementById("permission-domain") as HTMLDivElement;
508 | const grantBtn = document.getElementById("grant-btn") as HTMLButtonElement;
509 | const cancelBtn = document.getElementById("cancel-btn") as HTMLButtonElement;
510 | const permissionText = document.getElementById("permission-text") as HTMLParagraphElement;
511 |
512 | // Set the permissions in the modal
513 | domainElement.textContent = permissions.join(", ");
514 |
515 | // Update permission text for global permissions
516 | permissionText.textContent = "This will allow the extension to use these browser capabilities as requested by the MCP server.";
517 |
518 | // Show modal and blur main content
519 | modal.classList.remove("hidden");
520 | mainContent.classList.add("modal-open");
521 |
522 | // Handle grant permission button click
523 | const handleGrant = async () => {
524 | try {
525 | const granted = await browser.permissions.request({
526 | permissions: permissions as browser.permissions.Permissions["permissions"],
527 | });
528 |
529 | if (granted) {
530 | // Permission granted, close the window or redirect back
531 | window.close();
532 | } else {
533 | // Permission denied, hide modal and show main content
534 | hidePermissionModal();
535 | }
536 | } catch (error) {
537 | console.error("Error requesting permission:", error);
538 | hidePermissionModal();
539 | }
540 | };
541 |
542 | // Handle cancel button click
543 | const handleCancel = () => {
544 | hidePermissionModal();
545 | };
546 |
547 | // Add event listeners
548 | grantBtn.addEventListener("click", handleGrant);
549 | cancelBtn.addEventListener("click", handleCancel);
550 |
551 | // Store references to remove listeners later
552 | (window as any).permissionHandlers = {
553 | handleGrant,
554 | handleCancel,
555 | grantBtn,
556 | cancelBtn
557 | };
558 | }
559 |
560 | function hidePermissionModal() {
561 | const modal = document.getElementById("permission-modal") as HTMLDivElement;
562 | const mainContent = document.getElementById("main-content") as HTMLDivElement;
563 |
564 | // Hide modal and restore main content
565 | modal.classList.add("hidden");
566 | mainContent.classList.remove("modal-open");
567 |
568 | // Clean up event listeners
569 | const handlers = (window as any).permissionHandlers;
570 | if (handlers) {
571 | handlers.grantBtn.removeEventListener("click", handlers.handleGrant);
572 | handlers.cancelBtn.removeEventListener("click", handlers.handleCancel);
573 | delete (window as any).permissionHandlers;
574 | }
575 | }
576 |
577 | // Initialize the page
578 | copyButton.addEventListener("click", copyToClipboard);
579 | saveDomainListsButton.addEventListener("click", saveDomainLists);
580 | savePortsButton.addEventListener("click", savePorts);
581 | clearAuditLogButton.addEventListener("click", handleClearAuditLog);
582 | document.addEventListener("DOMContentLoaded", () => {
583 | loadSecret();
584 | createToolSettingsUI();
585 | loadDomainLists();
586 | loadPorts();
587 | loadAuditLog();
588 | initializeCollapsibleSections();
589 |
590 | // Ensure modal is hidden by default
591 | const modal = document.getElementById("permission-modal") as HTMLDivElement;
592 | const mainContent = document.getElementById("main-content") as HTMLDivElement;
593 | modal.classList.add("hidden");
594 | mainContent.classList.remove("modal-open");
595 |
596 | const params = new URLSearchParams(window.location.search);
597 | const requestUrl = params.get("requestUrl");
598 | const requestPermissions = params.get("requestPermissions");
599 |
600 | if (requestUrl) {
601 | // Show UI for requesting permission for this specific URL
602 | showPermissionRequest(requestUrl);
603 | } else if (requestPermissions) {
604 | // Show UI for requesting global permissions
605 | try {
606 | const permissions = JSON.parse(decodeURIComponent(requestPermissions));
607 | showGlobalPermissionRequest(permissions);
608 | } catch (error) {
609 | console.error("Error parsing requestPermissions:", error);
610 | }
611 | }
612 |
613 | // Add interval to refresh the audit log every 5 seconds:
614 | setInterval(() => {
615 | loadAuditLog();
616 | }, 5000);
617 | });
618 |
```