@pokutuna/mcp-chrome-tabs 0.1.4
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 +78 -0
- package/dist/chrome.d.ts +16 -0
- package/dist/chrome.js +156 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +87 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +177 -0
- package/dist/view.d.ts +8 -0
- package/dist/view.js +38 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @pokutuna/mcp-chrome-tabs
|
|
2
|
+
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> **macOS only** - This MCP server uses AppleScript and only works on macOS.
|
|
5
|
+
|
|
6
|
+
Model Context Protocol (MCP) server that provides direct access to your browser's open tabs content. No additional fetching or authentication required - simply access what you're already viewing.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **Access browser tabs** - Direct access to content from tabs currently open in your browser
|
|
11
|
+
- **Active tab shortcut** - Quick reference to currently active tab without specifying tab ID
|
|
12
|
+
- **URL opening** - Open AI-provided URLs directly in new browser tabs
|
|
13
|
+
- **Tools & Resources** - Dual interface supporting both MCP tools and resources with automatic refresh notifications when tab collection changes
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
> [!IMPORTANT]
|
|
18
|
+
> **Requirements**: Enable "Allow JavaScript from Apple Events" in Chrome
|
|
19
|
+
> - (en) **View** > **Developer** > **Allow JavaScript from Apple Events**
|
|
20
|
+
> - (ja) **表示** > **開発 / 管理** > **Apple Events からのJavaScript を許可**
|
|
21
|
+
|
|
22
|
+
### Manual Configuration
|
|
23
|
+
Add to your MCP configuration file (e.g., `claude_desktop_config.json`):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"chrome-tabs": {
|
|
29
|
+
"command": "npx",
|
|
30
|
+
"args": ["-y", "@pokutuna/mcp-chrome-tabs"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### For Claude Code
|
|
37
|
+
```bash
|
|
38
|
+
claude mcp add -s user chrome-tabs -- npx -y @pokutuna/mcp-chrome-tabs
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Command Line Options
|
|
42
|
+
The server accepts optional command line arguments for configuration:
|
|
43
|
+
|
|
44
|
+
- `--application-name` - Application name to control (default: "Google Chrome")
|
|
45
|
+
- `--exclude-hosts` - Comma-separated list of domains to exclude from tab listing and content access
|
|
46
|
+
- `--check-interval` - Interval in milliseconds to check for tab changes and notify clients (default: 3000, set to 0 to disable)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
### `list_tabs`
|
|
52
|
+
List all open tabs in the user's browser with their titles, URLs, and tab references.
|
|
53
|
+
- Returns: Markdown formatted list of tabs with tab IDs for reference
|
|
54
|
+
|
|
55
|
+
### `read_tab_content`
|
|
56
|
+
Get readable content from a tab in the user's browser.
|
|
57
|
+
- `id` (optional): Tab reference from `list_tabs` output (e.g., `ID:12345:67890`)
|
|
58
|
+
- If `id` is omitted, uses the currently active tab
|
|
59
|
+
- Returns: Clean, readable content extracted using Mozilla Readability
|
|
60
|
+
|
|
61
|
+
### `open_in_new_tab`
|
|
62
|
+
Open a URL in a new tab to present content or enable user interaction with webpages.
|
|
63
|
+
- `url` (required): URL to open in the browser
|
|
64
|
+
|
|
65
|
+
## Resources
|
|
66
|
+
|
|
67
|
+
### `tab://current`
|
|
68
|
+
Resource representing the content of the currently active tab.
|
|
69
|
+
- **URI**: `tab://current`
|
|
70
|
+
- **MIME type**: `text/markdown`
|
|
71
|
+
- **Content**: Real-time content of the active browser tab
|
|
72
|
+
|
|
73
|
+
### `tab://{windowId}/{tabId}`
|
|
74
|
+
Resource template for accessing specific tabs.
|
|
75
|
+
- **URI pattern**: `tab://{windowId}/{tabId}`
|
|
76
|
+
- **MIME type**: `text/markdown`
|
|
77
|
+
- **Content**: Content of the specified tab
|
|
78
|
+
- Resources are dynamically generated based on currently open tabs
|
package/dist/chrome.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type TabRef = {
|
|
2
|
+
windowId: string;
|
|
3
|
+
tabId: string;
|
|
4
|
+
};
|
|
5
|
+
export type Tab = TabRef & {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
export type TabContent = {
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
content: string;
|
|
13
|
+
};
|
|
14
|
+
export declare function getChromeTabList(applicationName: string): Promise<Tab[]>;
|
|
15
|
+
export declare function getPageContent(applicationName: string, tab?: TabRef | null): Promise<TabContent>;
|
|
16
|
+
export declare function openURL(applicationName: string, url: string): Promise<void>;
|
package/dist/chrome.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { JSDOM } from "jsdom";
|
|
3
|
+
import { Readability } from "@mozilla/readability";
|
|
4
|
+
import { promisify } from "util";
|
|
5
|
+
import TurndownService from "turndown";
|
|
6
|
+
import turndownPluginGfm from "turndown-plugin-gfm";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
function escapeAppleScript(str) {
|
|
9
|
+
return str
|
|
10
|
+
.replace(/\\/g, "\\\\")
|
|
11
|
+
.replace(/"/g, '\\"')
|
|
12
|
+
.replace(/\n/g, "\\n")
|
|
13
|
+
.replace(/\r/g, "\\r");
|
|
14
|
+
}
|
|
15
|
+
export async function getChromeTabList(applicationName) {
|
|
16
|
+
const sep = separator();
|
|
17
|
+
const appleScript = `
|
|
18
|
+
tell application "${applicationName}"
|
|
19
|
+
set output to ""
|
|
20
|
+
repeat with aWindow in (every window)
|
|
21
|
+
set windowId to id of aWindow
|
|
22
|
+
repeat with aTab in (every tab of aWindow)
|
|
23
|
+
set tabId to id of aTab
|
|
24
|
+
set tabTitle to title of aTab
|
|
25
|
+
set tabURL to URL of aTab
|
|
26
|
+
set output to output & windowId & "${sep}" & tabId & "${sep}" & tabTitle & "${sep}" & tabURL & "\\n"
|
|
27
|
+
end repeat
|
|
28
|
+
end repeat
|
|
29
|
+
return output
|
|
30
|
+
end tell
|
|
31
|
+
`;
|
|
32
|
+
const result = await executeAppleScript(appleScript);
|
|
33
|
+
const lines = result.trim().split("\n");
|
|
34
|
+
const tabs = [];
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const [wId, tId, title, url] = line.split(sep);
|
|
37
|
+
if (!/^https?:\/\//.test(url))
|
|
38
|
+
continue;
|
|
39
|
+
tabs.push({
|
|
40
|
+
windowId: wId,
|
|
41
|
+
tabId: tId,
|
|
42
|
+
title: title.trim(),
|
|
43
|
+
url: url.trim(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return tabs;
|
|
47
|
+
}
|
|
48
|
+
export async function getPageContent(applicationName, tab) {
|
|
49
|
+
const sep = separator();
|
|
50
|
+
const inner = `
|
|
51
|
+
set tabTitle to title
|
|
52
|
+
set tabURL to URL
|
|
53
|
+
set tabContent to execute javascript "document.documentElement.outerHTML"
|
|
54
|
+
return tabTitle & "${sep}" & tabURL & "${sep}" & tabContent
|
|
55
|
+
`;
|
|
56
|
+
const appleScript = tab
|
|
57
|
+
? `
|
|
58
|
+
try
|
|
59
|
+
tell application "${applicationName}"
|
|
60
|
+
tell window id "${tab.windowId}"
|
|
61
|
+
tell tab id "${tab.tabId}"
|
|
62
|
+
(* Chrome によって suspend されたタブで js を実行すると動作が停止する
|
|
63
|
+
タイムアウトにより osascript コマンドの実行を retry したくないので
|
|
64
|
+
apple script 内で timeout をしてエラーを返すようにする *)
|
|
65
|
+
with timeout of 3 seconds
|
|
66
|
+
${inner}
|
|
67
|
+
end timeout
|
|
68
|
+
end tell
|
|
69
|
+
end tell
|
|
70
|
+
end tell
|
|
71
|
+
on error errMsg
|
|
72
|
+
return "ERROR" & "${sep}" & errMsg
|
|
73
|
+
end try
|
|
74
|
+
`
|
|
75
|
+
: `
|
|
76
|
+
try
|
|
77
|
+
tell application "${applicationName}"
|
|
78
|
+
repeat with w in windows
|
|
79
|
+
tell w
|
|
80
|
+
set t to tab (active tab index)
|
|
81
|
+
if URL of t is not "about:blank" then
|
|
82
|
+
tell t
|
|
83
|
+
${inner}
|
|
84
|
+
end tell
|
|
85
|
+
end if
|
|
86
|
+
end tell
|
|
87
|
+
end repeat
|
|
88
|
+
error "No active tab found"
|
|
89
|
+
end tell
|
|
90
|
+
on error errMsg
|
|
91
|
+
return "ERROR" & "${sep}" & errMsg
|
|
92
|
+
end try
|
|
93
|
+
`;
|
|
94
|
+
const result = await executeAppleScript(appleScript);
|
|
95
|
+
if (result.startsWith(`ERROR${sep}`))
|
|
96
|
+
throw new Error(result.split(sep)[1]);
|
|
97
|
+
const parts = result.split(sep).map((part) => part.trim());
|
|
98
|
+
if (parts.length < 3)
|
|
99
|
+
throw new Error("Failed to read the tab content");
|
|
100
|
+
const [title, url, content] = parts;
|
|
101
|
+
const dom = new JSDOM(content, { url });
|
|
102
|
+
const reader = new Readability(dom.window.document, {
|
|
103
|
+
charThreshold: 10,
|
|
104
|
+
});
|
|
105
|
+
const article = reader.parse();
|
|
106
|
+
if (!article?.content)
|
|
107
|
+
throw new Error("Failed to parse the page content");
|
|
108
|
+
const turndownService = new TurndownService();
|
|
109
|
+
turndownService.use(turndownPluginGfm.gfm);
|
|
110
|
+
const md = turndownService.turndown(article.content);
|
|
111
|
+
return {
|
|
112
|
+
title,
|
|
113
|
+
url,
|
|
114
|
+
content: md,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export async function openURL(applicationName, url) {
|
|
118
|
+
const escapedUrl = escapeAppleScript(url);
|
|
119
|
+
const appleScript = `
|
|
120
|
+
tell application "${applicationName}"
|
|
121
|
+
open location "${escapedUrl}"
|
|
122
|
+
end tell
|
|
123
|
+
`;
|
|
124
|
+
await executeAppleScript(appleScript);
|
|
125
|
+
}
|
|
126
|
+
async function retry(fn, options) {
|
|
127
|
+
const { maxRetries = 2, retryDelay = 1000 } = options || {};
|
|
128
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
129
|
+
try {
|
|
130
|
+
return await fn();
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (attempt === maxRetries) {
|
|
134
|
+
console.error("retry failed after maximum attempts:", error);
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error("unreachable");
|
|
141
|
+
}
|
|
142
|
+
async function executeAppleScript(script) {
|
|
143
|
+
return retry(async () => {
|
|
144
|
+
const { stdout, stderr } = await execFileAsync("osascript", ["-e", script], {
|
|
145
|
+
timeout: 5 * 1000,
|
|
146
|
+
maxBuffer: 5 * 1024 * 1024, // 5MB
|
|
147
|
+
});
|
|
148
|
+
if (stderr)
|
|
149
|
+
console.error("AppleScript stderr:", stderr);
|
|
150
|
+
return stdout.trim();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function separator() {
|
|
154
|
+
const uniqueId = Math.random().toString(36).substring(2);
|
|
155
|
+
return `<|SEP:${uniqueId}|>`;
|
|
156
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "util";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { createMcpServer } from "./mcp.js";
|
|
5
|
+
function showHelp() {
|
|
6
|
+
console.log(`
|
|
7
|
+
MCP Chrome Tabs Server
|
|
8
|
+
|
|
9
|
+
USAGE:
|
|
10
|
+
mcp-chrome-tabs [OPTIONS]
|
|
11
|
+
|
|
12
|
+
OPTIONS:
|
|
13
|
+
--application-name=<name> Application name to control via AppleScript
|
|
14
|
+
(default: "Google Chrome")
|
|
15
|
+
Example: "Google Chrome Canary"
|
|
16
|
+
|
|
17
|
+
--exclude-hosts=<hosts> Comma-separated list of hosts to exclude
|
|
18
|
+
(default: "")
|
|
19
|
+
Example: "github.com,example.com,test.com"
|
|
20
|
+
|
|
21
|
+
--check-interval=<ms> Interval for checking browser tabs in milliseconds
|
|
22
|
+
(default: 3000, set to 0 to disable)
|
|
23
|
+
Example: 1000
|
|
24
|
+
|
|
25
|
+
--help Show this help message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
REQUIREMENTS:
|
|
29
|
+
Chrome must allow JavaScript from Apple Events:
|
|
30
|
+
1. Open Chrome
|
|
31
|
+
2. Go to View > Developer > Allow JavaScript from Apple Events
|
|
32
|
+
3. Enable the option
|
|
33
|
+
|
|
34
|
+
MCP CONFIGURATION EXAMPLE:
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"chrome-tabs": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "@pokutuna/mcp-chrome-tabs"]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
`.trimStart());
|
|
44
|
+
}
|
|
45
|
+
function parseCliArgs(args) {
|
|
46
|
+
const { values } = parseArgs({
|
|
47
|
+
args,
|
|
48
|
+
options: {
|
|
49
|
+
"application-name": {
|
|
50
|
+
type: "string",
|
|
51
|
+
default: "Google Chrome",
|
|
52
|
+
},
|
|
53
|
+
"exclude-hosts": {
|
|
54
|
+
type: "string",
|
|
55
|
+
default: "",
|
|
56
|
+
},
|
|
57
|
+
"check-interval": {
|
|
58
|
+
type: "string",
|
|
59
|
+
default: "3000",
|
|
60
|
+
},
|
|
61
|
+
help: {
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
allowPositionals: false,
|
|
67
|
+
tokens: true,
|
|
68
|
+
});
|
|
69
|
+
const parsed = {
|
|
70
|
+
applicationName: values["application-name"],
|
|
71
|
+
excludeHosts: values["exclude-hosts"]
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((d) => d.trim())
|
|
74
|
+
.filter(Boolean),
|
|
75
|
+
checkInterval: parseInt(values["check-interval"], 10),
|
|
76
|
+
help: values.help,
|
|
77
|
+
};
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
const options = parseCliArgs(process.argv.slice(2));
|
|
81
|
+
if (options.help) {
|
|
82
|
+
showHelp();
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
const server = await createMcpServer(options);
|
|
86
|
+
const transport = new StdioServerTransport();
|
|
87
|
+
await server.connect(transport);
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export type McpServerOptions = {
|
|
3
|
+
applicationName: string;
|
|
4
|
+
excludeHosts: string[];
|
|
5
|
+
checkInterval: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function createMcpServer(options: McpServerOptions): Promise<McpServer>;
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import * as chrome from "./chrome.js";
|
|
4
|
+
import { readFile } from "fs/promises";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { createHash } from "crypto";
|
|
8
|
+
import * as view from "./view.js";
|
|
9
|
+
function isExcludedHost(url, excludeHosts) {
|
|
10
|
+
const u = new URL(url);
|
|
11
|
+
return excludeHosts.some((d) => u.hostname === d || u.hostname.endsWith("." + d));
|
|
12
|
+
}
|
|
13
|
+
async function listTabs(opts) {
|
|
14
|
+
const tabs = await chrome.getChromeTabList(opts.applicationName);
|
|
15
|
+
return tabs.filter((t) => !isExcludedHost(t.url, opts.excludeHosts));
|
|
16
|
+
}
|
|
17
|
+
async function getTab(tabRef, opts) {
|
|
18
|
+
const content = await chrome.getPageContent(opts.applicationName, tabRef);
|
|
19
|
+
if (isExcludedHost(content.url, opts.excludeHosts)) {
|
|
20
|
+
throw new Error("Content not available for excluded host");
|
|
21
|
+
}
|
|
22
|
+
return content;
|
|
23
|
+
}
|
|
24
|
+
async function packageVersion() {
|
|
25
|
+
const packageJsonText = await readFile(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8");
|
|
26
|
+
const packageJson = JSON.parse(packageJsonText);
|
|
27
|
+
return packageJson.version;
|
|
28
|
+
}
|
|
29
|
+
function hashTabList(tabs) {
|
|
30
|
+
const sortedTabs = tabs.slice().sort((a, b) => {
|
|
31
|
+
if (a.windowId !== b.windowId)
|
|
32
|
+
return a.windowId < b.windowId ? -1 : 1;
|
|
33
|
+
if (a.tabId !== b.tabId)
|
|
34
|
+
return a.tabId < b.tabId ? -1 : 1;
|
|
35
|
+
return 0;
|
|
36
|
+
});
|
|
37
|
+
const dump = sortedTabs
|
|
38
|
+
.map((tab) => `${tab.windowId}:${tab.tabId}:${tab.title}:${tab.url}`)
|
|
39
|
+
.join("|");
|
|
40
|
+
return createHash("sha256").update(dump, "utf8").digest("hex");
|
|
41
|
+
}
|
|
42
|
+
export async function createMcpServer(options) {
|
|
43
|
+
const server = new McpServer({
|
|
44
|
+
name: "chrome-tabs",
|
|
45
|
+
version: await packageVersion(),
|
|
46
|
+
}, {
|
|
47
|
+
capabilities: {
|
|
48
|
+
resources: {
|
|
49
|
+
listChanged: true,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
debouncedNotificationMethods: ["notifications/resources/list_changed"],
|
|
53
|
+
});
|
|
54
|
+
server.registerTool("list_tabs", {
|
|
55
|
+
description: "List all open tabs in the user's browser with their titles, URLs, and tab references",
|
|
56
|
+
inputSchema: {},
|
|
57
|
+
}, async () => {
|
|
58
|
+
const tabs = await listTabs(options);
|
|
59
|
+
return {
|
|
60
|
+
content: [
|
|
61
|
+
{
|
|
62
|
+
type: "text",
|
|
63
|
+
text: view.formatList(tabs),
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
server.registerTool("read_tab_content", {
|
|
69
|
+
description: "Get readable content from a tab in the user's browser. Provide ID (from list_tabs output) to read a specific tab, or omit for the active tab.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
id: z
|
|
72
|
+
.string()
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Tab reference from list_tabs output (e.g: ID:12345:67890). If omitted, uses the currently active tab."),
|
|
75
|
+
},
|
|
76
|
+
}, async (args) => {
|
|
77
|
+
const { id } = args;
|
|
78
|
+
const tab = await getTab(id ? view.parseTabRef(id) : null, options);
|
|
79
|
+
return {
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: view.formatTabContent(tab),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
server.registerTool("open_in_new_tab", {
|
|
89
|
+
description: "Open a URL in a new tab to present content or enable user interaction with webpages",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
url: z.string().url().describe("URL to open in the browser"),
|
|
92
|
+
},
|
|
93
|
+
}, async (args) => {
|
|
94
|
+
const { url } = args;
|
|
95
|
+
await chrome.openURL(options.applicationName, url);
|
|
96
|
+
return {
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
type: "text",
|
|
100
|
+
text: `Successfully opened the URL`,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
server.registerResource("current_tab", "tab://current", {
|
|
106
|
+
title: "Active Browser Tab",
|
|
107
|
+
description: "Content of the currently active tab in the user's browser",
|
|
108
|
+
mimeType: "text/markdown",
|
|
109
|
+
}, async (uri) => {
|
|
110
|
+
const tab = await getTab(null, options);
|
|
111
|
+
const text = view.formatTabContent(tab);
|
|
112
|
+
return {
|
|
113
|
+
contents: [
|
|
114
|
+
{
|
|
115
|
+
uri: uri.href,
|
|
116
|
+
name: tab.title,
|
|
117
|
+
text,
|
|
118
|
+
mimeType: "text/markdown",
|
|
119
|
+
size: new Blob([text]).size,
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
server.registerResource("tabs", new ResourceTemplate(view.uriTemplate, {
|
|
125
|
+
list: async () => {
|
|
126
|
+
const tabs = await listTabs(options);
|
|
127
|
+
return {
|
|
128
|
+
resources: tabs.map((tab) => ({
|
|
129
|
+
uri: view.formatUri(tab),
|
|
130
|
+
name: tab.title,
|
|
131
|
+
mimeType: "text/markdown",
|
|
132
|
+
})),
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
}), {
|
|
136
|
+
title: "Browser Tabs",
|
|
137
|
+
description: "Content of a specific tab in the user's browser",
|
|
138
|
+
mimeType: "text/markdown",
|
|
139
|
+
}, async (uri, { windowId, tabId }) => {
|
|
140
|
+
const tabRef = {
|
|
141
|
+
windowId: String(windowId),
|
|
142
|
+
tabId: String(tabId),
|
|
143
|
+
};
|
|
144
|
+
const tab = await getTab(tabRef, options);
|
|
145
|
+
const text = view.formatTabContent(tab);
|
|
146
|
+
return {
|
|
147
|
+
contents: [
|
|
148
|
+
{
|
|
149
|
+
uri: uri.href,
|
|
150
|
+
name: tab.title,
|
|
151
|
+
mimeType: "text/markdown",
|
|
152
|
+
text,
|
|
153
|
+
size: new Blob([text]).size,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
if (options.checkInterval > 0) {
|
|
159
|
+
let lastHash = hashTabList(await listTabs(options));
|
|
160
|
+
const check = async () => {
|
|
161
|
+
try {
|
|
162
|
+
const hash = hashTabList(await listTabs(options));
|
|
163
|
+
if (hash !== lastHash) {
|
|
164
|
+
server.sendResourceListChanged();
|
|
165
|
+
lastHash = hash;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
console.error("Error during periodic tab list update:", error);
|
|
170
|
+
}
|
|
171
|
+
// Use setTimeout instead of setInterval to avoid overlapping calls
|
|
172
|
+
setTimeout(check, options.checkInterval);
|
|
173
|
+
};
|
|
174
|
+
check();
|
|
175
|
+
}
|
|
176
|
+
return server;
|
|
177
|
+
}
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Tab, TabRef, TabContent } from "./chrome.js";
|
|
2
|
+
export declare function formatTabRef(tab: Tab): string;
|
|
3
|
+
export declare function parseTabRef(tabRef: string): TabRef | null;
|
|
4
|
+
export declare function formatList(tabs: Tab[]): string;
|
|
5
|
+
export declare function formatListItem(tab: Tab): string;
|
|
6
|
+
export declare function formatTabContent(tab: TabContent): string;
|
|
7
|
+
export declare const uriTemplate = "tab://{windowId}/{tabId}";
|
|
8
|
+
export declare function formatUri(ref: TabRef): string;
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function formatTabRef(tab) {
|
|
2
|
+
return `ID:${tab.windowId}:${tab.tabId}`;
|
|
3
|
+
}
|
|
4
|
+
export function parseTabRef(tabRef) {
|
|
5
|
+
const match = tabRef.match(/ID:(\d+):(\d+)$/);
|
|
6
|
+
if (!match)
|
|
7
|
+
return null;
|
|
8
|
+
const windowId = match[1];
|
|
9
|
+
const tabId = match[2];
|
|
10
|
+
return { windowId, tabId };
|
|
11
|
+
}
|
|
12
|
+
function truncateUrl(tab, over = 120) {
|
|
13
|
+
const url = tab.url;
|
|
14
|
+
if (url.length <= over)
|
|
15
|
+
return url;
|
|
16
|
+
return url.slice(0, over) + "...";
|
|
17
|
+
}
|
|
18
|
+
export function formatList(tabs) {
|
|
19
|
+
const list = tabs.map(formatListItem).join("\n");
|
|
20
|
+
const header = `### Current Tabs (${tabs.length} tabs exists)\n`;
|
|
21
|
+
return header + list;
|
|
22
|
+
}
|
|
23
|
+
export function formatListItem(tab) {
|
|
24
|
+
return `- ${formatTabRef(tab)} [${tab.title}](${truncateUrl(tab)})`;
|
|
25
|
+
}
|
|
26
|
+
export function formatTabContent(tab) {
|
|
27
|
+
return `
|
|
28
|
+
---
|
|
29
|
+
title: ${tab.title}
|
|
30
|
+
---
|
|
31
|
+
${tab.content}
|
|
32
|
+
`.trimStart();
|
|
33
|
+
}
|
|
34
|
+
export const uriTemplate = "tab://{windowId}/{tabId}";
|
|
35
|
+
export function formatUri(ref) {
|
|
36
|
+
// TODO give domain & title for incremental search
|
|
37
|
+
return `tab://${ref.windowId}/${ref.tabId}`;
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pokutuna/mcp-chrome-tabs",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/pokutuna/mcp-chrome-tabs"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"mcp-chrome-tabs": "./dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/**/*",
|
|
15
|
+
"README.md",
|
|
16
|
+
"package.json"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "node --import tsx src/cli.ts",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"start": "node dist/index.js",
|
|
23
|
+
"inspector": "npx @modelcontextprotocol/inspector",
|
|
24
|
+
"test": "vitest",
|
|
25
|
+
"test:run": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.16.0",
|
|
29
|
+
"@mozilla/readability": "^0.6.0",
|
|
30
|
+
"jsdom": "^26.1.0",
|
|
31
|
+
"turndown": "^7.2.0",
|
|
32
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
33
|
+
"zod": "^3.25.76"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/jsdom": "^21.1.7",
|
|
37
|
+
"@types/node": "^20.11.17",
|
|
38
|
+
"@types/turndown": "^5.0.5",
|
|
39
|
+
"tsx": "^4.7.1",
|
|
40
|
+
"typescript": "~5.8.3",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
}
|
|
43
|
+
}
|