2manytabs-mcp-host 2.1.0 → 3.0.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/package.json CHANGED
@@ -1,12 +1,32 @@
1
1
  {
2
2
  "name": "2manytabs-mcp-host",
3
- "version": "2.1.0",
4
- "description": "MCP server that exposes Chrome tabs via the 2ManyTabs MCP extension",
3
+ "version": "3.0.0",
4
+ "description": "MCP server that exposes Chrome and Firefox tabs via the 2ManyTabs MCP extension",
5
+ "keywords": [
6
+ "mcp",
7
+ "mcp-server",
8
+ "model-context-protocol",
9
+ "claude",
10
+ "ai-agent",
11
+ "browser",
12
+ "tabs",
13
+ "tab-manager",
14
+ "tab-groups",
15
+ "browser-automation",
16
+ "chrome-extension",
17
+ "firefox-extension",
18
+ "librewolf"
19
+ ],
5
20
  "type": "module",
6
21
  "main": "host.js",
7
22
  "bin": {
8
23
  "2manytabs-mcp": "host.js"
9
24
  },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/jamubc/2ManyTabs-MCP.git"
28
+ },
29
+ "homepage": "https://github.com/jamubc/2ManyTabs-MCP#readme",
10
30
  "files": [
11
31
  "host.js",
12
32
  "bridge.js",
@@ -16,7 +36,10 @@
16
36
  "scripts": {
17
37
  "start": "node host.js",
18
38
  "test": "node --test lib/tabs.test.js lib/background.test.js",
19
- "lint": "eslint ."
39
+ "test:multibrowser": "MANYTABS_BRIDGE_PORT=19878 node test/multibrowser.js",
40
+ "lint": "eslint .",
41
+ "prepack": "cp ../README.md ../LICENSE .",
42
+ "postpack": "rm -f README.md LICENSE"
20
43
  },
21
44
  "devDependencies": {
22
45
  "@eslint/js": "^9.0.0",
@@ -14,12 +14,14 @@ export const activateTabTool = {
14
14
  openWorldHint: true,
15
15
  },
16
16
  inputSchema: {
17
- tab_id: z.number().int()
18
- .describe('The numeric ID of the tab to activate.'),
17
+ tab_id: z.union([z.number().int(), z.string()])
18
+ .describe('The numeric ID of the tab (or composite like "chrome:123") to activate.'),
19
+ browser: z.string().optional()
20
+ .describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric.'),
19
21
  },
20
22
 
21
- execute: async ({ tab_id }) => {
22
- const res = await callExtension('activate_tab', { tab_id });
23
+ execute: async ({ tab_id, browser }) => {
24
+ const res = await callExtension('activate_tab', { tab_id, browser });
23
25
  return JSON.stringify({
24
26
  message: `Successfully activated tab ${tab_id}.`,
25
27
  tabId: res.activated,
@@ -6,7 +6,7 @@ export const closeTabsTool = {
6
6
  name: 'close_tabs',
7
7
  title: 'Close Tabs',
8
8
  description:
9
- 'Close Chrome tabs by one selection mode: `tab_ids` (explicit), `match` (substring of ' +
9
+ 'Close browser tabs by one selection mode: `tab_ids` (explicit), `match` (substring of ' +
10
10
  'title/URL), or `duplicates` (every tab sharing a URL with an earlier one). Set `dry_run` ' +
11
11
  'to preview the exact tabs that would close without touching them. Destructive — closed ' +
12
12
  'tabs cannot be recovered through this tool.\n\n' +
@@ -30,18 +30,19 @@ export const closeTabsTool = {
30
30
  openWorldHint: true,
31
31
  },
32
32
  inputSchema: {
33
- tab_ids: z.array(z.number().int()).optional()
34
- .describe('Explicit tab IDs to close (from list_tabs).'),
33
+ tab_ids: z.array(z.union([z.number().int(), z.string()])).optional()
34
+ .describe('Explicit tab IDs to close (numbers or composite like "chrome:123" from list_tabs).'),
35
35
  match: z.string().optional()
36
36
  .describe('Close every tab whose title or URL contains this substring (case-insensitive).'),
37
37
  duplicates: z.boolean().default(false)
38
38
  .describe('Close duplicate tabs, keeping the first occurrence of each URL.'),
39
39
  dry_run: z.boolean().default(false)
40
40
  .describe('Preview the tabs that would close — does not close anything. Recommended for bulk closes.'),
41
+ browser: z.string().optional()
42
+ .describe('Optional browser filter ("chrome", "firefox") to limit close actions to a single browser.'),
41
43
  },
42
44
 
43
- execute: async ({ tab_ids, match, duplicates, dry_run }) => {
44
- // Enforce exactly one selection mode.
45
+ execute: async ({ tab_ids, match, duplicates, dry_run, browser }) => {
45
46
  const modes = [tab_ids?.length ? 'tab_ids' : null, match ? 'match' : null, duplicates ? 'duplicates' : null]
46
47
  .filter(Boolean);
47
48
  if (modes.length === 0) {
@@ -58,13 +59,30 @@ export const closeTabsTool = {
58
59
  console.error('Failed to fetch tabs:', err);
59
60
  throw new Error('Failed to fetch open tabs from extension.');
60
61
  }
62
+ if (browser) {
63
+ all = all.filter((t) => (t.browser || '').toLowerCase() === browser.toLowerCase());
64
+ }
65
+
61
66
  const byId = new Map(all.map((t) => [t.id, t]));
67
+ const byComposite = new Map(all.map((t) => [`${t.browser}:${t.id}`, t]));
62
68
 
63
69
  let targets, reason;
64
70
  if (tab_ids?.length) {
65
- targets = tab_ids.filter((id) => byId.has(id)).map((id) => byId.get(id));
71
+ targets = [];
72
+ const missing = [];
73
+ for (const rawId of tab_ids) {
74
+ if (typeof rawId === 'string' && rawId.includes(':')) {
75
+ const found = byComposite.get(rawId.toLowerCase());
76
+ if (found) targets.push(found);
77
+ else missing.push(rawId);
78
+ } else {
79
+ const numId = Number(rawId);
80
+ const found = byId.get(numId);
81
+ if (found) targets.push(found);
82
+ else missing.push(rawId);
83
+ }
84
+ }
66
85
  reason = `${targets.length} tab(s) by id`;
67
- const missing = tab_ids.filter((id) => !byId.has(id));
68
86
  if (missing.length) reason += ` (${missing.length} id(s) no longer exist, skipped)`;
69
87
  } else if (duplicates) {
70
88
  const dupIds = new Set(findDuplicateIds(all));
@@ -87,14 +105,20 @@ export const closeTabsTool = {
87
105
  acc[d] = (acc[d] ?? 0) + 1;
88
106
  return acc;
89
107
  }, {}),
90
- sample: targets.slice(0, 10).map((t) => ({ id: t.id, title: t.title || '(untitled)' })),
108
+ sample: targets.slice(0, 10).map((t) => ({
109
+ id: t.browser ? `${t.browser}:${t.id}` : t.id,
110
+ title: t.title || '(untitled)',
111
+ })),
91
112
  };
92
113
 
93
114
  if (dry_run) {
94
115
  return JSON.stringify({ dry_run: true, ...preview }, null, 2);
95
116
  }
96
117
 
97
- const res = await callExtension('close_tabs', { tab_ids: targets.map((t) => t.id) });
118
+ const res = await callExtension('close_tabs', {
119
+ tab_ids: targets.map((t) => t.browserInstance ? `${t.browserInstance}:${t.id}` : t.browser ? `${t.browser}:${t.id}` : t.id),
120
+ browser,
121
+ });
98
122
  return JSON.stringify({ dry_run: false, closed: res.closed, reason, by_domain: preview.by_domain, sample: preview.sample }, null, 2);
99
123
  },
100
124
  };
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ const MAX_OUTPUT_CHARS = 50_000;
5
+
6
+ export const executeScriptTool = {
7
+ name: 'execute_script',
8
+ title: 'Execute Script in Tab',
9
+ description:
10
+ 'Execute a JavaScript expression or script in the context of an open browser tab and return the serialized result. ' +
11
+ 'Useful for pulling elements from pages (e.g. images, links, tables, article bodies), clicking elements, or extracting dynamic DOM state. ' +
12
+ 'Firefox only: Chrome tabs return an error, so use a firefox: tab id. ' +
13
+ 'Fails on restricted system URLs (e.g. chrome://, about:, extensions).',
14
+ annotations: {
15
+ readOnlyHint: false,
16
+ destructiveHint: false,
17
+ idempotentHint: false,
18
+ openWorldHint: true,
19
+ },
20
+ inputSchema: {
21
+ tab_id: z.union([z.number().int(), z.string()])
22
+ .describe('The target tab ID (number or composite ID like "chrome:12" or "firefox:34").'),
23
+ script: z.string()
24
+ .describe('The JavaScript code or expression to run in the tab. Can return a value or a Promise (e.g. Array.from(document.images).map(i => i.src)).'),
25
+ browser: z.string().optional()
26
+ .describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric and ambiguous.'),
27
+ world: z.enum(['ISOLATED', 'MAIN']).default('ISOLATED')
28
+ .describe('Execution context world. "ISOLATED" (default) has full DOM access. "MAIN" runs in the page JavaScript window context.'),
29
+ },
30
+
31
+ execute: async ({ tab_id, script, browser, world }) => {
32
+ const res = await callExtension('execute_script', { tab_id, script, browser, world });
33
+ if (!res || res.success === false) {
34
+ throw new Error(res?.error || 'Script execution failed without error details.');
35
+ }
36
+
37
+ const value = res.result;
38
+ let output;
39
+ if (typeof value === 'string') {
40
+ output = value;
41
+ } else if (value === null || value === undefined) {
42
+ output = '(null / undefined returned)';
43
+ } else {
44
+ output = JSON.stringify(value, null, 2);
45
+ }
46
+
47
+ if (output.length > MAX_OUTPUT_CHARS) {
48
+ output = output.slice(0, MAX_OUTPUT_CHARS) +
49
+ `\n\n... [Result truncated: exceeded ${MAX_OUTPUT_CHARS} characters]`;
50
+ }
51
+
52
+ return output;
53
+ },
54
+ };
@@ -15,12 +15,14 @@ export const getTabTextTool = {
15
15
  openWorldHint: true,
16
16
  },
17
17
  inputSchema: {
18
- tab_id: z.number().int()
19
- .describe('The numeric ID of the tab whose body text to extract.'),
18
+ tab_id: z.union([z.number().int(), z.string()])
19
+ .describe('The numeric ID (or composite like "chrome:123") of the tab whose body text to extract.'),
20
+ browser: z.string().optional()
21
+ .describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric.'),
20
22
  },
21
23
 
22
- execute: async ({ tab_id }) => {
23
- const res = await callExtension('get_tab_text', { tab_id });
24
+ execute: async ({ tab_id, browser }) => {
25
+ const res = await callExtension('get_tab_text', { tab_id, browser });
24
26
  if (!res.text) {
25
27
  return `Tab ${tab_id} returned no text content (it might be empty, loading, or restricted).`;
26
28
  }
@@ -5,7 +5,7 @@ export const groupTabsTool = {
5
5
  name: 'group_tabs',
6
6
  title: 'Group Tabs',
7
7
  description:
8
- 'Group open Chrome tabs into native Chrome Tab Groups. Can create a new group or ' +
8
+ 'Group open tabs into native browser tab groups. Can create a new group or ' +
9
9
  'add tabs to an existing group. Optionally set a title and a color for the group.',
10
10
  annotations: {
11
11
  readOnlyHint: false,
@@ -14,18 +14,20 @@ export const groupTabsTool = {
14
14
  openWorldHint: true,
15
15
  },
16
16
  inputSchema: {
17
- tab_ids: z.array(z.number().int()).min(1)
18
- .describe('Array of tab IDs to add to the group.'),
19
- group_id: z.number().int().optional()
20
- .describe('Add to an existing group ID. If omitted, a new group is created.'),
17
+ tab_ids: z.array(z.union([z.number().int(), z.string()])).min(1)
18
+ .describe('Tab IDs to add to the group (numbers or composite like "chrome:12"). All must be in the same browser.'),
19
+ group_id: z.union([z.number().int(), z.string()]).optional()
20
+ .describe('Add to an existing group ID (number or composite like "chrome:5"). If omitted, a new group is created.'),
21
21
  title: z.string().optional()
22
22
  .describe('Title to set on the group (new or existing).'),
23
23
  color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
24
24
  .describe('Color to set on the group (new or existing).'),
25
+ browser: z.string().optional()
26
+ .describe('Optional browser hint ("chrome", "firefox") if ids are numeric.'),
25
27
  },
26
28
 
27
- execute: async ({ tab_ids, group_id, title, color }) => {
28
- const res = await callExtension('group_tabs', { tab_ids, group_id, title, color });
29
+ execute: async ({ tab_ids, group_id, title, color, browser }) => {
30
+ const res = await callExtension('group_tabs', { tab_ids, group_id, title, color, browser });
29
31
  return JSON.stringify({
30
32
  message: `Successfully grouped ${tab_ids.length} tab(s).`,
31
33
  groupId: res.groupId,
package/tools/index.js CHANGED
@@ -1,7 +1,3 @@
1
- // Tool registry — the single place that knows which tools exist.
2
- // To add a tool: create ./my-thing.tool.js exporting a tool object, then add it here.
3
- // host.js handles all SDK wiring (schema generation, validation, dispatch) generically.
4
-
5
1
  import { listTabsTool } from './list-tabs.tool.js';
6
2
  import { closeTabsTool } from './close-tabs.tool.js';
7
3
  import { openTabsTool } from './open-tabs.tool.js';
@@ -12,6 +8,7 @@ import { updateTabGroupTool } from './update-tab-group.tool.js';
12
8
  import { activateTabTool } from './activate-tab.tool.js';
13
9
  import { updateTabTool } from './update-tab.tool.js';
14
10
  import { getTabTextTool } from './get-tab-text.tool.js';
11
+ import { executeScriptTool } from './execute-script.tool.js';
15
12
 
16
13
  export const toolRegistry = [
17
14
  listTabsTool,
@@ -24,4 +21,5 @@ export const toolRegistry = [
24
21
  activateTabTool,
25
22
  updateTabTool,
26
23
  getTabTextTool,
24
+ executeScriptTool,
27
25
  ];
@@ -5,7 +5,7 @@ export const listTabGroupsTool = {
5
5
  name: 'list_tab_groups',
6
6
  title: 'List Tab Groups',
7
7
  description:
8
- 'List all existing native Chrome Tab Groups in the current browser session. ' +
8
+ 'List all existing native tab groups across all connected browsers. ' +
9
9
  'Returns their IDs, titles, colors, collapsed states, and parent window IDs.',
10
10
  annotations: { readOnlyHint: true, openWorldHint: true },
11
11
  inputSchema: {
@@ -13,7 +13,6 @@ export const listTabGroupsTool = {
13
13
  .describe('Case-insensitive substring filter for group titles.'),
14
14
  },
15
15
 
16
- // fallow-ignore-next-line complexity
17
16
  execute: async ({ title_query }) => {
18
17
  let groups;
19
18
  try {
@@ -38,7 +37,7 @@ export const listTabGroupsTool = {
38
37
  for (const g of filtered) {
39
38
  const titleStr = g.title ? `"${g.title}"` : '(unnamed)';
40
39
  const collapsedStr = g.collapsed ? ' ⏸ (collapsed)' : ' ▶ (expanded)';
41
- lines.push(` · [ID: ${g.id}] ${titleStr} [Color: ${g.color}] [Window: ${g.windowId}]${collapsedStr}`);
40
+ lines.push(` · [ID: ${g.browser ? `${g.browser}:${g.id}` : g.id}] ${titleStr} [Color: ${g.color}] [Window: ${g.windowId}]${collapsedStr}`);
42
41
  }
43
42
 
44
43
  return lines.join('\n');
@@ -10,7 +10,7 @@ export const listTabsTool = {
10
10
  name: 'list_tabs',
11
11
  title: 'List Tabs',
12
12
  description:
13
- 'Read open Chrome tabs. Returns a domain histogram (the fastest way to understand a large ' +
13
+ 'Read open browser tabs. Returns a domain histogram (the fastest way to understand a large ' +
14
14
  'tab set) plus a detailed view. When summarizing the results for the user, you should ' +
15
15
  'include the histogram bar graph in your response. Filter with `query`, reshape with ' +
16
16
  '`group_by`, or isolate redundant tabs with `duplicates_only`. Read-only — never closes anything.',
@@ -23,20 +23,24 @@ export const listTabsTool = {
23
23
  "'window' groups by browser window; 'none' returns a flat list."),
24
24
  duplicates_only: z.boolean().default(false)
25
25
  .describe('Only include tabs that are duplicates (share a URL with an earlier tab).'),
26
+ browser: z.string().optional()
27
+ .describe('Filter tabs to a specific browser (e.g. "chrome", "firefox"). Omit to list tabs across all open browsers.'),
26
28
  },
27
29
 
28
- execute: async ({ query, group_by, duplicates_only }) => {
29
- const all = await callExtension('query_tabs');
30
+ execute: async ({ query, group_by, duplicates_only, browser }) => {
31
+ let all = await callExtension('query_tabs');
32
+ if (browser) {
33
+ all = all.filter((t) => (t.browser || '').toLowerCase() === browser.toLowerCase());
34
+ }
30
35
 
31
- let groups;
36
+ let groups = [];
32
37
  try {
33
38
  groups = await callExtension('query_groups');
34
- } catch (err) {
35
- console.error('Failed to fetch tab groups:', err);
36
- throw new Error('Failed to fetch tab groups from extension.');
39
+ } catch {
40
+ groups = [];
37
41
  }
38
42
 
39
- const groupMap = new Map(groups.map(g => [g.id, g]));
43
+ const groupMap = new Map((Array.isArray(groups) ? groups : []).map(g => [g.browser ? `${g.browser}:${g.id}` : g.id, g]));
40
44
 
41
45
  let tabs = all;
42
46
  if (query) tabs = tabs.filter((t) => matchesQuery(t, query));
@@ -45,13 +49,12 @@ export const listTabsTool = {
45
49
  tabs = tabs.filter((t) => dupIds.has(t.id));
46
50
  }
47
51
 
48
- // Apply MAX_TAB_LIST limit to prevent token overflow
49
52
  if (tabs.length > MAX_TAB_LIST) {
50
53
  tabs = tabs.slice(0, MAX_TAB_LIST);
51
54
  }
52
55
 
53
56
  const histogram = domainHistogram(tabs);
54
- const windows = new Set(tabs.map((t) => t.windowId)).size;
57
+ const windows = new Set(tabs.map((t) => t.browser ? `${t.browser}:${t.windowId}` : t.windowId)).size;
55
58
  const dupCount = findDuplicateIds(tabs).length;
56
59
 
57
60
  return formatTabData({
@@ -5,7 +5,7 @@ export const openTabsTool = {
5
5
  name: 'open_tabs',
6
6
  title: 'Open Tabs',
7
7
  description:
8
- 'Open one or more URLs in new Chrome tabs. This tool allows you to navigate the user to ' +
8
+ 'Open one or more URLs in new browser tabs. This tool allows you to navigate the user to ' +
9
9
  'specific web pages or search queries.\n\n' +
10
10
  'Examples:\n' +
11
11
  '- {"urls": ["https://github.com", "https://youtube.com"]}\n' +
@@ -15,21 +15,23 @@ export const openTabsTool = {
15
15
  ' - opened (int): Number of tabs successfully opened',
16
16
  annotations: {
17
17
  readOnlyHint: false,
18
- destructiveHint: false, // opening tabs is non-destructive
18
+ destructiveHint: false,
19
19
  idempotentHint: false,
20
20
  openWorldHint: true,
21
21
  },
22
22
  inputSchema: {
23
23
  urls: z.array(z.string()).min(1)
24
24
  .describe('An array of URLs to open. If no protocol is provided, https:// will be prepended automatically.'),
25
+ browser: z.string().optional()
26
+ .describe('Target browser to open tabs in (e.g. "chrome", "firefox"). Defaults to the active browser.'),
25
27
  },
26
28
 
27
- execute: async ({ urls }) => {
29
+ execute: async ({ urls, browser }) => {
28
30
  if (!urls || urls.length === 0) {
29
31
  throw new Error('Provide at least one URL to open.');
30
32
  }
31
33
 
32
- const res = await callExtension('open_tabs', { urls });
34
+ const res = await callExtension('open_tabs', { urls, browser });
33
35
  return JSON.stringify({ opened: res.opened }, null, 2);
34
36
  },
35
37
  };
package/tools/present.js CHANGED
@@ -52,12 +52,15 @@ function renderByDomain(tabs, groupMap) {
52
52
  function renderByWindow(tabs, groupMap) {
53
53
  const grouped = {};
54
54
  for (const t of tabs) {
55
- (grouped[t.windowId] ??= []).push(t);
55
+ const key = t.browser ? `${t.browser}:${t.windowId}` : `${t.windowId}`;
56
+ (grouped[key] ??= []).push(t);
56
57
  }
57
58
 
58
59
  const lines = [];
59
- for (const [winId, tabsInWin] of Object.entries(grouped)) {
60
- lines.push(formatWindowHeader(winId, tabsInWin.length));
60
+ for (const [key, tabsInWin] of Object.entries(grouped)) {
61
+ const browser = tabsInWin[0]?.browser;
62
+ const winId = tabsInWin[0]?.windowId ?? key;
63
+ lines.push(formatWindowHeader(winId, tabsInWin.length, browser));
61
64
  for (const t of tabsInWin) {
62
65
  const label = tabLabel(t);
63
66
  const flags = buildFlags(t);
@@ -100,8 +103,13 @@ export function formatTabData(data) {
100
103
 
101
104
  const lines = [];
102
105
 
106
+ const browsers = [...new Set(tabs.map((t) => t.browser).filter(Boolean))];
107
+ const browserSummary = browsers.length > 1
108
+ ? ` across ${browsers.length} browsers (${browsers.map((b) => `${b}: ${tabs.filter((t) => t.browser === b).length}`).join(', ')}) and`
109
+ : ' across';
110
+
103
111
  lines.push(
104
- `📊 ${tabs.length} tab(s) across ${windows} window(s)` +
112
+ `📊 ${tabs.length} tab(s)${browserSummary} ${windows} window(s)` +
105
113
  (query ? ` matching "${query}"` : '') +
106
114
  (duplicates_only ? ' (duplicates only)' : '') +
107
115
  (dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
@@ -1,6 +1,3 @@
1
- /** Rendering adapters for tab display - delegates to dedicated formatters. */
2
-
3
- /** Build emoji flags for tab metadata attributes. */
4
1
  export function buildFlags(tab) {
5
2
  const flags = [];
6
3
  if (tab.pinned) flags.push('📌');
@@ -8,41 +5,36 @@ export function buildFlags(tab) {
8
5
  return flags;
9
6
  }
10
7
 
11
- /** Format emoji flags as display string. */
12
8
  export function formatFlags(flags) {
13
9
  return flags.length ? ' ' + flags.join('') : '';
14
10
  }
15
11
 
16
- /** Draw ASCII histogram bar for a domain count. */
17
12
  export function drawBar(count, maxCount, maxWidth) {
18
13
  return '▇'.repeat(Math.max(1, Math.round((count / maxCount) * maxWidth)));
19
14
  }
20
15
 
21
- /** Draw a single histogram line. */
22
16
  export function formatHistogramLine(domain, count, bar) {
23
17
  return ` ${bar} ${domain.padEnd(28)} ${count} tab(s)`;
24
18
  }
25
19
 
26
- /** Draw domain header. */
27
20
  export function formatDomainHeader(domain, count) {
28
21
  return `📁 ${domain} — ${count} tab(s)`;
29
22
  }
30
23
 
31
- /** Draw window header. */
32
- export function formatWindowHeader(winId, count) {
33
- return `🪟 Window ${winId} — ${count} tab(s)`;
24
+ export function formatWindowHeader(winId, count, browser) {
25
+ const browserPrefix = browser ? `[${browser.charAt(0).toUpperCase() + browser.slice(1)}] ` : '';
26
+ return `🪟 ${browserPrefix}Window ${winId} — ${count} tab(s)`;
34
27
  }
35
28
 
36
- /** Draw group prefix. */
37
29
  export function formatGroupPrefix(groupMap, tab) {
38
30
  if (tab.groupId === undefined || tab.groupId === -1) return '';
39
- const g = groupMap.get(tab.groupId);
31
+ const g = groupMap.get(tab.browser ? `${tab.browser}:${tab.groupId}` : tab.groupId);
40
32
  return g ? `[Group: ${g.title || 'Group ' + g.id}] ` : '';
41
33
  }
42
34
 
43
- /** Draw the tab's numeric id - every id-based tool (activate_tab, update_tab,
44
- * get_tab_text, group_tabs, ungroup_tabs, close_tabs' tab_ids mode) needs one,
45
- * and this is the only place callers can read it from. */
46
35
  export function formatIdPrefix(tab) {
36
+ if (tab.browser) {
37
+ return `[id:${tab.browser}:${tab.id}] `;
38
+ }
47
39
  return `[id:${tab.id}] `;
48
40
  }
@@ -4,7 +4,7 @@ import { callExtension } from '../bridge.js';
4
4
  export const ungroupTabsTool = {
5
5
  name: 'ungroup_tabs',
6
6
  title: 'Ungroup Tabs',
7
- description: 'Remove one or more open Chrome tabs from their current native Tab Groups.',
7
+ description: 'Remove one or more open tabs from their current native tab groups.',
8
8
  annotations: {
9
9
  readOnlyHint: false,
10
10
  destructiveHint: false,
@@ -12,12 +12,14 @@ export const ungroupTabsTool = {
12
12
  openWorldHint: true,
13
13
  },
14
14
  inputSchema: {
15
- tab_ids: z.array(z.number().int()).min(1)
16
- .describe('Array of tab IDs to remove from any groups.'),
15
+ tab_ids: z.array(z.union([z.number().int(), z.string()])).min(1)
16
+ .describe('Tab IDs to remove from any groups (numbers or composite like "chrome:12").'),
17
+ browser: z.string().optional()
18
+ .describe('Optional browser hint ("chrome", "firefox") if ids are numeric.'),
17
19
  },
18
20
 
19
- execute: async ({ tab_ids }) => {
20
- const res = await callExtension('ungroup_tabs', { tab_ids });
21
+ execute: async ({ tab_ids, browser }) => {
22
+ const res = await callExtension('ungroup_tabs', { tab_ids, browser });
21
23
  return JSON.stringify({
22
24
  message: `Successfully ungrouped ${res.ungrouped} tab(s).`,
23
25
  ungrouped: res.ungrouped,
@@ -5,7 +5,7 @@ export const updateTabGroupTool = {
5
5
  name: 'update_tab_group',
6
6
  title: 'Update Tab Group',
7
7
  description:
8
- 'Update properties of an existing native Chrome Tab Group, including ' +
8
+ 'Update properties of an existing native browser tab group, including ' +
9
9
  'its title, color, or collapsed/expanded state.',
10
10
  annotations: {
11
11
  readOnlyHint: false,
@@ -14,18 +14,20 @@ export const updateTabGroupTool = {
14
14
  openWorldHint: true,
15
15
  },
16
16
  inputSchema: {
17
- group_id: z.number().int()
18
- .describe('The numeric ID of the tab group to update (obtained from list_tab_groups or list_tabs).'),
17
+ group_id: z.union([z.number().int(), z.string()])
18
+ .describe('The ID (number or composite like "chrome:5") of the tab group to update (obtained from list_tab_groups or list_tabs).'),
19
19
  title: z.string().optional()
20
20
  .describe('New title for the group.'),
21
21
  color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
22
22
  .describe('New color for the group.'),
23
23
  collapsed: z.boolean().optional()
24
24
  .describe('Whether the group should be collapsed (true) or expanded (false).'),
25
+ browser: z.string().optional()
26
+ .describe('Optional browser hint ("chrome", "firefox") if ids are numeric.'),
25
27
  },
26
28
 
27
- execute: async ({ group_id, title, color, collapsed }) => {
28
- const res = await callExtension('update_group', { group_id, title, color, collapsed });
29
+ execute: async ({ group_id, title, color, collapsed, browser }) => {
30
+ const res = await callExtension('update_group', { group_id, title, color, collapsed, browser });
29
31
  return JSON.stringify({
30
32
  message: `Successfully updated tab group ${group_id}.`,
31
33
  groupId: res.updated,
@@ -14,18 +14,20 @@ export const updateTabTool = {
14
14
  openWorldHint: true,
15
15
  },
16
16
  inputSchema: {
17
- tab_id: z.number().int()
18
- .describe('The numeric ID of the tab to update.'),
17
+ tab_id: z.union([z.number().int(), z.string()])
18
+ .describe('The numeric ID (or composite like "chrome:123") of the tab to update.'),
19
19
  url: z.string().optional()
20
20
  .describe('A new URL to navigate the tab to. If no protocol is provided, https:// will be prepended.'),
21
21
  pinned: z.boolean().optional()
22
22
  .describe('Set to true to pin the tab, or false to unpin it.'),
23
23
  muted: z.boolean().optional()
24
24
  .describe('Set to true to mute the tab, or false to unmute it.'),
25
+ browser: z.string().optional()
26
+ .describe('Optional browser hint ("chrome", "firefox") if tab_id is numeric.'),
25
27
  },
26
28
 
27
- execute: async ({ tab_id, url, pinned, muted }) => {
28
- const res = await callExtension('update_tab', { tab_id, url, pinned, muted });
29
+ execute: async ({ tab_id, url, pinned, muted, browser }) => {
30
+ const res = await callExtension('update_tab', { tab_id, url, pinned, muted, browser });
29
31
  return JSON.stringify({
30
32
  message: `Successfully updated tab ${tab_id}.`,
31
33
  tabId: res.updated,