chrome-bridge-mcp 1.15.0 → 1.15.1
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/extension/lib/download-state.js +43 -0
- package/extension/manifest.json +1 -1
- package/extension/service-worker.js +46 -17
- package/package.json +1 -1
- package/server/tools.js +20 -9
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legge lo stato reale di un download.
|
|
3
|
+
*
|
|
4
|
+
* `chrome.downloads.download()` restituisce un id e basta: dire "started: true"
|
|
5
|
+
* e fermarsi lì è come fidarsi di un HTTP 200 senza guardare il corpo. Su
|
|
6
|
+
* ChromeOS, con l'impostazione «Chiedi dove salvare ogni file» attiva, Chrome
|
|
7
|
+
* ignora `saveAs: false` e apre il selettore: il file viene scaricato per intero
|
|
8
|
+
* ma non viene scritto da nessuna parte finché un umano non sceglie.
|
|
9
|
+
*
|
|
10
|
+
* La firma di quella condizione è precisa e verificata dal vivo:
|
|
11
|
+
* `state: 'in_progress'`, `filename: ''`, `bytesReceived === totalBytes`.
|
|
12
|
+
* Senza distinguerla, un agente aspetta un file che non arriverà mai da solo.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyDownload(item) {
|
|
15
|
+
if (!item) return { state: 'unknown', done: false, reason: 'download not found' };
|
|
16
|
+
|
|
17
|
+
const { state, filename, bytesReceived = 0, totalBytes = 0, error } = item;
|
|
18
|
+
|
|
19
|
+
if (state === 'complete') return { state: 'complete', done: true, filename };
|
|
20
|
+
if (state === 'interrupted') {
|
|
21
|
+
return { state: 'interrupted', done: true, error: error || 'interrupted', filename };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Nessuna destinazione decisa: il selettore è aperto e aspetta una persona.
|
|
25
|
+
if (!filename) {
|
|
26
|
+
const fetched = totalBytes > 0 && bytesReceived >= totalBytes;
|
|
27
|
+
return {
|
|
28
|
+
state: 'waiting_for_user',
|
|
29
|
+
done: false,
|
|
30
|
+
bytes: { received: bytesReceived, total: totalBytes },
|
|
31
|
+
reason: fetched
|
|
32
|
+
? 'bytes fetched, but Chrome is asking where to save — the "Ask where to save each file" setting overrides saveAs:false'
|
|
33
|
+
: 'no destination chosen yet — Chrome is showing the save dialog',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
state: 'in_progress',
|
|
39
|
+
done: false,
|
|
40
|
+
filename,
|
|
41
|
+
bytes: { received: bytesReceived, total: totalBytes },
|
|
42
|
+
};
|
|
43
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome Bridge for Claude Code",
|
|
4
|
-
"version": "1.15.
|
|
4
|
+
"version": "1.15.1",
|
|
5
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,6 +8,7 @@
|
|
|
8
8
|
import './telemetry.js';
|
|
9
9
|
import { buildMarkdown } from './lib/page-markdown.js';
|
|
10
10
|
import { computeTiles } from './lib/tile-layout.js';
|
|
11
|
+
import { classifyDownload } from './lib/download-state.js';
|
|
11
12
|
const { pushError } = globalThis.__cbTelemetry;
|
|
12
13
|
|
|
13
14
|
const DEFAULT_PORT = 8765;
|
|
@@ -1092,7 +1093,7 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
|
|
|
1092
1093
|
};
|
|
1093
1094
|
}
|
|
1094
1095
|
|
|
1095
|
-
async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, width, height, index = -1 }) {
|
|
1096
|
+
async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type, left, top, width, height, index = -1 }) {
|
|
1096
1097
|
if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
|
|
1097
1098
|
if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
|
|
1098
1099
|
|
|
@@ -1100,8 +1101,11 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, wi
|
|
|
1100
1101
|
|
|
1101
1102
|
if (new_window) {
|
|
1102
1103
|
// tabs.move vuole una finestra che esiste già; per estrarre in una finestra
|
|
1103
|
-
// nuova (e posizionata) l'unica via è windows.create({tabId}).
|
|
1104
|
+
// nuova (e posizionata) l'unica via è windows.create({tabId}). windows.create
|
|
1105
|
+
// conosce solo normal e popup: le finestre app sono della SWA, non nostre —
|
|
1106
|
+
// popup è il massimo di "finestra terminale" ottenibile da un'estensione.
|
|
1104
1107
|
const winOpts = { tabId: tab_id };
|
|
1108
|
+
if (window_type === 'popup') winOpts.type = 'popup';
|
|
1105
1109
|
for (const [k, v] of [['left', left], ['top', top], ['width', width], ['height', height]]) {
|
|
1106
1110
|
if (typeof v === 'number') winOpts[k] = v;
|
|
1107
1111
|
}
|
|
@@ -1111,6 +1115,9 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, left, top, wi
|
|
|
1111
1115
|
url: before.url,
|
|
1112
1116
|
from_window: before.windowId,
|
|
1113
1117
|
to_window: win.id,
|
|
1118
|
+
// Il tipo lo dice Chrome, non la richiesta: se popup viene declassato a
|
|
1119
|
+
// normal il chiamante deve vederlo da qui.
|
|
1120
|
+
window_type: win.type,
|
|
1114
1121
|
new_window: true,
|
|
1115
1122
|
index: 0,
|
|
1116
1123
|
same_window: before.windowId === win.id,
|
|
@@ -1910,7 +1917,7 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
1910
1917
|
if (Object.keys(updateOpts).length === 0) {
|
|
1911
1918
|
if (state) {
|
|
1912
1919
|
const w = await chrome.windows.get(tab.windowId);
|
|
1913
|
-
return { requested: { state },
|
|
1920
|
+
return { requested: { state }, window: { left: w.left, top: w.top, width: w.width, height: w.height, state: w.state } };
|
|
1914
1921
|
}
|
|
1915
1922
|
throw new Error('Provide preset, width, height, left, top or state');
|
|
1916
1923
|
}
|
|
@@ -1920,24 +1927,40 @@ async function cmdViewportResize({ preset, width, height, left, top, state, read
|
|
|
1920
1927
|
await new Promise((r) => setTimeout(r, 200));
|
|
1921
1928
|
}
|
|
1922
1929
|
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1930
|
+
// La misura del viewport passa da un'injection, e `chrome://` e
|
|
1931
|
+
// `chrome-untrusted://` la rifiutano: le finestre del Terminale e di
|
|
1932
|
+
// media-app sono esattamente quelle. Prima questo errore veniva propagato e
|
|
1933
|
+
// il tool falliva DOPO aver spostato la finestra — l'azione riuscita
|
|
1934
|
+
// riportata come guasto, che è il modo di far ritentare chi chiama qualcosa
|
|
1935
|
+
// che è già successo. La geometria della finestra non richiede injection:
|
|
1936
|
+
// arriva da chrome.windows.get e c'è sempre.
|
|
1937
|
+
let actual = {};
|
|
1938
|
+
let viewport_error = null;
|
|
1939
|
+
try {
|
|
1940
|
+
const results = await chrome.scripting.executeScript({
|
|
1941
|
+
target: { tabId },
|
|
1942
|
+
func: () => ({
|
|
1943
|
+
viewportWidth: window.innerWidth,
|
|
1944
|
+
viewportHeight: window.innerHeight,
|
|
1945
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
1946
|
+
}),
|
|
1947
|
+
world: 'MAIN',
|
|
1948
|
+
});
|
|
1949
|
+
actual = results?.[0]?.result ?? {};
|
|
1950
|
+
} catch (e) {
|
|
1951
|
+
viewport_error = e.message;
|
|
1952
|
+
}
|
|
1953
|
+
const winNow = await chrome.windows.get(tab.windowId);
|
|
1954
|
+
const geometry = { left: winNow.left, top: winNow.top, width: winNow.width, height: winNow.height, state: winNow.state };
|
|
1955
|
+
if (read_only) return { actual, window: geometry, viewport_error };
|
|
1956
|
+
const win = winNow;
|
|
1935
1957
|
return {
|
|
1936
1958
|
requested: { width: targetW, height: targetH, preset: preset || null, left, top, state },
|
|
1937
1959
|
actual,
|
|
1960
|
+
viewport_error,
|
|
1938
1961
|
// Il window manager può accettare e ignorare: senza il confronto una
|
|
1939
1962
|
// finestra rimasta ferma passerebbe per spostata.
|
|
1940
|
-
window:
|
|
1963
|
+
window: geometry,
|
|
1941
1964
|
};
|
|
1942
1965
|
}
|
|
1943
1966
|
|
|
@@ -3687,7 +3710,13 @@ async function cmdManageDownloads({ action, url, filename, timeout = 30000, limi
|
|
|
3687
3710
|
const opts = { url, saveAs: false };
|
|
3688
3711
|
if (filename) opts.filename = filename;
|
|
3689
3712
|
const id = await chrome.downloads.download(opts);
|
|
3690
|
-
|
|
3713
|
+
// Non basta l'id: su ChromeOS l'impostazione «Chiedi dove salvare ogni file»
|
|
3714
|
+
// scavalca saveAs:false e apre il selettore, quindi il download resta fermo
|
|
3715
|
+
// ad aspettare una persona. Una breve occhiata allo stato reale distingue
|
|
3716
|
+
// "partito" da "in attesa di te".
|
|
3717
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
3718
|
+
const [item] = await chrome.downloads.search({ id });
|
|
3719
|
+
return { id, url, ...classifyDownload(item) };
|
|
3691
3720
|
}
|
|
3692
3721
|
|
|
3693
3722
|
if (action === 'list') {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-bridge-mcp",
|
|
3
3
|
"mcpName": "io.github.frsorrentino/chrome-bridge",
|
|
4
|
-
"version": "1.15.
|
|
4
|
+
"version": "1.15.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "MCP server with 63 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
|
|
7
7
|
"main": "server/index.js",
|
package/server/tools.js
CHANGED
|
@@ -1349,7 +1349,10 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1349
1349
|
'tab_action',
|
|
1350
1350
|
'Tab lifecycle: close, activate (focus), reload (optional cache bypass), back, forward. close discards the '
|
|
1351
1351
|
+ 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
|
|
1352
|
-
+ 'reload and navigation drop injected CSS, emulations and page hooks.'
|
|
1352
|
+
+ 'reload and navigation drop injected CSS, emulations and page hooks. duplicate works where create_tab is '
|
|
1353
|
+
+ 'forbidden (chrome-untrusted://, verified on a ChromeOS Terminal tab) and lands in the source tab\'s own '
|
|
1354
|
+
+ 'window, app windows included — the only way to open a new Terminal session inside the Terminal window. '
|
|
1355
|
+
+ 'discard replaces the tab id: the result carries the new one, saved ids go stale.',
|
|
1353
1356
|
{
|
|
1354
1357
|
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'),
|
|
1355
1358
|
bypass_cache: z.boolean().optional().default(false).describe('reload only'),
|
|
@@ -1666,9 +1669,12 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1666
1669
|
// --- manage_downloads ---
|
|
1667
1670
|
server.tool(
|
|
1668
1671
|
'manage_downloads',
|
|
1669
|
-
'List downloads or wait for
|
|
1672
|
+
'List downloads, start one, or wait for the newest to finish. Files land in the browser Downloads folder, '
|
|
1673
|
+
+ 'not on the server. action=download reports the download\'s real state rather than just an id: with '
|
|
1674
|
+
+ 'Chrome set to ask where to save each file, saveAs:false is overridden and it comes back as '
|
|
1675
|
+
+ 'waiting_for_user — every byte fetched, no destination chosen, and nothing will move until someone picks one.',
|
|
1670
1676
|
{
|
|
1671
|
-
action: z.enum(['list', 'wait_for_complete', 'download']).describe('download
|
|
1677
|
+
action: z.enum(['list', 'wait_for_complete', 'download']).describe('download fetches with the browser cookie jar and reports the real state; wait_for_complete blocks until the newest finishes'),
|
|
1672
1678
|
url: z.string().optional().describe('What to download (action=download); sent with the session cookies of its origin'),
|
|
1673
1679
|
filename: z.string().optional().describe('Relative path inside the Downloads folder (action=download)'),
|
|
1674
1680
|
timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
|
|
@@ -1757,25 +1763,30 @@ export function registerTools(server, wsManager, caps = 'all') {
|
|
|
1757
1763
|
// --- move_tab ---
|
|
1758
1764
|
server.tool(
|
|
1759
1765
|
'move_tab',
|
|
1760
|
-
'Move an existing tab into another window (chrome.tabs.move)
|
|
1761
|
-
+ '
|
|
1762
|
-
+ '
|
|
1763
|
-
+ 'an app window,
|
|
1766
|
+
'Move an existing tab into another window (chrome.tabs.move). Nothing is created or closed: the tab keeps '
|
|
1767
|
+
+ 'its id, history and page state, and this works even on chrome-untrusted:// tabs where creating one is '
|
|
1768
|
+
+ 'forbidden — verified on a ChromeOS Terminal tab. The **destination** must be a normal window: moving out '
|
|
1769
|
+
+ 'of an app or popup window is fine, moving into one is refused with "Tabs can only be moved to and from '
|
|
1770
|
+
+ 'normal windows". So a ChromeOS Terminal session can be pulled out to its own window (new_window, then '
|
|
1771
|
+
+ 'position it with viewport_resize) but cannot be merged back into the Terminal window — the closest look '
|
|
1772
|
+
+ 'is window_type popup: no tab strip, no omnibox, visually a terminal window. An extension cannot create '
|
|
1773
|
+
+ 'app windows: normal and popup are all Chrome offers.',
|
|
1764
1774
|
{
|
|
1765
1775
|
tab_id: z.number().describe('Tab to move; get it from get_tabs'),
|
|
1766
1776
|
window_id: z.number().optional().describe('Destination window; get_tabs reports windowId for every tab'),
|
|
1767
1777
|
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'),
|
|
1778
|
+
window_type: z.enum(['normal', 'popup']).optional().default('normal').describe('new_window only: popup has no tab strip and no omnibox — the terminal-window look for detached chrome-untrusted://terminal tabs'),
|
|
1768
1779
|
left: z.number().optional().describe('New window x (new_window)'),
|
|
1769
1780
|
top: z.number().optional().describe('New window y (new_window)'),
|
|
1770
1781
|
width: z.number().optional().describe('New window width px (new_window)'),
|
|
1771
1782
|
height: z.number().optional().describe('New window height px (new_window)'),
|
|
1772
1783
|
index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
|
|
1773
1784
|
},
|
|
1774
|
-
async ({ tab_id, window_id, new_window, left, top, width, height, index }) => {
|
|
1785
|
+
async ({ tab_id, window_id, new_window, window_type, left, top, width, height, index }) => {
|
|
1775
1786
|
if (window_id == null && new_window !== true) {
|
|
1776
1787
|
throw new Error('Provide window_id (an existing window) or new_window: true');
|
|
1777
1788
|
}
|
|
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 });
|
|
1789
|
+
const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, new_window: new_window === true || undefined, window_type, left, top, width, height, index: index ?? -1 });
|
|
1779
1790
|
return { content: [{ type: 'text', text: jsonText(data) }] };
|
|
1780
1791
|
}
|
|
1781
1792
|
);
|