2manytabs-mcp-host 2.0.0 → 2.1.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/bridge.js CHANGED
@@ -32,6 +32,9 @@ const PEER_PATH = '/peer'; // followers connect here; the extension
32
32
  const CALL_TIMEOUT_MS = 10_000;
33
33
  const ROUTE_GRACE_MS = 3_500; // absorb brief owner↔follower failover before erroring
34
34
 
35
+ // chrome-extension:// for Chromium builds, moz-extension:// for Firefox.
36
+ const ALLOWED_EXTENSION_ORIGIN_PREFIXES = ['chrome-extension://', 'moz-extension://'];
37
+
35
38
  const EXT_NOT_CONNECTED_MSG =
36
39
  'Chrome extension is not connected. Load the 2ManyTabs MCP extension in your browser ' +
37
40
  'and confirm its popup shows "Connected".';
@@ -127,8 +130,8 @@ function attemptBind() {
127
130
  }
128
131
  handlePeerConnection(socket);
129
132
  } else {
130
- // Extension connections must originate from a chrome-extension:// URI.
131
- if (!origin.startsWith('chrome-extension://')) {
133
+ // Extension connections must originate from a browser-extension URI.
134
+ if (!ALLOWED_EXTENSION_ORIGIN_PREFIXES.some(prefix => origin.startsWith(prefix))) {
132
135
  log(`Rejected extension connection from unauthorized origin: ${origin}`);
133
136
  socket.close(4003, 'Unauthorized origin');
134
137
  return;
package/host.js CHANGED
@@ -1,31 +1,19 @@
1
1
  #!/usr/bin/env node
2
- // 2ManyTabs MCP Host (MCP server over stdio)
3
- //
4
- // Hermes ─stdio(JSON-RPC)─▶ host.js ─ws/127.0.0.1:9876─▶ background.js ─▶ chrome.tabs
5
- //
6
- // Architecture mirrors gemini-mcp-tool's "unified tool" pattern: one self-contained
7
- // file per tool, collected in tools/index.js. The difference — and the point of this
8
- // rewrite — is that SDK 1.x's McpServer.registerTool() absorbs the hand-rolled
9
- // registry.ts (zod-to-json-schema, getToolDefinitions, executeTool, manual Zod parsing)
10
- // that the 0.5-era SDK forced us to write. We keep the modular philosophy; the SDK
11
- // keeps the boilerplate.
12
-
13
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
- import { startBridge } from './bridge.js';
16
- import { toolRegistry } from './tools/index.js';
4
+ import { startBridge } from './bridge.js';
5
+ import { toolRegistry } from './tools/index.js';
17
6
 
18
- const server = new McpServer({ name: '2manytabs-mcp', version: '2.0.0' });
7
+ const server = new McpServer({ name: '2manytabs-mcp', version: '2.1.0' });
19
8
 
20
- // Register every tool generically. Adding a tool requires zero changes here.
21
9
  for (const tool of toolRegistry) {
22
10
  server.registerTool(
23
11
  tool.name,
24
12
  {
25
- title: tool.title,
13
+ title: tool.title,
26
14
  description: tool.description,
27
- inputSchema: tool.inputSchema, // raw Zod shape — SDK derives JSON Schema + validates
28
- annotations: tool.annotations, // readOnly/destructive hints (new in modern MCP)
15
+ inputSchema: tool.inputSchema,
16
+ annotations: tool.annotations,
29
17
  },
30
18
  async (args) => {
31
19
  try {
@@ -41,14 +29,12 @@ for (const tool of toolRegistry) {
41
29
  );
42
30
  }
43
31
 
44
- // Bring up the extension bridge, then connect MCP over stdio.
45
32
  startBridge();
46
33
 
47
34
  const transport = new StdioServerTransport();
48
35
  await server.connect(transport);
49
36
  process.stderr.write(`[2manytabs-mcp] MCP server ready (${toolRegistry.length} tools) on stdio\n`);
50
37
 
51
- // Ensure clean exit when the parent process disconnects or terminates
52
38
  const cleanup = () => process.exit(0);
53
39
  process.stdin.on('close', cleanup);
54
40
  process.stdin.on('end', cleanup);
package/lib/tabs.js CHANGED
@@ -1,17 +1,30 @@
1
- // Pure tab-selection helpers shared by the list_tabs and close_tabs tools.
2
- // Keeping these side-effect-free makes the tools easy to reason about and test.
1
+ // Utilities for filtering, grouping, and formatting tab data.
2
+ // All functions are side-effect-free to simplify testing and reasoning.
3
3
 
4
- /** Extract a display domain from a tab URL, tolerating chrome://, file://, blank. */
4
+ /** Maximum number of tabs to include in list operations. */
5
+ export const MAX_TAB_LIST = 1000;
6
+
7
+ /** Title truncation length for full tab descriptions. */
8
+ export const TITLE_TRUNCATE_FULL = 80;
9
+
10
+ /** Title truncation length for compact labels. */
11
+ export const TITLE_TRUNCATE_LABEL = 90;
12
+
13
+ /** Maximum width (in characters) of histogram bars. */
14
+ export const HISTOGRAM_BAR_MAX = 20;
15
+
16
+ /** Extract a display domain from a tab URL, tolerating chrome://, file://, blank.
17
+ * Throws on URL parse errors to preserve data integrity visibility. */
5
18
  export function domainOf(tab) {
6
- try {
7
- const u = new URL(tab.url);
8
- if (u.protocol === 'chrome:' || u.protocol === 'chrome-extension:') {
9
- return `${u.protocol}//${u.hostname || u.pathname.split('/')[0] || ''}`.replace(/\/$/, '');
10
- }
11
- return u.hostname || '(local)';
12
- } catch {
13
- return '(unknown)';
19
+ if (!tab || tab.url == null) {
20
+ console.error('[domainOf] tab URL is missing or tab is null/undefined:', tab);
21
+ throw new Error('tab.url is missing or null');
22
+ }
23
+ const u = new URL(tab.url);
24
+ if (u.protocol === 'chrome:' || u.protocol === 'chrome-extension:') {
25
+ return `${u.protocol}//${u.hostname || u.pathname.split('/')[0] || ''}`.replace(/\/$/, '');
14
26
  }
27
+ return u.hostname || '(local)';
15
28
  }
16
29
 
17
30
  /** Case-insensitive substring match against title OR url. */
@@ -80,13 +93,27 @@ export function groupByWindow(tabs) {
80
93
  );
81
94
  }
82
95
 
83
- /** Trim a tab to a compact shape for listing (keeps token cost sane at 1000 tabs). */
96
+ /** Return the [Group: …] prefix string for a tab, or '' if ungrouped. */
97
+ export function getGroupPrefix(tab, groupMap) {
98
+ if (tab.groupId === undefined || tab.groupId === -1) return '';
99
+ const g = groupMap.get(tab.groupId);
100
+ return g ? `[Group: ${g.title || 'Group ' + g.id}] ` : '';
101
+ }
102
+
103
+ /** Tab label truncation helper. */
104
+ export function tabLabel(tab) {
105
+ const t = tab.title || '(untitled)';
106
+ return t.length > TITLE_TRUNCATE_LABEL ? t.slice(0, TITLE_TRUNCATE_LABEL - 3) + '…' : t;
107
+ }
108
+
109
+ /** Trim a tab to a compact shape for listing. */
84
110
  export function compact(tab) {
85
111
  const title = tab.title ?? '';
86
112
  return {
87
113
  id: tab.id,
88
114
  window: tab.windowId,
89
- title: title.length > 80 ? title.slice(0, 77) + '…' : title,
115
+ group: (tab.groupId !== undefined && tab.groupId !== -1) ? tab.groupId : undefined,
116
+ title: title.length > TITLE_TRUNCATE_FULL ? title.slice(0, TITLE_TRUNCATE_FULL - 3) + '…' : title,
90
117
  url: tab.url ?? '',
91
118
  pinned: tab.pinned || undefined,
92
119
  audible: tab.audible || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "2manytabs-mcp-host",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server that exposes Chrome tabs via the 2ManyTabs MCP extension",
5
5
  "type": "module",
6
6
  "main": "host.js",
@@ -10,11 +10,18 @@
10
10
  "files": [
11
11
  "host.js",
12
12
  "bridge.js",
13
- "lib/",
13
+ "lib/tabs.js",
14
14
  "tools/"
15
15
  ],
16
16
  "scripts": {
17
- "start": "node host.js"
17
+ "start": "node host.js",
18
+ "test": "node --test lib/tabs.test.js lib/background.test.js",
19
+ "lint": "eslint ."
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/js": "^9.0.0",
23
+ "eslint": "^9.0.0",
24
+ "globals": "^15.0.0"
18
25
  },
19
26
  "dependencies": {
20
27
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const activateTabTool = {
5
+ name: 'activate_tab',
6
+ title: 'Activate Tab',
7
+ description:
8
+ 'Bring a specific browser tab to the foreground. This activates the tab ' +
9
+ 'and focuses its containing window so the user sees it immediately.',
10
+ annotations: {
11
+ readOnlyHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: false,
14
+ openWorldHint: true,
15
+ },
16
+ inputSchema: {
17
+ tab_id: z.number().int()
18
+ .describe('The numeric ID of the tab to activate.'),
19
+ },
20
+
21
+ execute: async ({ tab_id }) => {
22
+ const res = await callExtension('activate_tab', { tab_id });
23
+ return JSON.stringify({
24
+ message: `Successfully activated tab ${tab_id}.`,
25
+ tabId: res.activated,
26
+ }, null, 2);
27
+ },
28
+ };
@@ -2,9 +2,6 @@ import { z } from 'zod';
2
2
  import { callExtension } from '../bridge.js';
3
3
  import { matchesQuery, findDuplicateIds, domainOf } from '../lib/tabs.js';
4
4
 
5
- // ACT tool. Absorbs the old close_tabs + close_tabs_matching + close_duplicate_tabs.
6
- // Exactly one selection mode must be supplied. `dry_run` previews without closing —
7
- // the safe way to verify a bulk close of hundreds of tabs before committing.
8
5
  export const closeTabsTool = {
9
6
  name: 'close_tabs',
10
7
  title: 'Close Tabs',
@@ -54,7 +51,13 @@ export const closeTabsTool = {
54
51
  throw new Error(`Use only one selection mode at a time (got: ${modes.join(', ')}).`);
55
52
  }
56
53
 
57
- const all = await callExtension('query_tabs');
54
+ let all;
55
+ try {
56
+ all = await callExtension('query_tabs');
57
+ } catch (err) {
58
+ console.error('Failed to fetch tabs:', err);
59
+ throw new Error('Failed to fetch open tabs from extension.');
60
+ }
58
61
  const byId = new Map(all.map((t) => [t.id, t]));
59
62
 
60
63
  let targets, reason;
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const getTabTextTool = {
5
+ name: 'get_tab_text',
6
+ title: 'Get Tab Text',
7
+ description:
8
+ 'Extract the plain body text content of a loaded browser tab using scripting. ' +
9
+ 'This allows reading and analyzing tab contents (e.g. for summarization or classification). ' +
10
+ 'Note: Fails on restricted internal browser pages (e.g., chrome://, edge://, or extensions) or if the tab is not loaded.',
11
+ annotations: {
12
+ readOnlyHint: true,
13
+ destructiveHint: false,
14
+ idempotentHint: true,
15
+ openWorldHint: true,
16
+ },
17
+ inputSchema: {
18
+ tab_id: z.number().int()
19
+ .describe('The numeric ID of the tab whose body text to extract.'),
20
+ },
21
+
22
+ execute: async ({ tab_id }) => {
23
+ const res = await callExtension('get_tab_text', { tab_id });
24
+ if (!res.text) {
25
+ return `Tab ${tab_id} returned no text content (it might be empty, loading, or restricted).`;
26
+ }
27
+ return res.text;
28
+ },
29
+ };
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const groupTabsTool = {
5
+ name: 'group_tabs',
6
+ title: 'Group Tabs',
7
+ description:
8
+ 'Group open Chrome tabs into native Chrome Tab Groups. Can create a new group or ' +
9
+ 'add tabs to an existing group. Optionally set a title and a color for the group.',
10
+ annotations: {
11
+ readOnlyHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: false,
14
+ openWorldHint: true,
15
+ },
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.'),
21
+ title: z.string().optional()
22
+ .describe('Title to set on the group (new or existing).'),
23
+ color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
24
+ .describe('Color to set on the group (new or existing).'),
25
+ },
26
+
27
+ execute: async ({ tab_ids, group_id, title, color }) => {
28
+ const res = await callExtension('group_tabs', { tab_ids, group_id, title, color });
29
+ return JSON.stringify({
30
+ message: `Successfully grouped ${tab_ids.length} tab(s).`,
31
+ groupId: res.groupId,
32
+ }, null, 2);
33
+ },
34
+ };
package/tools/index.js CHANGED
@@ -5,9 +5,23 @@
5
5
  import { listTabsTool } from './list-tabs.tool.js';
6
6
  import { closeTabsTool } from './close-tabs.tool.js';
7
7
  import { openTabsTool } from './open-tabs.tool.js';
8
+ import { listTabGroupsTool } from './list-tab-groups.tool.js';
9
+ import { groupTabsTool } from './group-tabs.tool.js';
10
+ import { ungroupTabsTool } from './ungroup-tabs.tool.js';
11
+ import { updateTabGroupTool } from './update-tab-group.tool.js';
12
+ import { activateTabTool } from './activate-tab.tool.js';
13
+ import { updateTabTool } from './update-tab.tool.js';
14
+ import { getTabTextTool } from './get-tab-text.tool.js';
8
15
 
9
16
  export const toolRegistry = [
10
17
  listTabsTool,
11
18
  closeTabsTool,
12
19
  openTabsTool,
20
+ listTabGroupsTool,
21
+ groupTabsTool,
22
+ ungroupTabsTool,
23
+ updateTabGroupTool,
24
+ activateTabTool,
25
+ updateTabTool,
26
+ getTabTextTool,
13
27
  ];
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const listTabGroupsTool = {
5
+ name: 'list_tab_groups',
6
+ title: 'List Tab Groups',
7
+ description:
8
+ 'List all existing native Chrome Tab Groups in the current browser session. ' +
9
+ 'Returns their IDs, titles, colors, collapsed states, and parent window IDs.',
10
+ annotations: { readOnlyHint: true, openWorldHint: true },
11
+ inputSchema: {
12
+ title_query: z.string().optional()
13
+ .describe('Case-insensitive substring filter for group titles.'),
14
+ },
15
+
16
+ // fallow-ignore-next-line complexity
17
+ execute: async ({ title_query }) => {
18
+ let groups;
19
+ try {
20
+ groups = await callExtension('query_groups');
21
+ } catch (err) {
22
+ console.error('Failed to fetch tab groups:', err);
23
+ throw new Error('Failed to fetch tab groups from extension.');
24
+ }
25
+ let filtered = groups;
26
+ if (title_query) {
27
+ const q = title_query.toLowerCase();
28
+ filtered = groups.filter((g) => (g.title || '').toLowerCase().includes(q));
29
+ }
30
+
31
+ if (filtered.length === 0) {
32
+ return title_query
33
+ ? `No tab groups matched "${title_query}".`
34
+ : 'No tab groups found in the browser session.';
35
+ }
36
+
37
+ const lines = [`📁 ${filtered.length} tab group(s) found:`];
38
+ for (const g of filtered) {
39
+ const titleStr = g.title ? `"${g.title}"` : '(unnamed)';
40
+ const collapsedStr = g.collapsed ? ' ⏸ (collapsed)' : ' ▶ (expanded)';
41
+ lines.push(` · [ID: ${g.id}] ${titleStr} [Color: ${g.color}] [Window: ${g.windowId}]${collapsedStr}`);
42
+ }
43
+
44
+ return lines.join('\n');
45
+ },
46
+ };
@@ -2,12 +2,10 @@ import { z } from 'zod';
2
2
  import { callExtension } from '../bridge.js';
3
3
  import {
4
4
  matchesQuery, findDuplicateIds, domainHistogram,
5
- groupByDomain, groupByWindow, compact,
5
+ MAX_TAB_LIST,
6
6
  } from '../lib/tabs.js';
7
+ import { formatTabData } from './present.js';
7
8
 
8
- // READ tool. Absorbs the old list_tabs + get_tab_groups.
9
- // Always returns a domain histogram up top so the agent gets an instant map of
10
- // 1000 tabs before deciding what to close.
11
9
  export const listTabsTool = {
12
10
  name: 'list_tabs',
13
11
  title: 'List Tabs',
@@ -30,6 +28,16 @@ export const listTabsTool = {
30
28
  execute: async ({ query, group_by, duplicates_only }) => {
31
29
  const all = await callExtension('query_tabs');
32
30
 
31
+ let groups;
32
+ try {
33
+ 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.');
37
+ }
38
+
39
+ const groupMap = new Map(groups.map(g => [g.id, g]));
40
+
33
41
  let tabs = all;
34
42
  if (query) tabs = tabs.filter((t) => matchesQuery(t, query));
35
43
  if (duplicates_only) {
@@ -37,75 +45,24 @@ export const listTabsTool = {
37
45
  tabs = tabs.filter((t) => dupIds.has(t.id));
38
46
  }
39
47
 
40
- const windows = new Set(tabs.map((t) => t.windowId)).size;
41
- const dupCount = findDuplicateIds(tabs).length;
42
-
43
- const histogram = domainHistogram(tabs);
44
-
45
- // Build a human-readable grouped summary that naturally guides the agent
46
- // to present tabs in a logical way.
47
- const lines = [];
48
-
49
- // Header line
50
- lines.push(`📊 ${tabs.length} tab(s) across ${windows} window(s)` +
51
- (query ? ` matching "${query}"` : '') +
52
- (duplicates_only ? ' (duplicates only)' : '') +
53
- (dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
54
- '\n');
55
-
56
- // Domain summary bar
57
- lines.push('Domains:');
58
- for (const d of histogram) {
59
- const bar = '▇'.repeat(Math.max(1, Math.round(d.count / Math.max(...histogram.map(x => x.count)) * 20)));
60
- lines.push(` ${bar} ${d.domain.padEnd(28)} ${d.count} tab(s)`);
48
+ // Apply MAX_TAB_LIST limit to prevent token overflow
49
+ if (tabs.length > MAX_TAB_LIST) {
50
+ tabs = tabs.slice(0, MAX_TAB_LIST);
61
51
  }
62
- lines.push('');
63
52
 
64
- // Detailed grouped view
65
- if (group_by === 'domain') {
66
- const groups = groupByDomain(tabs);
67
- for (const [domain, info] of Object.entries(groups)) {
68
- lines.push(`📁 ${domain} — ${info.count} tab(s)`);
69
- for (const id of info.tab_ids) {
70
- const tab = tabs.find(t => t.id === id);
71
- if (!tab) continue;
72
- const label = (tab.title || '(untitled)').length > 90
73
- ? (tab.title || '(untitled)').slice(0, 87) + '…'
74
- : (tab.title || '(untitled)');
75
- const flags = [];
76
- if (tab.pinned) flags.push('📌');
77
- if (tab.audible) flags.push('🔊');
78
- const flagStr = flags.length ? ' ' + flags.join('') : '';
79
- lines.push(` · ${label}${flagStr}`);
80
- }
81
- lines.push('');
82
- }
83
- } else if (group_by === 'window') {
84
- const groups = groupByWindow(tabs);
85
- for (const [winId, info] of Object.entries(groups)) {
86
- lines.push(`🪟 Window ${winId} — ${info.count} tab(s)`);
87
- for (const id of info.tab_ids) {
88
- const tab = tabs.find(t => t.id === id);
89
- if (!tab) continue;
90
- const label = (tab.title || '(untitled)').length > 90
91
- ? (tab.title || '(untitled)').slice(0, 87) + '…'
92
- : (tab.title || '(untitled)');
93
- lines.push(` · ${label}`);
94
- }
95
- lines.push('');
96
- }
97
- } else {
98
- for (const t of tabs) {
99
- const c = compact(t);
100
- const label = c.title.length > 90 ? c.title.slice(0, 87) + '…' : c.title;
101
- const flags = [];
102
- if (c.pinned) flags.push('📌');
103
- if (c.audible) flags.push('🔊');
104
- const flagStr = flags.length ? ' ' + flags.join('') : '';
105
- lines.push(` · ${label}${flagStr}`);
106
- }
107
- }
53
+ const histogram = domainHistogram(tabs);
54
+ const windows = new Set(tabs.map((t) => t.windowId)).size;
55
+ const dupCount = findDuplicateIds(tabs).length;
108
56
 
109
- return lines.join('\n');
57
+ return formatTabData({
58
+ tabs,
59
+ histogram,
60
+ groupMap,
61
+ group_by,
62
+ query,
63
+ duplicates_only,
64
+ windows,
65
+ dupCount,
66
+ });
110
67
  },
111
68
  };
@@ -0,0 +1,123 @@
1
+ import {
2
+ compact,
3
+ tabLabel,
4
+ TITLE_TRUNCATE_FULL,
5
+ HISTOGRAM_BAR_MAX,
6
+ } from '../lib/tabs.js';
7
+ import {
8
+ buildFlags,
9
+ formatFlags,
10
+ drawBar,
11
+ formatHistogramLine,
12
+ formatDomainHeader,
13
+ formatWindowHeader,
14
+ formatGroupPrefix,
15
+ formatIdPrefix,
16
+ } from './rendering.js';
17
+
18
+ function renderHistogram(histogram) {
19
+ const maxCount = Math.max(...histogram.map((x) => x.count));
20
+ const lines = ['Domains:'];
21
+
22
+ for (const { domain, count } of histogram) {
23
+ const bar = drawBar(count, maxCount, HISTOGRAM_BAR_MAX);
24
+ lines.push(formatHistogramLine(domain, count, bar));
25
+ }
26
+
27
+ return lines;
28
+ }
29
+
30
+ function renderByDomain(tabs, groupMap) {
31
+ const grouped = {};
32
+ for (const t of tabs) {
33
+ const domain = t.url ? new URL(t.url).hostname : '(local)';
34
+ (grouped[domain] ??= []).push(t);
35
+ }
36
+
37
+ const lines = [];
38
+ for (const [domain, tabsInDomain] of Object.entries(grouped)) {
39
+ lines.push(formatDomainHeader(domain, tabsInDomain.length));
40
+ for (const t of tabsInDomain) {
41
+ const label = tabLabel(t);
42
+ const flags = buildFlags(t);
43
+ const flagStr = formatFlags(flags);
44
+ lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
45
+ }
46
+ lines.push('');
47
+ }
48
+
49
+ return lines;
50
+ }
51
+
52
+ function renderByWindow(tabs, groupMap) {
53
+ const grouped = {};
54
+ for (const t of tabs) {
55
+ (grouped[t.windowId] ??= []).push(t);
56
+ }
57
+
58
+ const lines = [];
59
+ for (const [winId, tabsInWin] of Object.entries(grouped)) {
60
+ lines.push(formatWindowHeader(winId, tabsInWin.length));
61
+ for (const t of tabsInWin) {
62
+ const label = tabLabel(t);
63
+ const flags = buildFlags(t);
64
+ const flagStr = formatFlags(flags);
65
+ lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
66
+ }
67
+ lines.push('');
68
+ }
69
+
70
+ return lines;
71
+ }
72
+
73
+ function renderFlat(tabs, groupMap) {
74
+ const lines = [];
75
+ for (const t of tabs) {
76
+ const c = compact(t);
77
+ const label =
78
+ c.title.length > TITLE_TRUNCATE_FULL
79
+ ? c.title.slice(0, TITLE_TRUNCATE_FULL - 3) + '…'
80
+ : c.title;
81
+ const flags = buildFlags(c);
82
+ const flagStr = formatFlags(flags);
83
+ lines.push(` · ${formatIdPrefix(t)}${formatGroupPrefix(groupMap, t)}${label}${flagStr}`);
84
+ }
85
+
86
+ return lines;
87
+ }
88
+
89
+ export function formatTabData(data) {
90
+ const {
91
+ tabs,
92
+ histogram,
93
+ groupMap,
94
+ group_by,
95
+ query,
96
+ duplicates_only,
97
+ windows,
98
+ dupCount,
99
+ } = data;
100
+
101
+ const lines = [];
102
+
103
+ lines.push(
104
+ `📊 ${tabs.length} tab(s) across ${windows} window(s)` +
105
+ (query ? ` matching "${query}"` : '') +
106
+ (duplicates_only ? ' (duplicates only)' : '') +
107
+ (dupCount > 0 ? ` · ${dupCount} duplicate(s) found` : '') +
108
+ '\n',
109
+ );
110
+
111
+ lines.push(...renderHistogram(histogram));
112
+ lines.push('');
113
+
114
+ if (group_by === 'domain') {
115
+ lines.push(...renderByDomain(tabs, groupMap));
116
+ } else if (group_by === 'window') {
117
+ lines.push(...renderByWindow(tabs, groupMap));
118
+ } else {
119
+ lines.push(...renderFlat(tabs, groupMap));
120
+ }
121
+
122
+ return lines.join('\n');
123
+ }
@@ -0,0 +1,48 @@
1
+ /** Rendering adapters for tab display - delegates to dedicated formatters. */
2
+
3
+ /** Build emoji flags for tab metadata attributes. */
4
+ export function buildFlags(tab) {
5
+ const flags = [];
6
+ if (tab.pinned) flags.push('📌');
7
+ if (tab.audible) flags.push('🔊');
8
+ return flags;
9
+ }
10
+
11
+ /** Format emoji flags as display string. */
12
+ export function formatFlags(flags) {
13
+ return flags.length ? ' ' + flags.join('') : '';
14
+ }
15
+
16
+ /** Draw ASCII histogram bar for a domain count. */
17
+ export function drawBar(count, maxCount, maxWidth) {
18
+ return '▇'.repeat(Math.max(1, Math.round((count / maxCount) * maxWidth)));
19
+ }
20
+
21
+ /** Draw a single histogram line. */
22
+ export function formatHistogramLine(domain, count, bar) {
23
+ return ` ${bar} ${domain.padEnd(28)} ${count} tab(s)`;
24
+ }
25
+
26
+ /** Draw domain header. */
27
+ export function formatDomainHeader(domain, count) {
28
+ return `📁 ${domain} — ${count} tab(s)`;
29
+ }
30
+
31
+ /** Draw window header. */
32
+ export function formatWindowHeader(winId, count) {
33
+ return `🪟 Window ${winId} — ${count} tab(s)`;
34
+ }
35
+
36
+ /** Draw group prefix. */
37
+ export function formatGroupPrefix(groupMap, tab) {
38
+ if (tab.groupId === undefined || tab.groupId === -1) return '';
39
+ const g = groupMap.get(tab.groupId);
40
+ return g ? `[Group: ${g.title || 'Group ' + g.id}] ` : '';
41
+ }
42
+
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
+ export function formatIdPrefix(tab) {
47
+ return `[id:${tab.id}] `;
48
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const ungroupTabsTool = {
5
+ name: 'ungroup_tabs',
6
+ title: 'Ungroup Tabs',
7
+ description: 'Remove one or more open Chrome tabs from their current native Tab Groups.',
8
+ annotations: {
9
+ readOnlyHint: false,
10
+ destructiveHint: false,
11
+ idempotentHint: false,
12
+ openWorldHint: true,
13
+ },
14
+ inputSchema: {
15
+ tab_ids: z.array(z.number().int()).min(1)
16
+ .describe('Array of tab IDs to remove from any groups.'),
17
+ },
18
+
19
+ execute: async ({ tab_ids }) => {
20
+ const res = await callExtension('ungroup_tabs', { tab_ids });
21
+ return JSON.stringify({
22
+ message: `Successfully ungrouped ${res.ungrouped} tab(s).`,
23
+ ungrouped: res.ungrouped,
24
+ }, null, 2);
25
+ },
26
+ };
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const updateTabGroupTool = {
5
+ name: 'update_tab_group',
6
+ title: 'Update Tab Group',
7
+ description:
8
+ 'Update properties of an existing native Chrome Tab Group, including ' +
9
+ 'its title, color, or collapsed/expanded state.',
10
+ annotations: {
11
+ readOnlyHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: false,
14
+ openWorldHint: true,
15
+ },
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).'),
19
+ title: z.string().optional()
20
+ .describe('New title for the group.'),
21
+ color: z.enum(['grey', 'blue', 'red', 'yellow', 'green', 'pink', 'purple', 'cyan', 'orange']).optional()
22
+ .describe('New color for the group.'),
23
+ collapsed: z.boolean().optional()
24
+ .describe('Whether the group should be collapsed (true) or expanded (false).'),
25
+ },
26
+
27
+ execute: async ({ group_id, title, color, collapsed }) => {
28
+ const res = await callExtension('update_group', { group_id, title, color, collapsed });
29
+ return JSON.stringify({
30
+ message: `Successfully updated tab group ${group_id}.`,
31
+ groupId: res.updated,
32
+ }, null, 2);
33
+ },
34
+ };
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { callExtension } from '../bridge.js';
3
+
4
+ export const updateTabTool = {
5
+ name: 'update_tab',
6
+ title: 'Update Tab',
7
+ description:
8
+ 'Modify properties of an open browser tab, such as navigating it to a new URL, ' +
9
+ 'pinning/unpinning it, or muting/unmuting its audio.',
10
+ annotations: {
11
+ readOnlyHint: false,
12
+ destructiveHint: false,
13
+ idempotentHint: false,
14
+ openWorldHint: true,
15
+ },
16
+ inputSchema: {
17
+ tab_id: z.number().int()
18
+ .describe('The numeric ID of the tab to update.'),
19
+ url: z.string().optional()
20
+ .describe('A new URL to navigate the tab to. If no protocol is provided, https:// will be prepended.'),
21
+ pinned: z.boolean().optional()
22
+ .describe('Set to true to pin the tab, or false to unpin it.'),
23
+ muted: z.boolean().optional()
24
+ .describe('Set to true to mute the tab, or false to unmute it.'),
25
+ },
26
+
27
+ execute: async ({ tab_id, url, pinned, muted }) => {
28
+ const res = await callExtension('update_tab', { tab_id, url, pinned, muted });
29
+ return JSON.stringify({
30
+ message: `Successfully updated tab ${tab_id}.`,
31
+ tabId: res.updated,
32
+ }, null, 2);
33
+ },
34
+ };