Skip to content

Commit d7bc6cb

Browse files
authored
Merge pull request #26 from arvoreeducacao/joao-barros-/-feat-arc-browser-mcp
feat: Arc Browser MCP Server (hybrid AppleScript + CDP)
2 parents d2058db + 20dd02e commit d7bc6cb

10 files changed

Lines changed: 1312 additions & 1884 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,20 @@ Manage Magalu Cloud infrastructure through the MGC CLI.
277277
- Semantic search across Magalu developer documentation
278278
- Configurable region via `MGC_REGION` env var
279279

280+
### [@arvoretech/arc-browser-mcp](./packages/arc-browser)
281+
282+
Control Arc Browser on macOS via a hybrid AppleScript + Chrome DevTools Protocol approach.
283+
284+
**Features:**
285+
286+
- Tab management: list, open, close, search, focus
287+
- Space management: list, get active, switch
288+
- Screenshots via CDP (viewport or full page)
289+
- Network request capture and console log monitoring
290+
- DOM interaction: click, hover, type, scroll, wait for selector
291+
- JavaScript execution (AppleScript for quick queries, CDP for async/await)
292+
- Cookie inspection and page content extraction
293+
280294
## 🚀 Quick Start
281295

282296
### Installation

eslint.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export default [
2929
Response: "readonly",
3030
Headers: "readonly",
3131
Request: "readonly",
32+
WebSocket: "readonly",
33+
AbortSignal: "readonly",
34+
ErrorEvent: "readonly",
3235
},
3336
},
3437
plugins: {

packages/arc-browser/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# @arvoretech/arc-browser-mcp
2+
3+
MCP server for controlling Arc Browser on macOS. Uses a hybrid approach: AppleScript for tab/space management and Chrome DevTools Protocol (CDP) for advanced features like screenshots, network capture, and DOM interaction.
4+
5+
## Requirements
6+
7+
- macOS (AppleScript is macOS-only)
8+
- Node.js >= 22
9+
- Arc Browser
10+
11+
## Setup
12+
13+
### Basic (AppleScript only)
14+
15+
```json
16+
{
17+
"arc-browser": {
18+
"command": "npx",
19+
"args": ["-y", "@arvoretech/arc-browser-mcp"],
20+
"autoApprove": ["*"]
21+
}
22+
}
23+
```
24+
25+
### Full (with CDP)
26+
27+
Start Arc with remote debugging enabled:
28+
29+
```bash
30+
open -a "Arc" --args --remote-debugging-port=9222
31+
```
32+
33+
Then use the same MCP config above. The server auto-detects CDP availability.
34+
35+
## Tools
36+
37+
### AppleScript (always available)
38+
39+
| Tool | Description |
40+
|------|-------------|
41+
| `list_tabs` | List all open tabs across all windows |
42+
| `get_active_tab` | Get title and URL of the active tab |
43+
| `open_url` | Open a URL in Arc |
44+
| `search_tabs` | Search tabs by title or URL |
45+
| `close_tab` | Close a tab by URL |
46+
| `focus_tab` | Focus a tab by URL |
47+
| `execute_js` | Execute JavaScript in the active tab |
48+
| `list_spaces` | List all spaces with their tabs |
49+
| `get_active_space` | Get the active space name |
50+
| `switch_space` | Switch to a space by name |
51+
52+
### CDP (requires `--remote-debugging-port=9222`)
53+
54+
| Tool | Description |
55+
|------|-------------|
56+
| `screenshot` | Capture page screenshot (viewport or full page) |
57+
| `cdp_evaluate` | Execute JS with async/await support |
58+
| `network_capture` | Capture network requests for N seconds |
59+
| `get_console_logs` | Capture console output for N seconds |
60+
| `get_cookies` | Get cookies for current page or URL |
61+
| `get_page_content` | Get page text (CDP with AppleScript fallback) |
62+
| `click` | Click element by CSS selector |
63+
| `hover` | Hover element by CSS selector |
64+
| `type_text` | Type text into focused element |
65+
| `scroll` | Scroll the page |
66+
| `wait_for_selector` | Wait for element to appear in DOM |
67+
| `cdp_status` | Check CDP availability and list targets |
68+
69+
## Security
70+
71+
- CDP listens on `localhost:9222` only (not exposed to network)
72+
- No secrets or credentials in the package
73+
- Consider not using `autoApprove: ["*"]` in shared environments
74+
75+
## License
76+
77+
MIT

packages/arc-browser/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@arvoretech/arc-browser-mcp",
3+
"version": "1.1.0",
4+
"description": "Arc Browser MCP Server — control tabs, navigate, execute JS, and capture screenshots via AppleScript (macOS only)",
5+
"main": "dist/index.js",
6+
"type": "module",
7+
"files": ["dist"],
8+
"bin": {
9+
"arc-browser-mcp": "./dist/index.js"
10+
},
11+
"scripts": {
12+
"build": "tsc",
13+
"dev": "tsx src/index.ts",
14+
"start": "node dist/index.js",
15+
"lint": "eslint src/**/*.ts",
16+
"lint:fix": "eslint src/**/*.ts --fix"
17+
},
18+
"keywords": ["mcp", "arc", "browser", "applescript", "macos", "arvore"],
19+
"author": "Arvore",
20+
"license": "MIT",
21+
"engines": {
22+
"node": ">=22.0.0"
23+
},
24+
"repository": {
25+
"type": "git",
26+
"url": "https://github.com/arvoreeducacao/arvore-mcp-servers.git",
27+
"directory": "packages/arc-browser"
28+
},
29+
"dependencies": {
30+
"@modelcontextprotocol/sdk": "~1.22.0",
31+
"zod": "^3.22.4"
32+
}
33+
}

packages/arc-browser/src/arc.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { execFile } from "node:child_process";
2+
import { promisify } from "node:util";
3+
4+
const exec = promisify(execFile);
5+
6+
export async function osascript(script: string): Promise<string> {
7+
const { stdout } = await exec("osascript", ["-e", script], {
8+
timeout: 15_000,
9+
});
10+
return stdout.trim();
11+
}
12+
13+
export interface Tab {
14+
title: string;
15+
url: string;
16+
}
17+
18+
export async function listTabs(): Promise<Tab[]> {
19+
const raw = await osascript(`
20+
tell application "Arc"
21+
set output to ""
22+
repeat with w in windows
23+
repeat with t in tabs of w
24+
set output to output & (title of t) & "|||" & (URL of t) & "\\n"
25+
end repeat
26+
end repeat
27+
return output
28+
end tell
29+
`);
30+
31+
return raw
32+
.split("\n")
33+
.filter(Boolean)
34+
.map((line) => {
35+
const [title, url] = line.split("|||");
36+
return { title: title?.trim() ?? "", url: url?.trim() ?? "" };
37+
});
38+
}
39+
40+
export async function getActiveTab(): Promise<Tab> {
41+
const title = await osascript(
42+
'tell application "Arc" to get the title of active tab of first window'
43+
);
44+
const url = await osascript(
45+
'tell application "Arc" to get the URL of active tab of first window'
46+
);
47+
return { title, url };
48+
}
49+
50+
export async function openUrl(url: string): Promise<void> {
51+
await osascript(`tell application "Arc" to open location "${url}"`);
52+
}
53+
54+
export async function newTab(url: string): Promise<void> {
55+
await osascript(`
56+
tell application "Arc"
57+
activate
58+
open location "${url}"
59+
end tell
60+
`);
61+
}
62+
63+
export async function closeTab(url: string): Promise<boolean> {
64+
const result = await osascript(`
65+
tell application "Arc"
66+
repeat with w in windows
67+
repeat with t in tabs of w
68+
if URL of t is "${url}" then
69+
close t
70+
return "closed"
71+
end if
72+
end repeat
73+
end repeat
74+
return "not_found"
75+
end tell
76+
`);
77+
return result === "closed";
78+
}
79+
80+
export async function searchTabs(query: string): Promise<Tab[]> {
81+
const tabs = await listTabs();
82+
const q = query.toLowerCase();
83+
return tabs.filter(
84+
(t) => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q)
85+
);
86+
}
87+
88+
export async function executeJavaScript(js: string): Promise<string> {
89+
const encoded = Buffer.from(js, "utf8").toString("base64");
90+
return osascript(`
91+
tell application "Arc"
92+
tell active tab of first window
93+
set jsResult to execute javascript "eval(atob('${encoded}'))"
94+
end tell
95+
return jsResult
96+
end tell
97+
`);
98+
}
99+
100+
export async function focusTab(url: string): Promise<boolean> {
101+
const result = await osascript(`
102+
tell application "Arc"
103+
activate
104+
repeat with w in windows
105+
set tabIndex to 0
106+
repeat with t in tabs of w
107+
set tabIndex to tabIndex + 1
108+
if URL of t is "${url}" then
109+
set active tab index of w to tabIndex
110+
return "focused"
111+
end if
112+
end repeat
113+
end repeat
114+
return "not_found"
115+
end tell
116+
`);
117+
return result === "focused";
118+
}
119+
120+
export interface Space {
121+
title: string;
122+
id: string;
123+
tabs: Tab[];
124+
}
125+
126+
export async function listSpaces(): Promise<Space[]> {
127+
const raw = await osascript(`
128+
tell application "Arc"
129+
set output to ""
130+
tell first window
131+
repeat with s in spaces
132+
set spaceTitle to title of s
133+
set spaceId to id of s
134+
set output to output & "SPACE:" & spaceTitle & "|||" & spaceId & "\\n"
135+
repeat with t in tabs of s
136+
set output to output & "TAB:" & (title of t) & "|||" & (URL of t) & "\\n"
137+
end repeat
138+
end repeat
139+
end tell
140+
return output
141+
end tell
142+
`);
143+
144+
const spaces: Space[] = [];
145+
let current: Space | null = null;
146+
147+
for (const line of raw.split("\n").filter(Boolean)) {
148+
if (line.startsWith("SPACE:")) {
149+
const [title, id] = line.slice(6).split("|||");
150+
current = { title: title?.trim() ?? "", id: id?.trim() ?? "", tabs: [] };
151+
spaces.push(current);
152+
} else if (line.startsWith("TAB:") && current) {
153+
const [title, url] = line.slice(4).split("|||");
154+
current.tabs.push({ title: title?.trim() ?? "", url: url?.trim() ?? "" });
155+
}
156+
}
157+
158+
return spaces;
159+
}
160+
161+
export async function getActiveSpace(): Promise<string> {
162+
return osascript(`
163+
tell application "Arc"
164+
tell first window
165+
return title of active space
166+
end tell
167+
end tell
168+
`);
169+
}
170+
171+
export async function switchSpace(title: string): Promise<string> {
172+
const keyCodes = [18, 19, 20, 21, 23, 22, 26, 28, 25, 29];
173+
const spaces = await listSpaces();
174+
const index = spaces.findIndex(
175+
(s) => s.title.toLowerCase() === title.toLowerCase()
176+
);
177+
if (index === -1) return "not_found";
178+
if (index >= keyCodes.length) return "too_many_spaces";
179+
180+
try {
181+
await osascript(`
182+
tell application "Arc" to activate
183+
delay 0.3
184+
tell application "System Events"
185+
key code ${keyCodes[index]} using control down
186+
end tell
187+
`);
188+
return "switched";
189+
} catch {
190+
return "no_accessibility_permission";
191+
}
192+
}

0 commit comments

Comments
 (0)