web-access-mcp 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/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # web-access-mcp
2
+
3
+ 把 [`eze-is/web-access`](https://github.com/eze-is/web-access) 里真正“可执行”的浏览器能力改成一个显式 MCP server,避免依赖 skill 自动激活。
4
+
5
+ 当前版本聚焦于 Chrome CDP 自动化,不再依赖 `SKILL.md` 的提示词调度逻辑。
6
+
7
+ ## 资源限制
8
+
9
+ - MCP 自己创建的受管 tab 最多 10 个
10
+ - 超过 10 个后,`new_tab` 会直接报错,防止 Chrome target 过多导致卡顿或内存爆掉
11
+ - `adopt_tab` 接管你原本已打开的 tab 后,不计入这 10 个配额
12
+ - `close_tab` 会释放配额
13
+ - 默认情况下,用户本来就打开的 Chrome tab 不计入这 10 个配额,除非你显式执行 `adopt_tab`
14
+
15
+ ## 已实现能力
16
+
17
+ - `browser_health`: 检查 Chrome remote debugging 是否可发现
18
+ - `browser_health` 额外返回当前受管总数、`created` 配额占用与上限
19
+ - `list_tabs`: 列出当前页面 tab
20
+ - `list_tabs` 额外标记 `isManaged` 与 `managedSource`
21
+ - `new_tab`: 新建后台 tab
22
+ - `close_tab`: 关闭 tab
23
+ - `close_all_managed_tabs`: 一键关闭全部受管 tab
24
+ - `close_created_tabs`: 只关闭 MCP 新开的 `created` tab,保留接管的 `adopted` tab
25
+ - `adopt_tab`: 接管一个用户已打开的 tab
26
+ - `navigate`: 页面跳转并等待加载
27
+ - `go_back`: 后退
28
+ - `page_info`: 读取标题、URL、readyState
29
+ - `eval`: 在页面内执行 JavaScript
30
+ - `click`: `element.click()`
31
+ - `click_at`: 真实鼠标点击
32
+ - `set_files`: 给 file input 注入本地文件
33
+ - `scroll`: 页面滚动
34
+ - `screenshot`: 截图,支持返回 base64 或落盘
35
+
36
+ ## 前置条件
37
+
38
+ 1. Node.js 22+
39
+ 2. Chrome 已打开
40
+ 3. 在 Chrome 打开 `chrome://inspect/#remote-debugging`
41
+ 4. 勾选 `Allow remote debugging for this browser instance`
42
+
43
+ ## 本地运行
44
+
45
+ ```bash
46
+ npm run check
47
+ npm start
48
+ ```
49
+
50
+ ## MCP 接入示例
51
+
52
+ Claude Desktop / Codex 类客户端可将该 server 配成 stdio:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "web-access": {
58
+ "command": "node",
59
+ "args": ["D:/Desktop/浏览器mcp/src/index.mjs"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ## 与原 skill 的差异
66
+
67
+ - 原 skill 的“自动选择 WebSearch / WebFetch / Jina / CDP”是提示词层能力,不是脚本层能力
68
+ - 本仓库把稳定、确定、可调用的部分下沉成 MCP tools
69
+ - 如果后续需要,可继续补 `fetch_url`、`fetch_html`、`site_pattern_lookup` 等工具
70
+
71
+ ## 设计取舍
72
+
73
+ - 目前无第三方依赖,直接用 Node 22 自带 `WebSocket`
74
+ - 采用 stdio + JSON-RPC 实现最小 MCP server
75
+ - 保留原仓库的核心思路:复用用户自己的 Chrome 登录态,而不是启动独立浏览器
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "web-access-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for browser automation over Chrome CDP, adapted from eze-is/web-access",
5
+ "type": "module",
6
+ "bin": {
7
+ "web-access-mcp": "./src/index.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node ./src/index.mjs",
11
+ "check": "node --check ./src/index.mjs && node --check ./src/cdp-client.mjs"
12
+ },
13
+ "engines": {
14
+ "node": ">=22.0.0"
15
+ },
16
+ "license": "MIT"
17
+ }
@@ -0,0 +1,631 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const DEFAULT_PORTS = [9222, 9229, 9333];
7
+ const MAX_MANAGED_TABS = 10;
8
+
9
+ function delay(ms) {
10
+ return new Promise((resolve) => setTimeout(resolve, ms));
11
+ }
12
+
13
+ function portIsOpen(port, host = '127.0.0.1', timeoutMs = 2000) {
14
+ return new Promise((resolve) => {
15
+ const socket = net.createConnection(port, host);
16
+ const timer = setTimeout(() => {
17
+ socket.destroy();
18
+ resolve(false);
19
+ }, timeoutMs);
20
+
21
+ socket.once('connect', () => {
22
+ clearTimeout(timer);
23
+ socket.destroy();
24
+ resolve(true);
25
+ });
26
+
27
+ socket.once('error', () => {
28
+ clearTimeout(timer);
29
+ resolve(false);
30
+ });
31
+ });
32
+ }
33
+
34
+ function getActivePortCandidates() {
35
+ const home = os.homedir();
36
+ const localAppData = process.env.LOCALAPPDATA || '';
37
+ switch (os.platform()) {
38
+ case 'darwin':
39
+ return [
40
+ path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),
41
+ path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),
42
+ path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),
43
+ ];
44
+ case 'linux':
45
+ return [
46
+ path.join(home, '.config/google-chrome/DevToolsActivePort'),
47
+ path.join(home, '.config/chromium/DevToolsActivePort'),
48
+ ];
49
+ case 'win32':
50
+ return [
51
+ path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),
52
+ path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),
53
+ ];
54
+ default:
55
+ return [];
56
+ }
57
+ }
58
+
59
+ async function discoverChromeDebugTarget() {
60
+ for (const filePath of getActivePortCandidates()) {
61
+ try {
62
+ const lines = fs.readFileSync(filePath, 'utf8').trim().split(/\r?\n/).filter(Boolean);
63
+ const port = Number.parseInt(lines[0], 10);
64
+ const wsPath = lines[1] || null;
65
+ if (Number.isInteger(port) && port > 0 && port < 65536 && await portIsOpen(port)) {
66
+ return { port, wsPath };
67
+ }
68
+ } catch {
69
+ // Ignore missing files or parse errors and continue probing.
70
+ }
71
+ }
72
+
73
+ for (const port of DEFAULT_PORTS) {
74
+ if (await portIsOpen(port)) {
75
+ return { port, wsPath: null };
76
+ }
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ function socketIsOpen(ws) {
83
+ return Boolean(ws && (ws.readyState === WebSocket.OPEN || ws.readyState === 1));
84
+ }
85
+
86
+ export class ChromeCdpClient {
87
+ constructor() {
88
+ this.ws = null;
89
+ this.chromePort = null;
90
+ this.chromeWsPath = null;
91
+ this.commandId = 0;
92
+ this.pending = new Map();
93
+ this.sessions = new Map();
94
+ this.portGuardedSessions = new Set();
95
+ this.connectingPromise = null;
96
+ this.managedTargets = new Map();
97
+ }
98
+
99
+ async discover() {
100
+ if (this.chromePort) {
101
+ return {
102
+ port: this.chromePort,
103
+ wsPath: this.chromeWsPath,
104
+ };
105
+ }
106
+
107
+ const discovered = await discoverChromeDebugTarget();
108
+ if (!discovered) {
109
+ throw new Error(
110
+ '未发现 Chrome remote debugging。请打开 Chrome,然后访问 chrome://inspect/#remote-debugging 并启用 "Allow remote debugging for this browser instance"。'
111
+ );
112
+ }
113
+
114
+ this.chromePort = discovered.port;
115
+ this.chromeWsPath = discovered.wsPath;
116
+ return discovered;
117
+ }
118
+
119
+ async connect() {
120
+ if (socketIsOpen(this.ws)) {
121
+ return;
122
+ }
123
+ if (this.connectingPromise) {
124
+ return this.connectingPromise;
125
+ }
126
+
127
+ const { port, wsPath } = await this.discover();
128
+ const wsUrl = wsPath
129
+ ? `ws://127.0.0.1:${port}${wsPath}`
130
+ : `ws://127.0.0.1:${port}/devtools/browser`;
131
+
132
+ this.connectingPromise = new Promise((resolve, reject) => {
133
+ const ws = new WebSocket(wsUrl);
134
+ this.ws = ws;
135
+
136
+ const cleanup = () => {
137
+ ws.removeEventListener('open', onOpen);
138
+ ws.removeEventListener('error', onError);
139
+ };
140
+
141
+ const onOpen = () => {
142
+ cleanup();
143
+ this.connectingPromise = null;
144
+ resolve();
145
+ };
146
+
147
+ const onError = (event) => {
148
+ cleanup();
149
+ this.connectingPromise = null;
150
+ this.ws = null;
151
+ this.chromePort = null;
152
+ this.chromeWsPath = null;
153
+ reject(new Error(event?.message || '连接 Chrome CDP 失败'));
154
+ };
155
+
156
+ const onClose = () => {
157
+ this.ws = null;
158
+ this.chromePort = null;
159
+ this.chromeWsPath = null;
160
+ this.sessions.clear();
161
+ this.portGuardedSessions.clear();
162
+ };
163
+
164
+ const onMessage = (event) => {
165
+ const message = JSON.parse(typeof event.data === 'string' ? event.data : event.data.toString());
166
+
167
+ if (message.method === 'Target.attachedToTarget') {
168
+ const { sessionId, targetInfo } = message.params;
169
+ this.sessions.set(targetInfo.targetId, sessionId);
170
+ }
171
+
172
+ if (message.method === 'Fetch.requestPaused') {
173
+ const { requestId, sessionId } = message.params;
174
+ this.sendCommand('Fetch.failRequest', { requestId, errorReason: 'ConnectionRefused' }, sessionId)
175
+ .catch(() => {});
176
+ }
177
+
178
+ if (message.id && this.pending.has(message.id)) {
179
+ const { resolve: done, reject: fail, timeout } = this.pending.get(message.id);
180
+ clearTimeout(timeout);
181
+ this.pending.delete(message.id);
182
+ if (message.error) {
183
+ fail(new Error(message.error.message || JSON.stringify(message.error)));
184
+ } else {
185
+ done(message);
186
+ }
187
+ }
188
+ };
189
+
190
+ ws.addEventListener('open', onOpen);
191
+ ws.addEventListener('error', onError);
192
+ ws.addEventListener('close', onClose);
193
+ ws.addEventListener('message', onMessage);
194
+ });
195
+
196
+ return this.connectingPromise;
197
+ }
198
+
199
+ async sendCommand(method, params = {}, sessionId = null) {
200
+ await this.connect();
201
+
202
+ return new Promise((resolve, reject) => {
203
+ const id = ++this.commandId;
204
+ const message = { id, method, params };
205
+ if (sessionId) {
206
+ message.sessionId = sessionId;
207
+ }
208
+
209
+ const timeout = setTimeout(() => {
210
+ this.pending.delete(id);
211
+ reject(new Error(`CDP 命令超时: ${method}`));
212
+ }, 30000);
213
+
214
+ this.pending.set(id, { resolve, reject, timeout });
215
+ this.ws.send(JSON.stringify(message));
216
+ });
217
+ }
218
+
219
+ async ensureSession(targetId) {
220
+ if (this.sessions.has(targetId)) {
221
+ return this.sessions.get(targetId);
222
+ }
223
+
224
+ const response = await this.sendCommand('Target.attachToTarget', { targetId, flatten: true });
225
+ const sessionId = response.result?.sessionId;
226
+ if (!sessionId) {
227
+ throw new Error(`无法附着到 target ${targetId}`);
228
+ }
229
+
230
+ this.sessions.set(targetId, sessionId);
231
+ await this.enablePortGuard(sessionId);
232
+ return sessionId;
233
+ }
234
+
235
+ async enablePortGuard(sessionId) {
236
+ if (!this.chromePort || this.portGuardedSessions.has(sessionId)) {
237
+ return;
238
+ }
239
+
240
+ try {
241
+ await this.sendCommand('Fetch.enable', {
242
+ patterns: [
243
+ { urlPattern: `http://127.0.0.1:${this.chromePort}/*`, requestStage: 'Request' },
244
+ { urlPattern: `http://localhost:${this.chromePort}/*`, requestStage: 'Request' },
245
+ ],
246
+ }, sessionId);
247
+ this.portGuardedSessions.add(sessionId);
248
+ } catch {
249
+ // Do not block normal automation if request interception is unavailable.
250
+ }
251
+ }
252
+
253
+ async waitForLoad(sessionId, timeoutMs = 15000) {
254
+ await this.sendCommand('Page.enable', {}, sessionId);
255
+
256
+ const startedAt = Date.now();
257
+ while (Date.now() - startedAt < timeoutMs) {
258
+ const state = await this.sendCommand('Runtime.evaluate', {
259
+ expression: 'document.readyState',
260
+ returnByValue: true,
261
+ }, sessionId);
262
+
263
+ if (state.result?.result?.value === 'complete') {
264
+ return 'complete';
265
+ }
266
+
267
+ await delay(500);
268
+ }
269
+
270
+ return 'timeout';
271
+ }
272
+
273
+ async health() {
274
+ let discovered = null;
275
+ let error = null;
276
+ let managedTabCount = this.managedTargets.size;
277
+ let createdTabCount = [...this.managedTargets.values()].filter((source) => source === 'created').length;
278
+
279
+ try {
280
+ discovered = await this.discover();
281
+ managedTabCount = await this.getManagedTabCount();
282
+ createdTabCount = this.getCreatedTabCount();
283
+ } catch (err) {
284
+ error = err.message;
285
+ }
286
+
287
+ return {
288
+ connected: socketIsOpen(this.ws),
289
+ chromePort: discovered?.port ?? this.chromePort ?? null,
290
+ wsPath: discovered?.wsPath ?? this.chromeWsPath ?? null,
291
+ sessions: this.sessions.size,
292
+ managedTabCount,
293
+ createdTabCount,
294
+ managedTargets: [...this.managedTargets.entries()].map(([targetId, source]) => ({ targetId, source })),
295
+ maxManagedTabs: MAX_MANAGED_TABS,
296
+ error,
297
+ };
298
+ }
299
+
300
+ async pruneManagedTargets() {
301
+ if (this.managedTargets.size === 0) {
302
+ return 0;
303
+ }
304
+
305
+ const openTargets = await this.listTargets();
306
+ const openTargetIds = new Set(openTargets.map((target) => target.targetId));
307
+ for (const targetId of this.managedTargets.keys()) {
308
+ if (!openTargetIds.has(targetId)) {
309
+ this.managedTargets.delete(targetId);
310
+ this.sessions.delete(targetId);
311
+ }
312
+ }
313
+
314
+ return this.managedTargets.size;
315
+ }
316
+
317
+ async getManagedTabCount() {
318
+ return this.pruneManagedTargets();
319
+ }
320
+
321
+ getCreatedTabCount() {
322
+ let count = 0;
323
+ for (const source of this.managedTargets.values()) {
324
+ if (source === 'created') {
325
+ count += 1;
326
+ }
327
+ }
328
+ return count;
329
+ }
330
+
331
+ async listTargets() {
332
+ const response = await this.sendCommand('Target.getTargets');
333
+ const targets = response.result?.targetInfos?.filter((target) => target.type === 'page') ?? [];
334
+ return targets.map((target) => ({
335
+ ...target,
336
+ isManaged: this.managedTargets.has(target.targetId),
337
+ managedSource: this.managedTargets.get(target.targetId) ?? null,
338
+ }));
339
+ }
340
+
341
+ async assertTabExists(targetId) {
342
+ const targets = await this.listTargets();
343
+ const target = targets.find((item) => item.targetId === targetId);
344
+ if (!target) {
345
+ throw new Error(`目标 tab 不存在: ${targetId}`);
346
+ }
347
+ return target;
348
+ }
349
+
350
+ async adoptTab(targetId) {
351
+ await this.getManagedTabCount();
352
+ const target = await this.assertTabExists(targetId);
353
+ if (this.managedTargets.has(targetId)) {
354
+ return {
355
+ targetId,
356
+ title: target.title,
357
+ url: target.url,
358
+ managedSource: this.managedTargets.get(targetId),
359
+ managedTabCount: this.managedTargets.size,
360
+ createdTabCount: this.getCreatedTabCount(),
361
+ maxManagedTabs: MAX_MANAGED_TABS,
362
+ };
363
+ }
364
+
365
+ this.managedTargets.set(targetId, 'adopted');
366
+ return {
367
+ targetId,
368
+ title: target.title,
369
+ url: target.url,
370
+ managedSource: 'adopted',
371
+ managedTabCount: this.managedTargets.size,
372
+ createdTabCount: this.getCreatedTabCount(),
373
+ maxManagedTabs: MAX_MANAGED_TABS,
374
+ };
375
+ }
376
+
377
+ async newTab(url = 'about:blank') {
378
+ await this.getManagedTabCount();
379
+ const createdTabCount = this.getCreatedTabCount();
380
+ if (createdTabCount >= MAX_MANAGED_TABS) {
381
+ throw new Error(`MCP 新开 tab 配额已达上限 ${MAX_MANAGED_TABS},请先关闭部分 created 窗口再创建新窗口`);
382
+ }
383
+
384
+ const response = await this.sendCommand('Target.createTarget', {
385
+ url,
386
+ background: true,
387
+ });
388
+ const targetId = response.result?.targetId;
389
+ if (!targetId) {
390
+ throw new Error('创建 tab 失败');
391
+ }
392
+ this.managedTargets.set(targetId, 'created');
393
+
394
+ if (url !== 'about:blank') {
395
+ const sessionId = await this.ensureSession(targetId);
396
+ await this.waitForLoad(sessionId);
397
+ }
398
+
399
+ return {
400
+ targetId,
401
+ managedSource: 'created',
402
+ managedTabCount: this.managedTargets.size,
403
+ createdTabCount: this.getCreatedTabCount(),
404
+ maxManagedTabs: MAX_MANAGED_TABS,
405
+ };
406
+ }
407
+
408
+ async closeTab(targetId) {
409
+ const response = await this.sendCommand('Target.closeTarget', { targetId });
410
+ this.sessions.delete(targetId);
411
+ this.managedTargets.delete(targetId);
412
+ return {
413
+ ...(response.result ?? { success: true }),
414
+ managedTabCount: this.managedTargets.size,
415
+ createdTabCount: this.getCreatedTabCount(),
416
+ maxManagedTabs: MAX_MANAGED_TABS,
417
+ };
418
+ }
419
+
420
+ async closeAllManagedTabs() {
421
+ await this.getManagedTabCount();
422
+ const targetIds = [...this.managedTargets.keys()];
423
+ const closed = [];
424
+ const failed = [];
425
+
426
+ for (const targetId of targetIds) {
427
+ const source = this.managedTargets.get(targetId) ?? null;
428
+ try {
429
+ await this.closeTab(targetId);
430
+ closed.push({ targetId, source });
431
+ } catch (error) {
432
+ failed.push({ targetId, source, error: error.message });
433
+ }
434
+ }
435
+
436
+ return {
437
+ requested: targetIds.length,
438
+ closed,
439
+ failed,
440
+ managedTabCount: this.managedTargets.size,
441
+ createdTabCount: this.getCreatedTabCount(),
442
+ maxManagedTabs: MAX_MANAGED_TABS,
443
+ };
444
+ }
445
+
446
+ async closeCreatedTabs() {
447
+ await this.getManagedTabCount();
448
+ const targetIds = [...this.managedTargets.entries()]
449
+ .filter(([, source]) => source === 'created')
450
+ .map(([targetId]) => targetId);
451
+ const closed = [];
452
+ const failed = [];
453
+
454
+ for (const targetId of targetIds) {
455
+ try {
456
+ await this.closeTab(targetId);
457
+ closed.push({ targetId, source: 'created' });
458
+ } catch (error) {
459
+ failed.push({ targetId, source: 'created', error: error.message });
460
+ }
461
+ }
462
+
463
+ return {
464
+ requested: targetIds.length,
465
+ closed,
466
+ failed,
467
+ managedTabCount: this.managedTargets.size,
468
+ createdTabCount: this.getCreatedTabCount(),
469
+ maxManagedTabs: MAX_MANAGED_TABS,
470
+ };
471
+ }
472
+
473
+ async navigate(targetId, url) {
474
+ const sessionId = await this.ensureSession(targetId);
475
+ const response = await this.sendCommand('Page.navigate', { url }, sessionId);
476
+ const loadState = await this.waitForLoad(sessionId);
477
+ return {
478
+ frameId: response.result?.frameId ?? null,
479
+ loaderId: response.result?.loaderId ?? null,
480
+ loadState,
481
+ };
482
+ }
483
+
484
+ async goBack(targetId) {
485
+ const sessionId = await this.ensureSession(targetId);
486
+ await this.sendCommand('Runtime.evaluate', { expression: 'history.back()' }, sessionId);
487
+ const loadState = await this.waitForLoad(sessionId);
488
+ return { success: true, loadState };
489
+ }
490
+
491
+ async pageInfo(targetId) {
492
+ const sessionId = await this.ensureSession(targetId);
493
+ const response = await this.sendCommand('Runtime.evaluate', {
494
+ expression: '({ title: document.title, url: location.href, readyState: document.readyState })',
495
+ returnByValue: true,
496
+ awaitPromise: true,
497
+ }, sessionId);
498
+ return response.result?.result?.value ?? {};
499
+ }
500
+
501
+ async evaluate(targetId, expression) {
502
+ const sessionId = await this.ensureSession(targetId);
503
+ const response = await this.sendCommand('Runtime.evaluate', {
504
+ expression,
505
+ returnByValue: true,
506
+ awaitPromise: true,
507
+ }, sessionId);
508
+
509
+ if (response.result?.exceptionDetails) {
510
+ throw new Error(response.result.exceptionDetails.text || '页面脚本执行失败');
511
+ }
512
+
513
+ return response.result?.result?.value;
514
+ }
515
+
516
+ async click(targetId, selector) {
517
+ const selectorLabel = JSON.stringify(selector);
518
+ const script = `(() => {
519
+ const selector = ${selectorLabel};
520
+ const el = document.querySelector(selector);
521
+ if (!el) return { error: '未找到元素: ' + selector };
522
+ el.scrollIntoView({ block: 'center' });
523
+ el.click();
524
+ return {
525
+ clicked: true,
526
+ tag: el.tagName,
527
+ text: (el.textContent || '').slice(0, 200)
528
+ };
529
+ })()`;
530
+ const result = await this.evaluate(targetId, script);
531
+ if (result?.error) {
532
+ throw new Error(result.error);
533
+ }
534
+ return result;
535
+ }
536
+
537
+ async clickAt(targetId, selector) {
538
+ const sessionId = await this.ensureSession(targetId);
539
+ const selectorLabel = JSON.stringify(selector);
540
+ const coords = await this.evaluate(targetId, `(() => {
541
+ const selector = ${selectorLabel};
542
+ const el = document.querySelector(selector);
543
+ if (!el) return { error: '未找到元素: ' + selector };
544
+ el.scrollIntoView({ block: 'center' });
545
+ const rect = el.getBoundingClientRect();
546
+ return {
547
+ x: rect.x + rect.width / 2,
548
+ y: rect.y + rect.height / 2,
549
+ tag: el.tagName,
550
+ text: (el.textContent || '').slice(0, 200)
551
+ };
552
+ })()`);
553
+
554
+ if (!coords || coords.error) {
555
+ throw new Error(coords?.error || '无法定位点击坐标');
556
+ }
557
+
558
+ await this.sendCommand('Input.dispatchMouseEvent', {
559
+ type: 'mousePressed',
560
+ x: coords.x,
561
+ y: coords.y,
562
+ button: 'left',
563
+ clickCount: 1,
564
+ }, sessionId);
565
+
566
+ await this.sendCommand('Input.dispatchMouseEvent', {
567
+ type: 'mouseReleased',
568
+ x: coords.x,
569
+ y: coords.y,
570
+ button: 'left',
571
+ clickCount: 1,
572
+ }, sessionId);
573
+
574
+ return { clicked: true, ...coords };
575
+ }
576
+
577
+ async setFiles(targetId, selector, files) {
578
+ const sessionId = await this.ensureSession(targetId);
579
+ await this.sendCommand('DOM.enable', {}, sessionId);
580
+ const documentNode = await this.sendCommand('DOM.getDocument', {}, sessionId);
581
+ const node = await this.sendCommand('DOM.querySelector', {
582
+ nodeId: documentNode.result.root.nodeId,
583
+ selector,
584
+ }, sessionId);
585
+
586
+ const nodeId = node.result?.nodeId;
587
+ if (!nodeId) {
588
+ throw new Error(`未找到元素: ${selector}`);
589
+ }
590
+
591
+ await this.sendCommand('DOM.setFileInputFiles', { nodeId, files }, sessionId);
592
+ return { success: true, count: files.length };
593
+ }
594
+
595
+ async scroll(targetId, { y = 3000, direction = 'down' } = {}) {
596
+ const signedY = Math.abs(y);
597
+ let expression = `window.scrollBy(0, ${signedY}); "scrolled down ${signedY}px"`;
598
+
599
+ if (direction === 'up') {
600
+ expression = `window.scrollBy(0, -${signedY}); "scrolled up ${signedY}px"`;
601
+ } else if (direction === 'top') {
602
+ expression = 'window.scrollTo(0, 0); "scrolled to top"';
603
+ } else if (direction === 'bottom') {
604
+ expression = 'window.scrollTo(0, document.body.scrollHeight); "scrolled to bottom"';
605
+ }
606
+
607
+ const value = await this.evaluate(targetId, expression);
608
+ await delay(800);
609
+ return { value };
610
+ }
611
+
612
+ async screenshot(targetId, { filePath = null, format = 'png' } = {}) {
613
+ const sessionId = await this.ensureSession(targetId);
614
+ const response = await this.sendCommand('Page.captureScreenshot', {
615
+ format,
616
+ quality: format === 'jpeg' ? 80 : undefined,
617
+ }, sessionId);
618
+
619
+ const base64 = response.result?.data;
620
+ if (!base64) {
621
+ throw new Error('截图失败');
622
+ }
623
+
624
+ if (filePath) {
625
+ fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
626
+ return { saved: filePath, format };
627
+ }
628
+
629
+ return { format, data: base64 };
630
+ }
631
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { ChromeCdpClient } from './cdp-client.mjs';
4
+
5
+ const SERVER_INFO = {
6
+ name: 'web-access-mcp',
7
+ version: '0.1.0',
8
+ };
9
+
10
+ const client = new ChromeCdpClient();
11
+
12
+ const tools = [
13
+ {
14
+ name: 'browser_health',
15
+ description: 'Check Chrome remote debugging status, all managed tabs, and the quota used only by MCP-created tabs.',
16
+ inputSchema: {
17
+ type: 'object',
18
+ properties: {},
19
+ additionalProperties: false,
20
+ },
21
+ },
22
+ {
23
+ name: 'list_tabs',
24
+ description: 'List current Chrome page targets, including whether each tab is managed by this MCP server.',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {},
28
+ additionalProperties: false,
29
+ },
30
+ },
31
+ {
32
+ name: 'new_tab',
33
+ description: 'Create a new background tab and optionally navigate it to a URL. Refuses once MCP-created tabs reach the quota of 10; adopted user tabs do not consume quota.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ url: { type: 'string', description: 'URL to open. Defaults to about:blank.' },
38
+ },
39
+ additionalProperties: false,
40
+ },
41
+ },
42
+ {
43
+ name: 'close_tab',
44
+ description: 'Close a tab by targetId and release one managed tab slot if the tab was created by this MCP server.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ targetId: { type: 'string' },
49
+ },
50
+ required: ['targetId'],
51
+ additionalProperties: false,
52
+ },
53
+ },
54
+ {
55
+ name: 'close_all_managed_tabs',
56
+ description: 'Close every tab currently managed by this MCP server, including tabs adopted from the user session.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {},
60
+ additionalProperties: false,
61
+ },
62
+ },
63
+ {
64
+ name: 'close_created_tabs',
65
+ description: 'Close only MCP-created managed tabs. Adopted user tabs are preserved.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {},
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ {
73
+ name: 'adopt_tab',
74
+ description: 'Take over an existing user tab so it becomes managed by this MCP server. Adopted tabs do not consume the 10-tab quota reserved for MCP-created tabs.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: {
78
+ targetId: { type: 'string' },
79
+ },
80
+ required: ['targetId'],
81
+ additionalProperties: false,
82
+ },
83
+ },
84
+ {
85
+ name: 'navigate',
86
+ description: 'Navigate an existing tab to a URL and wait for the page to load.',
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ targetId: { type: 'string' },
91
+ url: { type: 'string' },
92
+ },
93
+ required: ['targetId', 'url'],
94
+ additionalProperties: false,
95
+ },
96
+ },
97
+ {
98
+ name: 'go_back',
99
+ description: 'Trigger history.back() in an existing tab and wait for the page to load.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ targetId: { type: 'string' },
104
+ },
105
+ required: ['targetId'],
106
+ additionalProperties: false,
107
+ },
108
+ },
109
+ {
110
+ name: 'page_info',
111
+ description: 'Read title, current URL, and readyState from a tab.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ targetId: { type: 'string' },
116
+ },
117
+ required: ['targetId'],
118
+ additionalProperties: false,
119
+ },
120
+ },
121
+ {
122
+ name: 'eval',
123
+ description: 'Run JavaScript in a page context and return the resulting JSON-serializable value.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ targetId: { type: 'string' },
128
+ expression: { type: 'string' },
129
+ },
130
+ required: ['targetId', 'expression'],
131
+ additionalProperties: false,
132
+ },
133
+ },
134
+ {
135
+ name: 'click',
136
+ description: 'Use element.click() on the first DOM node matching a CSS selector.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ targetId: { type: 'string' },
141
+ selector: { type: 'string' },
142
+ },
143
+ required: ['targetId', 'selector'],
144
+ additionalProperties: false,
145
+ },
146
+ },
147
+ {
148
+ name: 'click_at',
149
+ description: 'Perform a real Chrome mouse click on the center of the first element matching a CSS selector.',
150
+ inputSchema: {
151
+ type: 'object',
152
+ properties: {
153
+ targetId: { type: 'string' },
154
+ selector: { type: 'string' },
155
+ },
156
+ required: ['targetId', 'selector'],
157
+ additionalProperties: false,
158
+ },
159
+ },
160
+ {
161
+ name: 'set_files',
162
+ description: 'Attach local files to a file input selected by CSS.',
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ targetId: { type: 'string' },
167
+ selector: { type: 'string' },
168
+ files: {
169
+ type: 'array',
170
+ items: { type: 'string' },
171
+ minItems: 1,
172
+ },
173
+ },
174
+ required: ['targetId', 'selector', 'files'],
175
+ additionalProperties: false,
176
+ },
177
+ },
178
+ {
179
+ name: 'scroll',
180
+ description: 'Scroll a page by distance or to top/bottom.',
181
+ inputSchema: {
182
+ type: 'object',
183
+ properties: {
184
+ targetId: { type: 'string' },
185
+ y: { type: 'integer', minimum: 0 },
186
+ direction: {
187
+ type: 'string',
188
+ enum: ['down', 'up', 'top', 'bottom'],
189
+ },
190
+ },
191
+ required: ['targetId'],
192
+ additionalProperties: false,
193
+ },
194
+ },
195
+ {
196
+ name: 'screenshot',
197
+ description: 'Capture a page screenshot. Returns base64 unless filePath is provided.',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {
201
+ targetId: { type: 'string' },
202
+ filePath: { type: 'string' },
203
+ format: {
204
+ type: 'string',
205
+ enum: ['png', 'jpeg'],
206
+ },
207
+ },
208
+ required: ['targetId'],
209
+ additionalProperties: false,
210
+ },
211
+ },
212
+ ];
213
+
214
+ function makeResponse(id, result) {
215
+ return {
216
+ jsonrpc: '2.0',
217
+ id,
218
+ result,
219
+ };
220
+ }
221
+
222
+ function makeError(id, code, message, data = undefined) {
223
+ return {
224
+ jsonrpc: '2.0',
225
+ id,
226
+ error: {
227
+ code,
228
+ message,
229
+ ...(data === undefined ? {} : { data }),
230
+ },
231
+ };
232
+ }
233
+
234
+ function toolResult(payload) {
235
+ const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
236
+ return {
237
+ content: [
238
+ {
239
+ type: 'text',
240
+ text,
241
+ },
242
+ ],
243
+ };
244
+ }
245
+
246
+ async function runTool(name, args = {}) {
247
+ switch (name) {
248
+ case 'browser_health':
249
+ return toolResult(await client.health());
250
+ case 'list_tabs':
251
+ return toolResult(await client.listTargets());
252
+ case 'new_tab':
253
+ return toolResult(await client.newTab(args.url));
254
+ case 'close_tab':
255
+ return toolResult(await client.closeTab(args.targetId));
256
+ case 'close_all_managed_tabs':
257
+ return toolResult(await client.closeAllManagedTabs());
258
+ case 'close_created_tabs':
259
+ return toolResult(await client.closeCreatedTabs());
260
+ case 'adopt_tab':
261
+ return toolResult(await client.adoptTab(args.targetId));
262
+ case 'navigate':
263
+ return toolResult(await client.navigate(args.targetId, args.url));
264
+ case 'go_back':
265
+ return toolResult(await client.goBack(args.targetId));
266
+ case 'page_info':
267
+ return toolResult(await client.pageInfo(args.targetId));
268
+ case 'eval':
269
+ return toolResult(await client.evaluate(args.targetId, args.expression));
270
+ case 'click':
271
+ return toolResult(await client.click(args.targetId, args.selector));
272
+ case 'click_at':
273
+ return toolResult(await client.clickAt(args.targetId, args.selector));
274
+ case 'set_files':
275
+ return toolResult(await client.setFiles(args.targetId, args.selector, args.files));
276
+ case 'scroll':
277
+ return toolResult(await client.scroll(args.targetId, {
278
+ y: args.y,
279
+ direction: args.direction,
280
+ }));
281
+ case 'screenshot':
282
+ return toolResult(await client.screenshot(args.targetId, {
283
+ filePath: args.filePath,
284
+ format: args.format,
285
+ }));
286
+ default:
287
+ throw new Error(`未知工具: ${name}`);
288
+ }
289
+ }
290
+
291
+ function writeMessage(message) {
292
+ const payload = Buffer.from(JSON.stringify(message), 'utf8');
293
+ process.stdout.write(`Content-Length: ${payload.length}\r\n\r\n`);
294
+ process.stdout.write(payload);
295
+ }
296
+
297
+ function parseMessages(onMessage) {
298
+ let buffer = Buffer.alloc(0);
299
+
300
+ process.stdin.on('data', (chunk) => {
301
+ buffer = Buffer.concat([buffer, chunk]);
302
+
303
+ while (true) {
304
+ const headerEnd = buffer.indexOf('\r\n\r\n');
305
+ if (headerEnd === -1) {
306
+ return;
307
+ }
308
+
309
+ const headerText = buffer.slice(0, headerEnd).toString('utf8');
310
+ const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
311
+ if (!contentLengthMatch) {
312
+ throw new Error('缺少 Content-Length 头');
313
+ }
314
+
315
+ const contentLength = Number.parseInt(contentLengthMatch[1], 10);
316
+ const totalLength = headerEnd + 4 + contentLength;
317
+ if (buffer.length < totalLength) {
318
+ return;
319
+ }
320
+
321
+ const body = buffer.slice(headerEnd + 4, totalLength).toString('utf8');
322
+ buffer = buffer.slice(totalLength);
323
+ onMessage(JSON.parse(body));
324
+ }
325
+ });
326
+ }
327
+
328
+ async function handleRequest(message) {
329
+ const { id, method, params } = message;
330
+
331
+ try {
332
+ switch (method) {
333
+ case 'initialize':
334
+ return makeResponse(id, {
335
+ protocolVersion: '2024-11-05',
336
+ capabilities: {
337
+ tools: {},
338
+ },
339
+ serverInfo: SERVER_INFO,
340
+ });
341
+ case 'ping':
342
+ return makeResponse(id, {});
343
+ case 'notifications/initialized':
344
+ return null;
345
+ case 'tools/list':
346
+ return makeResponse(id, { tools });
347
+ case 'tools/call':
348
+ return makeResponse(id, await runTool(params.name, params.arguments || {}));
349
+ default:
350
+ return makeError(id, -32601, `Method not found: ${method}`);
351
+ }
352
+ } catch (error) {
353
+ return makeError(id, -32000, error.message);
354
+ }
355
+ }
356
+
357
+ parseMessages(async (message) => {
358
+ const response = await handleRequest(message);
359
+ if (response) {
360
+ writeMessage(response);
361
+ }
362
+ });