abu-browser-bridge 0.5.1 → 0.6.6

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.js CHANGED
@@ -17,6 +17,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
17
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
18
  import { startWSServer, stopWSServer } from './wsServer.js';
19
19
  import { registerTools } from './tools.js';
20
+ import { PKG_VERSION } from './version.js';
20
21
  const DEFAULT_WS_PORT = 9876;
21
22
  const DISCOVERY_PORT = 9875;
22
23
  /**
@@ -71,7 +72,7 @@ async function killStaleBridges(wsPort) {
71
72
  }
72
73
  for (const pid of pids) {
73
74
  try {
74
- execSync(`taskkill /PID ${pid} /F`, { timeout: 3000 });
75
+ process.kill(pid, 'SIGTERM');
75
76
  console.error(`[abu-bridge] Killed stale process ${pid} (port scan)`);
76
77
  }
77
78
  catch { /* already dead */ }
@@ -79,8 +80,8 @@ async function killStaleBridges(wsPort) {
79
80
  }
80
81
  else {
81
82
  const { execSync } = await import('child_process');
82
- const portFlags = portsToCheck.map(p => `-ti:${p}`);
83
- const output = execSync(`lsof ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
83
+ const portFlags = portsToCheck.map(p => `-i:${p}`);
84
+ const output = execSync(`lsof -t ${portFlags.join(' ')}`, { encoding: 'utf-8', timeout: 5000 }).trim();
84
85
  if (output) {
85
86
  const pids = new Set(output.split('\n').map(l => parseInt(l.trim(), 10)).filter(pid => pid && pid !== myPid));
86
87
  for (const pid of pids) {
@@ -123,7 +124,7 @@ async function main() {
123
124
  // 2. Create MCP server
124
125
  const mcpServer = new McpServer({
125
126
  name: 'abu-browser-bridge',
126
- version: '0.5.1',
127
+ version: PKG_VERSION,
127
128
  });
128
129
  // 3. Register browser tools
129
130
  registerTools(mcpServer);
package/dist/tools.js CHANGED
@@ -27,6 +27,36 @@ function formatResult(response) {
27
27
  }
28
28
  return JSON.stringify(response.data, null, 2);
29
29
  }
30
+ /**
31
+ * Parse and validate a JSON locator string from LLM input.
32
+ * Ensures the result is a plain object with at least one known locator key.
33
+ */
34
+ function parseLocator(raw) {
35
+ const parsed = JSON.parse(raw);
36
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
37
+ throw new Error('Locator must be a JSON object');
38
+ }
39
+ const validKeys = ['css', 'text', 'tag', 'role', 'name', 'xpath', 'testId', 'ref'];
40
+ const hasValidKey = Object.keys(parsed).some(k => validKeys.includes(k));
41
+ if (!hasValidKey) {
42
+ throw new Error(`Locator must contain at least one of: ${validKeys.join(', ')}`);
43
+ }
44
+ return parsed;
45
+ }
46
+ /**
47
+ * Parse and validate a JSON wait condition string from LLM input.
48
+ */
49
+ function parseCondition(raw) {
50
+ const parsed = JSON.parse(raw);
51
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
52
+ throw new Error('Condition must be a JSON object');
53
+ }
54
+ const validTypes = ['appear', 'disappear', 'enabled', 'textContains', 'urlContains'];
55
+ if (!validTypes.includes(parsed.type)) {
56
+ throw new Error(`Condition type must be one of: ${validTypes.join(', ')}`);
57
+ }
58
+ return parsed;
59
+ }
30
60
  // --- Register all tools ---
31
61
  export function registerTools(server) {
32
62
  // 1. browser_get_tabs
@@ -37,7 +67,7 @@ export function registerTools(server) {
37
67
  });
38
68
  // 2. browser_snapshot
39
69
  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'),
70
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
41
71
  selector: z.string().optional().describe('Optional CSS selector to scope the snapshot to a specific area of the page'),
42
72
  }, async ({ tabId, selector }) => {
43
73
  ensureConnected();
@@ -46,55 +76,55 @@ export function registerTools(server) {
46
76
  });
47
77
  // 3. browser_click
48
78
  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'),
79
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
50
80
  locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
51
81
  }, async ({ tabId, locator }) => {
52
82
  ensureConnected();
53
- const parsed = JSON.parse(locator);
83
+ const parsed = parseLocator(locator);
54
84
  const res = await sendToExtension('click', { tabId, locator: parsed });
55
85
  return { content: [{ type: 'text', text: formatResult(res) }] };
56
86
  });
57
87
  // 4. browser_fill
58
88
  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'),
89
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
60
90
  locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
61
91
  value: z.string().describe('The text value to fill into the field'),
62
92
  }, async ({ tabId, locator, value }) => {
63
93
  ensureConnected();
64
- const parsed = JSON.parse(locator);
94
+ const parsed = parseLocator(locator);
65
95
  const res = await sendToExtension('fill', { tabId, locator: parsed, value });
66
96
  return { content: [{ type: 'text', text: formatResult(res) }] };
67
97
  });
68
98
  // 5. browser_select
69
99
  server.tool('select', 'Select an option from a <select> dropdown element.', {
70
- tabId: z.number().describe('Tab ID from get_tabs'),
100
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
71
101
  locator: z.string().describe(`JSON string of element locator. ${LocatorDescription}`),
72
102
  value: z.string().describe('The option value or visible text to select'),
73
103
  }, async ({ tabId, locator, value }) => {
74
104
  ensureConnected();
75
- const parsed = JSON.parse(locator);
105
+ const parsed = parseLocator(locator);
76
106
  const res = await sendToExtension('select', { tabId, locator: parsed, value });
77
107
  return { content: [{ type: 'text', text: formatResult(res) }] };
78
108
  });
79
109
  // 6. browser_wait_for
80
110
  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'),
111
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
82
112
  condition: z.string().describe(`JSON string of wait condition. Options:
83
113
  - { "type": "appear", "locator": { "text": "成功" } } — wait for element to appear
84
114
  - { "type": "disappear", "locator": { "css": ".loading" } } — wait for element to disappear
85
115
  - { "type": "enabled", "locator": { "text": "提交" } } — wait for element to become clickable
86
116
  - { "type": "textContains", "locator": { "css": "#status" }, "text": "完成" } — wait for text content
87
117
  - { "type": "urlContains", "pattern": "/success" } — wait for URL change`),
88
- timeout: z.number().optional().default(30000).describe('Maximum wait time in ms (default: 30000)'),
118
+ timeout: z.coerce.number().optional().default(30000).describe('Maximum wait time in ms (default: 30000)'),
89
119
  }, async ({ tabId, condition, timeout }) => {
90
120
  ensureConnected();
91
- const parsed = JSON.parse(condition);
121
+ const parsed = parseCondition(condition);
92
122
  const res = await sendToExtension('wait_for', { tabId, condition: parsed, timeout }, timeout + 5000);
93
123
  return { content: [{ type: 'text', text: formatResult(res) }] };
94
124
  });
95
125
  // 7. browser_extract_text
96
126
  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'),
127
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
98
128
  selector: z.string().optional().describe('CSS selector to extract text from. If omitted, extracts the full page text (may be large).'),
99
129
  }, async ({ tabId, selector }) => {
100
130
  ensureConnected();
@@ -103,7 +133,7 @@ export function registerTools(server) {
103
133
  });
104
134
  // 8. browser_extract_table
105
135
  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'),
136
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
107
137
  selector: z.string().optional().describe('CSS selector for the target table. If omitted, extracts the largest table on the page.'),
108
138
  }, async ({ tabId, selector }) => {
109
139
  ensureConnected();
@@ -112,9 +142,9 @@ export function registerTools(server) {
112
142
  });
113
143
  // 9. browser_scroll
114
144
  server.tool('scroll', 'Scroll the page or a specific element.', {
115
- tabId: z.number().describe('Tab ID from get_tabs'),
145
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
116
146
  direction: z.enum(['up', 'down', 'left', 'right']).describe('Scroll direction'),
117
- amount: z.number().optional().default(500).describe('Scroll amount in pixels (default: 500)'),
147
+ amount: z.coerce.number().optional().default(500).describe('Scroll amount in pixels (default: 500)'),
118
148
  selector: z.string().optional().describe('CSS selector for the scrollable element. If omitted, scrolls the whole page.'),
119
149
  }, async ({ tabId, direction, amount, selector }) => {
120
150
  ensureConnected();
@@ -123,7 +153,7 @@ export function registerTools(server) {
123
153
  });
124
154
  // 10. browser_navigate
125
155
  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'),
156
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
127
157
  url: z.string().optional().describe('URL to navigate to. Omit for back/forward.'),
128
158
  action: z.enum(['goto', 'back', 'forward', 'reload']).optional().default('goto').describe('Navigation action (default: goto)'),
129
159
  }, async ({ tabId, url, action }) => {
@@ -133,7 +163,7 @@ export function registerTools(server) {
133
163
  });
134
164
  // 11. browser_keyboard
135
165
  server.tool('keyboard', 'Send keyboard events to the page. Supports key combinations.', {
136
- tabId: z.number().describe('Tab ID from get_tabs'),
166
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
137
167
  key: z.string().describe('Key to press (e.g., "Enter", "Tab", "Escape", "a", "ArrowDown")'),
138
168
  modifiers: z.array(z.enum(['ctrl', 'shift', 'alt', 'meta'])).optional().describe('Modifier keys to hold'),
139
169
  }, async ({ tabId, key, modifiers }) => {
@@ -143,7 +173,7 @@ export function registerTools(server) {
143
173
  });
144
174
  // 12. browser_execute_js
145
175
  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'),
176
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
147
177
  code: z.string().describe('JavaScript code to execute. The last expression value is returned.'),
148
178
  }, async ({ tabId, code }) => {
149
179
  ensureConnected();
@@ -152,7 +182,7 @@ export function registerTools(server) {
152
182
  });
153
183
  // 13. browser_screenshot
154
184
  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'),
185
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
156
186
  }, async ({ tabId }) => {
157
187
  ensureConnected();
158
188
  const res = await sendToExtension('screenshot', { tabId });
@@ -167,7 +197,25 @@ export function registerTools(server) {
167
197
  }
168
198
  return { content: [{ type: 'text', text: formatResult(res) }] };
169
199
  });
170
- // 14. browser_connection_status
200
+ // 14. browser_screenshot_full_page
201
+ server.tool('screenshot_full_page', 'Take a full-page screenshot by scrolling and stitching the entire page content. Returns a base64-encoded PNG image of the complete page. Use this when the user asks for a "long screenshot" or wants to capture content beyond the visible viewport. This is slower than a regular screenshot.', {
202
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
203
+ }, async ({ tabId }) => {
204
+ ensureConnected();
205
+ // Full-page capture needs more time: scroll + multiple captures + stitch
206
+ const res = await sendToExtension('screenshot_full_page', { tabId }, 120_000);
207
+ if (res.success && typeof res.data === 'string') {
208
+ return {
209
+ content: [{
210
+ type: 'image',
211
+ data: res.data.replace(/^data:image\/png;base64,/, ''),
212
+ mimeType: 'image/png',
213
+ }]
214
+ };
215
+ }
216
+ return { content: [{ type: 'text', text: formatResult(res) }] };
217
+ });
218
+ // 15. browser_connection_status
171
219
  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
220
  const connected = isExtensionConnected();
173
221
  return {
@@ -179,4 +227,26 @@ export function registerTools(server) {
179
227
  }]
180
228
  };
181
229
  });
230
+ // 15. get_downloads — recent download activity
231
+ server.tool('get_downloads', 'Get recent file downloads from the browser. Useful for confirming that a file was downloaded after clicking a download button.', async () => {
232
+ ensureConnected();
233
+ const res = await sendToExtension('get_downloads');
234
+ return { content: [{ type: 'text', text: formatResult(res) }] };
235
+ });
236
+ // 16. start_recording — record user interactions
237
+ server.tool('start_recording', 'Start recording user interactions on a page (clicks, inputs, selects). The user performs actions manually, then call stop_recording to get a list of recorded steps that can be used as an automation template.', {
238
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
239
+ }, async ({ tabId }) => {
240
+ ensureConnected();
241
+ const res = await sendToExtension('start_recording', { tabId });
242
+ return { content: [{ type: 'text', text: formatResult(res) }] };
243
+ });
244
+ // 17. stop_recording — stop recording and return captured steps
245
+ server.tool('stop_recording', 'Stop recording user interactions and return the captured steps. Each step includes the action type, element locator, and value. Use these steps as a template to replay the automation.', {
246
+ tabId: z.coerce.number().describe('Tab ID from get_tabs'),
247
+ }, async ({ tabId }) => {
248
+ ensureConnected();
249
+ const res = await sendToExtension('stop_recording', { tabId });
250
+ return { content: [{ type: 'text', text: formatResult(res) }] };
251
+ });
182
252
  }
@@ -0,0 +1 @@
1
+ export declare const PKG_VERSION: string;
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, resolve } from 'path';
4
+ const __filename = fileURLToPath(import.meta.url);
5
+ const __dirname = dirname(__filename);
6
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
7
+ export const PKG_VERSION = pkg.version;
@@ -4,12 +4,15 @@
4
4
  *
5
5
  * Also exposes a lightweight HTTP discovery endpoint on a fixed port
6
6
  * so the Chrome Extension can reliably find the WS port.
7
+ *
8
+ * Security: Generates a random auth token on startup. The Chrome Extension
9
+ * must fetch this token from the discovery endpoint and include it as
10
+ * `Sec-WebSocket-Protocol` header when connecting.
7
11
  */
8
12
  import type { BridgeResponse } from './types.js';
9
13
  /**
10
14
  * Start the WebSocket server on a fixed port.
11
- * The caller (index.ts) is responsible for killing stale bridges first.
12
- * No port fallback — if the port is taken, it's a fatal error.
15
+ * Validates auth token from Sec-WebSocket-Protocol header on connection.
13
16
  */
14
17
  export declare function startWSServer(port?: number): Promise<number>;
15
18
  /**
package/dist/wsServer.js CHANGED
@@ -4,14 +4,18 @@
4
4
  *
5
5
  * Also exposes a lightweight HTTP discovery endpoint on a fixed port
6
6
  * so the Chrome Extension can reliably find the WS port.
7
+ *
8
+ * Security: Generates a random auth token on startup. The Chrome Extension
9
+ * must fetch this token from the discovery endpoint and include it as
10
+ * `Sec-WebSocket-Protocol` header when connecting.
7
11
  */
8
12
  import { WebSocketServer, WebSocket } from 'ws';
9
13
  import { createServer } from 'http';
10
- import { createConnection } from 'net';
14
+ import { randomBytes } from 'crypto';
15
+ import { PKG_VERSION } from './version.js';
11
16
  const DEFAULT_WS_PORT = 9876;
12
17
  const DISCOVERY_PORT = 9875;
13
- const HEARTBEAT_INTERVAL = 15_000; // 15s — more frequent for faster disconnect detection
14
- const PONG_TIMEOUT = 5_000; // If no pong within 5s, consider dead
18
+ const HEARTBEAT_INTERVAL = 15_000; // 15s
15
19
  let wss = null;
16
20
  let discoveryServer = null;
17
21
  let extensionSocket = null;
@@ -20,44 +24,40 @@ let pongReceived = true;
20
24
  const pendingRequests = new Map();
21
25
  let requestCounter = 0;
22
26
  let activePort = null;
27
+ // Auth token — generated once per bridge process, shared via discovery endpoint
28
+ const authToken = randomBytes(24).toString('hex');
23
29
  function generateId() {
24
- return `req_${Date.now().toString(36)}_${(++requestCounter).toString(36)}`;
25
- }
26
- /**
27
- * Check if a port is in use by trying to connect to it.
28
- */
29
- function isPortInUse(port, host) {
30
- return new Promise((resolve) => {
31
- const socket = createConnection({ port, host });
32
- socket.on('connect', () => {
33
- socket.destroy();
34
- resolve(true);
35
- });
36
- socket.on('error', () => {
37
- socket.destroy();
38
- resolve(false);
39
- });
40
- socket.setTimeout(1000, () => {
41
- socket.destroy();
42
- resolve(false);
43
- });
44
- });
30
+ const rand = randomBytes(4).toString('hex');
31
+ return `req_${Date.now().toString(36)}_${(++requestCounter).toString(36)}_${rand}`;
45
32
  }
46
33
  // --- HTTP Discovery Endpoint ---
47
34
  /**
48
35
  * Start the HTTP discovery server on a fixed well-known port.
49
- * Chrome Extension queries this to find the actual WS port.
36
+ * Chrome Extension queries this to find the actual WS port and auth token.
50
37
  *
51
- * GET /status → { wsPort, pid, extensionConnected, uptime }
38
+ * GET /status → { wsPort, pid, extensionConnected, uptime, version, token }
39
+ *
40
+ * CORS restricted to chrome-extension:// origins only.
52
41
  */
53
42
  function startDiscoveryServer() {
54
43
  return new Promise((resolve, reject) => {
55
44
  const startTime = Date.now();
56
45
  discoveryServer = createServer((req, res) => {
57
- // CORS headers for Chrome Extension fetch
58
- res.setHeader('Access-Control-Allow-Origin', '*');
46
+ const origin = req.headers.origin ?? '';
47
+ // Only allow chrome-extension:// and no-origin (direct fetch from extension background)
48
+ const isAllowedOrigin = !origin || origin.startsWith('chrome-extension://');
49
+ if (!isAllowedOrigin) {
50
+ res.writeHead(403);
51
+ res.end('Forbidden');
52
+ return;
53
+ }
54
+ // CORS headers for allowed origins
55
+ if (origin) {
56
+ res.setHeader('Access-Control-Allow-Origin', origin);
57
+ }
59
58
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
60
59
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
60
+ res.setHeader('Vary', 'Origin');
61
61
  if (req.method === 'OPTIONS') {
62
62
  res.writeHead(204);
63
63
  res.end();
@@ -70,7 +70,8 @@ function startDiscoveryServer() {
70
70
  pid: process.pid,
71
71
  extensionConnected: isExtensionConnected(),
72
72
  uptime: Math.round((Date.now() - startTime) / 1000),
73
- version: '0.5.1',
73
+ version: PKG_VERSION,
74
+ token: authToken,
74
75
  }));
75
76
  return;
76
77
  }
@@ -80,7 +81,7 @@ function startDiscoveryServer() {
80
81
  discoveryServer.on('error', (err) => {
81
82
  if (err.code === 'EADDRINUSE') {
82
83
  console.error(`[abu-bridge] Discovery port ${DISCOVERY_PORT} in use — old bridge still running?`);
83
- // Not fatal — extension can still fall back to port scanning
84
+ // Not fatal — extension can still fall back to fixed port
84
85
  resolve();
85
86
  }
86
87
  else {
@@ -96,8 +97,7 @@ function startDiscoveryServer() {
96
97
  // --- WebSocket Server ---
97
98
  /**
98
99
  * Start the WebSocket server on a fixed port.
99
- * The caller (index.ts) is responsible for killing stale bridges first.
100
- * No port fallback — if the port is taken, it's a fatal error.
100
+ * Validates auth token from Sec-WebSocket-Protocol header on connection.
101
101
  */
102
102
  export async function startWSServer(port = DEFAULT_WS_PORT) {
103
103
  await startDiscoveryServer();
@@ -107,7 +107,21 @@ export async function startWSServer(port = DEFAULT_WS_PORT) {
107
107
  }
108
108
  function listenOnPort(port) {
109
109
  return new Promise((resolve, reject) => {
110
- wss = new WebSocketServer({ port, host: '127.0.0.1' });
110
+ wss = new WebSocketServer({
111
+ port,
112
+ host: '127.0.0.1',
113
+ verifyClient: (info, callback) => {
114
+ // Validate auth token from Sec-WebSocket-Protocol header
115
+ const protocol = info.req.headers['sec-websocket-protocol'];
116
+ if (protocol === authToken) {
117
+ callback(true);
118
+ }
119
+ else {
120
+ console.error(`[abu-bridge] Rejected WS connection: invalid auth token`);
121
+ callback(false, 401, 'Unauthorized');
122
+ }
123
+ },
124
+ });
111
125
  wss.on('listening', () => {
112
126
  console.error(`[abu-bridge] WS server listening on ws://127.0.0.1:${port}`);
113
127
  startHeartbeat();
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "abu-browser-bridge",
3
- "version": "0.5.1",
3
+ "version": "0.6.6",
4
4
  "description": "MCP Server that bridges Abu AI assistant with Chrome Extension for browser automation",
5
5
  "type": "module",
6
- "license": "MIT",
6
+ "license": "SEE LICENSE IN LICENSE",
7
7
  "author": "pm-shawn",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/anthropics/abu"
10
+ "url": "https://github.com/PM-Shawn/Abu-Cowork"
11
11
  },
12
12
  "keywords": ["mcp", "browser", "automation", "chrome-extension", "abu"],
13
13
  "bin": {