chrome-extension-tester-mcp 2.1.0 → 2.3.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 +137 -0
- package/package.json +1 -1
- package/src/index.js +7 -2
- package/src/state.js +36 -11
- package/src/tools/account-login.js +8 -2
- package/src/tools/assertion.js +1 -1
- package/src/tools/badge.js +2 -2
- package/src/tools/connect-browser.js +423 -0
- package/src/tools/context-menu.js +2 -2
- package/src/tools/dom.js +1 -1
- package/src/tools/index.js +2 -0
- package/src/tools/load-extension.js +2 -0
- package/src/tools/messaging.js +2 -2
- package/src/tools/network.js +1 -1
- package/src/tools/options-page.js +1 -1
- package/src/tools/popup.js +1 -0
- package/src/tools/storage.js +2 -2
- package/src/tools/tabs.js +11 -10
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.
|
|
3
|
+
"version": "2.3.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:
|
|
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(
|
|
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
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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,34 @@ 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.
|
|
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
|
-
|
|
66
|
-
|
|
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();
|
|
80
|
+
|
|
81
|
+
if (state.extensionId) {
|
|
82
|
+
const targetWorker = workers.find((w) => w.url().includes(state.extensionId));
|
|
83
|
+
if (targetWorker) return targetWorker;
|
|
84
|
+
const workerList = workers.map((w) => ` ${w.url()}`).join("\n") || " (none)";
|
|
85
|
+
throw new Error(
|
|
86
|
+
`No service worker found for extension ${state.extensionId}.\n` +
|
|
87
|
+
`Active workers:\n${workerList}\n` +
|
|
88
|
+
`Re-run connect_browser with the correct extension_id to retarget.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
if (!workers.length) throw new Error("No service worker found. Extension may not have a background service worker.");
|
|
68
93
|
return workers[0];
|
|
69
94
|
}
|
|
@@ -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
|
|
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
|
|
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
|
}
|
package/src/tools/assertion.js
CHANGED
|
@@ -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;
|
package/src/tools/badge.js
CHANGED
|
@@ -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,423 @@
|
|
|
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
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/BraveSoftware/Brave-Browser/Default/Extensions`,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "Chrome",
|
|
21
|
+
executable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
22
|
+
userDataDir: `${os.homedir()}/Library/Application Support/Google/Chrome`,
|
|
23
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/Google/Chrome/Default/Extensions`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "Chromium",
|
|
27
|
+
executable: "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
28
|
+
userDataDir: `${os.homedir()}/Library/Application Support/Chromium`,
|
|
29
|
+
extensionsDir: `${os.homedir()}/Library/Application Support/Chromium/Default/Extensions`,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function readExtensionManifest(extensionsDir, extensionId) {
|
|
34
|
+
try {
|
|
35
|
+
const versionDirs = fs.readdirSync(`${extensionsDir}/${extensionId}`);
|
|
36
|
+
for (const version of versionDirs) {
|
|
37
|
+
const manifestPath = `${extensionsDir}/${extensionId}/${version}/manifest.json`;
|
|
38
|
+
if (fs.existsSync(manifestPath)) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function listInstalledExtensions(extensionsDir) {
|
|
47
|
+
if (!fs.existsSync(extensionsDir)) return [];
|
|
48
|
+
const ids = fs.readdirSync(extensionsDir).filter((d) => /^[a-z]{32}$/.test(d));
|
|
49
|
+
return ids.map((id) => {
|
|
50
|
+
const manifest = readExtensionManifest(extensionsDir, id);
|
|
51
|
+
const name = manifest?.name || "(unknown)";
|
|
52
|
+
return { id, name };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Accepts a 32-char extension ID or a name substring; returns the resolved ID or throws on ambiguity.
|
|
57
|
+
function resolveExtensionId(nameOrId, extensionsDir) {
|
|
58
|
+
if (/^[a-z]{32}$/.test(nameOrId)) return nameOrId;
|
|
59
|
+
const extensions = listInstalledExtensions(extensionsDir);
|
|
60
|
+
const needle = nameOrId.toLowerCase();
|
|
61
|
+
const matches = extensions.filter((e) => e.name.toLowerCase().includes(needle));
|
|
62
|
+
if (matches.length === 0) return null;
|
|
63
|
+
if (matches.length > 1) {
|
|
64
|
+
const list = matches.map((e) => ` ${e.id} ${e.name}`).join("\n");
|
|
65
|
+
throw new Error(`"${nameOrId}" matched ${matches.length} extensions — pass the full ID instead:\n${list}`);
|
|
66
|
+
}
|
|
67
|
+
return matches[0].id;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fetchCdpVersionInfo(port) {
|
|
71
|
+
try {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timeoutId = setTimeout(() => controller.abort(), 800);
|
|
74
|
+
const response = await fetch(`http://localhost:${port}/json/version`, {
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
clearTimeout(timeoutId);
|
|
78
|
+
if (response.ok) return await response.json();
|
|
79
|
+
} catch {}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchCdpTargets(port) {
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(`http://localhost:${port}/json`);
|
|
86
|
+
if (response.ok) return await response.json();
|
|
87
|
+
} catch {}
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function scanRunningBrowsers() {
|
|
92
|
+
const results = [];
|
|
93
|
+
for (let port = DEBUG_PORT_RANGE_START; port <= DEBUG_PORT_RANGE_END; port++) {
|
|
94
|
+
const info = await fetchCdpVersionInfo(port);
|
|
95
|
+
if (info) {
|
|
96
|
+
results.push({ port, browser: info.Browser });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return results;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function detectInstalledBrowsers() {
|
|
103
|
+
return KNOWN_BROWSERS.filter((b) => fs.existsSync(b.executable));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractExtensionIdFromUrl(url) {
|
|
107
|
+
const match = url?.match(/chrome-extension:\/\/([a-z]{32})\//);
|
|
108
|
+
return match ? match[1] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findExtensionIdFromWorkers(workers) {
|
|
112
|
+
for (const worker of workers) {
|
|
113
|
+
const id = extractExtensionIdFromUrl(worker.url());
|
|
114
|
+
if (id) return id;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function findExtensionIdFromTargets(port) {
|
|
120
|
+
const targets = await fetchCdpTargets(port);
|
|
121
|
+
for (const target of targets) {
|
|
122
|
+
const id = extractExtensionIdFromUrl(target.url);
|
|
123
|
+
if (id) return id;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function attachSwLogListeners(context) {
|
|
129
|
+
const attachToWorker = (sw) => {
|
|
130
|
+
sw.on("console", (msg) => {
|
|
131
|
+
// Check state.extensionId at capture time so retargeting takes effect immediately.
|
|
132
|
+
const workerExtId = extractExtensionIdFromUrl(sw.url());
|
|
133
|
+
if (!state.extensionId || workerExtId === state.extensionId) {
|
|
134
|
+
state.swLogs.push(`[${new Date().toISOString()}] ${msg.type()}: ${msg.text()}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
context.serviceWorkers().forEach(attachToWorker);
|
|
140
|
+
context.on("serviceworker", attachToWorker);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function teardownExistingConnection() {
|
|
144
|
+
if (!state.browser) return;
|
|
145
|
+
try {
|
|
146
|
+
await state.browser.close();
|
|
147
|
+
} catch {}
|
|
148
|
+
state.browser = null;
|
|
149
|
+
state.context = null;
|
|
150
|
+
state.page = null;
|
|
151
|
+
state.extensionId = null;
|
|
152
|
+
state.connectionMode = null;
|
|
153
|
+
state.swLogs.length = 0;
|
|
154
|
+
state.networkCaptures.length = 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function connectToDebugPort(port) {
|
|
158
|
+
const endpoint = `http://localhost:${port}`;
|
|
159
|
+
const browser = await chromium.connectOverCDP(endpoint);
|
|
160
|
+
|
|
161
|
+
const contexts = browser.contexts();
|
|
162
|
+
const context = contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
163
|
+
|
|
164
|
+
const swWorkers = context.serviceWorkers();
|
|
165
|
+
let extensionId = findExtensionIdFromWorkers(swWorkers);
|
|
166
|
+
if (!extensionId) {
|
|
167
|
+
extensionId = await findExtensionIdFromTargets(port);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const openPages = context.pages();
|
|
171
|
+
const page = openPages.length > 0 ? openPages[openPages.length - 1] : await context.newPage();
|
|
172
|
+
|
|
173
|
+
state.browser = browser;
|
|
174
|
+
state.context = context;
|
|
175
|
+
state.page = page;
|
|
176
|
+
state.extensionId = extensionId;
|
|
177
|
+
state.connectionMode = "cdp";
|
|
178
|
+
|
|
179
|
+
attachSwLogListeners(context);
|
|
180
|
+
|
|
181
|
+
return extensionId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function pollUntilCdpReady(port) {
|
|
185
|
+
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;
|
|
186
|
+
while (Date.now() < deadline) {
|
|
187
|
+
const info = await fetchCdpVersionInfo(port);
|
|
188
|
+
if (info) return true;
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function launchBrowserProcess(browserConfig, port) {
|
|
195
|
+
const launchArgs = [
|
|
196
|
+
`--remote-debugging-port=${port}`,
|
|
197
|
+
`--user-data-dir=${browserConfig.userDataDir}`,
|
|
198
|
+
"--no-first-run",
|
|
199
|
+
"--no-default-browser-check",
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const child = spawn(browserConfig.executable, launchArgs, {
|
|
203
|
+
detached: true,
|
|
204
|
+
stdio: "ignore",
|
|
205
|
+
});
|
|
206
|
+
child.unref();
|
|
207
|
+
|
|
208
|
+
const ready = await pollUntilCdpReady(port);
|
|
209
|
+
if (!ready) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${browserConfig.name} did not expose debugging on port ${port} within ${LAUNCH_TIMEOUT_MS}ms. ` +
|
|
212
|
+
`If ${browserConfig.name} is already running with the same profile, close it first and try again.`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const definition = {
|
|
218
|
+
name: "connect_browser",
|
|
219
|
+
description:
|
|
220
|
+
"Connect to an existing Brave/Chrome/Chromium browser using your real logged-in sessions. " +
|
|
221
|
+
"Use action 'scan' first to see what's available, then 'connect' to attach to a running browser " +
|
|
222
|
+
"or 'launch' to start one with debugging enabled. Your logins and tabs are preserved.",
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: {
|
|
226
|
+
action: {
|
|
227
|
+
type: "string",
|
|
228
|
+
enum: ["scan", "connect", "launch", "list_extensions"],
|
|
229
|
+
description:
|
|
230
|
+
"'scan' — list running debuggable browsers and installed browsers. " +
|
|
231
|
+
"'connect' — attach to a browser already running with --remote-debugging-port. " +
|
|
232
|
+
"'launch' — start an installed browser with your real profile and remote debugging, then connect. " +
|
|
233
|
+
"'list_extensions' — list all installed extensions with their IDs and names.",
|
|
234
|
+
},
|
|
235
|
+
port: {
|
|
236
|
+
type: "number",
|
|
237
|
+
description: "CDP debug port to connect to (for 'connect' action). Defaults to 9222.",
|
|
238
|
+
},
|
|
239
|
+
browser_name: {
|
|
240
|
+
type: "string",
|
|
241
|
+
enum: ["Brave", "Chrome", "Chromium"],
|
|
242
|
+
description: "Which browser to launch (for 'launch' action). Defaults to 'Brave'.",
|
|
243
|
+
},
|
|
244
|
+
debug_port: {
|
|
245
|
+
type: "number",
|
|
246
|
+
description: "Port to use for remote debugging when launching (for 'launch' action). Defaults to 9222.",
|
|
247
|
+
},
|
|
248
|
+
extension_id: {
|
|
249
|
+
type: "string",
|
|
250
|
+
description: "Extension ID (32 lowercase chars) or name substring to target (for 'connect' and 'launch' actions). Overrides auto-detection — pass the name (e.g. 'AudiTex') or full ID to pick the right extension when multiple are installed.",
|
|
251
|
+
},
|
|
252
|
+
browser_name_for_extensions: {
|
|
253
|
+
type: "string",
|
|
254
|
+
enum: ["Brave", "Chrome", "Chromium"],
|
|
255
|
+
description: "Which browser's extensions directory to scan (for 'list_extensions' action). Defaults to 'Brave'.",
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
required: ["action"],
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function getExtensionsDirForBrowser(browserName) {
|
|
263
|
+
const name = browserName || "Brave";
|
|
264
|
+
const config = KNOWN_BROWSERS.find((b) => b.name === name);
|
|
265
|
+
return config?.extensionsDir || null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function handler(args) {
|
|
269
|
+
const { action } = args;
|
|
270
|
+
|
|
271
|
+
if (action === "list_extensions") {
|
|
272
|
+
const extensionsDir = getExtensionsDirForBrowser(args.browser_name_for_extensions);
|
|
273
|
+
if (!extensionsDir || !fs.existsSync(extensionsDir)) {
|
|
274
|
+
throw new Error(`Extensions directory not found: ${extensionsDir}`);
|
|
275
|
+
}
|
|
276
|
+
const extensions = listInstalledExtensions(extensionsDir);
|
|
277
|
+
const lines = extensions.map((e) => ` ${e.id} ${e.name}`);
|
|
278
|
+
return {
|
|
279
|
+
content: [{
|
|
280
|
+
type: "text",
|
|
281
|
+
text: `Installed extensions (${extensions.length}):\n${lines.join("\n")}\n\nPass the ID or name substring as extension_id when connecting.`,
|
|
282
|
+
}],
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (action === "scan") {
|
|
287
|
+
const runningBrowsers = await scanRunningBrowsers();
|
|
288
|
+
const installedBrowsers = detectInstalledBrowsers();
|
|
289
|
+
|
|
290
|
+
const runningSection =
|
|
291
|
+
runningBrowsers.length > 0
|
|
292
|
+
? runningBrowsers.map((b) => ` Port ${b.port}: ${b.browser}`).join("\n")
|
|
293
|
+
: " None — browsers must be started with --remote-debugging-port to appear here.";
|
|
294
|
+
|
|
295
|
+
const installedSection =
|
|
296
|
+
installedBrowsers.length > 0
|
|
297
|
+
? installedBrowsers.map((b) => ` ${b.name}: ${b.executable}`).join("\n")
|
|
298
|
+
: " None found in /Applications.";
|
|
299
|
+
|
|
300
|
+
const nextStep =
|
|
301
|
+
runningBrowsers.length > 0
|
|
302
|
+
? `Use action:"connect" with port:${runningBrowsers[0].port} to attach.`
|
|
303
|
+
: `Use action:"launch" with browser_name:"${installedBrowsers[0]?.name || "Brave"}" to start one.\n` +
|
|
304
|
+
`Note: close your existing ${installedBrowsers.map((b) => b.name).join("/")} window first — ` +
|
|
305
|
+
`Chrome-based browsers won't open a second instance with the same profile.`;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
content: [
|
|
309
|
+
{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: [
|
|
312
|
+
"Running browsers with remote debugging:",
|
|
313
|
+
runningSection,
|
|
314
|
+
"",
|
|
315
|
+
"Installed browsers:",
|
|
316
|
+
installedSection,
|
|
317
|
+
"",
|
|
318
|
+
nextStep,
|
|
319
|
+
].join("\n"),
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (action === "connect") {
|
|
326
|
+
const port = args.port || 9222;
|
|
327
|
+
const versionInfo = await fetchCdpVersionInfo(port);
|
|
328
|
+
if (!versionInfo) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`No browser with remote debugging found on port ${port}. ` +
|
|
331
|
+
`Run action:"scan" to see what's available, or use action:"launch" to start one.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await teardownExistingConnection();
|
|
336
|
+
await connectToDebugPort(port);
|
|
337
|
+
|
|
338
|
+
if (args.extension_id) {
|
|
339
|
+
const extensionsDir = getExtensionsDirForBrowser("Brave");
|
|
340
|
+
const resolved = extensionsDir ? resolveExtensionId(args.extension_id, extensionsDir) : null;
|
|
341
|
+
if (!resolved) throw new Error(`No installed extension found matching "${args.extension_id}". Run action:"list_extensions" to see available extensions.`);
|
|
342
|
+
state.extensionId = resolved;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
content: [
|
|
347
|
+
{
|
|
348
|
+
type: "text",
|
|
349
|
+
text: [
|
|
350
|
+
`Connected to ${versionInfo.Browser} on port ${port}.`,
|
|
351
|
+
`Extension ID: ${state.extensionId || "not detected — run list_extensions to find it"}`,
|
|
352
|
+
"",
|
|
353
|
+
"Your existing tabs and logged-in sessions are preserved.",
|
|
354
|
+
"All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
|
|
355
|
+
].join("\n"),
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (action === "launch") {
|
|
362
|
+
const browserName = args.browser_name || "Brave";
|
|
363
|
+
const port = args.debug_port || 9222;
|
|
364
|
+
|
|
365
|
+
const browserConfig = KNOWN_BROWSERS.find((b) => b.name === browserName);
|
|
366
|
+
if (!browserConfig) {
|
|
367
|
+
const validNames = KNOWN_BROWSERS.map((b) => b.name).join(", ");
|
|
368
|
+
throw new Error(`Unknown browser "${browserName}". Choose from: ${validNames}`);
|
|
369
|
+
}
|
|
370
|
+
if (!fs.existsSync(browserConfig.executable)) {
|
|
371
|
+
throw new Error(`${browserName} not found at: ${browserConfig.executable}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const resolveTarget = (nameOrId) => {
|
|
375
|
+
if (!nameOrId) return null;
|
|
376
|
+
const resolved = resolveExtensionId(nameOrId, browserConfig.extensionsDir);
|
|
377
|
+
if (!resolved) throw new Error(`No installed extension found matching "${nameOrId}". Run action:"list_extensions" to see available extensions.`);
|
|
378
|
+
return resolved;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// If a browser is already debugging on the target port, just connect to it.
|
|
382
|
+
const alreadyRunning = await fetchCdpVersionInfo(port);
|
|
383
|
+
if (alreadyRunning) {
|
|
384
|
+
await teardownExistingConnection();
|
|
385
|
+
await connectToDebugPort(port);
|
|
386
|
+
if (args.extension_id) state.extensionId = resolveTarget(args.extension_id);
|
|
387
|
+
return {
|
|
388
|
+
content: [
|
|
389
|
+
{
|
|
390
|
+
type: "text",
|
|
391
|
+
text: [
|
|
392
|
+
`Browser already running with debugging on port ${port}. Connected to ${alreadyRunning.Browser}.`,
|
|
393
|
+
`Extension ID: ${state.extensionId || "not detected"}`,
|
|
394
|
+
].join("\n"),
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await teardownExistingConnection();
|
|
401
|
+
await launchBrowserProcess(browserConfig, port);
|
|
402
|
+
await connectToDebugPort(port);
|
|
403
|
+
if (args.extension_id) state.extensionId = resolveTarget(args.extension_id);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
content: [
|
|
407
|
+
{
|
|
408
|
+
type: "text",
|
|
409
|
+
text: [
|
|
410
|
+
`Launched ${browserName} with remote debugging on port ${port}.`,
|
|
411
|
+
`Profile: ${browserConfig.userDataDir}`,
|
|
412
|
+
`Extension ID: ${state.extensionId || "not detected — run list_extensions to find it"}`,
|
|
413
|
+
"",
|
|
414
|
+
"All your existing logins are available.",
|
|
415
|
+
"All other tools (interact_with_popup, inspect_dom, etc.) will now use this browser.",
|
|
416
|
+
].join("\n"),
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
throw new Error(`Unknown action "${action}". Valid actions: "scan", "connect", "launch", "list_extensions".`);
|
|
423
|
+
}
|
|
@@ -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
package/src/tools/index.js
CHANGED
|
@@ -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,
|
package/src/tools/messaging.js
CHANGED
|
@@ -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 {
|
package/src/tools/network.js
CHANGED
|
@@ -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";
|
package/src/tools/popup.js
CHANGED
package/src/tools/storage.js
CHANGED
|
@@ -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
|
@@ -25,10 +25,11 @@ export const definition = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
export async function handler(args) {
|
|
28
|
-
|
|
28
|
+
const ctx = state.context || state.browser;
|
|
29
|
+
if (!ctx) throw new Error("No browser connected. Call load_extension or connect_browser first.");
|
|
29
30
|
|
|
30
31
|
if (args.action === "list") {
|
|
31
|
-
const pages =
|
|
32
|
+
const pages = ctx.pages();
|
|
32
33
|
const list = await Promise.all(
|
|
33
34
|
pages.map(async (p, i) => {
|
|
34
35
|
const title = await p.title().catch(() => "(no title)");
|
|
@@ -41,17 +42,17 @@ export async function handler(args) {
|
|
|
41
42
|
|
|
42
43
|
if (args.action === "open") {
|
|
43
44
|
const url = args.url || "about:blank";
|
|
44
|
-
const newPage = await
|
|
45
|
+
const newPage = await ctx.newPage();
|
|
45
46
|
if (args.url) await newPage.goto(url, { waitUntil: "domcontentloaded" });
|
|
46
47
|
state.page = newPage;
|
|
47
|
-
const index =
|
|
48
|
+
const index = ctx.pages().length - 1;
|
|
48
49
|
return { content: [{ type: "text", text: `Opened new tab [${index}]: ${url}` }] };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
if (args.action === "switch") {
|
|
52
|
-
const pages =
|
|
53
|
+
const pages = ctx.pages();
|
|
53
54
|
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.` }] };
|
|
55
|
+
return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
|
|
55
56
|
}
|
|
56
57
|
state.page = pages[args.tab_index];
|
|
57
58
|
await state.page.bringToFront();
|
|
@@ -59,22 +60,22 @@ export async function handler(args) {
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
if (args.action === "close") {
|
|
62
|
-
const pages =
|
|
63
|
+
const pages = ctx.pages();
|
|
63
64
|
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.` }] };
|
|
65
|
+
return { content: [{ type: "text", text: `Invalid tab_index ${args.tab_index}. Run 'list' to see available tabs.` }], isError: true };
|
|
65
66
|
}
|
|
66
67
|
const toClose = pages[args.tab_index];
|
|
67
68
|
const closedUrl = toClose.url();
|
|
68
69
|
await toClose.close();
|
|
69
70
|
if (state.page === toClose || state.page?.isClosed()) {
|
|
70
|
-
const remaining =
|
|
71
|
+
const remaining = ctx.pages();
|
|
71
72
|
state.page = remaining.length ? remaining[remaining.length - 1] : null;
|
|
72
73
|
}
|
|
73
74
|
return { content: [{ type: "text", text: `Closed tab [${args.tab_index}]: ${closedUrl}` }] };
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
if (args.action === "close_all") {
|
|
77
|
-
const pages =
|
|
78
|
+
const pages = ctx.pages();
|
|
78
79
|
const closed = [];
|
|
79
80
|
for (const p of pages) {
|
|
80
81
|
const url = p.url();
|