@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 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
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }