├── src ├── types.d.ts ├── defuddle-worker.js ├── browser │ ├── browser.ts │ ├── osascript.ts │ ├── chrome.ts │ ├── safari.ts │ └── arc.ts ├── util.ts ├── view.ts ├── cli.ts └── mcp.ts ├── demo ├── .gitignore ├── demo.webp └── memo.md ├── .prettierignore ├── tests ├── integration │ ├── chrome-profile │ │ └── Default │ │ │ └── Preferences │ └── browser.e2e.ts ├── applescript.test.ts ├── util.test.ts ├── view.test.ts └── mcp.test.ts ├── .mcp.json ├── .prettierrc ├── playwright.config.ts ├── vitest.config.ts ├── sketch ├── current-tab.ts └── content-extraction-levels.ts ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── publish.yml ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── CHANGELOG.md ├── CLAUDE.md └── README.md /src/types.d.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | *.gif 2 | 3 | -------------------------------------------------------------------------------- /demo/demo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pokutuna/mcp-chrome-tabs/HEAD/demo/demo.webp -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | dist/ 3 | 4 | # Dependencies 5 | node_modules/ 6 | -------------------------------------------------------------------------------- /tests/integration/chrome-profile/Default/Preferences: -------------------------------------------------------------------------------- 1 | { 2 | "browser": { 3 | "allow_javascript_apple_events": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "chrome-tabs": { 4 | "command": "node", 5 | "args": ["--import=tsx", "src/cli.ts"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 80, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | testDir: "./tests/integration", 5 | testMatch: "**/*.e2e.ts", 6 | workers: 1, 7 | reporter: "list", 8 | }); 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "node", 6 | globals: true, 7 | include: ["tests/**/*.test.ts"], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /demo/memo.md: -------------------------------------------------------------------------------- 1 | - $ npx terminalizer record demo 2 | - run commands 3 | - edit demo.yml 4 | - $ npx terminalizer play -s 0.33 demo.yml 5 | - $ npx terminalizer render -s 3 demo.yml 6 | - $ mv render\* demo.gif 7 | - $ ffmpeg -i demo.gif -vf "scale=800:-1" demo.webp 8 | -------------------------------------------------------------------------------- /sketch/current-tab.ts: -------------------------------------------------------------------------------- 1 | import { chromeBrowser } from "../src/browser/chrome"; 2 | 3 | const tabs = await chromeBrowser.getTabList("Google Chrome"); 4 | console.log(tabs); 5 | 6 | const { content, ...rest } = await chromeBrowser.getPageContent( 7 | "Google Chrome", 8 | null 9 | ); 10 | console.log(rest); 11 | console.log(content); 12 | -------------------------------------------------------------------------------- /tests/applescript.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "vitest"; 2 | import { executeAppleScript } from "../src/browser/osascript.js"; 3 | 4 | test.skipIf(process.platform !== "darwin")( 5 | "apple script is available", 6 | async () => { 7 | const result = await executeAppleScript('return "Hello World"'); 8 | expect(result).toBe("Hello World"); 9 | } 10 | ); 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | open-pull-requests-limit: 10 8 | assignees: 9 | - "pokutuna" 10 | cooldown: 11 | default-days: 5 12 | commit-message: 13 | prefix: "deps" 14 | include: "scope" 15 | groups: 16 | dev-dependencies: 17 | dependency-type: "development" 18 | update-types: 19 | - "minor" 20 | - "patch" 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: macos-15 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x, 24.x] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: "npm" 20 | - run: npm ci 21 | - run: npm run test 22 | - run: npm run build 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .serena 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | lerna-debug.log* 11 | 12 | node_modules 13 | dist 14 | dist-ssr 15 | *.local 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # Test outputs 29 | test-results/ 30 | playwright-report/ 31 | 32 | # Chrome profile runtime changes (keep base settings, ignore runtime data) 33 | tests/integration/chrome-profile/ 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2024", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "strict": true, 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noUncheckedSideEffectImports": true, 15 | "allowJs": true, 16 | "outDir": "./dist" 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | -------------------------------------------------------------------------------- /src/defuddle-worker.js: -------------------------------------------------------------------------------- 1 | import { parentPort, workerData } from "worker_threads"; 2 | import { Defuddle } from "defuddle/node"; 3 | 4 | // This worker executes Defuddle extraction in a separate thread 5 | // to avoid blocking the main thread during CPU-intensive parsing 6 | 7 | if (!parentPort) { 8 | throw new Error("This module must be run as a worker thread"); 9 | } 10 | 11 | const input = workerData; 12 | 13 | try { 14 | const result = await Defuddle(input.html, input.url, { 15 | markdown: true, 16 | }); 17 | 18 | const output = { 19 | content: result?.content ?? null, 20 | }; 21 | 22 | parentPort.postMessage(output); 23 | } catch (error) { 24 | const output = { 25 | content: null, 26 | error: error instanceof Error ? error.message : String(error), 27 | }; 28 | 29 | parentPort.postMessage(output); 30 | } 31 | -------------------------------------------------------------------------------- /src/browser/browser.ts: -------------------------------------------------------------------------------- 1 | export type Browser = "chrome" | "safari" | "arc"; 2 | 3 | export type TabRef = { windowId: string; tabId: string }; 4 | 5 | export type Tab = TabRef & { 6 | title: string; 7 | url: string; 8 | }; 9 | 10 | export type TabContent = { 11 | title: string; 12 | url: string; 13 | content: string; // Raw HTML content 14 | }; 15 | 16 | export type BrowserInterface = { 17 | getTabList(applicationName: string): Promise; 18 | getPageContent( 19 | applicationName: string, 20 | tab?: TabRef | null 21 | ): Promise; 22 | openURL(applicationName: string, url: string): Promise; 23 | }; 24 | 25 | import { chromeBrowser } from "./chrome.js"; 26 | import { safariBrowser } from "./safari.js"; 27 | import { arcBrowser } from "./arc.js"; 28 | 29 | export function getInterface(browser: Browser): BrowserInterface { 30 | if (browser === "safari") { 31 | return safariBrowser; 32 | } 33 | if (browser === "arc") { 34 | return arcBrowser; 35 | } 36 | return chromeBrowser; 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 pokutuna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pokutuna/mcp-chrome-tabs", 3 | "version": "0.7.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/pokutuna/mcp-chrome-tabs" 7 | }, 8 | "license": "MIT", 9 | "type": "module", 10 | "keywords": [ 11 | "mcp", 12 | "model-context-protocol", 13 | "chrome", 14 | "ai", 15 | "macos" 16 | ], 17 | "bin": { 18 | "mcp-chrome-tabs": "./dist/cli.js" 19 | }, 20 | "files": [ 21 | "dist/**/*", 22 | "CHANGELOG.md", 23 | "README.md", 24 | "package.json" 25 | ], 26 | "scripts": { 27 | "build": "tsc", 28 | "dev": "node --import tsx src/cli.ts", 29 | "inspector": "npx @modelcontextprotocol/inspector", 30 | "lint": "prettier --check .", 31 | "lint:fix": "prettier --write .", 32 | "prepublishOnly": "npm run build", 33 | "start": "node dist/index.js", 34 | "test": "vitest run", 35 | "test:watch": "vitest", 36 | "test:e2e": "playwright test" 37 | }, 38 | "dependencies": { 39 | "@modelcontextprotocol/sdk": "^1.24.3", 40 | "defuddle": "^0.6.6", 41 | "jsdom": "^24.0.0", 42 | "zod": "^3.25.76" 43 | }, 44 | "devDependencies": { 45 | "@playwright/test": "^1.57.0", 46 | "@types/node": "^24.10.1", 47 | "playwright": "^1.54.1", 48 | "prettier": "^3.7.4", 49 | "tsx": "^4.21.0", 50 | "typescript": "~5.9.3", 51 | "vitest": "^4.0.15" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/browser/osascript.ts: -------------------------------------------------------------------------------- 1 | import { execFile } from "child_process"; 2 | import { promisify } from "util"; 3 | 4 | const execFileAsync = promisify(execFile); 5 | 6 | export function escapeAppleScript(str: string): string { 7 | // https://discussions.apple.com/thread/4247426?sortBy=rank 8 | return str 9 | .replace(/\\/g, "\\\\") 10 | .replace(/"/g, '\\"') 11 | .replace(/\n/g, "\\n") 12 | .replace(/\r/g, "\\r"); 13 | } 14 | 15 | export async function retry( 16 | fn: () => Promise, 17 | options?: { 18 | maxRetries?: number; 19 | retryDelay?: number; 20 | } 21 | ): Promise { 22 | const { maxRetries = 1, retryDelay = 1000 } = options || {}; 23 | for (let attempt = 0; attempt <= maxRetries; attempt++) { 24 | try { 25 | return await fn(); 26 | } catch (error: unknown) { 27 | if (attempt === maxRetries) { 28 | console.error("retry failed after maximum attempts:", error); 29 | throw error; 30 | } 31 | await new Promise((resolve) => 32 | setTimeout(resolve, retryDelay * Math.pow(2, attempt)) 33 | ); 34 | } 35 | } 36 | throw new Error("unreachable"); 37 | } 38 | 39 | export async function executeAppleScript(script: string): Promise { 40 | return retry(async () => { 41 | const { stdout, stderr } = await execFileAsync( 42 | "osascript", 43 | ["-e", script], 44 | { 45 | timeout: 5 * 1000, 46 | maxBuffer: 10 * 1024 * 1024, // 10MB 47 | } 48 | ); 49 | if (stderr) console.error("AppleScript stderr:", stderr); 50 | return stdout.trim(); 51 | }); 52 | } 53 | 54 | export function separator(): string { 55 | const uniqueId = Math.random().toString(36).substring(2); 56 | return `<|SEP:${uniqueId}|>`; 57 | } 58 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { Worker } from "worker_threads"; 2 | import { fileURLToPath } from "url"; 3 | import { dirname, join } from "path"; 4 | 5 | type DefuddleWorkerOutput = 6 | | { content: string; error?: never } 7 | | { content: null; error: string }; 8 | 9 | /** 10 | * Run Defuddle extraction in a worker thread to avoid blocking the main thread 11 | */ 12 | export async function runDefuddleInWorker( 13 | html: string, 14 | url: string, 15 | timeoutMs: number 16 | ): Promise { 17 | return new Promise((resolve, reject) => { 18 | const currentDir = dirname(fileURLToPath(import.meta.url)); 19 | const workerPath = join(currentDir, "defuddle-worker.js"); 20 | 21 | const worker = new Worker(workerPath, { 22 | workerData: { html, url }, 23 | stdout: true, // Don't pipe worker stdout to parent 24 | stderr: true, // Don't pipe worker stderr to parent 25 | }); 26 | 27 | // Set timeout for worker execution 28 | const timeout = setTimeout(() => { 29 | worker.terminate(); 30 | reject( 31 | new Error( 32 | `Worker timeout: Defuddle extraction took longer than ${timeoutMs}ms` 33 | ) 34 | ); 35 | }, timeoutMs); 36 | 37 | worker.on("message", (output: DefuddleWorkerOutput) => { 38 | clearTimeout(timeout); 39 | worker.terminate(); 40 | 41 | if (output.error) { 42 | reject(new Error(output.error)); 43 | } else if (output.content != null) { 44 | resolve(output.content); 45 | } else { 46 | reject(new Error("Failed to parse the page content")); 47 | } 48 | }); 49 | 50 | worker.on("error", (error) => { 51 | clearTimeout(timeout); 52 | worker.terminate(); 53 | reject(error); 54 | }); 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /tests/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { runDefuddleInWorker } from "../src/util.js"; 3 | 4 | describe("runDefuddleInWorker", () => { 5 | it("should extract content from HTML using worker thread", async () => { 6 | const html = ` 7 | 8 | 9 | Test Page 10 | 11 |

Test Heading

12 |

This is test content.

13 | 14 | 15 | `; 16 | const url = "https://example.com/test"; 17 | 18 | const content = await runDefuddleInWorker(html, url, 10000); 19 | 20 | expect(content).toBeTruthy(); 21 | expect(content).toContain("Test Heading"); 22 | expect(content).toContain("This is test content"); 23 | }); 24 | 25 | it("should handle empty HTML gracefully", async () => { 26 | // Empty HTML should be processed successfully (may return empty string) 27 | const html = ""; 28 | const url = "https://example.com/empty"; 29 | 30 | const content = await runDefuddleInWorker(html, url, 10000); 31 | expect(typeof content).toBe("string"); 32 | }); 33 | 34 | it("should timeout when processing takes too long", async () => { 35 | // Use a very large HTML to trigger timeout with short timeout duration 36 | const html = 37 | "" + "
content
".repeat(100000) + ""; 38 | const url = "https://example.com/large"; 39 | 40 | await expect( 41 | runDefuddleInWorker(html, url, 100) // 100ms timeout 42 | ).rejects.toThrow(/Worker timeout/); 43 | }); 44 | 45 | it("should process multiple requests concurrently", async () => { 46 | const html1 = ` 47 | 48 | 49 | Page 1 50 | 51 |
52 |

Page 1

53 |

Content for page 1

54 |
55 | 56 | 57 | `; 58 | const html2 = ` 59 | 60 | 61 | Page 2 62 | 63 |
64 |

Page 2

65 |

Content for page 2

66 |
67 | 68 | 69 | `; 70 | 71 | const [content1, content2] = await Promise.all([ 72 | runDefuddleInWorker(html1, "https://example.com/1", 10000), 73 | runDefuddleInWorker(html2, "https://example.com/2", 10000), 74 | ]); 75 | 76 | expect(content1).toContain("page 1"); 77 | expect(content2).toContain("page 2"); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Publish Package Workflow 2 | # 3 | # This workflow automatically publishes the package to npm and creates a GitHub release 4 | # when a version tag is pushed. 5 | # 6 | # Release Steps: 7 | # 1. Decide the new version number 8 | # - Follow Semantic Versioning: MAJOR.MINOR.PATCH 9 | # - MAJOR: breaking changes 10 | # - MINOR: new features (backward compatible) 11 | # - PATCH: bug fixes (backward compatible) 12 | # 13 | # 2. Update CHANGELOG.md 14 | # - Move items from [Unreleased] to a new version section 15 | # - Add the release date (YYYY-MM-DD) 16 | # - Update comparison links at the bottom 17 | # 18 | # 3. Commit CHANGELOG.md 19 | # - git add CHANGELOG.md 20 | # - git commit -m "Update CHANGELOG for vX.Y.Z" 21 | # 22 | # 4. Create version commit and tag with npm version 23 | # - npm version 24 | # - This updates package.json and creates a git tag (vX.Y.Z) 25 | # 26 | # 5. Push commits and tag 27 | # - git push origin main --tags 28 | # 29 | # The workflow will then automatically: 30 | # - Run tests 31 | # - Build the package 32 | # - Publish to npm (using OIDC trusted publishing) 33 | # - Create a GitHub release with auto-generated notes 34 | 35 | name: Publish Package 36 | 37 | on: 38 | push: 39 | tags: 40 | - "v[0-9]+.[0-9]+.[0-9]+" 41 | - "v[0-9]+.[0-9]+.[0-9]+-*" 42 | 43 | jobs: 44 | publish: 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: write # for GitHub Release 48 | id-token: write # for NPM Trusted Publisher OIDC 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Verify CHANGELOG contains release version 52 | run: | 53 | VERSION="${GITHUB_REF_NAME#v}" 54 | if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then 55 | echo "::error::CHANGELOG.md does not contain an entry for version ${VERSION}" 56 | echo "Please update CHANGELOG.md before releasing." 57 | exit 1 58 | fi 59 | echo "Found CHANGELOG entry for version ${VERSION}" 60 | - uses: actions/setup-node@v4 61 | with: 62 | node-version: "22" 63 | registry-url: "https://registry.npmjs.org" 64 | - run: npm install -g npm@latest 65 | - run: npm ci 66 | - run: npm run test 67 | - run: npm run build 68 | - run: npm publish --access public 69 | - run: gh release create ${{ github.ref_name }} --title "Release ${{ github.ref_name }}" --generate-notes 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import type { Tab, TabRef, TabContent } from "./browser/browser.js"; 2 | 3 | export function formatTabRef(tab: Tab): string { 4 | return `ID:${tab.windowId}:${tab.tabId}`; 5 | } 6 | 7 | export function parseTabRef(tabRef: string): TabRef | null { 8 | const match = tabRef.match(/^ID:([^:]+):([^:]+)$/); 9 | if (!match) return null; 10 | const windowId = match[1]; 11 | const tabId = match[2]; 12 | return { windowId, tabId }; 13 | } 14 | 15 | function getDomain(url: string): string { 16 | try { 17 | const u = new URL(url); 18 | return u.port ? `${u.hostname}:${u.port}` : u.hostname; 19 | } catch { 20 | return url; 21 | } 22 | } 23 | 24 | export function formatTabName(tab: { title: string; url: string }): string { 25 | return `${tab.title} (${getDomain(tab.url)})`; 26 | } 27 | 28 | export function formatList(tabs: Tab[], includeUrl: boolean = false): string { 29 | const list = tabs.map((tab) => formatListItem(tab, includeUrl)).join("\n"); 30 | const header = `### Current Tabs (${tabs.length} tabs exists)\n`; 31 | return header + list; 32 | } 33 | 34 | export function formatListItem(tab: Tab, includeUrl: boolean = false): string { 35 | if (includeUrl) { 36 | return `- ${formatTabRef(tab)} [${tab.title}](${tab.url})`; 37 | } else { 38 | return `- ${formatTabRef(tab)} ${formatTabName(tab)}`; 39 | } 40 | } 41 | 42 | type FrontMatter = { key: string; value: string | number | boolean }; 43 | 44 | export function formatTabContent( 45 | tab: TabContent, 46 | startIndex: number = 0, 47 | maxContentChars?: number 48 | ): string { 49 | const frontMatters: FrontMatter[] = [ 50 | { key: "url", value: tab.url }, 51 | { key: "title", value: tab.title }, 52 | ]; 53 | let content = tab.content; 54 | 55 | if (startIndex > 0) { 56 | content = content.slice(startIndex); 57 | frontMatters.push({ key: "startIndex", value: startIndex }); 58 | } 59 | const truncation = 60 | maxContentChars !== undefined && content.length > maxContentChars; 61 | if (truncation) { 62 | content = content.slice(0, maxContentChars); 63 | const nextStart = startIndex + maxContentChars; 64 | content += `\n\nContent truncated. Read with startIndex of ${nextStart} to get more content.`; 65 | frontMatters.push({ key: "truncated", value: truncation }); 66 | } 67 | 68 | const frontMatterText = frontMatters 69 | .map(({ key, value }) => `${key}: ${value}`) 70 | .join("\n"); 71 | 72 | return ["---", frontMatterText, "---", content].join("\n"); 73 | } 74 | 75 | export const uriTemplate = "tab://{windowId}/{tabId}"; 76 | 77 | export function formatUri(ref: TabRef): string { 78 | return `tab://${ref.windowId}/${ref.tabId}`; 79 | } 80 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.7.0] - 2025-10-17 11 | 12 | ### Added 13 | 14 | - Worker thread for Defuddle content extraction to prevent blocking (#56) 15 | - `--extraction-timeout` CLI option to configure content extraction timeout 16 | 17 | ### Changed 18 | 19 | - Resource subscription is now disabled by default (`--check-interval=0`) (#62) 20 | 21 | ### Fixed 22 | 23 | - Handle empty string content correctly in worker thread 24 | 25 | ## [0.6.0] - 2025-09-08 26 | 27 | ### Added 28 | 29 | - Return tab ID from `open_in_new_tab` tool (#34) 30 | - Demo animation in README 31 | 32 | ### Changed 33 | 34 | - Migrate to NPM Trusted Publishing with OIDC (#37) 35 | 36 | ## [0.5.0] - 2025-08-12 37 | 38 | ### Added 39 | 40 | - Arc browser support via `--experimental-browser=arc` (#20) 41 | 42 | ## [0.4.0] - 2025-08-04 43 | 44 | ### Added 45 | 46 | - Content pagination with `--max-content-chars` option (#17) 47 | - `includeUrl` option to `list_tabs` tool (#11) 48 | - URL in front matter of `formatTabContent` (#12) 49 | 50 | ## [0.3.0] - 2025-08-01 51 | 52 | ### Added 53 | 54 | - E2E tests with Playwright (#6) 55 | - MIT License 56 | 57 | ### Changed 58 | 59 | - Replace @mozilla/readability with defuddle for content extraction (#7) 60 | 61 | ## [0.2.0] - 2025-07-29 62 | 63 | ### Added 64 | 65 | - Safari browser support (experimental) via `--experimental-browser=safari` (#4) 66 | - Prettier configuration for code formatting (#5) 67 | - CLAUDE.md documentation 68 | 69 | ### Fixed 70 | 71 | - Replace deprecated actions/create-release with gh release create 72 | 73 | ## [0.1.4] - 2025-07-28 74 | 75 | ### Fixed 76 | 77 | - npm publish provenance configuration 78 | 79 | ## [0.1.3] - 2025-07-28 80 | 81 | ### Fixed 82 | 83 | - Add `--access public` to npm publish 84 | 85 | ## [0.1.2] - 2025-07-28 86 | 87 | ### Added 88 | 89 | - Initial release 90 | - `list_tabs` tool to list browser tabs 91 | - `read_tab_content` tool to extract readable content from tabs 92 | - `open_in_new_tab` tool to open URLs in browser 93 | - Chrome browser support via AppleScript automation 94 | 95 | [Unreleased]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.7.0...HEAD 96 | [0.7.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.6.0...v0.7.0 97 | [0.6.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.5.0...v0.6.0 98 | [0.5.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.4.0...v0.5.0 99 | [0.4.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.3.0...v0.4.0 100 | [0.3.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.2.0...v0.3.0 101 | [0.2.0]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.1.4...v0.2.0 102 | [0.1.4]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.1.3...v0.1.4 103 | [0.1.3]: https://github.com/pokutuna/mcp-chrome-tabs/compare/v0.1.2...v0.1.3 104 | [0.1.2]: https://github.com/pokutuna/mcp-chrome-tabs/releases/tag/v0.1.2 105 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | ### Building and Testing 8 | 9 | - `npm run build` - Compile TypeScript to JavaScript in `dist/` directory 10 | - `npm run test` - Run unit tests with Vitest 11 | - `npm test` - Alias for `npm run test` 12 | - `npm run test:watch` - Run tests in watch mode 13 | - `npm run test:e2e` - Run end-to-end tests with Playwright 14 | 15 | ### Development 16 | 17 | - `npm run dev` - Run the CLI directly from source using tsx 18 | - `npm run start` - Run the compiled version from dist/ 19 | - `npm run inspector` - Open MCP inspector for debugging 20 | 21 | ### Code Quality 22 | 23 | - `npm run lint` - Check code formatting with Prettier 24 | - `npm run lint:fix` - Fix code formatting issues 25 | 26 | ## Architecture 27 | 28 | This is an MCP (Model Context Protocol) server that provides access to browser tabs on macOS using AppleScript automation. 29 | 30 | ### Core Components 31 | 32 | - **src/mcp.ts** - Main MCP server implementation with tools and resources 33 | - **src/browser/** - Browser-specific implementations 34 | - **browser.ts** - Common browser interface and types 35 | - **chrome.ts** - Chrome-specific AppleScript automation 36 | - **safari.ts** - Safari-specific implementation (experimental) 37 | - **osascript.ts** - AppleScript execution utilities 38 | - **src/view.ts** - Content formatting and display utilities 39 | - **src/util.ts** - General utility functions 40 | - **src/cli.ts** - Command-line interface entry point 41 | 42 | ### Key Features 43 | 44 | The server provides three MCP tools: 45 | 46 | 1. `list_tabs` - List all browser tabs with IDs and metadata 47 | 2. `read_tab_content` - Extract readable content from tabs using Mozilla Readability 48 | 3. `open_in_new_tab` - Open new URLs in browser 49 | 50 | Resources: 51 | 52 | - `tab://current` - Active tab content 53 | - `tab://{windowId}/{tabId}` - Specific tab content 54 | 55 | ### macOS Requirements 56 | 57 | This project is macOS-only and requires: 58 | 59 | - "Allow JavaScript from Apple Events" enabled in Chrome 60 | - AppleScript permissions for browser automation 61 | - Node.js 20 or newer 62 | 63 | ### Testing Structure 64 | 65 | - **tests/unit/** - Unit tests for individual modules 66 | - **tests/integration/** - E2E tests using Playwright 67 | - Custom Chrome profile in `tests/integration/chrome-profile/` for isolated testing 68 | 69 | ### Configuration Options 70 | 71 | Command-line options: 72 | 73 | **Content Extraction Options** 74 | - `--max-content-chars` - Maximum content characters per single read (default: 20000) 75 | - `--extraction-timeout` - Timeout for content extraction worker in milliseconds (default: 20000) 76 | - `--exclude-hosts` - Comma-separated domains to exclude from access 77 | 78 | **Resource Options** 79 | - `--check-interval` - Tab change notification interval in ms (default: 0 disabled, set to 3000 for 3 seconds) 80 | 81 | **Browser Options** 82 | - `--application-name` - Target browser application (default: "Google Chrome") 83 | - `--experimental-browser` - Browser implementation to use: "chrome", "safari", or "arc" (default: "chrome") 84 | 85 | **Other Options** 86 | - `--help` - Show help message with all available options 87 | -------------------------------------------------------------------------------- /src/browser/chrome.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserInterface, TabRef, Tab, TabContent } from "./browser.js"; 2 | import { 3 | escapeAppleScript, 4 | executeAppleScript, 5 | separator, 6 | } from "./osascript.js"; 7 | 8 | async function getChromeTabList(applicationName: string): Promise { 9 | const sep = separator(); 10 | const appleScript = ` 11 | tell application "${applicationName}" 12 | set output to "" 13 | repeat with aWindow in (every window) 14 | set windowId to id of aWindow 15 | repeat with aTab in (every tab of aWindow) 16 | set tabId to id of aTab 17 | set tabTitle to title of aTab 18 | set tabURL to URL of aTab 19 | set output to output & windowId & "${sep}" & tabId & "${sep}" & tabTitle & "${sep}" & tabURL & "\\n" 20 | end repeat 21 | end repeat 22 | return output 23 | end tell 24 | `; 25 | 26 | const result = await executeAppleScript(appleScript); 27 | const lines = result.trim().split("\n"); 28 | const tabs: Tab[] = []; 29 | for (const line of lines) { 30 | const [wId, tId, title, url] = line.split(sep); 31 | if (!/^https?:\/\//.test(url)) continue; 32 | 33 | tabs.push({ 34 | windowId: wId, 35 | tabId: tId, 36 | title: title.trim(), 37 | url: url.trim(), 38 | }); 39 | } 40 | return tabs; 41 | } 42 | 43 | async function getPageContent( 44 | applicationName: string, 45 | tab?: TabRef | null 46 | ): Promise { 47 | const sep = separator(); 48 | const inner = ` 49 | set tabTitle to title 50 | set tabURL to URL 51 | set tabContent to execute javascript "document.documentElement.outerHTML" 52 | return tabTitle & "${sep}" & tabURL & "${sep}" & tabContent 53 | `; 54 | const appleScript = tab 55 | ? ` 56 | try 57 | tell application "${applicationName}" 58 | tell window id "${tab.windowId}" 59 | tell tab id "${tab.tabId}" 60 | (* Chrome によって suspend されたタブで js を実行すると動作が停止する 61 | タイムアウトにより osascript コマンドの実行を retry したくないので 62 | apple script 内で timeout をしてエラーを返すようにする *) 63 | with timeout of 3 seconds 64 | ${inner} 65 | end timeout 66 | end tell 67 | end tell 68 | end tell 69 | on error errMsg 70 | return "ERROR" & "${sep}" & errMsg 71 | end try 72 | ` 73 | : ` 74 | try 75 | tell application "${applicationName}" 76 | repeat with w in windows 77 | tell w 78 | set t to tab (active tab index) 79 | if URL of t is not "about:blank" then 80 | tell t 81 | ${inner} 82 | end tell 83 | end if 84 | end tell 85 | end repeat 86 | error "No active tab found" 87 | end tell 88 | on error errMsg 89 | return "ERROR" & "${sep}" & errMsg 90 | end try 91 | `; 92 | 93 | const scriptResult = await executeAppleScript(appleScript); 94 | if (scriptResult.startsWith(`ERROR${sep}`)) { 95 | throw new Error(scriptResult.split(sep)[1]); 96 | } 97 | 98 | const parts = scriptResult.split(sep).map((part) => part.trim()); 99 | if (parts.length < 3) { 100 | throw new Error("Failed to read the tab content"); 101 | } 102 | 103 | const [title, url, content] = parts; 104 | 105 | return { 106 | title, 107 | url, 108 | content, 109 | }; 110 | } 111 | 112 | async function openURL(applicationName: string, url: string): Promise { 113 | const escapedUrl = escapeAppleScript(url); 114 | const sep = separator(); 115 | const appleScript = ` 116 | tell application "${applicationName}" 117 | set newTab to (make new tab at end of tabs of front window with properties {URL:"${escapedUrl}"}) 118 | set windowId to id of front window 119 | set tabId to id of newTab 120 | return windowId & "${sep}" & tabId 121 | end tell 122 | `; 123 | const result = await executeAppleScript(appleScript); 124 | const [windowId, tabId] = result.trim().split(sep); 125 | return { windowId, tabId }; 126 | } 127 | 128 | export const chromeBrowser: BrowserInterface = { 129 | getTabList: getChromeTabList, 130 | getPageContent, 131 | openURL, 132 | }; 133 | -------------------------------------------------------------------------------- /src/browser/safari.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserInterface, TabRef, Tab, TabContent } from "./browser.js"; 2 | import { 3 | escapeAppleScript, 4 | executeAppleScript, 5 | separator, 6 | } from "./osascript.js"; 7 | 8 | async function getSafariTabList(applicationName: string): Promise { 9 | const sep = separator(); 10 | const appleScript = ` 11 | tell application "${applicationName}" 12 | set output to "" 13 | repeat with aWindow in (every window) 14 | set windowId to id of aWindow 15 | repeat with aTab in (every tab of aWindow) 16 | set tabIndex to index of aTab 17 | set tabTitle to name of aTab 18 | set tabURL to URL of aTab 19 | set output to output & windowId & "${sep}" & tabIndex & "${sep}" & tabTitle & "${sep}" & tabURL & "\\n" 20 | end repeat 21 | end repeat 22 | return output 23 | end tell 24 | `; 25 | 26 | const result = await executeAppleScript(appleScript); 27 | const lines = result.trim().split("\n"); 28 | const tabs: Tab[] = []; 29 | for (const line of lines) { 30 | const [wId, tId, title, url] = line.split(sep); 31 | if (!/^https?:\/\//.test(url)) continue; 32 | 33 | // Note: Safari tab IDs are volatile indices that change when tabs are closed 34 | // Unlike Chrome, Safari doesn't provide stable unique tab identifiers 35 | tabs.push({ 36 | windowId: wId, 37 | tabId: tId, 38 | title: title.trim(), 39 | url: url.trim(), 40 | }); 41 | } 42 | return tabs; 43 | } 44 | 45 | async function getPageContent( 46 | applicationName: string, 47 | tab?: TabRef | null 48 | ): Promise { 49 | const sep = separator(); 50 | const inner = ` 51 | set tabTitle to name 52 | set tabURL to URL 53 | set tabContent to do 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 ${tab.tabId} 62 | with timeout of 3 seconds 63 | ${inner} 64 | end timeout 65 | end tell 66 | end tell 67 | end tell 68 | on error errMsg 69 | return "ERROR" & "${sep}" & errMsg 70 | end try 71 | ` 72 | : ` 73 | try 74 | tell application "${applicationName}" 75 | tell front window 76 | set t to current tab 77 | if URL of t is not "about:blank" then 78 | tell t 79 | with timeout of 3 seconds 80 | ${inner} 81 | end timeout 82 | end tell 83 | else 84 | error "No active tab found" 85 | end if 86 | end tell 87 | end tell 88 | on error errMsg 89 | return "ERROR" & "${sep}" & errMsg 90 | end try 91 | `; 92 | 93 | const scriptResult = await executeAppleScript(appleScript); 94 | if (scriptResult.startsWith(`ERROR${sep}`)) { 95 | throw new Error(scriptResult.split(sep)[1]); 96 | } 97 | 98 | const parts = scriptResult.split(sep).map((part) => part.trim()); 99 | if (parts.length < 3) { 100 | throw new Error("Failed to read the tab content"); 101 | } 102 | 103 | const [title, url, content] = parts; 104 | 105 | return { 106 | title, 107 | url, 108 | content, 109 | }; 110 | } 111 | 112 | async function openURL(applicationName: string, url: string): Promise { 113 | const escapedUrl = escapeAppleScript(url); 114 | const sep = separator(); 115 | const appleScript = ` 116 | tell application "${applicationName}" 117 | tell front window 118 | set newTab to (make new tab with properties {URL:"${escapedUrl}"}) 119 | set windowId to id 120 | set tabIndex to index of newTab 121 | return (windowId as string) & "${sep}" & (tabIndex as string) 122 | end tell 123 | end tell 124 | `; 125 | const result = await executeAppleScript(appleScript); 126 | const [windowId, tabId] = result.trim().split(sep); 127 | return { windowId, tabId }; 128 | } 129 | 130 | export const safariBrowser: BrowserInterface = { 131 | getTabList: getSafariTabList, 132 | getPageContent, 133 | openURL, 134 | }; 135 | -------------------------------------------------------------------------------- /tests/integration/browser.e2e.ts: -------------------------------------------------------------------------------- 1 | import { test as base, expect, devices } from "@playwright/test"; 2 | import { getInterface, BrowserInterface } from "../../src/browser/browser.js"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | type MyFixtures = { 7 | applicationName: string; 8 | browserInterface: BrowserInterface; 9 | }; 10 | 11 | // Note: Safari support could be implemented, but Playwright's webkit cannot execute AppleScript 12 | const test = base.extend({ 13 | context: async ({ playwright }, use) => { 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 15 | const profilePath = path.resolve(__dirname, "chrome-profile"); 16 | const context = await playwright.chromium.launchPersistentContext( 17 | profilePath, // to keep AppleScript enabled 18 | { 19 | ...devices["Desktop Chrome"], 20 | channel: "chrome", 21 | } 22 | ); 23 | await use(context); 24 | await context.close(); 25 | }, 26 | browserInterface: async ({}, use) => { 27 | const browser = getInterface("chrome"); 28 | await use(browser); 29 | }, 30 | applicationName: async ({}, use) => { 31 | await use("Google Chrome"); 32 | }, 33 | }); 34 | 35 | test("getTabList", async ({ context, applicationName, browserInterface }) => { 36 | const pages = [ 37 | "http://example.com", 38 | "https://github.com/pokutuna/mcp-chrome-tabs", 39 | ]; 40 | for (const url of pages) { 41 | const page = await context.newPage(); 42 | await page.goto(url, { waitUntil: "domcontentloaded" }); 43 | } 44 | 45 | await expect(async () => { 46 | const tabs = await browserInterface.getTabList(applicationName); 47 | expect(tabs.length).toBeGreaterThanOrEqual(2); 48 | expect(tabs.find((t) => t.url.includes("example.com"))).toBeDefined(); 49 | expect( 50 | tabs.find((t) => t.url.includes("github.com/pokutuna/mcp-chrome-tabs")) 51 | ).toBeDefined(); 52 | }).toPass(); 53 | }); 54 | 55 | test("getTabContent with reference", async ({ 56 | context, 57 | applicationName, 58 | browserInterface, 59 | }) => { 60 | const page = await context.newPage(); 61 | await page.goto("http://example.com", { waitUntil: "domcontentloaded" }); 62 | 63 | const tabs = await browserInterface.getTabList(applicationName); 64 | const tab = tabs.find((t) => t.url.includes("example.com")); 65 | expect(tab).toBeDefined(); 66 | if (!tab) throw new Error("Tab not found"); 67 | 68 | const content = await browserInterface.getPageContent(applicationName, { 69 | windowId: tab.windowId, 70 | tabId: tab.tabId, 71 | }); 72 | expect(content).toHaveProperty("title"); 73 | expect(content).toHaveProperty("url"); 74 | expect(content).toHaveProperty("content"); 75 | }); 76 | 77 | test("getTabContent without reference", async ({ 78 | context, 79 | applicationName, 80 | browserInterface, 81 | }) => { 82 | const page = await context.newPage(); 83 | await page.goto("http://example.com", { waitUntil: "domcontentloaded" }); 84 | 85 | const content = await browserInterface.getPageContent(applicationName, null); 86 | expect(content).toHaveProperty("title"); 87 | expect(content).toHaveProperty("url"); 88 | expect(content).toHaveProperty("content"); 89 | }); 90 | 91 | test("openURL", async ({ applicationName, browserInterface }) => { 92 | const tabRef = await browserInterface.openURL( 93 | applicationName, 94 | "https://github.com/trending" 95 | ); 96 | 97 | // Verify that openURL returns a TabRef with windowId and tabId 98 | expect(tabRef).toHaveProperty("windowId"); 99 | expect(tabRef).toHaveProperty("tabId"); 100 | expect(typeof tabRef.windowId).toBe("string"); 101 | expect(typeof tabRef.tabId).toBe("string"); 102 | 103 | await expect(async () => { 104 | const tabs = await browserInterface.getTabList(applicationName); 105 | // Verify the tab exists in the tab list 106 | expect(tabs.some((t) => t.url.includes("github.com/trending"))).toBe(true); 107 | 108 | // Verify the returned TabRef matches a tab in the list 109 | const matchingTab = tabs.find( 110 | (t) => t.windowId === tabRef.windowId && t.tabId === tabRef.tabId 111 | ); 112 | expect(matchingTab).toBeDefined(); 113 | expect(matchingTab?.url).toContain("github.com/trending"); 114 | }).toPass(); 115 | 116 | // Test that we can immediately read content from the returned tab reference 117 | const content = await browserInterface.getPageContent( 118 | applicationName, 119 | tabRef 120 | ); 121 | expect(content).toHaveProperty("title"); 122 | expect(content).toHaveProperty("url"); 123 | expect(content).toHaveProperty("content"); 124 | expect(content.url).toContain("github.com/trending"); 125 | }); 126 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { parseArgs } from "util"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { createMcpServer, McpServerOptions } from "./mcp.js"; 6 | import type { Browser } from "./browser/browser.js"; 7 | 8 | type CliOptions = McpServerOptions & { 9 | help: boolean; 10 | }; 11 | 12 | function showHelp(): void { 13 | console.log( 14 | ` 15 | MCP Chrome Tabs Server 16 | 17 | USAGE: 18 | mcp-chrome-tabs [OPTIONS] 19 | 20 | CONTENT EXTRACTION OPTIONS: 21 | --max-content-chars= Maximum content characters per single read 22 | (default: 20000) 23 | 24 | --extraction-timeout= Timeout for content extraction worker in milliseconds 25 | (default: 20000) 26 | Example: 5000 27 | 28 | --exclude-hosts= Comma-separated list of hosts to exclude 29 | (default: "") 30 | Example: "github.com,example.com,test.com" 31 | 32 | RESOURCE OPTIONS: 33 | --check-interval= Interval for checking browser tabs in milliseconds 34 | and sending listChanged notifications 35 | (default: 0 disabled, set to 3000 for 3 seconds) 36 | Example: 3000 37 | 38 | BROWSER OPTIONS: 39 | --application-name= Application name to control via AppleScript 40 | (default: "Google Chrome") 41 | Example: "Google Chrome Canary" 42 | 43 | --experimental-browser= Browser implementation to use 44 | (default: "chrome") 45 | Options: "chrome", "safari", "arc" 46 | 47 | OTHER OPTIONS: 48 | --help Show this help message 49 | 50 | 51 | REQUIREMENTS: 52 | Chrome: 53 | Chrome must allow JavaScript from Apple Events: 54 | 1. Open Chrome 55 | 2. Go to View > Developer > Allow JavaScript from Apple Events 56 | 3. Enable the option 57 | 58 | MCP CONFIGURATION EXAMPLE: 59 | { 60 | "mcpServers": { 61 | "chrome-tabs": { 62 | "command": "npx", 63 | "args": ["-y", "@pokutuna/mcp-chrome-tabs"] 64 | } 65 | } 66 | } 67 | `.trimStart() 68 | ); 69 | } 70 | 71 | function parseCliArgs(args: string[]): CliOptions { 72 | const { values } = parseArgs({ 73 | args, 74 | options: { 75 | "max-content-chars": { 76 | type: "string", 77 | default: "20000", 78 | }, 79 | "extraction-timeout": { 80 | type: "string", 81 | default: "20000", 82 | }, 83 | "check-interval": { 84 | type: "string", 85 | default: "0", 86 | }, 87 | "exclude-hosts": { 88 | type: "string", 89 | default: "", 90 | }, 91 | "application-name": { 92 | type: "string", 93 | default: "Google Chrome", 94 | }, 95 | "experimental-browser": { 96 | type: "string", 97 | default: "", 98 | }, 99 | help: { 100 | type: "boolean", 101 | default: false, 102 | }, 103 | }, 104 | allowPositionals: false, 105 | tokens: true, 106 | }); 107 | 108 | function parseBrowserOption(browser: string): Browser { 109 | if (browser === "" || browser === "chrome") return "chrome"; 110 | if (browser === "safari") return "safari"; 111 | if (browser === "arc") return "arc"; 112 | throw new Error( 113 | `Invalid --experimental-browser option: "${browser}". Use "chrome", "safari", or "arc".` 114 | ); 115 | } 116 | 117 | function parseIntWithDefault( 118 | value: string, 119 | defaultValue: number, 120 | minValue: number = 0 121 | ): number { 122 | const parsed = parseInt(value, 10); 123 | if (isNaN(parsed) || parsed < minValue) return defaultValue; 124 | return parsed; 125 | } 126 | 127 | const parsed: CliOptions = { 128 | applicationName: values["application-name"], 129 | browser: parseBrowserOption(values["experimental-browser"]), 130 | excludeHosts: values["exclude-hosts"] 131 | .split(",") 132 | .map((d) => d.trim()) 133 | .filter(Boolean), 134 | checkInterval: parseIntWithDefault(values["check-interval"], 0, 0), 135 | maxContentChars: parseIntWithDefault(values["max-content-chars"], 20000, 1), 136 | extractionTimeout: parseIntWithDefault( 137 | values["extraction-timeout"], 138 | 20000, 139 | 1000 140 | ), 141 | help: values.help, 142 | }; 143 | return parsed; 144 | } 145 | 146 | async function main(): Promise { 147 | const options = parseCliArgs(process.argv.slice(2)); 148 | if (options.help) { 149 | showHelp(); 150 | process.exit(0); 151 | } 152 | const server = await createMcpServer(options); 153 | const transport = new StdioServerTransport(); 154 | await server.connect(transport); 155 | 156 | const shutdown = async () => { 157 | await transport.close(); 158 | await server.close(); 159 | process.exit(0); 160 | }; 161 | process.on("SIGINT", shutdown); 162 | process.on("SIGTERM", shutdown); 163 | } 164 | 165 | await main().catch(console.error); 166 | -------------------------------------------------------------------------------- /src/browser/arc.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserInterface, TabRef, Tab, TabContent } from "./browser.js"; 2 | import { 3 | escapeAppleScript, 4 | executeAppleScript, 5 | separator, 6 | } from "./osascript.js"; 7 | 8 | /* 9 | Arc browser implementation notes 10 | - Tab/Window IDs are UUIDs (unlike Chrome's numeric IDs) 11 | - The return value of "execute javascript" may be wrapped in "..." and escaped (e.g., \u003C), so decode it with JSON.parse 12 | - Directly telling the active tab (front window/active tab) can fail depending on the environment; 13 | even when unspecified, first resolve the active tab's windowId/tabId and execute via the ID-targeted path for stability 14 | */ 15 | 16 | async function getArcTabList(applicationName: string): Promise { 17 | const sep = separator(); 18 | const appleScript = ` 19 | tell application "${applicationName}" 20 | set output to "" 21 | repeat with aWindow in (every window) 22 | set windowId to id of aWindow 23 | repeat with aTab in (every tab of aWindow) 24 | set tabId to id of aTab 25 | set tabTitle to title of aTab 26 | set tabURL to URL of aTab 27 | set output to output & windowId & "${sep}" & tabId & "${sep}" & tabTitle & "${sep}" & tabURL & "\\n" 28 | end repeat 29 | end repeat 30 | return output 31 | end tell 32 | `; 33 | 34 | const result = await executeAppleScript(appleScript); 35 | const lines = result.trim().split("\n"); 36 | const tabs: Tab[] = []; 37 | for (const line of lines) { 38 | const [wId, tId, title, url] = line.split(sep); 39 | if (!/^https?:\/\//.test(url)) continue; 40 | 41 | tabs.push({ 42 | windowId: wId, 43 | tabId: tId, 44 | title: title.trim(), 45 | url: url.trim(), 46 | }); 47 | } 48 | return tabs; 49 | } 50 | 51 | async function getActiveTabRef(applicationName: string): Promise { 52 | const sep = separator(); 53 | const appleScript = ` 54 | try 55 | tell application "${applicationName}" 56 | set wId to id of front window 57 | set tId to id of active tab of front window 58 | return wId & "${sep}" & tId 59 | end tell 60 | on error errMsg 61 | return "ERROR" & "${sep}" & errMsg 62 | end try 63 | `; 64 | const result = await executeAppleScript(appleScript); 65 | if (result.startsWith(`ERROR${sep}`)) { 66 | throw new Error(result.split(sep)[1]); 67 | } 68 | const [windowId, tabId] = result.split(sep); 69 | return { windowId: windowId.trim(), tabId: tabId.trim() }; 70 | } 71 | 72 | async function getPageContent( 73 | applicationName: string, 74 | tab?: TabRef | null 75 | ): Promise { 76 | const sep = separator(); 77 | const inner = ` 78 | set tabTitle to title 79 | set tabURL to URL 80 | set tabContent to execute javascript "document.documentElement.outerHTML" 81 | return tabTitle & "${sep}" & tabURL & "${sep}" & tabContent 82 | `; 83 | 84 | const targetTab: TabRef = tab ?? (await getActiveTabRef(applicationName)); 85 | 86 | const appleScript = ` 87 | try 88 | tell application "${applicationName}" 89 | tell window id "${targetTab.windowId}" 90 | tell tab id "${targetTab.tabId}" 91 | with timeout of 3 seconds 92 | ${inner} 93 | end timeout 94 | end tell 95 | end tell 96 | end tell 97 | on error errMsg 98 | return "ERROR" & "${sep}" & errMsg 99 | end try 100 | `; 101 | 102 | const scriptResult = await executeAppleScript(appleScript); 103 | if (scriptResult.startsWith(`ERROR${sep}`)) { 104 | throw new Error(scriptResult.split(sep)[1]); 105 | } 106 | 107 | const parts = scriptResult.split(sep).map((part) => part.trim()); 108 | if (parts.length < 3) { 109 | throw new Error("Failed to read the tab content"); 110 | } 111 | 112 | const [title, url, rawContent] = parts; 113 | 114 | // Arc's "execute javascript" return string may be wrapped in "..." and escaped like \u003C. 115 | // In such cases, decode with JSON.parse to restore the raw HTML. 116 | let content = rawContent; 117 | if (content.startsWith('"') && content.endsWith('"')) { 118 | try { 119 | content = JSON.parse(content); 120 | } catch { 121 | // If decoding fails, return the value as-is 122 | } 123 | } 124 | 125 | return { 126 | title, 127 | url, 128 | content, 129 | }; 130 | } 131 | 132 | async function openURL(applicationName: string, url: string): Promise { 133 | const escapedUrl = escapeAppleScript(url); 134 | const sep = separator(); 135 | const appleScript = ` 136 | tell application "${applicationName}" 137 | tell front window 138 | set newTab to (make new tab with properties {URL:"${escapedUrl}"}) 139 | set windowId to id 140 | set tabId to id of (active tab) -- cannot retrieve id of newTab 141 | return windowId & "${sep}" & tabId 142 | end tell 143 | end tell 144 | `; 145 | const result = await executeAppleScript(appleScript); 146 | const [windowId, tabId] = result.trim().split(sep); 147 | return { windowId, tabId }; 148 | } 149 | 150 | export const arcBrowser: BrowserInterface = { 151 | getTabList: getArcTabList, 152 | getPageContent, 153 | openURL, 154 | }; 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @pokutuna/mcp-chrome-tabs 2 | 3 | [![npm version](https://badge.fury.io/js/@pokutuna%2Fmcp-chrome-tabs.svg)](https://badge.fury.io/js/@pokutuna%2Fmcp-chrome-tabs) 4 | 5 | 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. 6 | 7 | 8 | 9 | ## Key Features 10 | 11 | - **Direct browser tab access** - No web scraping needed, reads content from already open tabs 12 | - **Content optimized for AI** - Automatic content extraction and markdown conversion to reduce token usage 13 | - **Active tab shortcut** - Instant access to currently focused tab without specifying IDs 14 | - **MCP listChanged notifications** - Follows MCP protocol to notify tab changes (set `--check-interval` to enable) 15 | 16 | ## Requirements 17 | 18 | > [!IMPORTANT] 19 | > **macOS only** - This MCP server uses AppleScript and only works on macOS. 20 | 21 | - **Node.js** 20 or newer 22 | - **MCP Client** such as Claude Desktop, Claude Code, or any MCP-compatible client 23 | - **macOS** only (uses AppleScript for browser automation) 24 | 25 | ## Getting Started 26 | 27 | First, enable "Allow JavaScript from Apple Events" in Chrome: 28 | 29 | - (en) **View** > **Developer** > **Allow JavaScript from Apple Events** 30 | - (ja) **表示** > **開発 / 管理** > **Apple Events からの JavaScript を許可** 31 | 32 | When you first use the MCP server, macOS will prompt you to grant AppleScript automation permission to your MCP client (e.g., Claude Desktop, Claude Code). Click **OK** to allow access to Chrome. If you accidentally dismissed the dialog, you can enable it in **System Settings** > **Privacy & Security** > **Automation**. 33 | 34 | Standard config works in most MCP clients (e.g., `.claude.json`, `.mcp.json`): 35 | 36 | ```json 37 | { 38 | "mcpServers": { 39 | "chrome-tabs": { 40 | "command": "npx", 41 | "args": ["-y", "@pokutuna/mcp-chrome-tabs@latest"] 42 | } 43 | } 44 | } 45 | ``` 46 | 47 | Or for Claude Code: 48 | 49 | ```bash 50 | claude mcp add -s user chrome-tabs -- npx -y @pokutuna/mcp-chrome-tabs@latest 51 | ``` 52 | 53 | ### Command Line Options 54 | 55 | The server accepts optional command line arguments for configuration: 56 | 57 | **Content Extraction Options** 58 | - `--max-content-chars` - Maximum content characters per single read (default: 20000) 59 | - `--extraction-timeout` - Timeout for content extraction worker in milliseconds (default: 20000) 60 | - `--exclude-hosts` - Comma-separated list of domains to exclude from tab listing and content access 61 | 62 | **Resource Options** 63 | - `--check-interval` - Interval in milliseconds to check for tab changes and send listChanged notifications (default: 0 disabled, set to 3000 for 3 seconds) 64 | 65 | **Browser Options** 66 | - `--application-name` - Application name to control (default: "Google Chrome") 67 | - `--experimental-browser` - Browser implementation to use: "chrome", "safari", or "arc" (default: "chrome") 68 | 69 | **Other Options** 70 | - `--help` - Show help message with all available options 71 | 72 | ### Resource Subscription (Optional) 73 | 74 | Setting `--check-interval` to a value greater than 0 enables resource subscription. When enabled, the server monitors tab list changes and sends MCP `listChanged` notifications to prompt clients to refresh their resource lists. This also makes `tab://{windowId}/{tabId}` resources available for all open tabs. 75 | 76 | In 2025-10, few MCP clients support resource subscriptions. Resource subscription is disabled by default (`--check-interval=0`). Most users only need the `tab://current` resource, which is always available. 77 | 78 | To enable resource subscription: 79 | 80 | ```json 81 | { 82 | "mcpServers": { 83 | "chrome-tabs": { 84 | "command": "npx", 85 | "args": [ 86 | "-y", 87 | "@pokutuna/mcp-chrome-tabs@latest", 88 | "--check-interval=3000" 89 | ] 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | ## Other Browser Support (Experimental) 96 | 97 | ### Safari 98 | 99 | Note that Safari lacks unique tab IDs, making it sensitive to tab order changes during execution: 100 | 101 | ```bash 102 | npx @pokutuna/mcp-chrome-tabs --application-name=Safari --experimental-browser=safari 103 | ``` 104 | 105 | ### Arc 106 | 107 | ```bash 108 | npx @pokutuna/mcp-chrome-tabs --application-name=Arc --experimental-browser=arc 109 | ``` 110 | 111 | ## MCP Features 112 | 113 | ### Tools 114 | 115 |
116 | list_tabs 117 | 118 | List all open tabs in the user's browser with their titles, URLs, and tab references. 119 | 120 | - Returns: Markdown formatted list of tabs with tab IDs for reference 121 | 122 |
123 | 124 |
125 | read_tab_content 126 | 127 | Get readable content from a tab in the user's browser. 128 | 129 | - `id` (optional): Tab reference from `list_tabs` output (e.g., `ID:12345:67890`) 130 | - If `id` is omitted, uses the currently active tab 131 | - Returns: Clean, readable content extracted using Mozilla Readability 132 | 133 |
134 | 135 |
136 | open_in_new_tab 137 | 138 | Open a URL in a new tab to present content or enable user interaction with webpages. 139 | 140 | - `url` (required): URL to open in the browser 141 | - Returns: Tab ID in format `ID:windowId:tabId` for immediate access to the new tab 142 | 143 |
144 | 145 | ### Resources 146 | 147 |
148 | tab://current 149 | 150 | Resource representing the content of the currently active tab. 151 | 152 | - **URI**: `tab://current` 153 | - **MIME type**: `text/markdown` 154 | - **Content**: Real-time content of the active browser tab 155 | - **Always available** regardless of `--check-interval` setting 156 | 157 |
158 | 159 |
160 | tab://{windowId}/{tabId} 161 | 162 | Resource template for accessing specific tabs. 163 | 164 | - **URI pattern**: `tab://{windowId}/{tabId}` 165 | - **MIME type**: `text/markdown` 166 | - **Content**: Content of the specified tab 167 | - **Availability**: Only when `--check-interval` is set to a value greater than 0 168 | - Resources are dynamically generated based on currently open tabs 169 | - When enabled, the server monitors tab changes and sends MCP listChanged notifications 170 | 171 |
172 | 173 | ## Troubleshooting 174 | 175 | ### `Current Tabs (0 tabs exists)` is displayed 176 | 177 | Ensure "Allow JavaScript from Apple Events" is enabled in Chrome (see [Getting Started](#getting-started)). 178 | 179 | If it was working before, try restarting your browser. 180 | -------------------------------------------------------------------------------- /tests/view.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { formatTabContent } from "../src/view.js"; 3 | import type { TabContent } from "../src/browser/browser.js"; 4 | 5 | describe("formatTabContent", () => { 6 | const mockTab: TabContent = { 7 | title: "Test Page", 8 | url: "https://example.com/test", 9 | content: 10 | "This is a very long content that should be truncated when the content character limit is applied. ".repeat( 11 | 100 12 | ), 13 | }; 14 | 15 | const shortTab: TabContent = { 16 | title: "Short Page", 17 | url: "https://example.com/short", 18 | content: "This is short content.", 19 | }; 20 | 21 | describe("basic functionality", () => { 22 | it("should format tab content without pagination", () => { 23 | const result = formatTabContent(shortTab); 24 | expect(result).toContain("---"); 25 | expect(result).toContain("url: https://example.com/short"); 26 | expect(result).toContain("title: Short Page"); 27 | expect(result).toContain("This is short content."); 28 | }); 29 | 30 | it("should format tab content with unlimited page size", () => { 31 | const result = formatTabContent(mockTab); 32 | expect(result).toContain("---"); 33 | expect(result).toContain("url: https://example.com/test"); 34 | expect(result).toContain("title: Test Page"); 35 | expect(result).toContain("This is a very long content"); 36 | }); 37 | }); 38 | 39 | describe("pagination functionality", () => { 40 | it("should not truncate content when under content limit", () => { 41 | const result = formatTabContent(shortTab, 0, 1000); 42 | expect(result).not.toContain("truncated:"); 43 | expect(result).not.toContain("Content truncated"); 44 | expect(result).toContain("This is short content."); 45 | }); 46 | 47 | it("should truncate content when over content limit", () => { 48 | const result = formatTabContent(mockTab, 0, 100); 49 | expect(result).toContain("Content truncated"); 50 | expect(result).toContain("truncated: true"); 51 | expect(result).toContain("startIndex of 100"); 52 | }); 53 | 54 | it("should handle startIndex correctly", () => { 55 | const result = formatTabContent(mockTab, 50, 100); 56 | expect(result).toContain("startIndex: 50"); 57 | expect(result).toContain("truncated: true"); 58 | expect(result).toContain("startIndex of 150"); 59 | }); 60 | 61 | it("should handle startIndex with no truncation needed", () => { 62 | const result = formatTabContent(shortTab, 5, 1000); 63 | expect(result).toContain("startIndex: 5"); 64 | expect(result).not.toContain("truncated:"); 65 | expect(result).not.toContain("next_start"); 66 | }); 67 | 68 | it("should handle edge case where content exactly matches limit", () => { 69 | const exactTab: TabContent = { 70 | ...shortTab, 71 | content: "a".repeat(100), 72 | }; 73 | const result = formatTabContent(exactTab, 0, 100); 74 | expect(result).not.toContain("Content truncated"); 75 | expect(result).not.toContain("truncated:"); 76 | }); 77 | 78 | it("should work with startIndex at end of content", () => { 79 | const contentLength = shortTab.content.length; 80 | const result = formatTabContent(shortTab, contentLength, 1000); 81 | expect(result).toContain("startIndex: " + contentLength); 82 | expect(result).not.toContain("truncated:"); 83 | }); 84 | }); 85 | 86 | describe("front matter", () => { 87 | it("should include startIndex when startIndex > 0", () => { 88 | const result = formatTabContent(shortTab, 5); 89 | expect(result).toContain("startIndex: 5"); 90 | expect(result).not.toContain("truncated:"); 91 | }); 92 | 93 | it("should include truncated when content is actually truncated", () => { 94 | const result = formatTabContent(mockTab, 0, 100); 95 | expect(result).toContain("truncated: true"); 96 | }); 97 | 98 | it("should not include pagination metadata for simple case", () => { 99 | const result = formatTabContent(shortTab); 100 | expect(result).not.toContain("startIndex:"); 101 | expect(result).not.toContain("truncated:"); 102 | }); 103 | 104 | it("should not include truncated when maxContentChars is specified but no truncation occurs", () => { 105 | const result = formatTabContent(shortTab, 0, 1000); 106 | expect(result).not.toContain("truncated:"); 107 | }); 108 | 109 | it("should include both startIndex and truncated when both apply", () => { 110 | const result = formatTabContent(mockTab, 50, 100); 111 | expect(result).toContain("startIndex: 50"); 112 | expect(result).toContain("truncated: true"); 113 | }); 114 | }); 115 | 116 | describe("pagination end-to-end", () => { 117 | it("should allow reading entire long content through pagination", () => { 118 | // Create predictable content for testing 119 | const longContent = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".repeat(10); // 260 chars 120 | const longTab: TabContent = { 121 | title: "Long Content", 122 | url: "https://example.com/long", 123 | content: longContent, 124 | }; 125 | 126 | const pageSize = 50; 127 | let collectedContent = ""; 128 | let currentIndex = 0; 129 | let iterations = 0; 130 | const maxIterations = 10; // Safety guard 131 | 132 | // Read content page by page 133 | while (currentIndex < longContent.length && iterations < maxIterations) { 134 | const result = formatTabContent(longTab, currentIndex, pageSize); 135 | 136 | // Extract content portion (after front matter) 137 | const contentPart = result.split("\n---\n")[1]; 138 | 139 | const isTruncated = result.includes("truncated: true"); 140 | 141 | if (isTruncated) { 142 | // Find content before ERROR message 143 | const errorIndex = contentPart.indexOf(""); 144 | const pageContent = contentPart.slice(0, errorIndex - 2); // Remove the \n\n before ERROR 145 | 146 | // Should be exactly pageSize characters 147 | expect(pageContent.length).toBe(pageSize); 148 | 149 | // Extract next startIndex from error message 150 | const nextIndexMatch = result.match(/startIndex of (\d+)/); 151 | expect(nextIndexMatch).toBeTruthy(); 152 | const nextIndex = parseInt(nextIndexMatch![1]); 153 | expect(nextIndex).toBe(currentIndex + pageSize); 154 | 155 | collectedContent += pageContent; 156 | currentIndex = nextIndex; 157 | } else { 158 | // Last page - add remaining content 159 | const pageContent = contentPart; 160 | collectedContent += pageContent; 161 | break; 162 | } 163 | 164 | iterations++; 165 | } 166 | 167 | // Verify we read the entire content without loss 168 | expect(collectedContent).toBe(longContent); 169 | expect(iterations).toBeLessThan(maxIterations); // Ensure we didn't hit safety guard 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /sketch/content-extraction-levels.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | 3 | /** 4 | * Content Extraction Levels Verification Script 5 | * 6 | * Issue#9 の準備実装として、defuddle で取得できるコンテンツのバリエーションを検証する 7 | * 3つの異なる抽出レベルでのコンテンツを比較する: 8 | * - auto: defuddle による自動抽出 (現在の動作) 9 | * - text: DOM の textContent による生テキスト抽出 10 | * - html: 完全な HTML ソース 11 | */ 12 | 13 | import { Defuddle } from "defuddle/node"; 14 | import { JSDOM } from "jsdom"; 15 | 16 | interface ContentResult { 17 | level: "auto" | "text" | "html"; 18 | content: string; 19 | size: number; 20 | preview: string; 21 | } 22 | 23 | /** 24 | * 異なる抽出レベルでコンテンツを処理する 25 | */ 26 | async function extractContentWithLevels( 27 | html: string, 28 | url: string 29 | ): Promise { 30 | const results: ContentResult[] = []; 31 | 32 | // Level: auto (defuddle) 33 | try { 34 | const defuddleResult = await Defuddle(html, url, { markdown: true }); 35 | const autoContent = defuddleResult?.content || ""; 36 | results.push({ 37 | level: "auto", 38 | content: autoContent, 39 | size: autoContent.length, 40 | preview: autoContent, // 全文表示 41 | }); 42 | } catch (error) { 43 | const errorMessage = `ERROR: ${error instanceof Error ? error.message : String(error)}`; 44 | results.push({ 45 | level: "auto", 46 | content: errorMessage, 47 | size: 0, 48 | preview: errorMessage, 49 | }); 50 | } 51 | 52 | // Level: text (textContent) 53 | try { 54 | const dom = new JSDOM(html); 55 | const textContent = dom.window.document.body?.textContent || ""; 56 | results.push({ 57 | level: "text", 58 | content: textContent, 59 | size: textContent.length, 60 | preview: textContent, // 全文表示 61 | }); 62 | } catch (error) { 63 | const errorMessage = `ERROR: ${error instanceof Error ? error.message : String(error)}`; 64 | results.push({ 65 | level: "text", 66 | content: errorMessage, 67 | size: 0, 68 | preview: errorMessage, 69 | }); 70 | } 71 | 72 | // Level: html (full HTML) 73 | const htmlContent = html; 74 | results.push({ 75 | level: "html", 76 | content: htmlContent, 77 | size: htmlContent.length, 78 | preview: 79 | htmlContent.slice(0, 200) + (htmlContent.length > 200 ? "..." : ""), // preview のみ 80 | }); 81 | 82 | return results; 83 | } 84 | 85 | /** 86 | * 現在のアクティブタブからコンテンツを取得する (AppleScript 経由) 87 | */ 88 | async function getCurrentTabContent( 89 | applicationName: string = "Google Chrome" 90 | ): Promise<{ title: string; url: string; html: string } | null> { 91 | try { 92 | const { executeAppleScript, separator } = await import( 93 | "../src/browser/osascript.js" 94 | ); 95 | const sep = separator(); 96 | 97 | // AppleScript でアクティブタブのコンテンツを直接取得 98 | const appleScript = ` 99 | try 100 | tell application "${applicationName}" 101 | repeat with w in windows 102 | tell w 103 | set t to tab (active tab index) 104 | if URL of t is not "about:blank" then 105 | tell t 106 | set tabTitle to title 107 | set tabURL to URL 108 | set tabContent to execute javascript "document.documentElement.outerHTML" 109 | return tabTitle & "${sep}" & tabURL & "${sep}" & tabContent 110 | end tell 111 | end if 112 | end tell 113 | end repeat 114 | error "No active tab found" 115 | end tell 116 | on error errMsg 117 | return "ERROR" & "${sep}" & errMsg 118 | end try 119 | `; 120 | 121 | const result = await executeAppleScript(appleScript); 122 | if (result.startsWith(`ERROR${sep}`)) { 123 | throw new Error(result.split(sep)[1]); 124 | } 125 | 126 | const parts = result.split(sep).map((part) => part.trim()); 127 | if (parts.length < 3) { 128 | throw new Error("Failed to read the tab content"); 129 | } 130 | 131 | const [title, url, html] = parts; 132 | return { title, url, html }; 133 | } catch (error) { 134 | console.error("❌ Error getting current tab content:", error); 135 | return null; 136 | } 137 | } 138 | 139 | /** 140 | * 結果を表示する 141 | */ 142 | function displayResults(sampleName: string, results: ContentResult[]): void { 143 | console.log(`\n━━━ ${sampleName} ━━━`); 144 | 145 | results.forEach((result) => { 146 | console.log(`\n📄 Level: ${result.level.toUpperCase()}`); 147 | console.log(`📏 Size: ${result.size} characters`); 148 | 149 | if (result.level === "html") { 150 | console.log(`👀 Preview:`); 151 | console.log(result.preview); 152 | } else { 153 | console.log(`📄 Content:`); 154 | console.log(result.preview); // auto と text は全文 155 | } 156 | 157 | console.log(`${"─".repeat(50)}`); 158 | }); 159 | 160 | // サイズ比較 161 | const autoSize = results.find((r) => r.level === "auto")?.size || 0; 162 | const textSize = results.find((r) => r.level === "text")?.size || 0; 163 | const htmlSize = results.find((r) => r.level === "html")?.size || 0; 164 | 165 | console.log(`\n📊 Size Comparison:`); 166 | console.log( 167 | ` auto: ${autoSize} chars (${((autoSize / htmlSize) * 100).toFixed(1)}% of HTML)` 168 | ); 169 | console.log( 170 | ` text: ${textSize} chars (${((textSize / htmlSize) * 100).toFixed(1)}% of HTML)` 171 | ); 172 | console.log(` html: ${htmlSize} chars (100.0% of HTML)`); 173 | } 174 | 175 | /** 176 | * メイン実行関数 177 | */ 178 | async function main(): Promise { 179 | console.log("🔍 Content Extraction Levels Verification"); 180 | console.log("Testing different content extraction approaches for issue#9\n"); 181 | 182 | // 現在のタブからコンテンツを取得 183 | console.log("📱 Getting content from current tab..."); 184 | const currentTab = await getCurrentTabContent(); 185 | 186 | if (!currentTab) { 187 | console.error("❌ Could not get current tab content."); 188 | console.error("Please make sure:"); 189 | console.error(" 1. Chrome is running with an active tab"); 190 | console.error( 191 | " 2. 'Allow JavaScript from Apple Events' is enabled in Chrome" 192 | ); 193 | console.error(" 3. The tab is not suspended or blank"); 194 | process.exit(1); 195 | } 196 | 197 | console.log(`🌐 Current tab: ${currentTab.title}`); 198 | console.log(`🔗 URL: ${currentTab.url}\n`); 199 | 200 | try { 201 | const results = await extractContentWithLevels( 202 | currentTab.html, 203 | currentTab.url 204 | ); 205 | displayResults("Current Tab", results); 206 | } catch (error) { 207 | console.error("❌ Error processing current tab:", error); 208 | process.exit(1); 209 | } 210 | 211 | console.log("\n✨ Verification complete!"); 212 | console.log("\n💡 Key Observations:"); 213 | console.log( 214 | " - 'auto' level provides clean, readable content but may miss some information" 215 | ); 216 | console.log( 217 | " - 'text' level gives raw text without formatting, useful for fallback" 218 | ); 219 | console.log( 220 | " - 'html' level provides complete content but requires client-side processing" 221 | ); 222 | console.log("\n📋 Next Steps:"); 223 | console.log( 224 | " 1. Implement content_level parameter in read_tab_content tool" 225 | ); 226 | console.log(" 2. Add proper error handling for each extraction method"); 227 | console.log(" 3. Consider HTML sanitization for security"); 228 | console.log(" 4. Update tool schema and documentation"); 229 | } 230 | 231 | // スクリプトとして直接実行された場合のみ main を呼び出す 232 | if (import.meta.url === `file://${process.argv[1]}`) { 233 | main().catch((error) => { 234 | console.error("❌ Script failed:", error); 235 | process.exit(1); 236 | }); 237 | } 238 | 239 | export { extractContentWithLevels, getCurrentTabContent }; 240 | -------------------------------------------------------------------------------- /src/mcp.ts: -------------------------------------------------------------------------------- 1 | import { 2 | McpServer, 3 | ResourceTemplate, 4 | } from "@modelcontextprotocol/sdk/server/mcp.js"; 5 | import { z } from "zod"; 6 | import { 7 | Browser, 8 | Tab, 9 | TabRef, 10 | TabContent, 11 | getInterface, 12 | } from "./browser/browser.js"; 13 | import { readFile } from "fs/promises"; 14 | import { dirname, join } from "path"; 15 | import { fileURLToPath } from "url"; 16 | import { createHash } from "crypto"; 17 | import * as view from "./view.js"; 18 | import { runDefuddleInWorker } from "./util.js"; 19 | 20 | export type McpServerOptions = { 21 | applicationName: string; 22 | excludeHosts: string[]; 23 | checkInterval: number; 24 | browser: Browser; 25 | maxContentChars: number; 26 | extractionTimeout: number; 27 | }; 28 | 29 | function isExcludedHost(url: string, excludeHosts: string[]): boolean { 30 | const u = new URL(url); 31 | return excludeHosts.some( 32 | (d) => u.hostname === d || u.hostname.endsWith("." + d) 33 | ); 34 | } 35 | 36 | async function listTabs(opts: McpServerOptions): Promise { 37 | const browser = getInterface(opts.browser); 38 | const tabs = await browser.getTabList(opts.applicationName); 39 | return tabs.filter((t) => !isExcludedHost(t.url, opts.excludeHosts)); 40 | } 41 | 42 | async function getTab( 43 | tabRef: TabRef | null, 44 | opts: McpServerOptions 45 | ): Promise { 46 | const browser = getInterface(opts.browser); 47 | const raw = await browser.getPageContent(opts.applicationName, tabRef); 48 | if (isExcludedHost(raw.url, opts.excludeHosts)) { 49 | throw new Error("Content not available for excluded host"); 50 | } 51 | try { 52 | const content = await runDefuddleInWorker( 53 | raw.content, 54 | raw.url, 55 | opts.extractionTimeout 56 | ); 57 | return { 58 | title: raw.title, 59 | url: raw.url, 60 | content, 61 | }; 62 | } catch (error) { 63 | throw new Error( 64 | `Failed to extract content: ${error instanceof Error ? error.message : String(error)}` 65 | ); 66 | } 67 | } 68 | 69 | async function packageVersion(): Promise { 70 | const packageJsonText = await readFile( 71 | join(dirname(fileURLToPath(import.meta.url)), "../package.json"), 72 | "utf8" 73 | ); 74 | const packageJson = JSON.parse(packageJsonText); 75 | return packageJson.version; 76 | } 77 | 78 | function hashTabList(tabs: Tab[]): string { 79 | const sortedTabs = tabs.slice().sort((a, b) => { 80 | if (a.windowId !== b.windowId) return a.windowId < b.windowId ? -1 : 1; 81 | if (a.tabId !== b.tabId) return a.tabId < b.tabId ? -1 : 1; 82 | return 0; 83 | }); 84 | const dump = sortedTabs 85 | .map((tab) => `${tab.windowId}:${tab.tabId}:${tab.title}:${tab.url}`) 86 | .join("|"); 87 | return createHash("sha256").update(dump, "utf8").digest("hex"); 88 | } 89 | 90 | export async function createMcpServer( 91 | options: McpServerOptions 92 | ): Promise { 93 | const server = new McpServer( 94 | { 95 | name: "chrome-tabs", 96 | title: "Chrome Tabs", 97 | version: await packageVersion(), 98 | }, 99 | { 100 | instructions: "Use this server to access the user's open browser tabs.", 101 | capabilities: { 102 | resources: { 103 | listChanged: true, 104 | }, 105 | }, 106 | debouncedNotificationMethods: ["notifications/resources/list_changed"], 107 | } 108 | ); 109 | 110 | server.registerTool( 111 | "list_tabs", 112 | { 113 | title: "List Tabs", 114 | description: 115 | "List all open tabs in the user's browser with their titles and tab references.", 116 | inputSchema: { 117 | includeUrl: z 118 | .boolean() 119 | .optional() 120 | .default(false) 121 | .describe( 122 | "Include URLs in the output. Enable only when you need to reference specific URLs. (default: false, hostnames always included)" 123 | ), 124 | }, 125 | }, 126 | async (args) => { 127 | const { includeUrl } = args; 128 | const tabs = await listTabs(options); 129 | return { 130 | content: [ 131 | { 132 | type: "text", 133 | text: view.formatList(tabs, includeUrl), 134 | }, 135 | ], 136 | }; 137 | } 138 | ); 139 | 140 | server.registerTool( 141 | "read_tab_content", 142 | { 143 | title: "Read Tab Content", 144 | description: 145 | "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.", 146 | inputSchema: { 147 | id: z 148 | .string() 149 | .optional() 150 | .describe( 151 | "Tab reference from list_tabs output (e.g: ID:12345:67890). If omitted, uses the currently active tab." 152 | ), 153 | startIndex: z 154 | .number() 155 | .int() 156 | .nonnegative() 157 | .optional() 158 | .default(0) 159 | .describe( 160 | "Starting character position for content extraction (default: 0)" 161 | ), 162 | }, 163 | }, 164 | async (args) => { 165 | const { id, startIndex } = args; 166 | const tab = await getTab(id ? view.parseTabRef(id) : null, options); 167 | return { 168 | content: [ 169 | { 170 | type: "text", 171 | text: view.formatTabContent( 172 | tab, 173 | startIndex, 174 | options.maxContentChars 175 | ), 176 | }, 177 | ], 178 | }; 179 | } 180 | ); 181 | 182 | server.registerTool( 183 | "open_in_new_tab", 184 | { 185 | title: "Open in New Tab", 186 | description: 187 | "Open a URL in a new tab to present content or enable user interaction with webpages", 188 | inputSchema: { 189 | url: z.string().url().describe("URL to open in the browser"), 190 | }, 191 | }, 192 | async (args) => { 193 | const { url } = args; 194 | const browser = getInterface(options.browser); 195 | const tabRef = await browser.openURL(options.applicationName, url); 196 | const tabId = `ID:${tabRef.windowId}:${tabRef.tabId}`; 197 | return { 198 | content: [ 199 | { 200 | type: "text", 201 | text: `Successfully opened URL in new tab. Tab: \`${tabId}\``, 202 | }, 203 | ], 204 | }; 205 | } 206 | ); 207 | 208 | server.registerResource( 209 | "current_tab", 210 | "tab://current", 211 | { 212 | title: "Active Browser Tab", 213 | description: "Content of the currently active tab in the user's browser", 214 | mimeType: "text/markdown", 215 | }, 216 | async (uri) => { 217 | const tab = await getTab(null, options); 218 | // TODO: Add pagination support for resources (startIndex parameter) 219 | const text = view.formatTabContent(tab, 0, undefined); 220 | return { 221 | contents: [ 222 | { 223 | uri: uri.href, 224 | text, 225 | mimeType: "text/markdown", 226 | }, 227 | ], 228 | }; 229 | } 230 | ); 231 | 232 | // Register tab://{windowId}/{tabId} resources only when checkInterval > 0 233 | // In 2025-10, few MCP clients support resource subscriptions, so this is disabled by default 234 | if (options.checkInterval > 0) { 235 | server.registerResource( 236 | "tabs", 237 | new ResourceTemplate(view.uriTemplate, { 238 | list: async () => { 239 | const tabs = await listTabs(options); 240 | return { 241 | resources: tabs.map((tab) => ({ 242 | uri: view.formatUri(tab), 243 | name: view.formatTabName(tab), 244 | mimeType: "text/markdown", 245 | })), 246 | }; 247 | }, 248 | }), 249 | { 250 | title: "Browser Tabs", 251 | description: "Content of a specific tab in the user's browser", 252 | mimeType: "text/markdown", 253 | }, 254 | async (uri, { windowId, tabId }) => { 255 | const tabRef: TabRef = { 256 | windowId: String(windowId), 257 | tabId: String(tabId), 258 | }; 259 | const tab = await getTab(tabRef, options); 260 | // TODO: Add pagination support for resources (startIndex parameter) 261 | const text = view.formatTabContent(tab, 0, undefined); 262 | return { 263 | contents: [ 264 | { 265 | uri: uri.href, 266 | mimeType: "text/markdown", 267 | text, 268 | }, 269 | ], 270 | }; 271 | } 272 | ); 273 | } 274 | 275 | if (options.checkInterval > 0) { 276 | let lastHash: string = hashTabList(await listTabs(options)); 277 | const check = async () => { 278 | try { 279 | const hash = hashTabList(await listTabs(options)); 280 | if (hash !== lastHash) { 281 | server.sendResourceListChanged(); 282 | lastHash = hash; 283 | } 284 | } catch (error) { 285 | console.error("Error during periodic tab list update:", error); 286 | } 287 | // Use setTimeout instead of setInterval to avoid overlapping calls 288 | setTimeout(check, options.checkInterval); 289 | }; 290 | check(); 291 | } 292 | 293 | return server; 294 | } 295 | -------------------------------------------------------------------------------- /tests/mcp.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from "vitest"; 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 3 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; 4 | import { createMcpServer, type McpServerOptions } from "../src/mcp.js"; 5 | import type { 6 | Tab, 7 | TabContent, 8 | BrowserInterface, 9 | } from "../src/browser/browser.js"; 10 | 11 | // Mock the getInterface function from browser.js 12 | const mockBrowserInterface: BrowserInterface = { 13 | getTabList: vi.fn(), 14 | getPageContent: vi.fn(), 15 | openURL: vi.fn(), 16 | }; 17 | 18 | vi.mock("../src/browser/browser.js", async (importOriginal) => { 19 | const actual = await importOriginal(); 20 | return { 21 | ...(actual as object), 22 | getInterface: vi.fn(() => mockBrowserInterface), 23 | }; 24 | }); 25 | 26 | const defaultTestOptions: McpServerOptions = { 27 | applicationName: "Google Chrome", 28 | browser: "chrome" as const, 29 | excludeHosts: [], 30 | checkInterval: 0, 31 | maxContentChars: 20000, 32 | extractionTimeout: 20000, 33 | }; 34 | 35 | const mockTabs: Tab[] = [ 36 | { 37 | windowId: "1001", 38 | tabId: "2001", 39 | title: "Example Page", 40 | url: "https://example.com/page1", 41 | }, 42 | { 43 | windowId: "1001", 44 | tabId: "2002", 45 | title: "GitHub", 46 | url: "https://github.com/user/repo", 47 | }, 48 | { 49 | windowId: "1002", 50 | tabId: "2003", 51 | title: "Test Site", 52 | url: "https://test.com/page", 53 | }, 54 | ]; 55 | 56 | const mockPageContent: TabContent = { 57 | title: "Example Page", 58 | url: "https://example.com/page1", 59 | content: ` 60 | 61 | 62 | Example Page 63 | 64 | 65 |

Example

66 |

This is test content.

67 | 68 | `, 69 | }; 70 | 71 | describe("MCP Server", () => { 72 | let client: Client; 73 | let server: any; 74 | 75 | beforeEach(async () => { 76 | vi.clearAllMocks(); 77 | 78 | // Create test client 79 | client = new Client({ 80 | name: "test client", 81 | version: "0.1.0", 82 | }); 83 | 84 | // Create server 85 | const options = defaultTestOptions; 86 | server = await createMcpServer(options); 87 | 88 | // Connect client and server via in-memory transport 89 | const [clientTransport, serverTransport] = 90 | InMemoryTransport.createLinkedPair(); 91 | await Promise.all([ 92 | client.connect(clientTransport), 93 | server.connect(serverTransport), 94 | ]); 95 | }); 96 | 97 | describe("list_tabs tool", () => { 98 | it("should return all tabs when no domains are excluded", async () => { 99 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 100 | 101 | const result = await client.callTool({ 102 | name: "list_tabs", 103 | arguments: {}, 104 | }); 105 | 106 | expect(result.content).toHaveLength(1); 107 | const text = (result.content as any)[0].text; 108 | expect(text).toContain("### Current Tabs (3 tabs exists)"); 109 | expect(text).toContain("ID:1001:2001 Example Page (example.com)"); 110 | expect(text).toContain("ID:1001:2002 GitHub (github.com)"); 111 | expect(text).toContain("ID:1002:2003 Test Site (test.com)"); 112 | }); 113 | 114 | it("should include full URLs when includeUrl is true", async () => { 115 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 116 | 117 | const result = await client.callTool({ 118 | name: "list_tabs", 119 | arguments: { includeUrl: true }, 120 | }); 121 | 122 | expect(result.content).toHaveLength(1); 123 | const text = (result.content as any)[0].text; 124 | expect(text).toContain("### Current Tabs (3 tabs exists)"); 125 | expect(text).toContain( 126 | "ID:1001:2001 [Example Page](https://example.com/page1)" 127 | ); 128 | expect(text).toContain( 129 | "ID:1001:2002 [GitHub](https://github.com/user/repo)" 130 | ); 131 | expect(text).toContain("ID:1002:2003 [Test Site](https://test.com/page)"); 132 | }); 133 | 134 | it("should filter out excluded domains", async () => { 135 | // Create server with excluded domains 136 | const options = { ...defaultTestOptions, excludeHosts: ["github.com"] }; 137 | const filteredServer = await createMcpServer(options); 138 | 139 | // Create new client for filtered server 140 | const filteredClient = new Client({ 141 | name: "test client", 142 | version: "0.1.0", 143 | }); 144 | 145 | const [clientTransport, serverTransport] = 146 | InMemoryTransport.createLinkedPair(); 147 | await Promise.all([ 148 | filteredClient.connect(clientTransport), 149 | filteredServer.connect(serverTransport), 150 | ]); 151 | 152 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 153 | 154 | const result = await filteredClient.callTool({ 155 | name: "list_tabs", 156 | arguments: {}, 157 | }); 158 | 159 | const text = (result.content as any)[0].text; 160 | expect(text).toContain("### Current Tabs (2 tabs exists)"); // github.com tab should be filtered out 161 | expect(text).not.toContain("github.com"); 162 | expect(text).toContain("example.com"); 163 | expect(text).toContain("test.com"); 164 | }); 165 | }); 166 | 167 | describe("read_tab_content tool", () => { 168 | it("should get page content for valid tab", async () => { 169 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 170 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 171 | mockPageContent 172 | ); 173 | 174 | const result = await client.callTool({ 175 | name: "read_tab_content", 176 | arguments: { 177 | id: "ID:1001:2001", 178 | }, 179 | }); 180 | 181 | expect(result.content).toHaveLength(1); 182 | const text = (result.content as any)[0].text; 183 | expect(text).toContain("---"); 184 | expect(text).toContain(mockPageContent.title); 185 | expect(text).toContain("## Example"); 186 | expect(text).toContain("This is test content."); 187 | }); 188 | 189 | it("should get content from active tab when no id provided", async () => { 190 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 191 | mockPageContent 192 | ); 193 | 194 | const result = await client.callTool({ 195 | name: "read_tab_content", 196 | arguments: {}, 197 | }); 198 | 199 | expect(result.content).toHaveLength(1); 200 | const text = (result.content as any)[0].text; 201 | expect(text).toContain("---"); 202 | expect(text).toContain(mockPageContent.title); 203 | expect(text).toContain("## Example"); 204 | expect(text).toContain("This is test content."); 205 | }); 206 | 207 | it("should reject content from excluded domains", async () => { 208 | // Create server with excluded domains 209 | const options = { ...defaultTestOptions, excludeHosts: ["example.com"] }; 210 | const filteredServer = await createMcpServer(options); 211 | 212 | // Create new client for filtered server 213 | const filteredClient = new Client({ 214 | name: "test client", 215 | version: "0.1.0", 216 | }); 217 | 218 | const [clientTransport, serverTransport] = 219 | InMemoryTransport.createLinkedPair(); 220 | await Promise.all([ 221 | filteredClient.connect(clientTransport), 222 | filteredServer.connect(serverTransport), 223 | ]); 224 | 225 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 226 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 227 | mockPageContent 228 | ); 229 | 230 | const result = await filteredClient.callTool({ 231 | name: "read_tab_content", 232 | arguments: { 233 | id: "ID:1001:2001", 234 | }, 235 | }); 236 | 237 | expect(result.isError).toBe(true); 238 | expect((result.content as any)[0].text).toContain( 239 | "Content not available for excluded host" 240 | ); 241 | }); 242 | }); 243 | 244 | describe("open_in_new_tab tool", () => { 245 | it("should open URL with correct application name", async () => { 246 | vi.mocked(mockBrowserInterface.openURL).mockResolvedValue({ 247 | windowId: "123", 248 | tabId: "456", 249 | }); 250 | 251 | const result = await client.callTool({ 252 | name: "open_in_new_tab", 253 | arguments: { 254 | url: "https://example.com", 255 | }, 256 | }); 257 | 258 | expect(vi.mocked(mockBrowserInterface.openURL)).toHaveBeenCalledWith( 259 | "Google Chrome", 260 | "https://example.com" 261 | ); 262 | expect(result.content).toHaveLength(1); 263 | expect((result.content as any)[0]).toEqual({ 264 | type: "text", 265 | text: "Successfully opened URL in new tab. Tab: `ID:123:456`", 266 | }); 267 | }); 268 | }); 269 | 270 | describe("Resources", () => { 271 | describe("current_tab resource", () => { 272 | it("should return content of active tab", async () => { 273 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 274 | mockPageContent 275 | ); 276 | 277 | const result = await client.readResource({ 278 | uri: "tab://current", 279 | }); 280 | 281 | expect(result.contents).toHaveLength(1); 282 | const content = result.contents[0]; 283 | expect(content.uri).toBe("tab://current"); 284 | expect(content.mimeType).toBe("text/markdown"); 285 | expect(content.text).toContain("---"); 286 | expect(content.text).toContain("title: " + mockPageContent.title); 287 | expect(content.text).toContain("## Example"); 288 | expect(content.text).toContain("This is test content."); 289 | }); 290 | 291 | it("should reject content from excluded domains", async () => { 292 | // Create server with excluded domains 293 | const options = { 294 | ...defaultTestOptions, 295 | excludeHosts: ["example.com"], 296 | }; 297 | const filteredServer = await createMcpServer(options); 298 | 299 | const filteredClient = new Client({ 300 | name: "test client", 301 | version: "0.1.0", 302 | }); 303 | 304 | const [clientTransport, serverTransport] = 305 | InMemoryTransport.createLinkedPair(); 306 | await Promise.all([ 307 | filteredClient.connect(clientTransport), 308 | filteredServer.connect(serverTransport), 309 | ]); 310 | 311 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 312 | mockPageContent 313 | ); 314 | 315 | await expect( 316 | filteredClient.readResource({ 317 | uri: "tab://current", 318 | }) 319 | ).rejects.toThrow("Content not available for excluded host"); 320 | }); 321 | }); 322 | 323 | describe("tabs resource template", () => { 324 | it("should return content of specific tab when checkInterval > 0", async () => { 325 | // Create server with checkInterval > 0 326 | const options = { 327 | ...defaultTestOptions, 328 | checkInterval: 3000, 329 | }; 330 | const subscriptionServer = await createMcpServer(options); 331 | 332 | const subscriptionClient = new Client({ 333 | name: "test client", 334 | version: "0.1.0", 335 | }); 336 | 337 | const [clientTransport, serverTransport] = 338 | InMemoryTransport.createLinkedPair(); 339 | await Promise.all([ 340 | subscriptionClient.connect(clientTransport), 341 | subscriptionServer.connect(serverTransport), 342 | ]); 343 | 344 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 345 | mockPageContent 346 | ); 347 | 348 | const result = await subscriptionClient.readResource({ 349 | uri: "tab://1001/2001", 350 | }); 351 | 352 | expect(result.contents).toHaveLength(1); 353 | const content = result.contents[0]; 354 | expect(content.uri).toBe("tab://1001/2001"); 355 | expect(content.mimeType).toBe("text/markdown"); 356 | expect(content.text).toContain("---"); 357 | expect(content.text).toContain("title: " + mockPageContent.title); 358 | expect(content.text).toContain("## Example"); 359 | expect(content.text).toContain("This is test content."); 360 | }); 361 | 362 | it("should reject content from excluded domains when checkInterval > 0", async () => { 363 | // Create server with excluded domains and checkInterval > 0 364 | const options = { 365 | ...defaultTestOptions, 366 | excludeHosts: ["example.com"], 367 | checkInterval: 3000, 368 | }; 369 | const filteredServer = await createMcpServer(options); 370 | 371 | const filteredClient = new Client({ 372 | name: "test client", 373 | version: "0.1.0", 374 | }); 375 | 376 | const [clientTransport, serverTransport] = 377 | InMemoryTransport.createLinkedPair(); 378 | await Promise.all([ 379 | filteredClient.connect(clientTransport), 380 | filteredServer.connect(serverTransport), 381 | ]); 382 | 383 | vi.mocked(mockBrowserInterface.getPageContent).mockResolvedValue( 384 | mockPageContent 385 | ); 386 | 387 | await expect( 388 | filteredClient.readResource({ 389 | uri: "tab://1001/2001", 390 | }) 391 | ).rejects.toThrow("Content not available for excluded host"); 392 | }); 393 | }); 394 | 395 | describe("resource listing", () => { 396 | it("should only list current_tab resource when checkInterval is 0", async () => { 397 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 398 | 399 | const result = await client.listResources(); 400 | 401 | // Should only have current_tab resource 402 | expect(result.resources.length).toBe(1); 403 | 404 | // Check current_tab resource 405 | const currentTabResource = result.resources.find( 406 | (r) => r.uri === "tab://current" 407 | ); 408 | expect(currentTabResource).toBeDefined(); 409 | expect(currentTabResource?.name).toBe("current_tab"); 410 | expect(currentTabResource?.mimeType).toBe("text/markdown"); 411 | 412 | // Should not have any tab://{windowId}/{tabId} resources 413 | const tabResources = result.resources.filter( 414 | (r) => r.uri?.startsWith("tab://") && r.uri !== "tab://current" 415 | ); 416 | expect(tabResources).toHaveLength(0); 417 | }); 418 | 419 | it("should list all tab resources when checkInterval > 0", async () => { 420 | // Create server with checkInterval > 0 421 | const options = { 422 | ...defaultTestOptions, 423 | checkInterval: 3000, 424 | }; 425 | const subscriptionServer = await createMcpServer(options); 426 | 427 | const subscriptionClient = new Client({ 428 | name: "test client", 429 | version: "0.1.0", 430 | }); 431 | 432 | const [clientTransport, serverTransport] = 433 | InMemoryTransport.createLinkedPair(); 434 | await Promise.all([ 435 | subscriptionClient.connect(clientTransport), 436 | subscriptionServer.connect(serverTransport), 437 | ]); 438 | 439 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 440 | 441 | const result = await subscriptionClient.listResources(); 442 | 443 | // Should have current_tab resource plus individual tab resources from template 444 | expect(result.resources.length).toBeGreaterThanOrEqual(1); 445 | 446 | // Check current_tab resource 447 | const currentTabResource = result.resources.find( 448 | (r) => r.uri === "tab://current" 449 | ); 450 | expect(currentTabResource).toBeDefined(); 451 | expect(currentTabResource?.name).toBe("current_tab"); 452 | expect(currentTabResource?.mimeType).toBe("text/markdown"); 453 | 454 | // Check that tab resources are generated from template 455 | const tabResources = result.resources.filter( 456 | (r) => r.uri?.startsWith("tab://") && r.uri !== "tab://current" 457 | ); 458 | expect(tabResources).toHaveLength(mockTabs.length); 459 | 460 | // Verify first tab resource 461 | const firstTabResource = tabResources[0]; 462 | expect(firstTabResource.name).toBe( 463 | `${mockTabs[0].title} (example.com)` 464 | ); 465 | expect(firstTabResource.mimeType).toBe("text/markdown"); 466 | }); 467 | 468 | it("should filter resources by excluded domains when checkInterval > 0", async () => { 469 | // Create server with excluded domains and checkInterval > 0 470 | const options = { 471 | ...defaultTestOptions, 472 | excludeHosts: ["github.com"], 473 | checkInterval: 3000, 474 | }; 475 | const filteredServer = await createMcpServer(options); 476 | 477 | const filteredClient = new Client({ 478 | name: "test client", 479 | version: "0.1.0", 480 | }); 481 | 482 | const [clientTransport, serverTransport] = 483 | InMemoryTransport.createLinkedPair(); 484 | await Promise.all([ 485 | filteredClient.connect(clientTransport), 486 | filteredServer.connect(serverTransport), 487 | ]); 488 | 489 | vi.mocked(mockBrowserInterface.getTabList).mockResolvedValue(mockTabs); 490 | 491 | const result = await filteredClient.listResources(); 492 | 493 | // Should have current_tab resource plus filtered tab resources 494 | expect(result.resources.length).toBeGreaterThanOrEqual(1); 495 | 496 | // Verify current_tab resource exists 497 | const currentTabResource = result.resources.find( 498 | (r) => r.uri === "tab://current" 499 | ); 500 | expect(currentTabResource).toBeDefined(); 501 | 502 | // Check filtered tab resources - github.com should be excluded 503 | const tabResources = result.resources.filter( 504 | (r) => r.uri?.startsWith("tab://") && r.uri !== "tab://current" 505 | ); 506 | // Should have 2 tabs (example.com and test.com), github.com is excluded 507 | expect(tabResources).toHaveLength(2); 508 | 509 | // Verify github.com tab is not in the list 510 | const githubTab = tabResources.find((r) => r.name === "GitHub"); 511 | expect(githubTab).toBeUndefined(); 512 | }); 513 | }); 514 | }); 515 | }); 516 | --------------------------------------------------------------------------------