chrome-extension-tester-mcp 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,8 @@ An **MCP (Model Context Protocol) server** that lets Claude interactively test a
6
6
 
7
7
  ## Table of Contents
8
8
 
9
+ - [Why](#why)
10
+ - [Architecture](#architecture)
9
11
  - [Features](#features)
10
12
  - [Requirements](#requirements)
11
13
  - [Installation](#installation)
@@ -13,12 +15,31 @@ An **MCP (Model Context Protocol) server** that lets Claude interactively test a
13
15
  - [Setup with Claude Code (npx)](#setup-with-claude-code-npx)
14
16
  - [Available Tools](#available-tools)
15
17
  - [Testing Agent Prompt](#testing-agent-prompt)
18
+ - [Example: testing an extension popup](#example-testing-an-extension-popup)
16
19
  - [Example Prompts](#example-prompts)
17
20
  - [Project Structure](#project-structure)
18
21
  - [Notes](#notes)
19
22
 
20
23
  ---
21
24
 
25
+ ## Why
26
+
27
+ Testing a Chrome extension during development means manually clicking reload, opening the popup, checking storage in DevTools, watching the service worker console, and copy-pasting errors back to the agent on every iteration. This MCP server gives an AI coding agent direct access to all of that through tool calls, so the agent can iterate on its own. It exists because the manual loop made working with Claude Code on extensions too slow.
28
+
29
+ ---
30
+
31
+ ## Architecture
32
+
33
+ ```mermaid
34
+ graph LR
35
+ A[AI Agent<br/>Claude / Cursor] -->|MCP protocol| B[This server<br/>14 tools]
36
+ B -->|Playwright| C[Chromium<br/>persistent context]
37
+ C -->|loads| D[Extension under test]
38
+ B -.->|reads / writes| E[(state.js<br/>browser, page, extensionId)]
39
+ ```
40
+
41
+ ---
42
+
22
43
  ## Features
23
44
 
24
45
  - Load and reload any unpacked Chrome extension
@@ -142,6 +163,76 @@ Add to your project's `.mcp.json` or user-level MCP config:
142
163
  | `simulate_tab_events` | Open, close, switch, list, or close all browser tabs |
143
164
  | `test_account_login` | Create or reuse a test account on any website using a disposable email; credentials are stored in `test-accounts.json` and reused across sessions |
144
165
 
166
+ ### `load_extension`
167
+ Launch Chromium with an unpacked extension and capture its ID.
168
+ **Inputs:** `extension_path` (string, required) — path to the unpacked extension folder.
169
+ **Returns:** Text confirming the resolved path and the detected extension ID.
170
+
171
+ ### `interact_with_popup`
172
+ Open the popup and click, type, or read its DOM.
173
+ **Inputs:** `action` (string, required: `open` | `click` | `type` | `get_text` | `get_html`); `selector` (string); `value` (string, for `type`).
174
+ **Returns:** Text describing the action result, or the requested text/HTML.
175
+
176
+ ### `open_options_page`
177
+ Open the options page (or any extension page) and interact with it.
178
+ **Inputs:** `page` (string, default `options.html`); `action` (string: `open` | `click` | `type` | `get_text` | `get_html`); `selector` (string); `value` (string).
179
+ **Returns:** Text describing the action result, or the requested text/HTML.
180
+
181
+ ### `inspect_dom`
182
+ Query a selector or evaluate JS in a page, optionally navigating first.
183
+ **Inputs:** `url` (string); `selector` (string); `script` (string, overrides `selector`).
184
+ **Returns:** Text with matched elements' outerHTML, or the JSON-serialized script result.
185
+
186
+ ### `get_service_worker_logs`
187
+ Read buffered background service worker console logs.
188
+ **Inputs:** `clear_after` (boolean, default `false`).
189
+ **Returns:** Text listing captured log entries, or a "none captured yet" message.
190
+
191
+ ### `take_screenshot`
192
+ Save a screenshot of the current page or popup.
193
+ **Inputs:** `output_path` (string, default `./screenshot.png`); `full_page` (boolean, default `false`).
194
+ **Returns:** Text with the saved file path.
195
+
196
+ ### `run_assertion`
197
+ Assert an element exists/has text, or that a JS expression is truthy.
198
+ **Inputs:** `description` (string, required); `selector` (string); `expected_text` (string); `script` (string, overrides `selector`).
199
+ **Returns:** Text beginning with `PASS` or `FAIL`, followed by detail.
200
+
201
+ ### `extension_storage`
202
+ Read from or write to `chrome.storage` (local / sync / session).
203
+ **Inputs:** `action` (string, required: `get` | `set` | `remove` | `clear`); `area` (string, default `local`); `keys` (string[]); `data` (object, for `set`).
204
+ **Returns:** Text with storage contents, or a confirmation of the write/removal.
205
+
206
+ ### `monitor_network`
207
+ Capture and inspect network requests during navigation.
208
+ **Inputs:** `action` (string, required: `navigate_and_capture` | `get_captured` | `clear`); `url` (string); `filter_pattern` (string); `include_types` (string[]).
209
+ **Returns:** Text listing captured requests as `[method] [type] status url`.
210
+
211
+ ### `check_badge`
212
+ Read or assert the action badge text and background color.
213
+ **Inputs:** `action` (string, required: `get` | `assert_text` | `assert_color`); `tab_id` (number); `expected_text` (string); `expected_color` (number[] RGBA).
214
+ **Returns:** Text with the badge value, or a `PASS` / `FAIL` assertion result.
215
+
216
+ ### `send_message_to_background`
217
+ Send `chrome.runtime.sendMessage` from the popup and return the response.
218
+ **Inputs:** `message` (object, required); `timeout_ms` (number, default `5000`).
219
+ **Returns:** Text with the sent message and JSON response, or a failure message.
220
+
221
+ ### `test_context_menu`
222
+ Check the `contextMenus` API, simulate a right-click, or trigger an item.
223
+ **Inputs:** `action` (string, required: `check_api` | `right_click` | `trigger_item`); `url` (string); `selector` (string); `menu_item_id` (string); `page_url` (string).
224
+ **Returns:** Text with API availability, dispatch confirmation, or trigger result.
225
+
226
+ ### `simulate_tab_events`
227
+ Open, close, switch, list, or close all browser tabs.
228
+ **Inputs:** `action` (string, required: `open` | `close` | `switch` | `list` | `close_all`); `url` (string); `tab_index` (number).
229
+ **Returns:** Text describing the affected tab(s), or the list of open tabs.
230
+
231
+ ### `test_account_login`
232
+ Create or reuse a test account on a site using a disposable email.
233
+ **Inputs:** `action` (string, required: `auto` | `create` | `login`); `account_key` (string, required); `signup_url` / `login_url` (string); selector overrides (`email_selector`, `password_selector`, `submit_selector`, `pre_click_selector`); multi-step fields (`step2_url`, `step2_password_selector`, `step2_submit_selector`).
234
+ **Returns:** Text reporting account creation/login status plus a screenshot path.
235
+
145
236
  ---
146
237
 
147
238
  ## Testing Agent Prompt
@@ -178,6 +269,52 @@ Claude will write the test plan, execute every test, and return a full report.
178
269
 
179
270
  ---
180
271
 
272
+ ## Example: testing an extension popup
273
+
274
+ A typical loop the agent can run on its own:
275
+
276
+ **1. Load the extension**
277
+
278
+ ```json
279
+ { "tool": "load_extension", "arguments": { "extension_path": "/tmp/my-extension" } }
280
+ ```
281
+
282
+ ```
283
+ Extension loaded.
284
+ Path: /tmp/my-extension
285
+ Extension ID: ddnjmkpjnchafihagpljebmkdpejhaoj
286
+ ```
287
+
288
+ **2. Open the popup and read its HTML**
289
+
290
+ ```json
291
+ { "tool": "interact_with_popup", "arguments": { "action": "open" } }
292
+ ```
293
+
294
+ ```html
295
+ <body>
296
+ <h1>Tab Saver</h1>
297
+ <button id="save">Save open tabs</button>
298
+ <span id="count">0 saved</span>
299
+ </body>
300
+ ```
301
+
302
+ **3. Read local storage**
303
+
304
+ ```json
305
+ { "tool": "extension_storage", "arguments": { "action": "get", "area": "local" } }
306
+ ```
307
+
308
+ ```json
309
+ storage.local contents:
310
+ {
311
+ "savedTabs": [],
312
+ "enabled": true
313
+ }
314
+ ```
315
+
316
+ ---
317
+
181
318
  ## Example Prompts
182
319
 
183
320
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrome-extension-tester-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for interactive Chrome extension testing via Playwright — load, interact, assert, inspect storage, network, badges, messaging, tabs, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -7,11 +7,16 @@ import {
7
7
  ListPromptsRequestSchema,
8
8
  GetPromptRequestSchema,
9
9
  } from "@modelcontextprotocol/sdk/types.js";
10
+ import { readFileSync } from "fs";
10
11
  import { TOOLS, HANDLERS } from "./tools/index.js";
11
12
  import { PROMPTS, PROMPT_HANDLERS } from "./prompts/index.js";
12
13
 
14
+ // Read the version from package.json so it never drifts from the published value.
15
+ const packageJsonUrl = new URL("../package.json", import.meta.url);
16
+ const pkg = JSON.parse(readFileSync(packageJsonUrl, "utf-8"));
17
+
13
18
  const server = new Server(
14
- { name: "chrome-extension-tester", version: "2.0.0" },
19
+ { name: "chrome-extension-tester", version: pkg.version },
15
20
  { capabilities: { tools: {}, prompts: {} } }
16
21
  );
17
22
 
@@ -64,4 +69,4 @@ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
64
69
 
65
70
  const transport = new StdioServerTransport();
66
71
  await server.connect(transport);
67
- console.error("Chrome Extension Tester MCP server running (v2.0.0)...");
72
+ console.error(`Chrome Extension Tester MCP server running (v${pkg.version})...`);
package/src/state.js CHANGED
@@ -4,6 +4,10 @@ import path from "path";
4
4
 
5
5
  export const state = {
6
6
  browser: null,
7
+ // BrowserContext when connected via CDP (browser.contexts()[0]); null when using launchPersistentContext
8
+ context: null,
9
+ // "launched" = Playwright owns the browser process; "cdp" = attached to user's real browser
10
+ connectionMode: null,
7
11
  page: null,
8
12
  extensionId: null,
9
13
  swLogs: [],
@@ -23,21 +27,27 @@ export async function ensureBrowser(extensionPath) {
23
27
  ],
24
28
  });
25
29
 
26
- await new Promise((r) => setTimeout(r, 1000));
27
- const workers = state.browser.serviceWorkers();
28
- if (workers.length > 0) {
29
- const url = workers[0].url();
30
- const match = url.match(/chrome-extension:\/\/([a-z]{32})\//);
31
- if (match) state.extensionId = match[1];
30
+ // Prefer an already-registered service worker; otherwise wait for one to register.
31
+ const existingWorkers = state.browser.serviceWorkers();
32
+ let workerUrl = existingWorkers.length > 0 ? existingWorkers[0].url() : null;
33
+
34
+ if (!workerUrl) {
35
+ const worker = await state.browser.waitForEvent("serviceworker", { timeout: 5000 });
36
+ workerUrl = worker.url();
32
37
  }
33
38
 
39
+ const extensionIdMatch = workerUrl.match(/chrome-extension:\/\/([a-z]{32})\//);
40
+ if (extensionIdMatch) state.extensionId = extensionIdMatch[1];
41
+
42
+ state.connectionMode = "launched";
34
43
  state.page = await state.browser.newPage();
35
44
  }
36
45
 
37
46
  export async function ensurePage() {
38
47
  if (!state.page || state.page.isClosed()) {
39
- if (!state.browser) throw new Error("Browser not started. Call load_extension first.");
40
- state.page = await state.browser.newPage();
48
+ const ctx = state.context || state.browser;
49
+ if (!ctx) throw new Error("No browser connected. Call load_extension or connect_browser first.");
50
+ state.page = await ctx.newPage();
41
51
  }
42
52
  return state.page;
43
53
  }
@@ -51,19 +61,22 @@ export async function ensurePageStandalone() {
51
61
  state.browser = await chromium.launchPersistentContext("", {
52
62
  headless: false,
53
63
  });
64
+ state.connectionMode = "launched";
54
65
  state.page = await state.browser.newPage();
55
66
  }
56
67
 
57
68
  if (!state.page || state.page.isClosed()) {
58
- state.page = await state.browser.newPage();
69
+ const ctx = state.context || state.browser;
70
+ state.page = await ctx.newPage();
59
71
  }
60
72
 
61
73
  return state.page;
62
74
  }
63
75
 
64
76
  export async function getServiceWorker() {
65
- if (!state.browser) throw new Error("Browser not started. Call load_extension first.");
66
- const workers = state.browser.serviceWorkers();
77
+ const ctx = state.context || state.browser;
78
+ if (!ctx) throw new Error("No browser connected. Call load_extension or connect_browser first.");
79
+ const workers = ctx.serviceWorkers();
67
80
  if (!workers.length) throw new Error("No service worker found. Extension may not have a background service worker.");
68
81
  return workers[0];
69
82
  }
@@ -427,7 +427,7 @@ function buildCredentialEntry(email, password, args, verified, verifiedAt, verif
427
427
  export const definition = {
428
428
  name: "test_account_login",
429
429
  description:
430
- "Create or reuse a test account for a website using a disposable email from temp-mail.org. " +
430
+ "Create or reuse a test account for a website using a disposable email from Guerrilla Mail. " +
431
431
  "Credentials are stored in test-accounts.json and reused across test sessions. " +
432
432
  "Use action='auto' to login if credentials exist or create new ones if they don't.",
433
433
  inputSchema: {
@@ -509,6 +509,7 @@ export async function handler(args) {
509
509
  `No stored credentials found for "${key}". ` +
510
510
  `Use action: "create" or "auto" to create an account first.`,
511
511
  }],
512
+ isError: true,
512
513
  };
513
514
  }
514
515
 
@@ -521,6 +522,7 @@ export async function handler(args) {
521
522
  `No login_url provided and none stored for "${key}". ` +
522
523
  `Pass login_url as an argument.`,
523
524
  }],
525
+ isError: true,
524
526
  };
525
527
  }
526
528
 
@@ -545,6 +547,7 @@ export async function handler(args) {
545
547
  `Login form interaction failed for "${key}": ${formError.message}\n` +
546
548
  `Screenshot: ${screenshotPath}`,
547
549
  }],
550
+ isError: true,
548
551
  };
549
552
  }
550
553
 
@@ -576,6 +579,7 @@ export async function handler(args) {
576
579
  `Use action: "create" to generate a new account.\n` +
577
580
  `Screenshot: ${screenshotPath}`,
578
581
  }],
582
+ isError: true,
579
583
  };
580
584
  }
581
585
 
@@ -586,7 +590,7 @@ export async function handler(args) {
586
590
  type: "text",
587
591
  text:
588
592
  `Logged in but the site is asking for email verification.\n` +
589
- `Navigate to temp-mail.org to find and click the verification link.\n` +
593
+ `Navigate to Guerrilla Mail to find and click the verification link.\n` +
590
594
  `Screenshot: ${screenshotPath}`,
591
595
  }],
592
596
  };
@@ -608,6 +612,7 @@ export async function handler(args) {
608
612
  type: "text",
609
613
  text: `signup_url is required when action is "${args.action}".`,
610
614
  }],
615
+ isError: true,
611
616
  };
612
617
  }
613
618
 
@@ -620,5 +625,6 @@ export async function handler(args) {
620
625
  type: "text",
621
626
  text: `Unknown action: "${args.action}". Valid values are "auto", "create", or "login".`,
622
627
  }],
628
+ isError: true,
623
629
  };
624
630
  }
@@ -50,7 +50,7 @@ export async function handler(args) {
50
50
  detail = `Element "${args.selector}" exists`;
51
51
  }
52
52
  } else {
53
- return { content: [{ type: "text", text: "Provide a selector or script for the assertion." }] };
53
+ return { content: [{ type: "text", text: "Provide a selector or script for the assertion." }], isError: true };
54
54
  }
55
55
  } catch (e) {
56
56
  passed = false;
@@ -46,7 +46,7 @@ export async function handler(args) {
46
46
  return {
47
47
  content: [{
48
48
  type: "text",
49
- text: `Badge Text: "${badge.text || "(empty)"}\nBadge Color (RGBA): [${badge.color.join(", ")}]`,
49
+ text: `Badge Text: "${badge.text || "(empty)"}"\nBadge Color (RGBA): [${badge.color.join(", ")}]`,
50
50
  }],
51
51
  };
52
52
  }
@@ -63,7 +63,7 @@ export async function handler(args) {
63
63
 
64
64
  if (args.action === "assert_color") {
65
65
  if (!args.expected_color?.length) {
66
- return { content: [{ type: "text", text: "Provide an 'expected_color' RGBA array for assert_color." }] };
66
+ return { content: [{ type: "text", text: "Provide an 'expected_color' RGBA array for assert_color." }], isError: true };
67
67
  }
68
68
  const passed = args.expected_color.every((v, i) => v === badge.color[i]);
69
69
  return {
@@ -0,0 +1,335 @@
1
+ import { spawn } from "child_process";
2
+ import os from "os";
3
+ import fs from "fs";
4
+ import { chromium } from "playwright";
5
+ import { state } from "../state.js";
6
+
7
+ const DEBUG_PORT_RANGE_START = 9222;
8
+ const DEBUG_PORT_RANGE_END = 9231;
9
+ const LAUNCH_TIMEOUT_MS = 15000;
10
+ const POLL_INTERVAL_MS = 500;
11
+
12
+ const KNOWN_BROWSERS = [
13
+ {
14
+ name: "Brave",
15
+ executable: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
16
+ userDataDir: `${os.homedir()}/Library/Application Support/BraveSoftware/Brave-Browser`,
17
+ },
18
+ {
19
+ name: "Chrome",
20
+ executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
21
+ userDataDir: `${os.homedir()}/Library/Application Support/Google/Chrome`,
22
+ },
23
+ {
24
+ name: "Chromium",
25
+ executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
26
+ userDataDir: `${os.homedir()}/Library/Application Support/Chromium`,
27
+ },
28
+ ];
29
+
30
+ async function fetchCdpVersionInfo(port) {
31
+ try {
32
+ const controller = new AbortController();
33
+ const timeoutId = setTimeout(() => controller.abort(), 800);
34
+ const response = await fetch(`http://localhost:${port}/json/version`, {
35
+ signal: controller.signal,
36
+ });
37
+ clearTimeout(timeoutId);
38
+ if (response.ok) return await response.json();
39
+ } catch {}
40
+ return null;
41
+ }
42
+
43
+ async function fetchCdpTargets(port) {
44
+ try {
45
+ const response = await fetch(`http://localhost:${port}/json`);
46
+ if (response.ok) return await response.json();
47
+ } catch {}
48
+ return [];
49
+ }
50
+
51
+ async function scanRunningBrowsers() {
52
+ const results = [];
53
+ for (let port = DEBUG_PORT_RANGE_START; port <= DEBUG_PORT_RANGE_END; port++) {
54
+ const info = await fetchCdpVersionInfo(port);
55
+ if (info) {
56
+ results.push({ port, browser: info.Browser });
57
+ }
58
+ }
59
+ return results;
60
+ }
61
+
62
+ function detectInstalledBrowsers() {
63
+ return KNOWN_BROWSERS.filter((b) => fs.existsSync(b.executable));
64
+ }
65
+
66
+ function extractExtensionIdFromUrl(url) {
67
+ const match = url?.match(/chrome-extension:\/\/([a-z]{32})\//);
68
+ return match ? match[1] : null;
69
+ }
70
+
71
+ function findExtensionIdFromWorkers(workers) {
72
+ for (const worker of workers) {
73
+ const id = extractExtensionIdFromUrl(worker.url());
74
+ if (id) return id;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ async function findExtensionIdFromTargets(port) {
80
+ const targets = await fetchCdpTargets(port);
81
+ for (const target of targets) {
82
+ const id = extractExtensionIdFromUrl(target.url);
83
+ if (id) return id;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function attachSwLogListeners(context) {
89
+ const existingWorkers = context.serviceWorkers();
90
+ existingWorkers.forEach((sw) => {
91
+ sw.on("console", (msg) =>
92
+ state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`)
93
+ );
94
+ });
95
+ context.on("serviceworker", (sw) => {
96
+ sw.on("console", (msg) =>
97
+ state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`)
98
+ );
99
+ });
100
+ }
101
+
102
+ export async function teardownExistingConnection() {
103
+ if (!state.browser) return;
104
+ try {
105
+ await state.browser.close();
106
+ } catch {}
107
+ state.browser = null;
108
+ state.context = null;
109
+ state.page = null;
110
+ state.extensionId = null;
111
+ state.connectionMode = null;
112
+ state.swLogs.length = 0;
113
+ state.networkCaptures.length = 0;
114
+ }
115
+
116
+ async function connectToDebugPort(port) {
117
+ const endpoint = `http://localhost:${port}`;
118
+ const browser = await chromium.connectOverCDP(endpoint);
119
+
120
+ const contexts = browser.contexts();
121
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
122
+
123
+ const swWorkers = context.serviceWorkers();
124
+ let extensionId = findExtensionIdFromWorkers(swWorkers);
125
+ if (!extensionId) {
126
+ extensionId = await findExtensionIdFromTargets(port);
127
+ }
128
+
129
+ const openPages = context.pages();
130
+ const page = openPages.length > 0 ? openPages[openPages.length - 1] : await context.newPage();
131
+
132
+ state.browser = browser;
133
+ state.context = context;
134
+ state.page = page;
135
+ state.extensionId = extensionId;
136
+ state.connectionMode = "cdp";
137
+
138
+ attachSwLogListeners(context);
139
+
140
+ return extensionId;
141
+ }
142
+
143
+ async function pollUntilCdpReady(port) {
144
+ const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
145
+ while (Date.now() < deadline) {
146
+ const info = await fetchCdpVersionInfo(port);
147
+ if (info) return true;
148
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
149
+ }
150
+ return false;
151
+ }
152
+
153
+ async function launchBrowserProcess(browserConfig, port) {
154
+ const launchArgs = [
155
+ `--remote-debugging-port=${port}`,
156
+ `--user-data-dir=${browserConfig.userDataDir}`,
157
+ "--no-first-run",
158
+ "--no-default-browser-check",
159
+ ];
160
+
161
+ const child = spawn(browserConfig.executable, launchArgs, {
162
+ detached: true,
163
+ stdio: "ignore",
164
+ });
165
+ child.unref();
166
+
167
+ const ready = await pollUntilCdpReady(port);
168
+ if (!ready) {
169
+ throw new Error(
170
+ `${browserConfig.name} did not expose debugging on port ${port} within ${LAUNCH_TIMEOUT_MS}ms. ` +
171
+ `If ${browserConfig.name} is already running with the same profile, close it first and try again.`
172
+ );
173
+ }
174
+ }
175
+
176
+ export const definition = {
177
+ name: "connect_browser",
178
+ description:
179
+ "Connect to an existing Brave/Chrome/Chromium browser using your real logged-in sessions. " +
180
+ "Use action 'scan' first to see what's available, then 'connect' to attach to a running browser " +
181
+ "or 'launch' to start one with debugging enabled. Your logins and tabs are preserved.",
182
+ inputSchema: {
183
+ type: "object",
184
+ properties: {
185
+ action: {
186
+ type: "string",
187
+ enum: ["scan", "connect", "launch"],
188
+ description:
189
+ "'scan' — list running debuggable browsers and installed browsers. " +
190
+ "'connect' — attach to a browser already running with --remote-debugging-port. " +
191
+ "'launch' — start an installed browser with your real profile and remote debugging, then connect.",
192
+ },
193
+ port: {
194
+ type: "number",
195
+ description: "CDP debug port to connect to (for 'connect' action). Defaults to 9222.",
196
+ },
197
+ browser_name: {
198
+ type: "string",
199
+ enum: ["Brave", "Chrome", "Chromium"],
200
+ description: "Which browser to launch (for 'launch' action). Defaults to 'Brave'.",
201
+ },
202
+ debug_port: {
203
+ type: "number",
204
+ description: "Port to use for remote debugging when launching (for 'launch' action). Defaults to 9222.",
205
+ },
206
+ },
207
+ required: ["action"],
208
+ },
209
+ };
210
+
211
+ export async function handler(args) {
212
+ const { action } = args;
213
+
214
+ if (action === "scan") {
215
+ const runningBrowsers = await scanRunningBrowsers();
216
+ const installedBrowsers = detectInstalledBrowsers();
217
+
218
+ const runningSection =
219
+ runningBrowsers.length > 0
220
+ ? runningBrowsers.map((b) => ` Port ${b.port}: ${b.browser}`).join("\n")
221
+ : " None — browsers must be started with --remote-debugging-port to appear here.";
222
+
223
+ const installedSection =
224
+ installedBrowsers.length > 0
225
+ ? installedBrowsers.map((b) => ` ${b.name}: ${b.executable}`).join("\n")
226
+ : " None found in /Applications.";
227
+
228
+ const nextStep =
229
+ runningBrowsers.length > 0
230
+ ? `Use action:"connect" with port:${runningBrowsers[0].port} to attach.`
231
+ : `Use action:"launch" with browser_name:"${installedBrowsers[0]?.name || "Brave"}" to start one.\n` +
232
+ `Note: close your existing ${installedBrowsers.map((b) => b.name).join("/")} window first — ` +
233
+ `Chrome-based browsers won't open a second instance with the same profile.`;
234
+
235
+ return {
236
+ content: [
237
+ {
238
+ type: "text",
239
+ text: [
240
+ "Running browsers with remote debugging:",
241
+ runningSection,
242
+ "",
243
+ "Installed browsers:",
244
+ installedSection,
245
+ "",
246
+ nextStep,
247
+ ].join("\n"),
248
+ },
249
+ ],
250
+ };
251
+ }
252
+
253
+ if (action === "connect") {
254
+ const port = args.port || 9222;
255
+ const versionInfo = await fetchCdpVersionInfo(port);
256
+ if (!versionInfo) {
257
+ throw new Error(
258
+ `No browser with remote debugging found on port ${port}. ` +
259
+ `Run action:"scan" to see what's available, or use action:"launch" to start one.`
260
+ );
261
+ }
262
+
263
+ await teardownExistingConnection();
264
+ const extensionId = await connectToDebugPort(port);
265
+
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text",
270
+ text: [
271
+ `Connected to ${versionInfo.Browser} on port ${port}.`,
272
+ `Extension ID: ${extensionId || "not detected — open chrome://extensions to find it"}`,
273
+ "",
274
+ "Your existing tabs and logged-in sessions are preserved.",
275
+ "All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
276
+ ].join("\n"),
277
+ },
278
+ ],
279
+ };
280
+ }
281
+
282
+ if (action === "launch") {
283
+ const browserName = args.browser_name || "Brave";
284
+ const port = args.debug_port || 9222;
285
+
286
+ const browserConfig = KNOWN_BROWSERS.find((b) => b.name === browserName);
287
+ if (!browserConfig) {
288
+ const validNames = KNOWN_BROWSERS.map((b) => b.name).join(", ");
289
+ throw new Error(`Unknown browser "${browserName}". Choose from: ${validNames}`);
290
+ }
291
+ if (!fs.existsSync(browserConfig.executable)) {
292
+ throw new Error(`${browserName} not found at: ${browserConfig.executable}`);
293
+ }
294
+
295
+ // If a browser is already debugging on the target port, just connect to it.
296
+ const alreadyRunning = await fetchCdpVersionInfo(port);
297
+ if (alreadyRunning) {
298
+ await teardownExistingConnection();
299
+ const extensionId = await connectToDebugPort(port);
300
+ return {
301
+ content: [
302
+ {
303
+ type: "text",
304
+ text: [
305
+ `Browser already running with debugging on port ${port}. Connected to ${alreadyRunning.Browser}.`,
306
+ `Extension ID: ${extensionId || "not detected"}`,
307
+ ].join("\n"),
308
+ },
309
+ ],
310
+ };
311
+ }
312
+
313
+ await teardownExistingConnection();
314
+ await launchBrowserProcess(browserConfig, port);
315
+ const extensionId = await connectToDebugPort(port);
316
+
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: [
322
+ `Launched ${browserName} with remote debugging on port ${port}.`,
323
+ `Profile: ${browserConfig.userDataDir}`,
324
+ `Extension ID: ${extensionId || "not detected — open chrome://extensions to find it"}`,
325
+ "",
326
+ "All your existing logins are available.",
327
+ "All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
328
+ ].join("\n"),
329
+ },
330
+ ],
331
+ };
332
+ }
333
+
334
+ throw new Error(`Unknown action "${action}". Valid actions: "scan", "connect", "launch".`);
335
+ }
@@ -50,7 +50,7 @@ export async function handler(args) {
50
50
  }
51
51
 
52
52
  if (args.action === "right_click") {
53
- if (!args.selector) return { content: [{ type: "text", text: "Provide a 'selector' for right_click action." }] };
53
+ if (!args.selector) return { content: [{ type: "text", text: "Provide a 'selector' for right_click action." }], isError: true };
54
54
  const p = await ensurePage();
55
55
  if (args.url) await p.goto(args.url, { waitUntil: "domcontentloaded" });
56
56
  await p.click(args.selector, { button: "right" });
@@ -63,7 +63,7 @@ export async function handler(args) {
63
63
  }
64
64
 
65
65
  if (args.action === "trigger_item") {
66
- if (!args.menu_item_id) return { content: [{ type: "text", text: "Provide a 'menu_item_id' to trigger." }] };
66
+ if (!args.menu_item_id) return { content: [{ type: "text", text: "Provide a 'menu_item_id' to trigger." }], isError: true };
67
67
  const sw = await getServiceWorker();
68
68
  // Directly invoke the onClicked listener by dispatching a synthetic event via the SW
69
69
  const result = await sw.evaluate(({ menuItemId, pageUrl }) => {
package/src/tools/dom.js CHANGED
@@ -43,5 +43,5 @@ export async function handler(args) {
43
43
  };
44
44
  }
45
45
 
46
- return { content: [{ type: "text", text: "Provide either a selector or a script." }] };
46
+ return { content: [{ type: "text", text: "Provide either a selector or a script." }], isError: true };
47
47
  }
@@ -1,4 +1,5 @@
1
1
  import * as loadExtension from "./load-extension.js";
2
+ import * as connectBrowser from "./connect-browser.js";
2
3
  import * as popup from "./popup.js";
3
4
  import * as dom from "./dom.js";
4
5
  import * as logs from "./logs.js";
@@ -15,6 +16,7 @@ import * as accountLogin from "./account-login.js";
15
16
 
16
17
  const allTools = [
17
18
  loadExtension,
19
+ connectBrowser,
18
20
  popup,
19
21
  dom,
20
22
  logs,
@@ -20,6 +20,8 @@ export async function handler(args) {
20
20
  if (state.browser) {
21
21
  await state.browser.close();
22
22
  state.browser = null;
23
+ state.context = null;
24
+ state.connectionMode = null;
23
25
  state.page = null;
24
26
  state.extensionId = null;
25
27
  state.swLogs.length = 0;
@@ -21,7 +21,7 @@ export const definition = {
21
21
 
22
22
  export async function handler(args) {
23
23
  if (!state.extensionId) {
24
- return { content: [{ type: "text", text: "Extension ID not detected. Call load_extension first." }] };
24
+ return { content: [{ type: "text", text: "Extension ID not detected. Call load_extension first." }], isError: true };
25
25
  }
26
26
 
27
27
  const p = await ensurePage();
@@ -54,7 +54,7 @@ export async function handler(args) {
54
54
  );
55
55
 
56
56
  if (result.error) {
57
- return { content: [{ type: "text", text: `Message failed: ${result.error}` }] };
57
+ return { content: [{ type: "text", text: `Message failed: ${result.error}` }], isError: true };
58
58
  }
59
59
 
60
60
  return {
@@ -50,7 +50,7 @@ export async function handler(args) {
50
50
  }
51
51
 
52
52
  if (args.action === "navigate_and_capture") {
53
- if (!args.url) return { content: [{ type: "text", text: "Provide a 'url' to navigate to." }] };
53
+ if (!args.url) return { content: [{ type: "text", text: "Provide a 'url' to navigate to." }], isError: true };
54
54
 
55
55
  const p = await ensurePage();
56
56
  const captured = [];
@@ -29,7 +29,7 @@ export const definition = {
29
29
 
30
30
  export async function handler(args) {
31
31
  if (!state.extensionId) {
32
- return { content: [{ type: "text", text: "Extension ID not detected. Call load_extension first." }] };
32
+ return { content: [{ type: "text", text: "Extension ID not detected. Call load_extension first." }], isError: true };
33
33
  }
34
34
 
35
35
  const pageName = args.page || "options.html";
@@ -28,6 +28,7 @@ export async function handler(args) {
28
28
  if (!state.extensionId && args.action === "open") {
29
29
  return {
30
30
  content: [{ type: "text", text: "Extension ID not detected. Make sure the extension has a background service worker." }],
31
+ isError: true,
31
32
  };
32
33
  }
33
34
 
@@ -47,7 +47,7 @@ export async function handler(args) {
47
47
 
48
48
  if (args.action === "set") {
49
49
  if (!args.data || !Object.keys(args.data).length) {
50
- return { content: [{ type: "text", text: "Provide a 'data' object for the set action." }] };
50
+ return { content: [{ type: "text", text: "Provide a 'data' object for the set action." }], isError: true };
51
51
  }
52
52
  await sw.evaluate(
53
53
  ({ area, data }) => new Promise((resolve) => chrome.storage[area].set(data, resolve)),
@@ -60,7 +60,7 @@ export async function handler(args) {
60
60
 
61
61
  if (args.action === "remove") {
62
62
  if (!args.keys?.length) {
63
- return { content: [{ type: "text", text: "Provide 'keys' array for the remove action." }] };
63
+ return { content: [{ type: "text", text: "Provide 'keys' array for the remove action." }], isError: true };
64
64
  }
65
65
  await sw.evaluate(
66
66
  ({ area, keys }) => new Promise((resolve) => chrome.storage[area].remove(keys, resolve)),
package/src/tools/tabs.js CHANGED
@@ -51,7 +51,7 @@ export async function handler(args) {
51
51
  if (args.action === "switch") {
52
52
  const pages = state.browser.pages();
53
53
  if (args.tab_index === undefined || args.tab_index < 0 || args.tab_index >= pages.length) {
54
- return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }] };
54
+ return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
55
55
  }
56
56
  state.page = pages[args.tab_index];
57
57
  await state.page.bringToFront();
@@ -61,7 +61,7 @@ export async function handler(args) {
61
61
  if (args.action === "close") {
62
62
  const pages = state.browser.pages();
63
63
  if (args.tab_index === undefined || args.tab_index < 0 || args.tab_index >= pages.length) {
64
- return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }] };
64
+ return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
65
65
  }
66
66
  const toClose = pages[args.tab_index];
67
67
  const closedUrl = toClose.url();