chrome-bridge-mcp 1.17.0 → 1.18.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.17.0",
4
+ "version": "1.18.0",
5
5
  "description": "Bridge your browser to Claude Code: 59 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",
@@ -683,7 +683,7 @@ async function bitmapToBase64Capped(bitmap, maxSide = MAX_IMAGE_SIDE) {
683
683
 
684
684
  // --- Implementazione comandi ---
685
685
 
686
- async function cmdGetTabs({ include_windows = false } = {}) {
686
+ async function cmdGetTabs({ include_windows = false, ended } = {}) {
687
687
  const tabs = await chrome.tabs.query({});
688
688
  const list = tabs.map((t) => ({
689
689
  id: t.id,
@@ -691,8 +691,17 @@ async function cmdGetTabs({ include_windows = false } = {}) {
691
691
  title: t.title,
692
692
  active: t.active,
693
693
  windowId: t.windowId,
694
+ // Una scheda scartata da Chrome (memory saver) esiste ancora ma non ha un
695
+ // renderer: execute_js e compagni falliscono finché non torna in primo piano.
696
+ ...(t.discarded ? { discarded: true } : {}),
694
697
  }));
695
- if (!include_windows) return list;
698
+ // ended = id che il server sa suoi e non trova più: la forma ad array resta
699
+ // quella di sempre per chi non lo chiede.
700
+ const open = new Set(list.map((t) => t.id));
701
+ const endings = Array.isArray(ended) && ended.length
702
+ ? Object.fromEntries(ended.filter((id) => !open.has(id)).map((id) => [id, tabEndings.get(id) ?? { reason: 'unknown' }]))
703
+ : null;
704
+ if (!include_windows) return endings ? { tabs: list, ended: endings } : list;
696
705
 
697
706
  // Senza geometria e stato non si può decidere dove mettere una finestra: si
698
707
  // può solo indovinare. `left` in particolare è ciò che identifica il monitor
@@ -712,7 +721,7 @@ async function cmdGetTabs({ include_windows = false } = {}) {
712
721
  // provarci.
713
722
  scriptable: (w.tabs || []).some((t) => /^(https?|file):/i.test(t.url || '')),
714
723
  }));
715
- return { tabs: list, windows };
724
+ return endings ? { tabs: list, windows, ended: endings } : { tabs: list, windows };
716
725
  }
717
726
 
718
727
  async function cmdNavigate({ url, tab_id }) {
@@ -1189,12 +1198,26 @@ async function cmdMoveTab({ tab_id, window_id, new_window = false, window_type,
1189
1198
  };
1190
1199
  }
1191
1200
 
1201
+ // Perché una scheda non c'è più: «No tab with id» da solo non distingue una
1202
+ // chiusura a mano da una sostituzione. In memoria e limitato: se il service
1203
+ // worker riparte il registro si svuota e il motivo torna 'unknown'.
1204
+ const tabEndings = new Map();
1205
+ const TAB_ENDINGS_MAX = 200;
1206
+ const bridgeClosing = new Set();
1207
+
1208
+ function recordTabEnding(tabId, ending) {
1209
+ if (bridgeClosing.delete(tabId) && ending.reason === 'closed') ending = { reason: 'closed_by_bridge' };
1210
+ tabEndings.set(tabId, { ...ending, at: new Date().toISOString() });
1211
+ if (tabEndings.size > TAB_ENDINGS_MAX) tabEndings.delete(tabEndings.keys().next().value);
1212
+ }
1213
+
1192
1214
  async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
1193
1215
  if (!action) throw new Error('Missing required parameter: action');
1194
1216
  const tabId = await resolveTabId(tab_id);
1195
1217
 
1196
1218
  if (action === 'close') {
1197
- await chrome.tabs.remove(tabId);
1219
+ bridgeClosing.add(tabId);
1220
+ try { await chrome.tabs.remove(tabId); } catch (e) { bridgeClosing.delete(tabId); throw e; }
1198
1221
  return { action, closed: tabId };
1199
1222
  }
1200
1223
  if (action === 'discard') {
@@ -4561,9 +4584,16 @@ async function cmdHttpAuth({ action, username, password }) {
4561
4584
 
4562
4585
  // --- Tab lifecycle: cleanup injection state ---
4563
4586
 
4564
- chrome.tabs.onRemoved.addListener((tabId) => {
4587
+ chrome.tabs.onRemoved.addListener((tabId, info) => {
4565
4588
  browserNetLog.delete(tabId);
4566
4589
  mainFrameHeaders.delete(tabId);
4590
+ recordTabEnding(tabId, { reason: info?.isWindowClosing ? 'window_closed' : 'closed' });
4591
+ });
4592
+
4593
+ // Chrome sostituisce l'id quando rimpiazza la scheda (prerender, alcuni
4594
+ // discard): per chi teneva il vecchio id è una scheda sparita senza chiusura.
4595
+ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
4596
+ recordTabEnding(removedTabId, { reason: 'replaced', replaced_by: addedTabId });
4567
4597
  });
4568
4598
 
4569
4599
  // --- get_interactives ---
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.17.0",
4
+ "version": "1.18.0",
5
5
  "type": "module",
6
6
  "description": "MCP server with 59 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
7
7
  "main": "server/index.js",
package/server/cli.js CHANGED
@@ -119,10 +119,13 @@ function connect(port) {
119
119
  sendCommand(type, params = {}) {
120
120
  return new Promise((res, rej) => {
121
121
  const command = createCommand(type, params);
122
+ // Come ws-manager: un timeout chiesto dal comando alza quello di trasporto.
123
+ const asked = Number(params?.timeout);
124
+ const timeout = Number.isFinite(asked) && asked > 0 ? Math.max(getTimeout(type), asked + 5000) : getTimeout(type);
122
125
  const timer = setTimeout(() => {
123
126
  pending.delete(command.id);
124
- rej(new Error(`Command ${type} timed out after ${getTimeout(type)}ms`));
125
- }, getTimeout(type));
127
+ rej(new Error(`Command ${type} timed out after ${timeout}ms`));
128
+ }, timeout);
126
129
  pending.set(command.id, { res, rej, timer });
127
130
  ws.send(JSON.stringify(command));
128
131
  });
@@ -92,7 +92,7 @@ export const MessageType = Object.freeze({
92
92
  });
93
93
 
94
94
  // Versione
95
- export const VERSION = '1.17.0';
95
+ export const VERSION = '1.18.0';
96
96
 
97
97
  // Configurazione
98
98
  export const DEFAULT_PORT = 8765;
package/server/tools.js CHANGED
@@ -113,6 +113,34 @@ function fitArray(items, max, serialize) {
113
113
  return items.slice(0, lo);
114
114
  }
115
115
 
116
+ /**
117
+ * Output di execute_js sotto `max` senza rompere il JSON: tagliare la
118
+ * serializzazione a metà stringa dava «Invalid control character» a chi la
119
+ * parsava. Una stringa si accorcia dentro il suo valore, un array per elementi
120
+ * (jsonText), il resto diventa un prefisso dichiarato come tale.
121
+ */
122
+ export function jsResultText(data, max) {
123
+ const text = JSON.stringify(data);
124
+ if (text == null || text.length <= max) return text;
125
+ const hint = 'raise max_length, or save_to for the whole result';
126
+ const value = data?.result;
127
+ const wrap = (field, cut, total) => JSON.stringify({ [field]: cut, truncated: true, total_chars: total, hint });
128
+ const fitString = (field, full) => {
129
+ // Gli escape JSON allungano la stringa: il prefisso più lungo che sta nel
130
+ // tetto si cerca sulla serializzazione, non sui caratteri grezzi.
131
+ let lo = 0;
132
+ let hi = Math.min(full.length, max);
133
+ while (lo < hi) {
134
+ const mid = Math.ceil((lo + hi) / 2);
135
+ if (wrap(field, full.slice(0, mid), full.length).length <= max) lo = mid; else hi = mid - 1;
136
+ }
137
+ return wrap(field, full.slice(0, lo), full.length);
138
+ };
139
+ if (typeof value === 'string') return fitString('result', value);
140
+ if (Array.isArray(value)) return jsonText(data, max, hint);
141
+ return fitString('result_json_prefix', JSON.stringify(value));
142
+ }
143
+
116
144
  /** Una riga (oggetto colonna→valore o array di celle) soddisfa il filtro where. */
117
145
  function tableRowMatches(row, where) {
118
146
  return Object.entries(where).every(([col, needle]) => {
@@ -534,21 +562,38 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
534
562
  server.tool(
535
563
  'get_tabs',
536
564
  'List every open tab with id, url, title, active flag and mine (created by this session). Read-only. Find a tab_id when the implicit '
537
- + 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling.',
565
+ + 'target is not the one you mean. include_windows adds position, size, state and type of each window — needed before moving or tiling. '
566
+ + 'Session tabs gone since the last call come back once in closed_session_tabs with the reason (closed, window_closed, replaced, closed_by_bridge).',
538
567
  {
539
568
  include_windows: z.boolean().optional().default(false)
540
569
  .describe('Also return the windows with bounds, state, type and tab count'),
541
570
  },
542
571
  async ({ include_windows }) => {
543
- const data = await send(MessageType.GET_TABS, { include_windows: include_windows === true });
572
+ // `ended` = le schede che il server sa sue: l'estensione dà il motivo di
573
+ // quelle che non trova più. Senza schede di sessione la risposta resta
574
+ // quella di sempre.
575
+ const data = await send(MessageType.GET_TABS, {
576
+ include_windows: include_windows === true,
577
+ ...(ownedTabs.size ? { ended: [...ownedTabs] } : {}),
578
+ });
544
579
  const list = Array.isArray(data) ? data : (data?.tabs ?? []);
580
+ const windows = Array.isArray(data) ? null : data?.windows;
545
581
  for (const t of list) if (ownedTabs.has(t.id)) t.mine = true;
546
- return {
547
- content: [{
548
- type: 'text',
549
- text: jsonText(data),
550
- }],
551
- };
582
+ const missing = [...ownedTabs].filter((id) => !list.some((t) => t.id === id));
583
+ const shaped = windows ? { tabs: list, windows } : list;
584
+ if (!missing.length) return { content: [{ type: 'text', text: jsonText(shaped) }] };
585
+
586
+ // Un'estensione precedente ignora `ended` e risponde con l'array: motivo ignoto.
587
+ const ended = (!Array.isArray(data) && data?.ended) || {};
588
+ const closed = missing.map((id) => ({ id, ...(ended[id] ?? { reason: 'unknown' }) }));
589
+ for (const c of closed) {
590
+ ownedTabs.delete(c.id);
591
+ const successor = c.replaced_by != null ? list.find((t) => t.id === c.replaced_by) : null;
592
+ // Scheda sostituita da Chrome: resta della sessione con il nuovo id.
593
+ if (successor) { ownedTabs.add(successor.id); successor.mine = true; }
594
+ if (sessionTabId === c.id) sessionTabId = successor?.id ?? null;
595
+ }
596
+ return { content: [{ type: 'text', text: jsonText({ tabs: list, ...(windows ? { windows } : {}), closed_session_tabs: closed }) }] };
552
597
  }
553
598
  );
554
599
 
@@ -645,21 +690,25 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
645
690
  // --- execute_js ---
646
691
  server.tool(
647
692
  'execute_js',
648
- 'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled.',
693
+ 'Run JavaScript in the page (MAIN world). Requires the extension\'s "Allow user scripts" toggle; errors explain setup if disabled. '
694
+ + 'A promise is awaited up to timeout. A cut result stays valid JSON and says truncated; save_to writes the whole result to disk.',
649
695
  {
650
696
  code: z.string().describe('JS evaluated in the page; the value of the last expression is returned'),
651
697
  tab_id: tabId,
652
698
  frame_id: frameId,
653
699
  max_length: z.number().optional().default(20000).describe('Max output chars'),
700
+ timeout: z.number().optional().describe('Max ms to wait for the result (default 30000); raise it for long async work'),
701
+ save_to: saveToField('the result (a string as is, anything else as JSON)'),
654
702
  },
655
- async ({ code, tab_id, frame_id, max_length }) => {
656
- const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id });
657
- return {
658
- content: [{
659
- type: 'text',
660
- text: truncateText(JSON.stringify(data), max_length ?? DEFAULT_MAX_OUTPUT, 'max_length'),
661
- }],
662
- };
703
+ async ({ code, tab_id, frame_id, max_length, timeout, save_to }) => {
704
+ guardWrite(save_to);
705
+ const data = await send(MessageType.EXECUTE_JS, { code, tab_id, frame_id, ...(timeout ? { timeout } : {}) });
706
+ if (save_to) {
707
+ const value = data?.result;
708
+ const isString = typeof value === 'string';
709
+ return savedSummary(save_to, Buffer.from(isString ? value : JSON.stringify(value ?? null), 'utf8'), { format: isString ? 'text' : 'json' });
710
+ }
711
+ return { content: [{ type: 'text', text: jsResultText(data, max_length ?? DEFAULT_MAX_OUTPUT) }] };
663
712
  }
664
713
  );
665
714
 
@@ -1367,6 +1416,7 @@ export function registerTools(server, wsManager, caps = 'all', options = {}) {
1367
1416
  return { content: [{ type: 'text', text: jsonText({ closed }) }] };
1368
1417
  }
1369
1418
  const data = await send(MessageType.TAB_ACTION, { action, bypass_cache, tab_id });
1419
+ if (action === 'close') ownedTabs.delete(data?.closed ?? tab_id ?? sessionTabId);
1370
1420
  if (action === 'close' && (tab_id == null || tab_id === sessionTabId)) sessionTabId = null;
1371
1421
  if (action === 'activate' && tab_id != null) sessionTabId = tab_id;
1372
1422
  return { content: [{ type: 'text', text: jsonText(data) }] };