chrome-bridge-mcp 1.13.0 → 1.15.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 +113 -130
- package/extension/manifest.json +2 -2
- package/extension/service-worker.js +116 -13
- package/package.json +2 -2
- package/server/protocol.js +1 -1
- package/server/tools.js +177 -18
package/README.md
CHANGED
|
@@ -1,166 +1,141 @@
|
|
|
1
1
|
# Chrome Bridge
|
|
2
2
|
|
|
3
|
-
       [](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**An MCP server that gives Claude Code your real, logged-in Chrome — measured
|
|
6
|
+
2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
|
|
7
|
+
extension on a form-filling task, with ~3× the toolset and no paid plan.**
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
63 web-development tools (navigation, DOM inspection, visual regression, audits,
|
|
10
|
+
network mocking) over a local WebSocket bridge, plus a headless instance for CI.
|
|
11
|
+
Self-hosted, local-only.
|
|
8
12
|
|
|
9
|
-
##
|
|
13
|
+
## Quickstart
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
**Requires** Node.js 18+ and Chrome 135+.
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
```bash
|
|
18
|
+
git clone git@github.com:frsorrentino/chrome-bridge.git
|
|
19
|
+
cd chrome-bridge && ./install.sh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
1. Open `chrome://extensions`, enable **Developer mode**, click **Load
|
|
23
|
+
unpacked**, select the `extension/` folder.
|
|
24
|
+
2. Restart Claude Code.
|
|
15
25
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
Then ask for something like *"open localhost:3000, run an accessibility audit
|
|
27
|
+
and find the Sign Up button"*: Claude Code calls `navigate`,
|
|
28
|
+
`accessibility_audit` and `find_text`. Because `navigate` already returns
|
|
29
|
+
element refs, `click(ref="n1")` follows with no discovery turn in between.
|
|
20
30
|
|
|
21
|
-
On
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
[bench/RESULTS.md](bench/RESULTS.md).
|
|
31
|
+
> **On ChromeOS/Crostini** install from the [Chrome Web
|
|
32
|
+
> Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
|
|
33
|
+
> instead: an unpacked extension is dropped on every reboot, because the
|
|
34
|
+
> container isn't mounted when Chrome starts.
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- **Honest caveat:** per *turn*, Chrome Bridge costs slightly more than Claude in Chrome (41,985 vs 36,613 cache-read tokens; $0.0373 vs $0.0337). The win is in the number of turns.
|
|
36
|
+
`install.sh` registers the MCP server with `--scope user`. To do it by hand:
|
|
37
|
+
`claude mcp add --scope user chrome-bridge node /path/to/server/index.js`.
|
|
38
|
+
For `execute_js`, enable **Allow user scripts** in `chrome://extensions` →
|
|
39
|
+
Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
Chrome Bridge ships 62 specialized tools (33 core by default) compared to ~20 in Claude in Chrome. It is also the only automation that drives the real host Chrome on ChromeOS/Crostini.
|
|
41
|
+
## Why Chrome Bridge?
|
|
35
42
|
|
|
36
43
|
| | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
|
|
37
44
|
|---|---|---|---|---|
|
|
38
45
|
| **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
|
|
39
|
-
| **Tools** | **
|
|
40
|
-
| **Requires
|
|
41
|
-
| **Network
|
|
42
|
-
| **Visual
|
|
43
|
-
| **Audits (
|
|
46
|
+
| **Tools** | **63** (34 core) | ~20 | ~50 | 23 core (71 total) |
|
|
47
|
+
| **Requires paid plan** | **No** | Yes (Pro+) | No | No |
|
|
48
|
+
| **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
|
|
49
|
+
| **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
|
|
50
|
+
| **Audits (a11y/SEO/sec)** | **Yes** (full suite) | No | Partial | No |
|
|
44
51
|
| **Headless / CI** | **Yes** | No | Yes | Yes |
|
|
45
|
-
| **GIF /
|
|
46
|
-
| **Breakpoints /
|
|
47
|
-
|
|
48
|
-
## Quickstart
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
git clone git@github.com:frsorrentino/chrome-bridge.git && cd chrome-bridge && ./install.sh
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
1. Load the extension: Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select the `extension/` folder.
|
|
55
|
-
2. Restart Claude Code.
|
|
56
|
-
|
|
57
|
-
> On **ChromeOS/Crostini**, install the published extension from the [Chrome Web Store](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb) instead of loading it unpacked — a filesystem-loaded extension is dropped on every reboot (the container isn't mounted when Chrome starts).
|
|
58
|
-
|
|
59
|
-
### Example Session
|
|
60
|
-
> *"Open localhost:3000, run an accessibility audit, and find the 'Sign Up' button"*
|
|
61
|
-
|
|
62
|
-
Claude Code calls `navigate` (returning clickable element refs), `accessibility_audit`, and `find_text`. Because `navigate` returns refs like `n1`, the agent can immediately call `click(ref="n1")` without a discovery turn.
|
|
52
|
+
| **GIF / video** | No | **Yes** | Partial | No |
|
|
53
|
+
| **Breakpoints / heap** | No | No | **Yes** | No |
|
|
63
54
|
|
|
64
|
-
|
|
55
|
+
It wins on **round trips, not payload size**: short element refs instead of the
|
|
56
|
+
screenshot-and-click loop, `fill_form` filling N fields in one call, table
|
|
57
|
+
filtering done server-side. Per single turn it actually costs slightly *more*.
|
|
65
58
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- **Payload Escape Hatches**: `save_to` on `read_page`, `extract`, `screenshot` and `http_request` writes the result to a file and returns the path — the bytes never enter the context unless the agent decides to read them. `read_page(mode="markdown")` keeps headings, links and tables at a fraction of the HTML cost. The [CLI](#cli) skips MCP schemas entirely, and recorded flows [replay](#launch-mode-headless--ci) without any model in the loop.
|
|
59
|
+
The full benchmark — method, every raw run including the unfavourable ones, and
|
|
60
|
+
what the harness can't measure — is in
|
|
61
|
+
[docs/EFFICIENCY.md](docs/EFFICIENCY.md).
|
|
70
62
|
|
|
71
|
-
##
|
|
63
|
+
## Using it
|
|
72
64
|
|
|
73
|
-
|
|
74
|
-
- **Network Mocking**: Block/redirect requests, rewrite headers, or stub response bodies with synthetic data.
|
|
75
|
-
- **Visual Regression**: Use `screenshot_diff` to compare current pages against named baselines.
|
|
76
|
-
- **Structured Extraction**: One-call `extract` for repeated data and `extract_table` with server-side `where` filtering.
|
|
65
|
+
Beyond the MCP tools, two lanes keep work away from the model entirely.
|
|
77
66
|
|
|
78
|
-
|
|
67
|
+
**CLI** — batch operations, piped through `grep` or `jq` before anything reaches
|
|
68
|
+
the context:
|
|
79
69
|
|
|
70
|
+
```bash
|
|
71
|
+
chrome-bridge navigate --url https://example.com
|
|
72
|
+
chrome-bridge read_console --level error | head -20
|
|
73
|
+
chrome-bridge assert --selector "#success" --text "Done"
|
|
74
|
+
chrome-bridge replay --file ./recordings/login.jsonl
|
|
80
75
|
```
|
|
81
|
-
Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
|
|
82
|
-
(server/) (extension/, MV3)
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
The **MCP Server** (Node.js) handles the protocol and tool logic. The **Chrome Extension** (MV3) executes commands via Chrome APIs. User scripts (`execute_js`) run via `chrome.userScripts.execute()`, requiring the "Allow user scripts" toggle in extension settings.
|
|
86
|
-
|
|
87
|
-
## Tools (62 total)
|
|
88
76
|
|
|
89
|
-
|
|
90
|
-
|
|
77
|
+
**Launch mode** — a dedicated Chromium instance with an ephemeral profile, for
|
|
78
|
+
isolated sessions or CI:
|
|
91
79
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
### DOM & Inspection (11)
|
|
96
|
-
`read_page`, `extract`, `get_page_info`, `query_dom`, `modify_dom`, `find_text`, `get_interactives`, `inject_css`, `highlight_elements`, `watch_dom`, `measure_spacing`.
|
|
80
|
+
```bash
|
|
81
|
+
node server/index.js --launch --headless
|
|
82
|
+
```
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
|
|
84
|
+
Pair it with `session_record` + `replay` for smoke tests with no model in the
|
|
85
|
+
loop. In launch mode `execute_js` falls back to `new Function` when the
|
|
86
|
+
user-script toggle isn't available.
|
|
100
87
|
|
|
101
|
-
|
|
102
|
-
`element_screenshot`, `full_page_screenshot`, `screenshot_diff`, `viewport_resize`, `set_zoom`, `emulate_media`, `set_geolocation`.
|
|
88
|
+
## Tools
|
|
103
89
|
|
|
104
|
-
|
|
105
|
-
|
|
90
|
+
63 in total, in seven groups. Only `core` (34 tools) loads by default; the rest
|
|
91
|
+
are opt-in via `--caps`.
|
|
106
92
|
|
|
107
|
-
|
|
108
|
-
|
|
93
|
+
| Group | N | What's in it |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| Core & Navigation | 9 | tabs, windows, `navigate`, `screenshot`, `tile_windows` |
|
|
96
|
+
| Interaction | 11 | `click`, `fill_form`, `upload_file`, dialogs, clipboard |
|
|
97
|
+
| DOM & Inspection | 11 | `read_page`, `extract`, `query_dom`, `watch_dom` |
|
|
98
|
+
| Debugging & Network | 9 | `execute_js`, console, network log, mocking, Web Vitals |
|
|
99
|
+
| Visual & Responsive | 7 | `screenshot_diff`, viewport, zoom, media emulation |
|
|
100
|
+
| Audits | 6 | a11y, SEO, security headers, links, `extract_table` |
|
|
101
|
+
| State, Storage & Files | 9 | storage, fixtures, MHTML, recording, `assert` |
|
|
109
102
|
|
|
110
|
-
|
|
103
|
+
Every tool, with the notes that matter: [docs/TOOLS.md](docs/TOOLS.md).
|
|
111
104
|
|
|
112
|
-
|
|
105
|
+
## How it works
|
|
113
106
|
|
|
114
|
-
### From Source
|
|
115
|
-
```bash
|
|
116
|
-
git clone git@github.com:frsorrentino/chrome-bridge.git
|
|
117
|
-
cd chrome-bridge
|
|
118
|
-
./install.sh
|
|
119
107
|
```
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
### Manual Registration
|
|
123
|
-
```bash
|
|
124
|
-
claude mcp add --scope user chrome-bridge node /path/to/server/index.js
|
|
108
|
+
Claude Code <--stdio--> MCP Server <--WebSocket :8765--> Chrome Extension
|
|
109
|
+
(server/) (extension/, MV3)
|
|
125
110
|
```
|
|
126
111
|
|
|
127
|
-
|
|
112
|
+
The Node.js server handles the protocol and tool logic; the MV3 extension
|
|
113
|
+
executes commands through Chrome APIs. User scripts (`execute_js`) run via
|
|
114
|
+
`chrome.userScripts.execute()`.
|
|
128
115
|
|
|
129
|
-
## Configuration
|
|
116
|
+
## Configuration and security
|
|
130
117
|
|
|
131
|
-
|
|
132
|
-
- `CHROME_BRIDGE_PORT`: Default `8765`.
|
|
133
|
-
- `CHROME_BRIDGE_HOST` / `--host`: Bind address, default `127.0.0.1`. Set to `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and pair it with a token.
|
|
134
|
-
- `CHROME_BRIDGE_TOKEN`: Shared secret required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind is not loopback.
|
|
135
|
-
- `CHROME_BRIDGE_CAPS` / `--caps`: Tool groups to load (`core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, or `all`). `install.sh` registers with `all`; the bare server defaults to `core` (33 tools). `get_status` reports `caps_active` and `caps_available`.
|
|
118
|
+
Environment variables, each with a matching CLI flag:
|
|
136
119
|
|
|
137
|
-
|
|
120
|
+
| Variable | Default | Notes |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `CHROME_BRIDGE_PORT` | `8765` | |
|
|
123
|
+
| `CHROME_BRIDGE_HOST` / `--host` | `127.0.0.1` | `0.0.0.0` **only** where the browser lives outside the container (ChromeOS/Crostini port-forward) — and only with a token |
|
|
124
|
+
| `CHROME_BRIDGE_TOKEN` | unset | Required on both `ext_init` and `relay_init`. Strongly recommended whenever the bind isn't loopback |
|
|
125
|
+
| `CHROME_BRIDGE_CAPS` / `--caps` | `core` | `core`, `audits`, `visual`, `network`, `storage`, `dom`, `files`, `all`. `install.sh` uses `all` |
|
|
138
126
|
|
|
139
|
-
|
|
127
|
+
The bridge binds loopback, accepts extension connections only from a
|
|
128
|
+
`chrome-extension://` origin, and — when a token is set — requires it on both
|
|
129
|
+
handshakes. Without one, any local process could act as a relay and reach
|
|
130
|
+
`execute_js` inside your authenticated browser session. Secondary MCP instances
|
|
131
|
+
connect via loopback and are acknowledged with `relay_init_ok`, so a foreign
|
|
132
|
+
process holding the port fails fast instead of timing out per command.
|
|
140
133
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
chrome-bridge replay --file ./recordings/login.jsonl
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
## Launch Mode (Headless / CI)
|
|
150
|
-
|
|
151
|
-
Launch mode starts a dedicated Chromium instance with an ephemeral profile:
|
|
152
|
-
```bash
|
|
153
|
-
node server/index.js --launch --headless
|
|
154
|
-
```
|
|
155
|
-
Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js` uses a `new Function` fallback in launch mode if the user-script toggle is unavailable.
|
|
156
|
-
|
|
157
|
-
## Security
|
|
158
|
-
|
|
159
|
-
- **Loopback by default**: the WebSocket server binds `127.0.0.1`. Exposing it to the network is opt-in via `CHROME_BRIDGE_HOST`/`--host`.
|
|
160
|
-
- **Origin validation**: extension connections are only accepted from a `chrome-extension://` origin, and — with a loopback bind — only from loopback.
|
|
161
|
-
- **Token on both handshakes**: `CHROME_BRIDGE_TOKEN`, when set, is required for `ext_init` *and* `relay_init`. Without it, any local process could act as a relay and reach `execute_js` in your authenticated browser session.
|
|
162
|
-
- **Relay security**: secondary MCP instances must connect via loopback and are acknowledged with `relay_init_ok`, so a foreign process holding the port fails fast instead of timing out per command.
|
|
163
|
-
- **What is *not* protected**: page content reaches the model unfiltered, so a hostile page's text is untrusted input; `get_storage`, `session_fixture`, HAR exports and screenshots are **not** redacted and may carry cookies, tokens or personal data. Do not point the automation at pages holding secrets you would not paste into a chat.
|
|
134
|
+
**What is *not* protected:** page content reaches the model unfiltered, so a
|
|
135
|
+
hostile page's text is untrusted input. `get_storage`, `session_fixture`, HAR
|
|
136
|
+
exports and screenshots are **not** redacted and may carry cookies, tokens or
|
|
137
|
+
personal data. Don't point the automation at pages holding secrets you wouldn't
|
|
138
|
+
paste into a chat.
|
|
164
139
|
|
|
165
140
|
## Troubleshooting
|
|
166
141
|
|
|
@@ -170,16 +145,24 @@ Perfect for CI smoke tests using `session_record` + `replay`. Note: `execute_js`
|
|
|
170
145
|
| Port 8765 already in use | Expected: a second MCP session becomes a **relay** and shares the one bridge. Set `CHROME_BRIDGE_PORT` for a separate one. |
|
|
171
146
|
| `Port N is held by a process that is not chrome-bridge` | Something else owns the port. Free it or change `CHROME_BRIDGE_PORT`. |
|
|
172
147
|
| `execute_js` fails | Enable **Allow user scripts** in `chrome://extensions` → Chrome Bridge → Details (Chrome 138+; on 135-137 enable Developer Mode). |
|
|
173
|
-
| `read_console` returns
|
|
148
|
+
| `read_console` returns `note=Instrumentation not loaded` | The page was opened before the extension, "Capture console & metrics" is off, or the page isn't injectable (`chrome://`). Reload it. |
|
|
174
149
|
| Screenshot times out | On ChromeOS a fully occluded window stops producing frames; captures fail after 10s. Bring the window forward. |
|
|
175
|
-
| Commands work then stop
|
|
176
|
-
| Extension
|
|
177
|
-
| Tool missing from the list | It's in an opt-in
|
|
150
|
+
| Commands work, then stop | The MV3 service worker restarted and in-memory state (network log, diff baselines, HTTP auth) was reset. Re-run the monitoring call. |
|
|
151
|
+
| Extension dropped on every ChromeOS reboot | Install from the Web Store instead of Load unpacked. |
|
|
152
|
+
| Tool missing from the list | It's in an opt-in group. Check `get_status` → `caps_available`, then set `CHROME_BRIDGE_CAPS=all`. |
|
|
153
|
+
|
|
154
|
+
## Documentation
|
|
155
|
+
|
|
156
|
+
- [docs/TOOLS.md](docs/TOOLS.md) — all 63 tools, by group
|
|
157
|
+
- [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
|
|
158
|
+
- [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
|
|
159
|
+
- [CHANGELOG.md](CHANGELOG.md)
|
|
178
160
|
|
|
179
161
|
## Tests
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
|
|
162
|
+
|
|
163
|
+
`npm test` (Chrome-free, ~22s) · `npm run test:e2e` (needs Chrome and a
|
|
164
|
+
connected extension) · `npm run measure` (schema cost).
|
|
183
165
|
|
|
184
166
|
## License
|
|
167
|
+
|
|
185
168
|
MIT
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Bridge your browser to Claude Code:
|
|
4
|
+
"version": "1.15.0",
|
|
5
|
+
"description": "Bridge your browser to Claude Code: 63 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
|
|
6
6
|
"homepage_url": "https://github.com/frsorrentino/chrome-bridge",
|
|
7
7
|
"minimum_chrome_version": "135",
|
|
8
8
|
"permissions": [
|
|
@@ -322,7 +322,7 @@ async function executeCommand(msg) {
|
|
|
322
322
|
|
|
323
323
|
switch (type) {
|
|
324
324
|
case 'get_tabs':
|
|
325
|
-
return await cmdGetTabs();
|
|
325
|
+
return await cmdGetTabs(params);
|
|
326
326
|
case 'navigate':
|
|
327
327
|
return await cmdNavigate(params);
|
|
328
328
|
case 'screenshot':
|
|
@@ -649,15 +649,36 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
|
|
|
649
649
|
|
|
650
650
|
// --- Implementazione comandi ---
|
|
651
651
|
|
|
652
|
-
async function cmdGetTabs() {
|
|
652
|
+
async function cmdGetTabs({ include_windows = false } = {}) {
|
|
653
653
|
const tabs = await chrome.tabs.query({});
|
|
654
|
-
|
|
654
|
+
const list = tabs.map((t) => ({
|
|
655
655
|
id: t.id,
|
|
656
656
|
url: t.url,
|
|
657
657
|
title: t.title,
|
|
658
658
|
active: t.active,
|
|
659
659
|
windowId: t.windowId,
|
|
660
660
|
}));
|
|
661
|
+
if (!include_windows) return list;
|
|
662
|
+
|
|
663
|
+
// Senza geometria e stato non si può decidere dove mettere una finestra: si
|
|
664
|
+
// può solo indovinare. `left` in particolare è ciò che identifica il monitor
|
|
665
|
+
// su una scrivania virtuale multi-schermo.
|
|
666
|
+
const windows = (await chrome.windows.getAll({ populate: true })).map((w) => ({
|
|
667
|
+
id: w.id,
|
|
668
|
+
type: w.type,
|
|
669
|
+
state: w.state,
|
|
670
|
+
focused: w.focused,
|
|
671
|
+
left: w.left,
|
|
672
|
+
top: w.top,
|
|
673
|
+
width: w.width,
|
|
674
|
+
height: w.height,
|
|
675
|
+
tabs: (w.tabs || []).length,
|
|
676
|
+
// Una finestra le cui schede sono tutte chrome:// o chrome-untrusted:// non
|
|
677
|
+
// può fornire l'area del monitor a tile_windows: meglio saperlo prima di
|
|
678
|
+
// provarci.
|
|
679
|
+
scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
|
|
680
|
+
}));
|
|
681
|
+
return { tabs: list, windows };
|
|
661
682
|
}
|
|
662
683
|
|
|
663
684
|
async function cmdNavigate({ url, tab_id }) {
|
|
@@ -920,7 +941,24 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
|
|
|
920
941
|
|
|
921
942
|
// --- create_tab ---
|
|
922
943
|
|
|
923
|
-
async function cmdCreateTab({ url, active = true }) {
|
|
944
|
+
async function cmdCreateTab({ url, active = true, new_window = false, left, top, width, height }) {
|
|
945
|
+
if (new_window) {
|
|
946
|
+
// windows.create con i bounds apre direttamente sul monitor scelto: left è
|
|
947
|
+
// la coordinata sulla scrivania virtuale, quindi è ciò che sceglie lo schermo.
|
|
948
|
+
const winOpts = { focused: active !== false };
|
|
949
|
+
if (url) winOpts.url = url;
|
|
950
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
951
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
952
|
+
}
|
|
953
|
+
const win = await chrome.windows.create(winOpts);
|
|
954
|
+
const tab = (win.tabs || [])[0];
|
|
955
|
+
if (url && tab) {
|
|
956
|
+
await waitForComplete(tab.id);
|
|
957
|
+
const updated = await chrome.tabs.get(tab.id);
|
|
958
|
+
return { id: updated.id, url: updated.url, title: updated.title, window_id: win.id };
|
|
959
|
+
}
|
|
960
|
+
return { id: tab?.id, url: tab?.url || 'chrome://newtab', title: tab?.title || '', window_id: win.id };
|
|
961
|
+
}
|
|
924
962
|
const opts = { active };
|
|
925
963
|
if (url) opts.url = url;
|
|
926
964
|
const tab = await chrome.tabs.create(opts);
|
|
@@ -965,7 +1003,7 @@ async function cmdCreateTab({ url, active = true }) {
|
|
|
965
1003
|
* chrome-untrusted:// non può fornire l'area, e il tool lo dice invece di
|
|
966
1004
|
* ripiegare su misure inventate.
|
|
967
1005
|
*/
|
|
968
|
-
async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types }) {
|
|
1006
|
+
async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid', padding = 0, include_types, area: explicitArea }) {
|
|
969
1007
|
const types = include_types?.length ? include_types : ['normal'];
|
|
970
1008
|
const all = await chrome.windows.getAll({ populate: true });
|
|
971
1009
|
|
|
@@ -986,9 +1024,12 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
|
|
|
986
1024
|
.flatMap((w) => (w.tabs || []).map((t) => ({ windowId: w.id, tabId: t.id, url: t.url || '' })))
|
|
987
1025
|
.filter((t) => /^(https?|file):/i.test(t.url));
|
|
988
1026
|
|
|
989
|
-
|
|
1027
|
+
// Un'area esplicita salta la lettura da una scheda: è l'unica strada quando
|
|
1028
|
+
// le finestre bersaglio hanno solo pagine interne di Chrome, come quelle del
|
|
1029
|
+
// Terminale ChromeOS.
|
|
1030
|
+
let area = explicitArea ?? null;
|
|
990
1031
|
const failures = [];
|
|
991
|
-
for (const cand of candidates) {
|
|
1032
|
+
for (const cand of (area ? [] : candidates)) {
|
|
992
1033
|
try {
|
|
993
1034
|
const [res] = await chrome.scripting.executeScript({
|
|
994
1035
|
target: { tabId: cand.tabId },
|
|
@@ -1051,11 +1092,30 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
|
|
|
1051
1092
|
};
|
|
1052
1093
|
}
|
|
1053
1094
|
|
|
1054
|
-
async function cmdMoveTab({ tab_id, window_id, index = -1 }) {
|
|
1095
|
+
async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, width, height, index = -1 }) {
|
|
1055
1096
|
if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
|
|
1056
|
-
if (typeof window_id !== 'number') throw new Error('
|
|
1097
|
+
if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
|
|
1057
1098
|
|
|
1058
1099
|
const before = await chrome.tabs.get(tab_id);
|
|
1100
|
+
|
|
1101
|
+
if (new_window) {
|
|
1102
|
+
// tabs.move vuole una finestra che esiste già; per estrarre in una finestra
|
|
1103
|
+
// nuova (e posizionata) l'unica via è windows.create({tabId}).
|
|
1104
|
+
const winOpts = { tabId: tab_id };
|
|
1105
|
+
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
1106
|
+
if (typeof v === 'number') winOpts[k] = v;
|
|
1107
|
+
}
|
|
1108
|
+
const win = await chrome.windows.create(winOpts);
|
|
1109
|
+
return {
|
|
1110
|
+
moved: tab_id,
|
|
1111
|
+
url: before.url,
|
|
1112
|
+
from_window: before.windowId,
|
|
1113
|
+
to_window: win.id,
|
|
1114
|
+
new_window: true,
|
|
1115
|
+
index: 0,
|
|
1116
|
+
same_window: before.windowId === win.id,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1059
1119
|
const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
|
|
1060
1120
|
const tab = Array.isArray(moved) ? moved[0] : moved;
|
|
1061
1121
|
|
|
@@ -1079,6 +1139,21 @@ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
|
|
|
1079
1139
|
await chrome.tabs.remove(tabId);
|
|
1080
1140
|
return { action, closed: tabId };
|
|
1081
1141
|
}
|
|
1142
|
+
if (action === 'discard') {
|
|
1143
|
+
// La scheda resta nella barra e si ricarica al focus: il processo muore, la
|
|
1144
|
+
// RAM torna. La tmux session dietro un terminale non c'entra: vive nel
|
|
1145
|
+
// container, non nel renderer.
|
|
1146
|
+
const t = await chrome.tabs.discard(tabId);
|
|
1147
|
+
return { action, tabId: t?.id ?? tabId, discarded: true };
|
|
1148
|
+
}
|
|
1149
|
+
if (action === 'mute' || action === 'unmute') {
|
|
1150
|
+
await chrome.tabs.update(tabId, { muted: action === 'mute' });
|
|
1151
|
+
return { action, tabId, muted: action === 'mute' };
|
|
1152
|
+
}
|
|
1153
|
+
if (action === 'duplicate') {
|
|
1154
|
+
const d = await chrome.tabs.duplicate(tabId);
|
|
1155
|
+
return { action, source: tabId, duplicated: d.id, window_id: d.windowId };
|
|
1156
|
+
}
|
|
1082
1157
|
if (action === 'activate') {
|
|
1083
1158
|
const tab = await chrome.tabs.get(tabId);
|
|
1084
1159
|
await chrome.tabs.update(tabId, { active: true });
|
|
@@ -1809,7 +1884,7 @@ async function cmdFillForm({ fields, submit_selector, tab_id, frame_id }) {
|
|
|
1809
1884
|
|
|
1810
1885
|
// --- viewport_resize ---
|
|
1811
1886
|
|
|
1812
|
-
async function cmdViewportResize({ preset, width, height, read_only = false, tab_id }) {
|
|
1887
|
+
async function cmdViewportResize({ preset, width, height, left, top, state, read_only = false, tab_id }) {
|
|
1813
1888
|
const tabId = await resolveTabId(tab_id);
|
|
1814
1889
|
const tab = await chrome.tabs.get(tabId);
|
|
1815
1890
|
|
|
@@ -1825,9 +1900,20 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
|
|
|
1825
1900
|
const updateOpts = {};
|
|
1826
1901
|
if (targetW) updateOpts.width = targetW;
|
|
1827
1902
|
if (targetH) updateOpts.height = targetH;
|
|
1903
|
+
if (typeof left === 'number') updateOpts.left = left;
|
|
1904
|
+
if (typeof top === 'number') updateOpts.top = top;
|
|
1828
1905
|
|
|
1829
1906
|
if (!read_only) {
|
|
1830
|
-
|
|
1907
|
+
// Lo stato va cambiato PRIMA dei bounds: una finestra massimizzata li
|
|
1908
|
+
// accetta e li ignora, e la chiamata riuscirebbe senza spostare nulla.
|
|
1909
|
+
if (state) await chrome.windows.update(tab.windowId, { state });
|
|
1910
|
+
if (Object.keys(updateOpts).length === 0) {
|
|
1911
|
+
if (state) {
|
|
1912
|
+
const w = await chrome.windows.get(tab.windowId);
|
|
1913
|
+
return { requested: { state }, actual: { left: w.left, top: w.top, width: w.width, height: w.height, state: w.state } };
|
|
1914
|
+
}
|
|
1915
|
+
throw new Error('Provide preset, width, height, left, top or state');
|
|
1916
|
+
}
|
|
1831
1917
|
await chrome.windows.update(tab.windowId, updateOpts);
|
|
1832
1918
|
// La finestra si ridisegna in modo asincrono: senza la pausa si leggono le
|
|
1833
1919
|
// dimensioni vecchie e si riporta un risultato falso.
|
|
@@ -1845,7 +1931,14 @@ async function cmdViewportResize({ preset, width, height, read_only = false, tab
|
|
|
1845
1931
|
});
|
|
1846
1932
|
const actual = results?.[0]?.result ?? {};
|
|
1847
1933
|
if (read_only) return { actual };
|
|
1848
|
-
|
|
1934
|
+
const win = await chrome.windows.get(tab.windowId);
|
|
1935
|
+
return {
|
|
1936
|
+
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
1937
|
+
actual,
|
|
1938
|
+
// Il window manager può accettare e ignorare: senza il confronto una
|
|
1939
|
+
// finestra rimasta ferma passerebbe per spostata.
|
|
1940
|
+
window: { left: win.left, top: win.top, width: win.width, height: win.height, state: win.state },
|
|
1941
|
+
};
|
|
1849
1942
|
}
|
|
1850
1943
|
|
|
1851
1944
|
// --- full_page_screenshot ---
|
|
@@ -3584,9 +3677,19 @@ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = f
|
|
|
3584
3677
|
|
|
3585
3678
|
// --- manage_downloads ---
|
|
3586
3679
|
|
|
3587
|
-
async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
|
|
3680
|
+
async function cmdManageDownloads({ action, url, filename, timeout = 30000, limit = 10 }) {
|
|
3588
3681
|
if (!action) throw new Error('Missing required parameter: action');
|
|
3589
3682
|
|
|
3683
|
+
if (action === 'download') {
|
|
3684
|
+
if (!url) throw new Error('action=download requires url');
|
|
3685
|
+
// Parte dal browser, quindi con i cookie di sessione dell'origine: un file
|
|
3686
|
+
// dietro login si scarica senza far passare i byte dal bridge.
|
|
3687
|
+
const opts = { url, saveAs: false };
|
|
3688
|
+
if (filename) opts.filename = filename;
|
|
3689
|
+
const id = await chrome.downloads.download(opts);
|
|
3690
|
+
return { started: true, id, url };
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3590
3693
|
if (action === 'list') {
|
|
3591
3694
|
const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
|
|
3592
3695
|
return {
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.15.0",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "MCP server with
|
|
6
|
+
"description": "MCP server with 63 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
|
8
8
|
"bin": {
|
|
9
9
|
"chrome-bridge-mcp": "server/index.js",
|
package/server/protocol.js
CHANGED
package/server/tools.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
|
-
import { basename, extname, join } from 'node:path';
|
|
10
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
11
11
|
import { parse as parseHtml } from 'node-html-parser';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { runAssert } from './assertions.js';
|
|
@@ -19,6 +19,9 @@ import { evaluateSecurityHeaders } from './security-headers.js';
|
|
|
19
19
|
import { consoleLines, networkLines, interactivesLines, linksLines } from './formatters.js';
|
|
20
20
|
|
|
21
21
|
const SESSIONS_DIR = join(homedir(), '.config', 'chrome-bridge', 'sessions');
|
|
22
|
+
// Sovrascrivibile nei test: i layout sono un file solo, non una directory.
|
|
23
|
+
const LAYOUTS_FILE = process.env.CHROME_BRIDGE_LAYOUTS_FILE
|
|
24
|
+
|| join(homedir(), '.config', 'chrome-bridge', 'layouts.json');
|
|
22
25
|
const RECORDINGS_DIR = process.env.CHROME_BRIDGE_RECORD_DIR || join(homedir(), '.config', 'chrome-bridge', 'recordings');
|
|
23
26
|
|
|
24
27
|
// Comandi rumore per un replay: letture interne (tabSnapshot) o senza effetto
|
|
@@ -291,6 +294,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
291
294
|
create_tab: rw({ open: true }),
|
|
292
295
|
move_tab: rw({ idempotent: true }), // rispostare dove è già = stesso esito
|
|
293
296
|
tile_windows: rw({ idempotent: true }),
|
|
297
|
+
window_layout: rw({ destructive: true, idempotent: true }), // save sovrascrive l'omonimo, restore sposta finestre
|
|
294
298
|
navigate: rw({ idempotent: true, open: true }),
|
|
295
299
|
tab_action: rw({ destructive: true, open: true }), // close chiude una tab dell'utente
|
|
296
300
|
|
|
@@ -480,10 +484,15 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
480
484
|
server.tool(
|
|
481
485
|
'get_tabs',
|
|
482
486
|
'List every open tab with id, url, title and active flag. Read-only. Use it to find a tab_id when the '
|
|
483
|
-
+ 'implicit target (last navigated tab, else the active one) is not the tab you mean.'
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
+
+ 'implicit target (last navigated tab, else the active one) is not the tab you mean. '
|
|
488
|
+
+ 'include_windows adds the windows themselves with their position, size, state and type — what you need '
|
|
489
|
+
+ 'before moving or tiling anything, and the only way to tell which monitor a window is on.',
|
|
490
|
+
{
|
|
491
|
+
include_windows: z.boolean().optional().default(false)
|
|
492
|
+
.describe('Also return the windows with bounds, state, type and tab count'),
|
|
493
|
+
},
|
|
494
|
+
async ({ include_windows }) => {
|
|
495
|
+
const data = await send(MessageType.GET_TABS, { include_windows: include_windows === true });
|
|
487
496
|
return {
|
|
488
497
|
content: [{
|
|
489
498
|
type: 'text',
|
|
@@ -871,9 +880,14 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
871
880
|
{
|
|
872
881
|
url: z.string().optional().describe('URL to open (default: new tab page)'),
|
|
873
882
|
active: z.boolean().optional().default(true).describe('false opens the tab in the background, leaving the current one focused'),
|
|
883
|
+
new_window: z.boolean().optional().default(false).describe('Open in a fresh window instead of a tab; with left/top it lands on the chosen monitor'),
|
|
884
|
+
left: z.number().optional().describe('Window x on the virtual desktop (new_window)'),
|
|
885
|
+
top: z.number().optional().describe('Window y (new_window)'),
|
|
886
|
+
width: z.number().optional().describe('Window width px (new_window)'),
|
|
887
|
+
height: z.number().optional().describe('Window height px (new_window)'),
|
|
874
888
|
},
|
|
875
|
-
async ({ url, active }) => {
|
|
876
|
-
const data = await send(MessageType.CREATE_TAB, { url, active });
|
|
889
|
+
async ({ url, active, new_window, left, top, width, height }) => {
|
|
890
|
+
const data = await send(MessageType.CREATE_TAB, { url, active, new_window: new_window === true || undefined, left, top, width, height });
|
|
877
891
|
if (data?.id != null) sessionTabId = data.id;
|
|
878
892
|
return {
|
|
879
893
|
content: [{
|
|
@@ -1028,12 +1042,16 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1028
1042
|
{
|
|
1029
1043
|
action: z.enum(['set', 'get']).optional().default('set').describe('get reports the current viewport without resizing anything'),
|
|
1030
1044
|
preset: z.enum(['mobile', 'tablet', 'desktop']).optional().describe('375x812, 768x1024, 1440x900'),
|
|
1045
|
+
left: z.number().optional().describe('Window x on the virtual desktop; on multi-monitor this is what picks the screen'),
|
|
1046
|
+
top: z.number().optional().describe('Window y on the virtual desktop'),
|
|
1047
|
+
state: z.enum(['normal', 'maximized', 'fullscreen', 'minimized']).optional()
|
|
1048
|
+
.describe('Applied before bounds: a maximized window accepts left/top/width/height and ignores them'),
|
|
1031
1049
|
width: z.number().optional().describe('Overrides preset'),
|
|
1032
1050
|
height: z.number().optional().describe('Overrides preset'),
|
|
1033
1051
|
tab_id: tabId,
|
|
1034
1052
|
},
|
|
1035
|
-
async ({ action, preset, width, height, tab_id }) => {
|
|
1036
|
-
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, read_only: (action ?? 'set') === 'get', tab_id });
|
|
1053
|
+
async ({ action, preset, width, height, left, top, state, tab_id }) => {
|
|
1054
|
+
const data = await send(MessageType.VIEWPORT_RESIZE, { preset, width, height, left, top, state, read_only: (action ?? 'set') === 'get', tab_id });
|
|
1037
1055
|
return {
|
|
1038
1056
|
content: [{
|
|
1039
1057
|
type: 'text',
|
|
@@ -1333,7 +1351,7 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1333
1351
|
+ 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
|
|
1334
1352
|
+ 'reload and navigation drop injected CSS, emulations and page hooks.',
|
|
1335
1353
|
{
|
|
1336
|
-
action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and
|
|
1354
|
+
action: z.enum(['close', 'activate', 'reload', 'back', 'forward', 'discard', 'mute', 'unmute', 'duplicate']).describe('close cannot be undone; discard frees memory, the tab reloads on focus; reload drops injected CSS and hooks'),
|
|
1337
1355
|
bypass_cache: z.boolean().optional().default(false).describe('reload only'),
|
|
1338
1356
|
tab_id: tabId,
|
|
1339
1357
|
},
|
|
@@ -1650,12 +1668,15 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1650
1668
|
'manage_downloads',
|
|
1651
1669
|
'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
|
|
1652
1670
|
{
|
|
1653
|
-
action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest
|
|
1671
|
+
action: z.enum(['list', 'wait_for_complete', 'download']).describe('download starts one with the browser cookie jar; wait_for_complete blocks until the newest finishes'),
|
|
1672
|
+
url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
|
|
1673
|
+
filename: z.string().optional().describe('Relative path inside the Downloads folder (action=download)'),
|
|
1654
1674
|
timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
|
|
1655
1675
|
limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
|
|
1656
1676
|
},
|
|
1657
|
-
async ({ action, timeout, limit }) => {
|
|
1658
|
-
|
|
1677
|
+
async ({ action, url, filename, timeout, limit }) => {
|
|
1678
|
+
if (action === 'download' && !url) throw new Error('action=download requires url');
|
|
1679
|
+
const data = await send(MessageType.MANAGE_DOWNLOADS, { action, url, filename, timeout, limit });
|
|
1659
1680
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1660
1681
|
}
|
|
1661
1682
|
);
|
|
@@ -1742,11 +1763,19 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1742
1763
|
+ 'an app window, and the refusal is reported verbatim rather than retried.',
|
|
1743
1764
|
{
|
|
1744
1765
|
tab_id: z.number().describe('Tab to move; get it from get_tabs'),
|
|
1745
|
-
window_id: z.number().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1766
|
+
window_id: z.number().optional().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1767
|
+
new_window: z.boolean().optional().default(false).describe('Extract the tab into a fresh window instead — tabs.move needs an existing window, this does not'),
|
|
1768
|
+
left: z.number().optional().describe('New window x (new_window)'),
|
|
1769
|
+
top: z.number().optional().describe('New window y (new_window)'),
|
|
1770
|
+
width: z.number().optional().describe('New window width px (new_window)'),
|
|
1771
|
+
height: z.number().optional().describe('New window height px (new_window)'),
|
|
1746
1772
|
index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
|
|
1747
1773
|
},
|
|
1748
|
-
async ({ tab_id, window_id, index }) => {
|
|
1749
|
-
|
|
1774
|
+
async ({ tab_id, window_id, new_window, left, top, width, height, index }) => {
|
|
1775
|
+
if (window_id == null && new_window !== true) {
|
|
1776
|
+
throw new Error('Provide window_id (an existing window) or new_window: true');
|
|
1777
|
+
}
|
|
1778
|
+
const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, new_window: new_window === true || undefined, left, top, width, height, index: index ?? -1 });
|
|
1750
1779
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1751
1780
|
}
|
|
1752
1781
|
);
|
|
@@ -1765,15 +1794,145 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1765
1794
|
layout: z.enum(['grid', 'columns', 'rows']).optional().default('grid').describe('columns splits left to right, rows top to bottom, grid keeps tiles as square as it can'),
|
|
1766
1795
|
padding: z.number().optional().default(0).describe('Px of empty margin kept inside the work area'),
|
|
1767
1796
|
include_types: z.array(z.enum(['normal', 'popup', 'app'])).optional().describe('Window types to include; omitted = normal only'),
|
|
1797
|
+
area: z.object({
|
|
1798
|
+
left: z.number(), top: z.number(), width: z.number(), height: z.number(),
|
|
1799
|
+
}).optional().describe('Monitor area to fill, when no target window has a scriptable tab to read it from'),
|
|
1768
1800
|
},
|
|
1769
|
-
async ({ window_ids, reference_window_id, layout, padding, include_types }) => {
|
|
1801
|
+
async ({ window_ids, reference_window_id, layout, padding, include_types, area }) => {
|
|
1770
1802
|
const data = await send(MessageType.TILE_WINDOWS, {
|
|
1771
|
-
window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types,
|
|
1803
|
+
window_ids, reference_window_id, layout: layout ?? 'grid', padding: padding ?? 0, include_types, area,
|
|
1772
1804
|
});
|
|
1773
1805
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1774
1806
|
}
|
|
1775
1807
|
);
|
|
1776
1808
|
|
|
1809
|
+
// --- window_layout ---
|
|
1810
|
+
server.tool(
|
|
1811
|
+
'window_layout',
|
|
1812
|
+
'Save the current window arrangement under a name, restore one, list or delete saved ones. save overwrites '
|
|
1813
|
+
+ 'a same-named layout without asking. Window ids do not survive a browser restart, so restore recognises '
|
|
1814
|
+
+ 'windows by the overlap of their tab URLs (same type required) and repositions the best match — windows '
|
|
1815
|
+
+ 'it cannot recognise are reported, not guessed. Needs extension >= 1.14.0 for window geometry.',
|
|
1816
|
+
{
|
|
1817
|
+
action: z.enum(['save', 'restore', 'list', 'delete']).describe('save snapshots every window; restore repositions the recognised ones'),
|
|
1818
|
+
name: z.string().optional().describe('Layout name, required except for list'),
|
|
1819
|
+
},
|
|
1820
|
+
async ({ action, name }) => {
|
|
1821
|
+
const readLayouts = async () => {
|
|
1822
|
+
try { return JSON.parse(await readFile(LAYOUTS_FILE, 'utf8')); } catch { return {}; }
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
if (action === 'list') {
|
|
1826
|
+
const all = await readLayouts();
|
|
1827
|
+
const rows = Object.entries(all).map(([n, l]) => ({ name: n, windows: l.windows.length, savedAt: l.savedAt }));
|
|
1828
|
+
return { content: [{ type: 'text', text: jsonText({ layouts: rows }) }] };
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
if (!name || !/^[\w-]+$/.test(name)) throw new Error('name is required and must match [\\w-]+');
|
|
1832
|
+
|
|
1833
|
+
if (action === 'delete') {
|
|
1834
|
+
const all = await readLayouts();
|
|
1835
|
+
const existed = Boolean(all[name]);
|
|
1836
|
+
delete all[name];
|
|
1837
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1838
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1839
|
+
return { content: [{ type: 'text', text: jsonText({ deleted: name, existed }) }] };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// save e restore hanno bisogno della fotografia corrente
|
|
1843
|
+
const snap = await send(MessageType.GET_TABS, { include_windows: true });
|
|
1844
|
+
if (Array.isArray(snap) || !snap?.windows) {
|
|
1845
|
+
// Un'estensione < 1.14.0 risponde con il solo array di schede: meglio
|
|
1846
|
+
// dirlo che fallire su una proprietà mancante.
|
|
1847
|
+
throw new Error('window_layout needs extension >= 1.14.0: get_tabs returned no window geometry');
|
|
1848
|
+
}
|
|
1849
|
+
const urlsByWindow = new Map();
|
|
1850
|
+
for (const t of snap.tabs || []) {
|
|
1851
|
+
if (!urlsByWindow.has(t.windowId)) urlsByWindow.set(t.windowId, []);
|
|
1852
|
+
urlsByWindow.get(t.windowId).push(t.url || '');
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
if (action === 'save') {
|
|
1856
|
+
const all = await readLayouts();
|
|
1857
|
+
all[name] = {
|
|
1858
|
+
savedAt: new Date().toISOString(),
|
|
1859
|
+
windows: snap.windows.map((w) => ({
|
|
1860
|
+
type: w.type,
|
|
1861
|
+
state: w.state,
|
|
1862
|
+
left: w.left, top: w.top, width: w.width, height: w.height,
|
|
1863
|
+
tabs: urlsByWindow.get(w.id) || [],
|
|
1864
|
+
})),
|
|
1865
|
+
};
|
|
1866
|
+
await mkdir(dirname(LAYOUTS_FILE), { recursive: true });
|
|
1867
|
+
await writeFile(LAYOUTS_FILE, JSON.stringify(all, null, 2));
|
|
1868
|
+
return { content: [{ type: 'text', text: jsonText({ saved: name, windows: all[name].windows.length }) }] };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// restore
|
|
1872
|
+
const all = await readLayouts();
|
|
1873
|
+
const layout = all[name];
|
|
1874
|
+
if (!layout) {
|
|
1875
|
+
throw new Error(`Layout "${name}" not found — saved: ${Object.keys(all).join(', ') || 'none'}`);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// Matching: sovrapposizione degli URL (Jaccard), a parità di tipo. Greedy
|
|
1879
|
+
// sulla coppia migliore. Gli id non entrano mai: non sopravvivono al
|
|
1880
|
+
// riavvio del browser.
|
|
1881
|
+
const current = snap.windows.map((w) => ({ ...w, urls: new Set(urlsByWindow.get(w.id) || []) }));
|
|
1882
|
+
const pairs = [];
|
|
1883
|
+
layout.windows.forEach((savedWin, si) => {
|
|
1884
|
+
current.forEach((cur, ci) => {
|
|
1885
|
+
if (savedWin.type !== cur.type) return;
|
|
1886
|
+
const savedUrls = new Set(savedWin.tabs);
|
|
1887
|
+
let shared = 0;
|
|
1888
|
+
for (const u of savedUrls) if (cur.urls.has(u)) shared += 1;
|
|
1889
|
+
const union = new Set([...savedUrls, ...cur.urls]).size || 1;
|
|
1890
|
+
const score = shared / union;
|
|
1891
|
+
if (score > 0) pairs.push({ si, ci, score });
|
|
1892
|
+
});
|
|
1893
|
+
});
|
|
1894
|
+
pairs.sort((a, b) => b.score - a.score);
|
|
1895
|
+
|
|
1896
|
+
const usedSaved = new Set();
|
|
1897
|
+
const usedCurrent = new Set();
|
|
1898
|
+
const results = [];
|
|
1899
|
+
for (const { si, ci, score } of pairs) {
|
|
1900
|
+
if (usedSaved.has(si) || usedCurrent.has(ci)) continue;
|
|
1901
|
+
usedSaved.add(si); usedCurrent.add(ci);
|
|
1902
|
+
const savedWin = layout.windows[si];
|
|
1903
|
+
const cur = current[ci];
|
|
1904
|
+
const anyTab = (snap.tabs || []).find((t) => t.windowId === cur.id);
|
|
1905
|
+
if (!anyTab) { results.push({ window_id: cur.id, error: 'no tab to address the window with' }); continue; }
|
|
1906
|
+
// Una finestra da massimizzare riceve SOLO lo stato: i bounds verrebbero
|
|
1907
|
+
// accettati e ignorati, e il confronto richiesto/ottenuto mentirebbe.
|
|
1908
|
+
const params = savedWin.state === 'normal'
|
|
1909
|
+
? { tab_id: anyTab.id, left: savedWin.left, top: savedWin.top, width: savedWin.width, height: savedWin.height, state: 'normal' }
|
|
1910
|
+
: { tab_id: anyTab.id, state: savedWin.state };
|
|
1911
|
+
try {
|
|
1912
|
+
const r = await send(MessageType.VIEWPORT_RESIZE, params);
|
|
1913
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), requested: params, window: r?.window ?? r?.actual ?? null });
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
results.push({ window_id: cur.id, score: Number(score.toFixed(2)), error: e.message });
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const unmatchedSaved = layout.windows.filter((_, i) => !usedSaved.has(i)).length;
|
|
1920
|
+
const unmatchedCurrent = current.filter((_, i) => !usedCurrent.has(i)).map((w) => w.id);
|
|
1921
|
+
return {
|
|
1922
|
+
content: [{
|
|
1923
|
+
type: 'text',
|
|
1924
|
+
text: jsonText({
|
|
1925
|
+
restored: name,
|
|
1926
|
+
matched: results.length,
|
|
1927
|
+
unmatched_saved: unmatchedSaved,
|
|
1928
|
+
unmatched_current: unmatchedCurrent,
|
|
1929
|
+
results,
|
|
1930
|
+
}),
|
|
1931
|
+
}],
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1777
1936
|
// --- set_zoom ---
|
|
1778
1937
|
server.tool(
|
|
1779
1938
|
'set_zoom',
|