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/LICENSE +21 -0
- package/README.md +158 -0
- package/bridge.js +285 -120
- package/host.js +1 -1
- package/lib/tabs.js +2 -21
- package/package.json +26 -3
- package/tools/activate-tab.tool.js +6 -4
- package/tools/close-tabs.tool.js +33 -9
- package/tools/execute-script.tool.js +54 -0
- package/tools/get-tab-text.tool.js +6 -4
- package/tools/group-tabs.tool.js +9 -7
- package/tools/index.js +2 -4
- package/tools/list-tab-groups.tool.js +2 -3
- package/tools/list-tabs.tool.js +13 -10
- package/tools/open-tabs.tool.js +6 -4
- package/tools/present.js +12 -4
- package/tools/rendering.js +7 -15
- package/tools/ungroup-tabs.tool.js +7 -5
- package/tools/update-tab-group.tool.js +7 -5
- package/tools/update-tab.tool.js +6 -4
package/package.json
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "2manytabs-mcp-host",
|
|
3
|
-
"version": "
|
|
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
|
-
"
|
|
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,
|
package/tools/close-tabs.tool.js
CHANGED
|
@@ -6,7 +6,7 @@ export const closeTabsTool = {
|
|
|
6
6
|
name: 'close_tabs',
|
|
7
7
|
title: 'Close Tabs',
|
|
8
8
|
description:
|
|
9
|
-
'Close
|
|
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 =
|
|
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) => ({
|
|
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', {
|
|
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
|
}
|
package/tools/group-tabs.tool.js
CHANGED
|
@@ -5,7 +5,7 @@ export const groupTabsTool = {
|
|
|
5
5
|
name: 'group_tabs',
|
|
6
6
|
title: 'Group Tabs',
|
|
7
7
|
description:
|
|
8
|
-
'Group open
|
|
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('
|
|
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
|
|
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');
|
package/tools/list-tabs.tool.js
CHANGED
|
@@ -10,7 +10,7 @@ export const listTabsTool = {
|
|
|
10
10
|
name: 'list_tabs',
|
|
11
11
|
title: 'List Tabs',
|
|
12
12
|
description:
|
|
13
|
-
'Read open
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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({
|
package/tools/open-tabs.tool.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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 [
|
|
60
|
-
|
|
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)
|
|
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` : '') +
|
package/tools/rendering.js
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
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,
|
package/tools/update-tab.tool.js
CHANGED
|
@@ -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,
|