abu-browser-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +14 -0
- package/dist/index.js +127 -0
- package/dist/tools.d.ts +6 -0
- package/dist/tools.js +182 -0
- package/dist/types.d.ts +104 -0
- package/dist/types.js +8 -0
- package/dist/wsServer.d.ts +23 -0
- package/dist/wsServer.js +184 -0
- package/package.json +34 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Abu Browser Bridge — MCP Server + WebSocket Server
|
|
4
|
+
*
|
|
5
|
+
* This process acts as a bridge between Abu (via MCP stdio transport)
|
|
6
|
+
* and the Chrome Extension (via WebSocket).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx abu-browser-bridge [--port 9876]
|
|
10
|
+
*
|
|
11
|
+
* Abu connects to this as an MCP server (stdio).
|
|
12
|
+
* Chrome Extension connects via WebSocket (ws://127.0.0.1:9876).
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Abu Browser Bridge — MCP Server + WebSocket Server
|
|
4
|
+
*
|
|
5
|
+
* This process acts as a bridge between Abu (via MCP stdio transport)
|
|
6
|
+
* and the Chrome Extension (via WebSocket).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx abu-browser-bridge [--port 9876]
|
|
10
|
+
*
|
|
11
|
+
* Abu connects to this as an MCP server (stdio).
|
|
12
|
+
* Chrome Extension connects via WebSocket (ws://127.0.0.1:9876).
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import { startWSServer, stopWSServer } from './wsServer.js';
|
|
18
|
+
import { registerTools } from './tools.js';
|
|
19
|
+
const DEFAULT_WS_PORT = 9876;
|
|
20
|
+
const PORT_RANGE = 5; // ports 9876-9880
|
|
21
|
+
/**
|
|
22
|
+
* Kill any stale abu-browser-bridge processes listening on our port range.
|
|
23
|
+
* This ensures the Extension always connects to the newest bridge.
|
|
24
|
+
* Cross-platform: uses lsof on macOS/Linux, netstat+taskkill on Windows.
|
|
25
|
+
*/
|
|
26
|
+
function killStaleBridges(basePort) {
|
|
27
|
+
const myPid = process.pid;
|
|
28
|
+
const isWindows = process.platform === 'win32';
|
|
29
|
+
try {
|
|
30
|
+
let pids;
|
|
31
|
+
if (isWindows) {
|
|
32
|
+
// Windows: use netstat to find PIDs on each port
|
|
33
|
+
pids = new Set();
|
|
34
|
+
const output = execSync('netstat -ano -p TCP', { encoding: 'utf-8', timeout: 5000 });
|
|
35
|
+
for (let p = basePort; p < basePort + PORT_RANGE; p++) {
|
|
36
|
+
// Match lines like " TCP 127.0.0.1:9876 0.0.0.0:0 LISTENING 12345"
|
|
37
|
+
const regex = new RegExp(`127\\.0\\.0\\.1:${p}\\s+.*LISTENING\\s+(\\d+)`, 'g');
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = regex.exec(output)) !== null) {
|
|
40
|
+
const pid = parseInt(match[1], 10);
|
|
41
|
+
if (pid && pid !== myPid)
|
|
42
|
+
pids.add(pid);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const pid of pids) {
|
|
46
|
+
try {
|
|
47
|
+
execSync(`taskkill /PID ${pid} /F`, { timeout: 3000 });
|
|
48
|
+
console.error(`[abu-bridge] Killed stale process ${pid}`);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Process may have already exited
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// macOS/Linux: single lsof call with multiple -i flags
|
|
57
|
+
const portFlags = Array.from({ length: PORT_RANGE }, (_, i) => `-ti:${basePort + i}`);
|
|
58
|
+
const output = execSync(`lsof ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
59
|
+
if (!output)
|
|
60
|
+
return;
|
|
61
|
+
pids = new Set(output.split('\n').map(l => parseInt(l.trim(), 10)).filter(pid => pid && pid !== myPid));
|
|
62
|
+
for (const pid of pids) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 'SIGTERM');
|
|
65
|
+
console.error(`[abu-bridge] Killed stale process ${pid}`);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Process may have already exited
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Command not available or no processes found — that's fine
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function main() {
|
|
78
|
+
// Parse CLI args
|
|
79
|
+
const args = process.argv.slice(2);
|
|
80
|
+
let wsPort = DEFAULT_WS_PORT;
|
|
81
|
+
const portIndex = args.indexOf('--port');
|
|
82
|
+
if (portIndex !== -1 && args[portIndex + 1]) {
|
|
83
|
+
wsPort = parseInt(args[portIndex + 1], 10);
|
|
84
|
+
if (isNaN(wsPort) || wsPort < 1 || wsPort > 65535) {
|
|
85
|
+
console.error(`Invalid port: ${args[portIndex + 1]}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 0. Kill any stale bridge processes so the Extension connects to us
|
|
90
|
+
killStaleBridges(wsPort);
|
|
91
|
+
// 1. Start WebSocket server (for Chrome Extension)
|
|
92
|
+
// startWSServer already tries ports 9876-9880 with fallback if a port is still in TIME_WAIT
|
|
93
|
+
try {
|
|
94
|
+
const actualPort = await startWSServer(wsPort);
|
|
95
|
+
if (actualPort !== wsPort) {
|
|
96
|
+
console.error(`[abu-bridge] Note: using port ${actualPort} instead of ${wsPort}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
console.error(`Failed to start WS server:`, err);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
// 2. Create MCP server (for Abu)
|
|
104
|
+
const mcpServer = new McpServer({
|
|
105
|
+
name: 'abu-browser-bridge',
|
|
106
|
+
version: '0.1.0',
|
|
107
|
+
});
|
|
108
|
+
// 3. Register browser tools
|
|
109
|
+
registerTools(mcpServer);
|
|
110
|
+
// 4. Connect MCP server to stdio transport
|
|
111
|
+
const transport = new StdioServerTransport();
|
|
112
|
+
await mcpServer.connect(transport);
|
|
113
|
+
console.error('[abu-bridge] MCP server connected via stdio');
|
|
114
|
+
console.error('[abu-bridge] Ready — waiting for Chrome Extension connection...');
|
|
115
|
+
// Graceful shutdown
|
|
116
|
+
const cleanup = () => {
|
|
117
|
+
console.error('[abu-bridge] Shutting down...');
|
|
118
|
+
stopWSServer();
|
|
119
|
+
process.exit(0);
|
|
120
|
+
};
|
|
121
|
+
process.on('SIGINT', cleanup);
|
|
122
|
+
process.on('SIGTERM', cleanup);
|
|
123
|
+
}
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
console.error('Fatal error:', err);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
});
|
package/dist/tools.d.ts
ADDED
package/dist/tools.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool definitions for browser automation.
|
|
3
|
+
* Each tool sends a request to the Chrome Extension via WebSocket.
|
|
4
|
+
*/
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { sendToExtension, isExtensionConnected } from './wsServer.js';
|
|
7
|
+
// --- Element Locator Schema (reusable) ---
|
|
8
|
+
const LocatorDescription = `How to find the element. Supports multiple strategies:
|
|
9
|
+
- { "text": "按钮文字" } — find by visible text content (most common)
|
|
10
|
+
- { "css": "#id" } or { "css": ".class" } — find by CSS selector
|
|
11
|
+
- { "role": "button", "name": "Submit" } — find by ARIA role
|
|
12
|
+
- { "testId": "submit-btn" } — find by data-testid attribute
|
|
13
|
+
- { "ref": "e3" } — use reference ID from a previous snapshot
|
|
14
|
+
- { "xpath": "//div[@class='x']" } — find by XPath (fallback)`;
|
|
15
|
+
// --- Helper ---
|
|
16
|
+
function ensureConnected() {
|
|
17
|
+
if (!isExtensionConnected()) {
|
|
18
|
+
throw new Error('Chrome Extension is not connected. Please install the Abu Browser Extension and ensure it is enabled.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function formatResult(response) {
|
|
22
|
+
if (!response.success) {
|
|
23
|
+
return `Error: ${response.error ?? 'Unknown error'}`;
|
|
24
|
+
}
|
|
25
|
+
if (typeof response.data === 'string') {
|
|
26
|
+
return response.data;
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(response.data, null, 2);
|
|
29
|
+
}
|
|
30
|
+
// --- Register all tools ---
|
|
31
|
+
export function registerTools(server) {
|
|
32
|
+
// 1. browser_get_tabs
|
|
33
|
+
server.tool('get_tabs', 'Get a list of all open browser tabs with their URLs and titles. Use this first to find the target tab.', async () => {
|
|
34
|
+
ensureConnected();
|
|
35
|
+
const res = await sendToExtension('get_tabs');
|
|
36
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
37
|
+
});
|
|
38
|
+
// 2. browser_snapshot
|
|
39
|
+
server.tool('snapshot', `Get a structured snapshot of all interactive elements on the page (buttons, inputs, links, selects, etc.). Returns each element with a short reference ID (e.g., "e1") that can be used in subsequent actions. This is the primary way to understand what's on a page before taking action.`, {
|
|
40
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
41
|
+
selector: z.string().optional().describe('Optional CSS selector to scope the snapshot to a specific area of the page'),
|
|
42
|
+
}, async ({ tabId, selector }) => {
|
|
43
|
+
ensureConnected();
|
|
44
|
+
const res = await sendToExtension('snapshot', { tabId, selector });
|
|
45
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
46
|
+
});
|
|
47
|
+
// 3. browser_click
|
|
48
|
+
server.tool('click', 'Click an element on the page. Returns the result of the click action.', {
|
|
49
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
50
|
+
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
51
|
+
}, async ({ tabId, locator }) => {
|
|
52
|
+
ensureConnected();
|
|
53
|
+
const parsed = JSON.parse(locator);
|
|
54
|
+
const res = await sendToExtension('click', { tabId, locator: parsed });
|
|
55
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
56
|
+
});
|
|
57
|
+
// 4. browser_fill
|
|
58
|
+
server.tool('fill', 'Fill in a text input, textarea, or other editable field. Clears existing content and types the new value, triggering proper input/change events for framework compatibility (React, Vue, etc.).', {
|
|
59
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
60
|
+
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
61
|
+
value: z.string().describe('The text value to fill into the field'),
|
|
62
|
+
}, async ({ tabId, locator, value }) => {
|
|
63
|
+
ensureConnected();
|
|
64
|
+
const parsed = JSON.parse(locator);
|
|
65
|
+
const res = await sendToExtension('fill', { tabId, locator: parsed, value });
|
|
66
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
67
|
+
});
|
|
68
|
+
// 5. browser_select
|
|
69
|
+
server.tool('select', 'Select an option from a <select> dropdown element.', {
|
|
70
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
71
|
+
locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
|
|
72
|
+
value: z.string().describe('The option value or visible text to select'),
|
|
73
|
+
}, async ({ tabId, locator, value }) => {
|
|
74
|
+
ensureConnected();
|
|
75
|
+
const parsed = JSON.parse(locator);
|
|
76
|
+
const res = await sendToExtension('select', { tabId, locator: parsed, value });
|
|
77
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
78
|
+
});
|
|
79
|
+
// 6. browser_wait_for
|
|
80
|
+
server.tool('wait_for', `Wait for a condition to be met on the page. Useful for waiting for elements to appear after a click, waiting for loading to complete, or waiting for page navigation. Returns when the condition is met or times out.`, {
|
|
81
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
82
|
+
condition: z.string().describe(`JSON string of wait condition. Options:
|
|
83
|
+
- { "type": "appear", "locator": { "text": "成功" } } — wait for element to appear
|
|
84
|
+
- { "type": "disappear", "locator": { "css": ".loading" } } — wait for element to disappear
|
|
85
|
+
- { "type": "enabled", "locator": { "text": "提交" } } — wait for element to become clickable
|
|
86
|
+
- { "type": "textContains", "locator": { "css": "#status" }, "text": "完成" } — wait for text content
|
|
87
|
+
- { "type": "urlContains", "pattern": "/success" } — wait for URL change`),
|
|
88
|
+
timeout: z.number().optional().default(30000).describe('Maximum wait time in ms (default: 30000)'),
|
|
89
|
+
}, async ({ tabId, condition, timeout }) => {
|
|
90
|
+
ensureConnected();
|
|
91
|
+
const parsed = JSON.parse(condition);
|
|
92
|
+
const res = await sendToExtension('wait_for', { tabId, condition: parsed, timeout }, timeout + 5000);
|
|
93
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
94
|
+
});
|
|
95
|
+
// 7. browser_extract_text
|
|
96
|
+
server.tool('extract_text', 'Extract text content from the page or a specific element. Useful for reading content, checking values, or verifying results.', {
|
|
97
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
98
|
+
selector: z.string().optional().describe('CSS selector to extract text from. If omitted, extracts the full page text (may be large).'),
|
|
99
|
+
}, async ({ tabId, selector }) => {
|
|
100
|
+
ensureConnected();
|
|
101
|
+
const res = await sendToExtension('extract_text', { tabId, selector });
|
|
102
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
103
|
+
});
|
|
104
|
+
// 8. browser_extract_table
|
|
105
|
+
server.tool('extract_table', 'Extract structured data from an HTML table on the page. Returns headers and rows as arrays.', {
|
|
106
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
107
|
+
selector: z.string().optional().describe('CSS selector for the target table. If omitted, extracts the largest table on the page.'),
|
|
108
|
+
}, async ({ tabId, selector }) => {
|
|
109
|
+
ensureConnected();
|
|
110
|
+
const res = await sendToExtension('extract_table', { tabId, selector });
|
|
111
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
112
|
+
});
|
|
113
|
+
// 9. browser_scroll
|
|
114
|
+
server.tool('scroll', 'Scroll the page or a specific element.', {
|
|
115
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
116
|
+
direction: z.enum(['up', 'down', 'left', 'right']).describe('Scroll direction'),
|
|
117
|
+
amount: z.number().optional().default(500).describe('Scroll amount in pixels (default: 500)'),
|
|
118
|
+
selector: z.string().optional().describe('CSS selector for the scrollable element. If omitted, scrolls the whole page.'),
|
|
119
|
+
}, async ({ tabId, direction, amount, selector }) => {
|
|
120
|
+
ensureConnected();
|
|
121
|
+
const res = await sendToExtension('scroll', { tabId, direction, amount, selector });
|
|
122
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
123
|
+
});
|
|
124
|
+
// 10. browser_navigate
|
|
125
|
+
server.tool('navigate', 'Navigate a tab to a specific URL, or go back/forward in history.', {
|
|
126
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
127
|
+
url: z.string().optional().describe('URL to navigate to. Omit for back/forward.'),
|
|
128
|
+
action: z.enum(['goto', 'back', 'forward', 'reload']).optional().default('goto').describe('Navigation action (default: goto)'),
|
|
129
|
+
}, async ({ tabId, url, action }) => {
|
|
130
|
+
ensureConnected();
|
|
131
|
+
const res = await sendToExtension('navigate', { tabId, url, action });
|
|
132
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
133
|
+
});
|
|
134
|
+
// 11. browser_keyboard
|
|
135
|
+
server.tool('keyboard', 'Send keyboard events to the page. Supports key combinations.', {
|
|
136
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
137
|
+
key: z.string().describe('Key to press (e.g., "Enter", "Tab", "Escape", "a", "ArrowDown")'),
|
|
138
|
+
modifiers: z.array(z.enum(['ctrl', 'shift', 'alt', 'meta'])).optional().describe('Modifier keys to hold'),
|
|
139
|
+
}, async ({ tabId, key, modifiers }) => {
|
|
140
|
+
ensureConnected();
|
|
141
|
+
const res = await sendToExtension('keyboard', { tabId, key, modifiers });
|
|
142
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
143
|
+
});
|
|
144
|
+
// 12. browser_execute_js
|
|
145
|
+
server.tool('execute_js', 'Execute arbitrary JavaScript code in the context of the page. Use this as a fallback when other tools cannot achieve the desired result. Returns the result of the expression.', {
|
|
146
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
147
|
+
code: z.string().describe('JavaScript code to execute. The last expression value is returned.'),
|
|
148
|
+
}, async ({ tabId, code }) => {
|
|
149
|
+
ensureConnected();
|
|
150
|
+
const res = await sendToExtension('execute_js', { tabId, code }, 60_000);
|
|
151
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
152
|
+
});
|
|
153
|
+
// 13. browser_screenshot
|
|
154
|
+
server.tool('screenshot', 'Take a screenshot of the visible area of a tab. Returns a base64-encoded PNG image. Useful for visual confirmation of actions.', {
|
|
155
|
+
tabId: z.number().describe('Tab ID from get_tabs'),
|
|
156
|
+
}, async ({ tabId }) => {
|
|
157
|
+
ensureConnected();
|
|
158
|
+
const res = await sendToExtension('screenshot', { tabId });
|
|
159
|
+
if (res.success && typeof res.data === 'string') {
|
|
160
|
+
return {
|
|
161
|
+
content: [{
|
|
162
|
+
type: 'image',
|
|
163
|
+
data: res.data.replace(/^data:image\/png;base64,/, ''),
|
|
164
|
+
mimeType: 'image/png',
|
|
165
|
+
}]
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return { content: [{ type: 'text', text: formatResult(res) }] };
|
|
169
|
+
});
|
|
170
|
+
// 14. browser_connection_status
|
|
171
|
+
server.tool('connection_status', 'Check whether the Chrome Extension is connected to this bridge. Use this to verify the extension is ready before performing browser actions.', async () => {
|
|
172
|
+
const connected = isExtensionConnected();
|
|
173
|
+
return {
|
|
174
|
+
content: [{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text: connected
|
|
177
|
+
? 'Chrome Extension is connected and ready.'
|
|
178
|
+
: 'Chrome Extension is NOT connected. Please ensure the Abu Browser Extension is installed and enabled in Chrome.'
|
|
179
|
+
}]
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Abu Browser Bridge communication protocol.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth — imported by both:
|
|
5
|
+
* - abu-browser-bridge (MCP server)
|
|
6
|
+
* - abu-chrome-extension (Chrome Extension)
|
|
7
|
+
*/
|
|
8
|
+
export interface BridgeRequest {
|
|
9
|
+
id: string;
|
|
10
|
+
action: string;
|
|
11
|
+
payload: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface BridgeResponse {
|
|
14
|
+
id: string;
|
|
15
|
+
success: boolean;
|
|
16
|
+
data?: unknown;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ElementLocator {
|
|
20
|
+
css?: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
tag?: string;
|
|
23
|
+
role?: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
xpath?: string;
|
|
26
|
+
testId?: string;
|
|
27
|
+
ref?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ElementInfo {
|
|
30
|
+
ref: string;
|
|
31
|
+
tag: string;
|
|
32
|
+
type?: string;
|
|
33
|
+
text?: string;
|
|
34
|
+
placeholder?: string;
|
|
35
|
+
value?: string;
|
|
36
|
+
href?: string;
|
|
37
|
+
role?: string;
|
|
38
|
+
ariaLabel?: string;
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
visible: boolean;
|
|
41
|
+
checked?: boolean;
|
|
42
|
+
selected?: boolean;
|
|
43
|
+
options?: {
|
|
44
|
+
value: string;
|
|
45
|
+
text: string;
|
|
46
|
+
}[];
|
|
47
|
+
}
|
|
48
|
+
export interface PageSnapshot {
|
|
49
|
+
url: string;
|
|
50
|
+
title: string;
|
|
51
|
+
elements: ElementInfo[];
|
|
52
|
+
}
|
|
53
|
+
export type WaitCondition = {
|
|
54
|
+
type: 'appear';
|
|
55
|
+
locator: ElementLocator;
|
|
56
|
+
timeout?: number;
|
|
57
|
+
} | {
|
|
58
|
+
type: 'disappear';
|
|
59
|
+
locator: ElementLocator;
|
|
60
|
+
timeout?: number;
|
|
61
|
+
} | {
|
|
62
|
+
type: 'enabled';
|
|
63
|
+
locator: ElementLocator;
|
|
64
|
+
timeout?: number;
|
|
65
|
+
} | {
|
|
66
|
+
type: 'textContains';
|
|
67
|
+
locator: ElementLocator;
|
|
68
|
+
text: string;
|
|
69
|
+
timeout?: number;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'urlContains';
|
|
72
|
+
pattern: string;
|
|
73
|
+
timeout?: number;
|
|
74
|
+
};
|
|
75
|
+
export interface TabInfo {
|
|
76
|
+
tabId: number;
|
|
77
|
+
url: string;
|
|
78
|
+
title: string;
|
|
79
|
+
active: boolean;
|
|
80
|
+
focused: boolean;
|
|
81
|
+
windowId: number;
|
|
82
|
+
windowFocused: boolean;
|
|
83
|
+
}
|
|
84
|
+
export interface ClickResult {
|
|
85
|
+
success: boolean;
|
|
86
|
+
message: string;
|
|
87
|
+
elementText?: string;
|
|
88
|
+
}
|
|
89
|
+
export interface FillResult {
|
|
90
|
+
success: boolean;
|
|
91
|
+
message: string;
|
|
92
|
+
previousValue?: string;
|
|
93
|
+
}
|
|
94
|
+
export interface WaitResult {
|
|
95
|
+
success: boolean;
|
|
96
|
+
message: string;
|
|
97
|
+
timedOut: boolean;
|
|
98
|
+
elapsed: number;
|
|
99
|
+
}
|
|
100
|
+
export interface ExtractTableResult {
|
|
101
|
+
headers: string[];
|
|
102
|
+
rows: string[][];
|
|
103
|
+
rowCount: number;
|
|
104
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket server that accepts connections from the Chrome Extension.
|
|
3
|
+
* Routes requests from MCP tools to the extension and returns responses.
|
|
4
|
+
*/
|
|
5
|
+
import type { BridgeResponse } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Start the WebSocket server. If the default port is occupied (by a stale bridge),
|
|
8
|
+
* try a few alternative ports. The Extension will discover the port via a well-known range.
|
|
9
|
+
*/
|
|
10
|
+
export declare function startWSServer(port?: number): Promise<number>;
|
|
11
|
+
/**
|
|
12
|
+
* Send a request to the Chrome Extension and wait for response.
|
|
13
|
+
*/
|
|
14
|
+
export declare function sendToExtension(action: string, payload?: Record<string, unknown>, timeoutMs?: number): Promise<BridgeResponse>;
|
|
15
|
+
/**
|
|
16
|
+
* Check if the Chrome Extension is currently connected.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isExtensionConnected(): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Get the port the WS server is actually listening on.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getActivePort(): number | null;
|
|
23
|
+
export declare function stopWSServer(): void;
|
package/dist/wsServer.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket server that accepts connections from the Chrome Extension.
|
|
3
|
+
* Routes requests from MCP tools to the extension and returns responses.
|
|
4
|
+
*/
|
|
5
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
|
+
import { createConnection } from 'net';
|
|
7
|
+
const DEFAULT_PORT = 9876;
|
|
8
|
+
const HEARTBEAT_INTERVAL = 30_000;
|
|
9
|
+
let wss = null;
|
|
10
|
+
let extensionSocket = null;
|
|
11
|
+
let heartbeatTimer = null;
|
|
12
|
+
const pendingRequests = new Map();
|
|
13
|
+
let requestCounter = 0;
|
|
14
|
+
let activePort = null;
|
|
15
|
+
function generateId() {
|
|
16
|
+
return `req_${Date.now().toString(36)}_${(++requestCounter).toString(36)}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Check if a port is in use by trying to connect to it.
|
|
20
|
+
* Cross-platform — works on macOS, Windows, Linux.
|
|
21
|
+
*/
|
|
22
|
+
function isPortInUse(port, host) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const socket = createConnection({ port, host });
|
|
25
|
+
socket.on('connect', () => {
|
|
26
|
+
socket.destroy();
|
|
27
|
+
resolve(true);
|
|
28
|
+
});
|
|
29
|
+
socket.on('error', () => {
|
|
30
|
+
socket.destroy();
|
|
31
|
+
resolve(false);
|
|
32
|
+
});
|
|
33
|
+
socket.setTimeout(1000, () => {
|
|
34
|
+
socket.destroy();
|
|
35
|
+
resolve(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start the WebSocket server. If the default port is occupied (by a stale bridge),
|
|
41
|
+
* try a few alternative ports. The Extension will discover the port via a well-known range.
|
|
42
|
+
*/
|
|
43
|
+
export async function startWSServer(port = DEFAULT_PORT) {
|
|
44
|
+
const MAX_RETRIES = 5;
|
|
45
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
46
|
+
const tryPort = port + attempt;
|
|
47
|
+
const inUse = await isPortInUse(tryPort, '127.0.0.1');
|
|
48
|
+
if (inUse) {
|
|
49
|
+
console.error(`[abu-bridge] Port ${tryPort} is in use, trying ${tryPort + 1}...`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
await listenOnPort(tryPort);
|
|
54
|
+
activePort = tryPort;
|
|
55
|
+
return tryPort;
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
59
|
+
// EADDRINUSE can still happen in a race condition
|
|
60
|
+
if (msg.includes('EADDRINUSE') && attempt < MAX_RETRIES - 1) {
|
|
61
|
+
console.error(`[abu-bridge] Port ${tryPort} race condition, trying next...`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`All ports ${port}-${port + MAX_RETRIES - 1} are in use`);
|
|
68
|
+
}
|
|
69
|
+
function listenOnPort(port) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
wss = new WebSocketServer({ port, host: '127.0.0.1' });
|
|
72
|
+
wss.on('listening', () => {
|
|
73
|
+
console.error(`[abu-bridge] WS server listening on ws://127.0.0.1:${port}`);
|
|
74
|
+
startHeartbeat();
|
|
75
|
+
resolve();
|
|
76
|
+
});
|
|
77
|
+
wss.on('error', (err) => {
|
|
78
|
+
console.error(`[abu-bridge] WS server error:`, err.message);
|
|
79
|
+
reject(err);
|
|
80
|
+
});
|
|
81
|
+
wss.on('connection', (ws, req) => {
|
|
82
|
+
const origin = req.headers.origin ?? 'unknown';
|
|
83
|
+
console.error(`[abu-bridge] Extension connected (origin: ${origin})`);
|
|
84
|
+
// Only allow one extension connection at a time
|
|
85
|
+
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
86
|
+
console.error('[abu-bridge] Replacing existing extension connection');
|
|
87
|
+
extensionSocket.close(1000, 'Replaced by new connection');
|
|
88
|
+
}
|
|
89
|
+
extensionSocket = ws;
|
|
90
|
+
ws.on('message', (data) => {
|
|
91
|
+
try {
|
|
92
|
+
const msg = JSON.parse(data.toString());
|
|
93
|
+
handleResponse(msg);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error('[abu-bridge] Invalid message from extension:', err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
ws.on('close', (code, reason) => {
|
|
100
|
+
console.error(`[abu-bridge] Extension disconnected (code: ${code}, reason: ${reason.toString()})`);
|
|
101
|
+
if (extensionSocket === ws) {
|
|
102
|
+
extensionSocket = null;
|
|
103
|
+
}
|
|
104
|
+
// Reject all pending requests
|
|
105
|
+
for (const [id, pending] of pendingRequests) {
|
|
106
|
+
pending.reject(new Error('Extension disconnected'));
|
|
107
|
+
clearTimeout(pending.timer);
|
|
108
|
+
pendingRequests.delete(id);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
ws.on('error', (err) => {
|
|
112
|
+
console.error('[abu-bridge] Extension socket error:', err.message);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function startHeartbeat() {
|
|
118
|
+
heartbeatTimer = setInterval(() => {
|
|
119
|
+
if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
|
|
120
|
+
extensionSocket.ping();
|
|
121
|
+
}
|
|
122
|
+
}, HEARTBEAT_INTERVAL);
|
|
123
|
+
}
|
|
124
|
+
function handleResponse(msg) {
|
|
125
|
+
const pending = pendingRequests.get(msg.id);
|
|
126
|
+
if (!pending) {
|
|
127
|
+
console.error(`[abu-bridge] Received response for unknown request: ${msg.id}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
clearTimeout(pending.timer);
|
|
131
|
+
pendingRequests.delete(msg.id);
|
|
132
|
+
pending.resolve(msg);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Send a request to the Chrome Extension and wait for response.
|
|
136
|
+
*/
|
|
137
|
+
export function sendToExtension(action, payload = {}, timeoutMs = 30_000) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
if (!extensionSocket || extensionSocket.readyState !== WebSocket.OPEN) {
|
|
140
|
+
reject(new Error('Chrome Extension is not connected. Please install and enable the Abu Browser Extension, then check the connection status in the extension popup.'));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const id = generateId();
|
|
144
|
+
const request = { id, action, payload };
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
pendingRequests.delete(id);
|
|
147
|
+
reject(new Error(`Request timed out after ${timeoutMs}ms (action: ${action})`));
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
pendingRequests.set(id, { resolve, reject, timer });
|
|
150
|
+
extensionSocket.send(JSON.stringify(request));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Check if the Chrome Extension is currently connected.
|
|
155
|
+
*/
|
|
156
|
+
export function isExtensionConnected() {
|
|
157
|
+
return extensionSocket !== null && extensionSocket.readyState === WebSocket.OPEN;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Get the port the WS server is actually listening on.
|
|
161
|
+
*/
|
|
162
|
+
export function getActivePort() {
|
|
163
|
+
return activePort;
|
|
164
|
+
}
|
|
165
|
+
export function stopWSServer() {
|
|
166
|
+
if (heartbeatTimer) {
|
|
167
|
+
clearInterval(heartbeatTimer);
|
|
168
|
+
heartbeatTimer = null;
|
|
169
|
+
}
|
|
170
|
+
for (const [id, pending] of pendingRequests) {
|
|
171
|
+
clearTimeout(pending.timer);
|
|
172
|
+
pending.reject(new Error('Server shutting down'));
|
|
173
|
+
pendingRequests.delete(id);
|
|
174
|
+
}
|
|
175
|
+
if (extensionSocket) {
|
|
176
|
+
extensionSocket.close(1000, 'Server shutting down');
|
|
177
|
+
extensionSocket = null;
|
|
178
|
+
}
|
|
179
|
+
if (wss) {
|
|
180
|
+
wss.close();
|
|
181
|
+
wss = null;
|
|
182
|
+
}
|
|
183
|
+
activePort = null;
|
|
184
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "abu-browser-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Server that bridges Abu AI assistant with Chrome Extension for browser automation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "pm-shawn",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/anthropics/abu"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["mcp", "browser", "automation", "chrome-extension", "abu"],
|
|
13
|
+
"bin": {
|
|
14
|
+
"abu-browser-bridge": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prebuild": "cp ../abu-browser-shared/types.ts src/types.ts",
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "cp ../abu-browser-shared/types.ts src/types.ts && tsc --watch",
|
|
23
|
+
"start": "node dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
27
|
+
"ws": "^8.18.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"@types/ws": "^8.5.0",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
}
|
|
34
|
+
}
|