chrome-bridge-mcp 1.14.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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # Chrome Bridge
2
2
 
3
- ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-174%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](https://chromewebstore.google.com/detail/chrome-bridge-for-claude/bioknpaeahidbelaljjohjofiloeodmb)
3
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Node 18+](https://img.shields.io/badge/node-%E2%89%A518-brightgreen) ![Chrome 135+](https://img.shields.io/badge/chrome-%E2%89%A5135-blue) ![Tests](https://img.shields.io/badge/tests-187%20unit%20%2B%20e2e-brightgreen) [![Chrome Web Store](https://img.shields.io/badge/web%20store-published-blue)](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
6
  2.75× fewer turns and 2.28× lower cost than the official "Claude in Chrome"
7
7
  extension on a form-filling task, with ~3× the toolset and no paid plan.**
8
8
 
9
- 62 web-development tools (navigation, DOM inspection, visual regression, audits,
9
+ 63 web-development tools (navigation, DOM inspection, visual regression, audits,
10
10
  network mocking) over a local WebSocket bridge, plus a headless instance for CI.
11
11
  Self-hosted, local-only.
12
12
 
@@ -43,7 +43,7 @@ Chrome Bridge → Details (on Chrome 135-137, enable Developer Mode instead).
43
43
  | | Chrome Bridge | Claude in Chrome | Chrome DevTools MCP | Playwright MCP |
44
44
  |---|---|---|---|---|
45
45
  | **ChromeOS / Crostini** | **Yes** (real host) | No | Container only | Container only |
46
- | **Tools** | **62** (33 core) | ~20 | ~50 | 23 core (71 total) |
46
+ | **Tools** | **63** (34 core) | ~20 | ~50 | 23 core (71 total) |
47
47
  | **Requires paid plan** | **No** | Yes (Pro+) | No | No |
48
48
  | **Network mocking** | **Yes** (stub/headers) | No | No | Yes |
49
49
  | **Visual regression** | **Yes** (`screenshot_diff`) | No | No | No |
@@ -87,7 +87,7 @@ user-script toggle isn't available.
87
87
 
88
88
  ## Tools
89
89
 
90
- 62 in total, in seven groups. Only `core` (33 tools) loads by default; the rest
90
+ 63 in total, in seven groups. Only `core` (34 tools) loads by default; the rest
91
91
  are opt-in via `--caps`.
92
92
 
93
93
  | Group | N | What's in it |
@@ -153,7 +153,7 @@ paste into a chat.
153
153
 
154
154
  ## Documentation
155
155
 
156
- - [docs/TOOLS.md](docs/TOOLS.md) — all 62 tools, by group
156
+ - [docs/TOOLS.md](docs/TOOLS.md) — all 63 tools, by group
157
157
  - [docs/EFFICIENCY.md](docs/EFFICIENCY.md) — the benchmark and the design behind it
158
158
  - [bench/RESULTS.md](bench/RESULTS.md) — raw runs and inclusion rule
159
159
  - [CHANGELOG.md](CHANGELOG.md)
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Chrome Bridge for Claude Code",
4
- "version": "1.14.0",
5
- "description": "Bridge your browser to Claude Code: 62 token-efficient web-dev automation tools over a local WebSocket. ChromeOS included.",
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": [
@@ -941,7 +941,24 @@ async function cmdReadPage({ mode = 'text', tab_id, frame_id }) {
941
941
 
942
942
  // --- create_tab ---
943
943
 
944
- 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
+ }
945
962
  const opts = { active };
946
963
  if (url) opts.url = url;
947
964
  const tab = await chrome.tabs.create(opts);
@@ -1075,11 +1092,30 @@ async function cmdTileWindows({ window_ids, reference_window_id, layout = 'grid'
1075
1092
  };
1076
1093
  }
1077
1094
 
1078
- 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 }) {
1079
1096
  if (typeof tab_id !== 'number') throw new Error('Missing required parameter: tab_id');
1080
- if (typeof window_id !== 'number') throw new Error('Missing required parameter: window_id');
1097
+ if (typeof window_id !== 'number' && !new_window) throw new Error('Provide window_id or new_window: true');
1081
1098
 
1082
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
+ }
1083
1119
  const moved = await chrome.tabs.move(tab_id, { windowId: window_id, index });
1084
1120
  const tab = Array.isArray(moved) ? moved[0] : moved;
1085
1121
 
@@ -1103,6 +1139,21 @@ async function cmdTabAction({ action, tab_id, bypass_cache = false }) {
1103
1139
  await chrome.tabs.remove(tabId);
1104
1140
  return { action, closed: tabId };
1105
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
+ }
1106
1157
  if (action === 'activate') {
1107
1158
  const tab = await chrome.tabs.get(tabId);
1108
1159
  await chrome.tabs.update(tabId, { active: true });
@@ -3626,9 +3677,19 @@ async function cmdSetGeolocation({ latitude, longitude, accuracy = 10, reset = f
3626
3677
 
3627
3678
  // --- manage_downloads ---
3628
3679
 
3629
- async function cmdManageDownloads({ action, timeout = 30000, limit = 10 }) {
3680
+ async function cmdManageDownloads({ action, url, filename, timeout = 30000, limit = 10 }) {
3630
3681
  if (!action) throw new Error('Missing required parameter: action');
3631
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
+
3632
3693
  if (action === 'list') {
3633
3694
  const items = await chrome.downloads.search({ orderBy: ['-startTime'], limit });
3634
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.14.0",
4
+ "version": "1.15.0",
5
5
  "type": "module",
6
- "description": "MCP server with 62 tools for browser automation via Chrome extension. Works on ChromeOS/Crostini.",
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",
@@ -84,7 +84,7 @@ export const MessageType = Object.freeze({
84
84
  });
85
85
 
86
86
  // Versione
87
- export const VERSION = '1.14.0';
87
+ export const VERSION = '1.15.0';
88
88
 
89
89
  // Configurazione
90
90
  export const DEFAULT_PORT = 8765;
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
 
@@ -876,9 +880,14 @@ export function registerTools(server, wsManager, caps = 'all') {
876
880
  {
877
881
  url: z.string().optional().describe('URL to open (default: new tab page)'),
878
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)'),
879
888
  },
880
- async ({ url, active }) => {
881
- 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 });
882
891
  if (data?.id != null) sessionTabId = data.id;
883
892
  return {
884
893
  content: [{
@@ -1342,7 +1351,7 @@ export function registerTools(server, wsManager, caps = 'all') {
1342
1351
  + 'tab and anything unsaved in it, and cannot be undone — it may be a tab the user is working in. '
1343
1352
  + 'reload and navigation drop injected CSS, emulations and page hooks.',
1344
1353
  {
1345
- action: z.enum(['close', 'activate', 'reload', 'back', 'forward']).describe('close cannot be undone; reload drops injected CSS and page hooks'),
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'),
1346
1355
  bypass_cache: z.boolean().optional().default(false).describe('reload only'),
1347
1356
  tab_id: tabId,
1348
1357
  },
@@ -1659,12 +1668,15 @@ export function registerTools(server, wsManager, caps = 'all') {
1659
1668
  'manage_downloads',
1660
1669
  'List downloads or wait for one to complete. Files land in the browser Downloads folder, not on the server.',
1661
1670
  {
1662
- action: z.enum(['list', 'wait_for_complete']).describe('wait_for_complete blocks until the newest download finishes or times out'),
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)'),
1663
1674
  timeout: z.number().optional().default(30000).describe('Max ms (wait_for_complete)'),
1664
1675
  limit: z.number().optional().default(10).describe('Max download entries returned, newest first'),
1665
1676
  },
1666
- async ({ action, timeout, limit }) => {
1667
- const data = await send(MessageType.MANAGE_DOWNLOADS, { action, timeout, limit });
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 });
1668
1680
  return { content: [{ type: 'text', text: jsonText(data) }] };
1669
1681
  }
1670
1682
  );
@@ -1751,11 +1763,19 @@ export function registerTools(server, wsManager, caps = 'all') {
1751
1763
  + 'an app window, and the refusal is reported verbatim rather than retried.',
1752
1764
  {
1753
1765
  tab_id: z.number().describe('Tab to move; get it from get_tabs'),
1754
- 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)'),
1755
1772
  index: z.number().optional().default(-1).describe('Position in the destination window; -1 appends at the end'),
1756
1773
  },
1757
- async ({ tab_id, window_id, index }) => {
1758
- const data = await send(MessageType.MOVE_TAB, { tab_id, window_id, index: index ?? -1 });
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 });
1759
1779
  return { content: [{ type: 'text', text: jsonText(data) }] };
1760
1780
  }
1761
1781
  );
@@ -1786,6 +1806,133 @@ export function registerTools(server, wsManager, caps = 'all') {
1786
1806
  }
1787
1807
  );
1788
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
+
1789
1936
  // --- set_zoom ---
1790
1937
  server.tool(
1791
1938
  'set_zoom',