chromex-mcp 1.0.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/LICENSE +21 -0
- package/README.md +411 -0
- package/package.json +50 -0
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +343 -0
- package/plugins/chromex/skills/chromex/scripts/lib/browser.mjs +66 -0
- package/plugins/chromex/skills/chromex/scripts/lib/client.mjs +98 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/console.mjs +37 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/cookies.mjs +77 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/coverage.mjs +95 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/cpu.mjs +14 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/dialog.mjs +38 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/domsnapshot.mjs +84 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/download.mjs +25 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/drag.mjs +71 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/emulate.mjs +44 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/evaluate.mjs +31 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/form.mjs +163 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/geo.mjs +37 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/har.mjs +101 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/heap.mjs +24 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/highlight.mjs +36 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/html.mjs +10 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/inject.mjs +39 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/interact.mjs +88 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/intercept.mjs +99 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/navigate.mjs +45 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/network.mjs +13 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/pdf.mjs +16 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/perf.mjs +98 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/refs.mjs +67 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/screenshot.mjs +54 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/scroll.mjs +44 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +100 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/storage.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/tab.mjs +31 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/throttle.mjs +38 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/touch.mjs +62 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/trace.mjs +51 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/upload.mjs +43 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/wait.mjs +84 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/webauthn.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/lib/config.mjs +100 -0
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +368 -0
- package/plugins/chromex/skills/chromex/scripts/lib/ipc.mjs +178 -0
- package/plugins/chromex/skills/chromex/scripts/lib/launcher.mjs +111 -0
- package/plugins/chromex/skills/chromex/scripts/lib/security.mjs +48 -0
- package/plugins/chromex/skills/chromex/scripts/lib/utils.mjs +47 -0
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +726 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chromex MCP server -- zero dependencies, stdio JSON-RPC 2.0
|
|
3
|
+
// Reuses existing daemon infrastructure via IPC (same as CLI)
|
|
4
|
+
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { loadConfig } from './lib/config.mjs';
|
|
8
|
+
import { CDP } from './lib/client.mjs';
|
|
9
|
+
import { getWsUrl, getPages, formatPageList } from './lib/browser.mjs';
|
|
10
|
+
import { audit } from './lib/security.mjs';
|
|
11
|
+
import { resolvePrefix, listDaemonSockets } from './lib/utils.mjs';
|
|
12
|
+
import { getOrStartTabDaemon, sendCommand, stopDaemons, checkTargetDomain } from './lib/ipc.mjs';
|
|
13
|
+
import { launchBrowser, incognitoContext } from './lib/launcher.mjs';
|
|
14
|
+
import { openTabStr, closeTabStr, focusTabStr } from './lib/commands/tab.mjs';
|
|
15
|
+
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
const SERVER_INFO = { name: 'chromex', version: '1.0.0' };
|
|
18
|
+
|
|
19
|
+
// ---- JSON-RPC helpers ----
|
|
20
|
+
|
|
21
|
+
function send(obj) {
|
|
22
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ok(text) {
|
|
26
|
+
return { content: [{ type: 'text', text: text ?? '' }] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function okWithImage(text, base64Data, mimeType = 'image/png') {
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{ type: 'image', data: base64Data, mimeType },
|
|
33
|
+
{ type: 'text', text },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fail(text) {
|
|
39
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- Schema helpers ----
|
|
43
|
+
|
|
44
|
+
const RO = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
|
|
45
|
+
const RW = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
46
|
+
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
|
|
47
|
+
|
|
48
|
+
function tool(name, description, properties, required, annotations) {
|
|
49
|
+
return {
|
|
50
|
+
name, description,
|
|
51
|
+
inputSchema: { type: 'object', properties: properties || {}, required: required || [], additionalProperties: false },
|
|
52
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true, ...annotations },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
|
|
57
|
+
|
|
58
|
+
// ---- Tool definitions (52 tools) ----
|
|
59
|
+
|
|
60
|
+
const TOOLS = [
|
|
61
|
+
// == PAGES (no daemon) ==
|
|
62
|
+
tool('chromex_list',
|
|
63
|
+
'List open browser pages with unique target ID prefixes. Run this first to get target IDs.',
|
|
64
|
+
{}, [], RO),
|
|
65
|
+
|
|
66
|
+
tool('chromex_launch',
|
|
67
|
+
'Launch browser with remote debugging enabled. Recommended over manual chrome://inspect setup.',
|
|
68
|
+
{
|
|
69
|
+
incognito: { type: 'boolean', description: 'Launch in incognito mode' },
|
|
70
|
+
browser: { type: 'string', enum: ['chrome', 'brave', 'edge', 'chromium', 'chrome-canary', 'vivaldi'], description: 'Browser to launch' },
|
|
71
|
+
profile: { type: 'string', description: 'Named profile directory' },
|
|
72
|
+
url: { type: 'string', description: 'URL to open on launch' },
|
|
73
|
+
}, [], RW),
|
|
74
|
+
|
|
75
|
+
tool('chromex_open',
|
|
76
|
+
'Open a new browser tab.',
|
|
77
|
+
{ url: { type: 'string', description: 'URL to open' } },
|
|
78
|
+
['url'], RW),
|
|
79
|
+
|
|
80
|
+
tool('chromex_close',
|
|
81
|
+
'Close a browser tab.',
|
|
82
|
+
{ target: P_TARGET }, ['target'], DESTRUCTIVE),
|
|
83
|
+
|
|
84
|
+
tool('chromex_focus',
|
|
85
|
+
'Activate/focus a browser tab.',
|
|
86
|
+
{ target: P_TARGET }, ['target'], RW),
|
|
87
|
+
|
|
88
|
+
tool('chromex_incognito',
|
|
89
|
+
'Create isolated incognito context with separate cookies/storage. No browser relaunch needed.',
|
|
90
|
+
{ url: { type: 'string', description: 'URL to open in the incognito context' } },
|
|
91
|
+
[], RW),
|
|
92
|
+
|
|
93
|
+
tool('chromex_stop',
|
|
94
|
+
'Stop per-tab daemon(s). Without target, stops all.',
|
|
95
|
+
{ target: { type: 'string', description: 'Target ID prefix. Omit to stop all.' } },
|
|
96
|
+
[], DESTRUCTIVE),
|
|
97
|
+
|
|
98
|
+
// == INSPECT (readOnly) ==
|
|
99
|
+
tool('chromex_snapshot',
|
|
100
|
+
'Accessibility tree snapshot. Prefer over HTML for page structure. Use refs=true to get @eN references for click/fill/hover.',
|
|
101
|
+
{
|
|
102
|
+
target: P_TARGET,
|
|
103
|
+
refs: { type: 'boolean', description: 'Assign @eN refs to interactive elements', default: false },
|
|
104
|
+
}, ['target'], RO),
|
|
105
|
+
|
|
106
|
+
tool('chromex_html',
|
|
107
|
+
'Get page HTML, optionally filtered by CSS selector.',
|
|
108
|
+
{
|
|
109
|
+
target: P_TARGET,
|
|
110
|
+
selector: { type: 'string', description: 'CSS selector (omit for full page)' },
|
|
111
|
+
}, ['target'], RO),
|
|
112
|
+
|
|
113
|
+
tool('chromex_screenshot',
|
|
114
|
+
'Take PNG screenshot. Returns inline image + file path. Image px = CSS px * DPR.',
|
|
115
|
+
{
|
|
116
|
+
target: P_TARGET,
|
|
117
|
+
filePath: { type: 'string', description: 'Output path (default: /tmp/screenshot.png)' },
|
|
118
|
+
fullPage: { type: 'boolean', description: 'Capture full page', default: false },
|
|
119
|
+
}, ['target'], RO),
|
|
120
|
+
|
|
121
|
+
tool('chromex_network',
|
|
122
|
+
'Get resource timing / network performance entries.',
|
|
123
|
+
{ target: P_TARGET }, ['target'], RO),
|
|
124
|
+
|
|
125
|
+
tool('chromex_perf',
|
|
126
|
+
'Core Web Vitals (LCP, FCP, CLS, TTFB), navigation timing, memory, DOM metrics.',
|
|
127
|
+
{ target: P_TARGET }, ['target'], RO),
|
|
128
|
+
|
|
129
|
+
tool('chromex_console',
|
|
130
|
+
'Capture console output (log/error/warn) for a duration.',
|
|
131
|
+
{
|
|
132
|
+
target: P_TARGET,
|
|
133
|
+
duration: { type: 'number', description: 'Capture duration in ms (default: 5000)' },
|
|
134
|
+
}, ['target'], RO),
|
|
135
|
+
|
|
136
|
+
tool('chromex_domsnapshot',
|
|
137
|
+
'Structured DOM snapshot with bounding rects and optional computed styles.',
|
|
138
|
+
{
|
|
139
|
+
target: P_TARGET,
|
|
140
|
+
styles: { type: 'boolean', description: 'Include computed styles', default: false },
|
|
141
|
+
}, ['target'], RO),
|
|
142
|
+
|
|
143
|
+
tool('chromex_highlight',
|
|
144
|
+
'Highlight DOM element with visual overlay, or clear highlight.',
|
|
145
|
+
{
|
|
146
|
+
target: P_TARGET,
|
|
147
|
+
selector: { type: 'string', description: "CSS selector to highlight, or 'clear'" },
|
|
148
|
+
}, ['target', 'selector'], RW),
|
|
149
|
+
|
|
150
|
+
// == EVALUATE ==
|
|
151
|
+
tool('chromex_eval',
|
|
152
|
+
'Evaluate JavaScript expression in page context. Returns serialized result.',
|
|
153
|
+
{
|
|
154
|
+
target: P_TARGET,
|
|
155
|
+
expression: { type: 'string', description: 'JS expression' },
|
|
156
|
+
}, ['target', 'expression'], RW),
|
|
157
|
+
|
|
158
|
+
tool('chromex_evalraw',
|
|
159
|
+
'Execute raw CDP command. Some methods blocked by security config.',
|
|
160
|
+
{
|
|
161
|
+
target: P_TARGET,
|
|
162
|
+
method: { type: 'string', description: 'CDP method (e.g. DOM.getDocument)' },
|
|
163
|
+
params: { type: 'string', description: 'JSON string of method parameters' },
|
|
164
|
+
}, ['target', 'method'], RW),
|
|
165
|
+
|
|
166
|
+
// == NAVIGATE ==
|
|
167
|
+
tool('chromex_navigate',
|
|
168
|
+
'Navigate to URL and wait for page load.',
|
|
169
|
+
{
|
|
170
|
+
target: P_TARGET,
|
|
171
|
+
url: { type: 'string', description: 'URL to navigate to' },
|
|
172
|
+
}, ['target', 'url'], RW),
|
|
173
|
+
|
|
174
|
+
tool('chromex_waitfor',
|
|
175
|
+
'Wait for CSS selector to appear in DOM.',
|
|
176
|
+
{
|
|
177
|
+
target: P_TARGET,
|
|
178
|
+
selector: { type: 'string', description: 'CSS selector' },
|
|
179
|
+
timeout: { type: 'number', description: 'Timeout in ms' },
|
|
180
|
+
}, ['target', 'selector'], RO),
|
|
181
|
+
|
|
182
|
+
tool('chromex_wait',
|
|
183
|
+
'Wait for lifecycle event.',
|
|
184
|
+
{
|
|
185
|
+
target: P_TARGET,
|
|
186
|
+
event: { type: 'string', enum: ['networkidle', 'load', 'domready', 'fcp'], description: 'Event to wait for' },
|
|
187
|
+
timeout: { type: 'number', description: 'Timeout in ms' },
|
|
188
|
+
}, ['target', 'event'], RO),
|
|
189
|
+
|
|
190
|
+
tool('chromex_scroll',
|
|
191
|
+
'Scroll page. Direction "to" accepts CSS selector as amount.',
|
|
192
|
+
{
|
|
193
|
+
target: P_TARGET,
|
|
194
|
+
direction: { type: 'string', enum: ['up', 'down', 'top', 'bottom', 'to'], description: 'Scroll direction' },
|
|
195
|
+
amount: { type: 'string', description: 'Pixels (up/down) or CSS selector (for "to")' },
|
|
196
|
+
}, ['target', 'direction'], RW),
|
|
197
|
+
|
|
198
|
+
// == INTERACT ==
|
|
199
|
+
tool('chromex_click',
|
|
200
|
+
'Click element by CSS selector or @eN ref from snapshot.',
|
|
201
|
+
{
|
|
202
|
+
target: P_TARGET,
|
|
203
|
+
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
204
|
+
}, ['target', 'selector'], RW),
|
|
205
|
+
|
|
206
|
+
tool('chromex_clickxy',
|
|
207
|
+
'Click at CSS pixel coordinates.',
|
|
208
|
+
{
|
|
209
|
+
target: P_TARGET,
|
|
210
|
+
x: { type: 'number', description: 'X in CSS pixels' },
|
|
211
|
+
y: { type: 'number', description: 'Y in CSS pixels' },
|
|
212
|
+
}, ['target', 'x', 'y'], RW),
|
|
213
|
+
|
|
214
|
+
tool('chromex_type',
|
|
215
|
+
'Type text at currently focused element.',
|
|
216
|
+
{
|
|
217
|
+
target: P_TARGET,
|
|
218
|
+
text: { type: 'string', description: 'Text to type' },
|
|
219
|
+
}, ['target', 'text'], RW),
|
|
220
|
+
|
|
221
|
+
tool('chromex_hover',
|
|
222
|
+
'Hover over element by @eN ref. Requires chromex_snapshot with refs=true first.',
|
|
223
|
+
{
|
|
224
|
+
target: P_TARGET,
|
|
225
|
+
ref: { type: 'string', description: 'Element ref (e.g. @e5)' },
|
|
226
|
+
}, ['target', 'ref'], RW),
|
|
227
|
+
|
|
228
|
+
tool('chromex_drag',
|
|
229
|
+
'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2).',
|
|
230
|
+
{
|
|
231
|
+
target: P_TARGET,
|
|
232
|
+
from: { type: 'string', description: 'Source selector or x,y' },
|
|
233
|
+
to: { type: 'string', description: 'Destination selector or x,y' },
|
|
234
|
+
}, ['target', 'from', 'to'], RW),
|
|
235
|
+
|
|
236
|
+
tool('chromex_touch',
|
|
237
|
+
'Touch gesture: tap, swipe, pinch, longpress.',
|
|
238
|
+
{
|
|
239
|
+
target: P_TARGET,
|
|
240
|
+
gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
|
|
241
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Gesture args: tap(x,y), swipe(x1,y1,x2,y2), pinch(x,y,scale), longpress(x,y,[ms])' },
|
|
242
|
+
}, ['target', 'gesture'], RW),
|
|
243
|
+
|
|
244
|
+
tool('chromex_dialog',
|
|
245
|
+
'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all.',
|
|
246
|
+
{
|
|
247
|
+
target: P_TARGET,
|
|
248
|
+
action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
|
|
249
|
+
text: { type: 'string', description: 'Text for prompt (only with accept)' },
|
|
250
|
+
}, ['target', 'action'], RW),
|
|
251
|
+
|
|
252
|
+
tool('chromex_loadall',
|
|
253
|
+
'Click "load more" button repeatedly until it disappears.',
|
|
254
|
+
{
|
|
255
|
+
target: P_TARGET,
|
|
256
|
+
selector: { type: 'string', description: 'CSS selector of load-more button' },
|
|
257
|
+
interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
|
|
258
|
+
}, ['target', 'selector'], RW),
|
|
259
|
+
|
|
260
|
+
// == FORMS ==
|
|
261
|
+
tool('chromex_fill',
|
|
262
|
+
'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref.',
|
|
263
|
+
{
|
|
264
|
+
target: P_TARGET,
|
|
265
|
+
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
266
|
+
value: { type: 'string', description: 'Value to fill' },
|
|
267
|
+
}, ['target', 'selector', 'value'], RW),
|
|
268
|
+
|
|
269
|
+
tool('chromex_clear',
|
|
270
|
+
'Clear input field.',
|
|
271
|
+
{
|
|
272
|
+
target: P_TARGET,
|
|
273
|
+
selector: { type: 'string', description: 'CSS selector' },
|
|
274
|
+
}, ['target', 'selector'], RW),
|
|
275
|
+
|
|
276
|
+
tool('chromex_select',
|
|
277
|
+
'Select option in dropdown.',
|
|
278
|
+
{
|
|
279
|
+
target: P_TARGET,
|
|
280
|
+
selector: { type: 'string', description: 'CSS selector of select element' },
|
|
281
|
+
value: { type: 'string', description: 'Option value or visible text' },
|
|
282
|
+
}, ['target', 'selector', 'value'], RW),
|
|
283
|
+
|
|
284
|
+
tool('chromex_check',
|
|
285
|
+
'Toggle checkbox or radio button.',
|
|
286
|
+
{
|
|
287
|
+
target: P_TARGET,
|
|
288
|
+
selector: { type: 'string', description: 'CSS selector' },
|
|
289
|
+
checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
|
|
290
|
+
}, ['target', 'selector'], RW),
|
|
291
|
+
|
|
292
|
+
tool('chromex_form',
|
|
293
|
+
'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes.',
|
|
294
|
+
{
|
|
295
|
+
target: P_TARGET,
|
|
296
|
+
fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
|
|
297
|
+
}, ['target', 'fields'], RW),
|
|
298
|
+
|
|
299
|
+
tool('chromex_upload',
|
|
300
|
+
'Upload file(s) to input[type=file].',
|
|
301
|
+
{
|
|
302
|
+
target: P_TARGET,
|
|
303
|
+
selector: { type: 'string', description: 'CSS selector of file input' },
|
|
304
|
+
files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
|
|
305
|
+
}, ['target', 'selector', 'files'], RW),
|
|
306
|
+
|
|
307
|
+
// == DATA ==
|
|
308
|
+
tool('chromex_cookies',
|
|
309
|
+
'Manage cookies: list (default), set (JSON), or clear.',
|
|
310
|
+
{
|
|
311
|
+
target: P_TARGET,
|
|
312
|
+
action: { type: 'string', enum: ['list', 'set', 'clear'], description: 'Action (default: list)' },
|
|
313
|
+
arg: { type: 'string', description: 'For set: JSON cookie. For clear: domain filter.' },
|
|
314
|
+
}, ['target'], RW),
|
|
315
|
+
|
|
316
|
+
tool('chromex_storage',
|
|
317
|
+
'Read localStorage, sessionStorage, or clear both.',
|
|
318
|
+
{
|
|
319
|
+
target: P_TARGET,
|
|
320
|
+
action: { type: 'string', enum: ['local', 'session', 'clear'], description: 'Storage action' },
|
|
321
|
+
}, ['target', 'action'], RW),
|
|
322
|
+
|
|
323
|
+
tool('chromex_pdf',
|
|
324
|
+
'Export page as PDF.',
|
|
325
|
+
{
|
|
326
|
+
target: P_TARGET,
|
|
327
|
+
filePath: { type: 'string', description: 'Output path (default: /tmp/page.pdf)' },
|
|
328
|
+
}, ['target'], RW),
|
|
329
|
+
|
|
330
|
+
// == NETWORK ==
|
|
331
|
+
tool('chromex_throttle',
|
|
332
|
+
'Throttle network: 3g, slow-3g, 4g, offline, custom, reset.',
|
|
333
|
+
{
|
|
334
|
+
target: P_TARGET,
|
|
335
|
+
preset: { type: 'string', description: 'Preset or "custom" or "reset"' },
|
|
336
|
+
latency: { type: 'number', description: 'Custom latency ms (only with custom)' },
|
|
337
|
+
download: { type: 'number', description: 'Custom download kbps (only with custom)' },
|
|
338
|
+
upload: { type: 'number', description: 'Custom upload kbps (only with custom)' },
|
|
339
|
+
}, ['target', 'preset'], RW),
|
|
340
|
+
|
|
341
|
+
tool('chromex_intercept',
|
|
342
|
+
'Intercept network requests: enable (on), block, mock, disable (off), list rules.',
|
|
343
|
+
{
|
|
344
|
+
target: P_TARGET,
|
|
345
|
+
action: { type: 'string', enum: ['on', 'block', 'mock', 'off', 'rules'], description: 'Action' },
|
|
346
|
+
pattern: { type: 'string', description: 'URL pattern (glob). Required for block/mock.' },
|
|
347
|
+
body: { type: 'string', description: 'Mock response body (JSON). Required for mock.' },
|
|
348
|
+
}, ['target', 'action'], RW),
|
|
349
|
+
|
|
350
|
+
tool('chromex_har',
|
|
351
|
+
'Record HTTP traffic as HAR file.',
|
|
352
|
+
{
|
|
353
|
+
target: P_TARGET,
|
|
354
|
+
action: { type: 'string', enum: ['start', 'stop'], description: 'Start or stop recording' },
|
|
355
|
+
filePath: { type: 'string', description: 'Output path for stop (default: /tmp/chromex.har)' },
|
|
356
|
+
}, ['target', 'action'], RW),
|
|
357
|
+
|
|
358
|
+
// == EMULATE ==
|
|
359
|
+
tool('chromex_emulate',
|
|
360
|
+
'Emulate device viewport and user agent.',
|
|
361
|
+
{
|
|
362
|
+
target: P_TARGET,
|
|
363
|
+
device: { type: 'string', description: 'Device: iphone-14, iphone-15-pro, ipad-pro, pixel-7, galaxy-s23, macbook-air, desktop-1080p, desktop-4k, or "reset"' },
|
|
364
|
+
}, ['target', 'device'], RW),
|
|
365
|
+
|
|
366
|
+
tool('chromex_geo',
|
|
367
|
+
'Override geolocation or reset.',
|
|
368
|
+
{
|
|
369
|
+
target: P_TARGET,
|
|
370
|
+
latitude: { type: 'string', description: 'Latitude or "reset"' },
|
|
371
|
+
longitude: { type: 'string', description: 'Longitude' },
|
|
372
|
+
accuracy: { type: 'number', description: 'Accuracy in meters (default: 100)' },
|
|
373
|
+
}, ['target', 'latitude'], RW),
|
|
374
|
+
|
|
375
|
+
tool('chromex_timezone',
|
|
376
|
+
'Override timezone or reset.',
|
|
377
|
+
{
|
|
378
|
+
target: P_TARGET,
|
|
379
|
+
timezone: { type: 'string', description: 'IANA timezone (e.g. America/Sao_Paulo) or "reset"' },
|
|
380
|
+
}, ['target', 'timezone'], RW),
|
|
381
|
+
|
|
382
|
+
tool('chromex_locale',
|
|
383
|
+
'Override browser locale or reset.',
|
|
384
|
+
{
|
|
385
|
+
target: P_TARGET,
|
|
386
|
+
locale: { type: 'string', description: 'BCP 47 locale (e.g. pt-BR) or "reset"' },
|
|
387
|
+
}, ['target', 'locale'], RW),
|
|
388
|
+
|
|
389
|
+
tool('chromex_cpu',
|
|
390
|
+
'CPU throttle (1=normal, 4=4x slower, 6=mobile).',
|
|
391
|
+
{
|
|
392
|
+
target: P_TARGET,
|
|
393
|
+
rate: { type: 'string', description: 'Throttle rate or "reset"' },
|
|
394
|
+
}, ['target', 'rate'], RW),
|
|
395
|
+
|
|
396
|
+
// == ADVANCED ==
|
|
397
|
+
tool('chromex_inject',
|
|
398
|
+
'Inject JS on every page navigation. Use --file, --remove, --list, or inline script.',
|
|
399
|
+
{
|
|
400
|
+
target: P_TARGET,
|
|
401
|
+
action: { type: 'string', description: 'Script source, "--file", "--remove", or "--list"' },
|
|
402
|
+
arg: { type: 'string', description: 'File path (--file) or script ID (--remove)' },
|
|
403
|
+
}, ['target', 'action'], RW),
|
|
404
|
+
|
|
405
|
+
tool('chromex_download',
|
|
406
|
+
'Control file download behavior.',
|
|
407
|
+
{
|
|
408
|
+
target: P_TARGET,
|
|
409
|
+
action: { type: 'string', enum: ['allow', 'deny', 'reset'], description: 'Download behavior' },
|
|
410
|
+
path: { type: 'string', description: 'Download directory (only with allow)' },
|
|
411
|
+
}, ['target', 'action'], RW),
|
|
412
|
+
|
|
413
|
+
tool('chromex_coverage',
|
|
414
|
+
'CSS/JS code coverage. Start collecting, then stop to get report.',
|
|
415
|
+
{
|
|
416
|
+
target: P_TARGET,
|
|
417
|
+
action: { type: 'string', enum: ['start', 'stop'], description: 'Start or stop' },
|
|
418
|
+
}, ['target', 'action'], RW),
|
|
419
|
+
|
|
420
|
+
tool('chromex_trace',
|
|
421
|
+
'Performance trace in chrome://tracing format.',
|
|
422
|
+
{
|
|
423
|
+
target: P_TARGET,
|
|
424
|
+
action: { type: 'string', enum: ['start', 'stop'], description: 'Start or stop' },
|
|
425
|
+
arg: { type: 'string', description: 'For start: categories. For stop: output file path.' },
|
|
426
|
+
}, ['target', 'action'], RW),
|
|
427
|
+
|
|
428
|
+
tool('chromex_heap',
|
|
429
|
+
'Heap snapshot for memory analysis.',
|
|
430
|
+
{
|
|
431
|
+
target: P_TARGET,
|
|
432
|
+
filePath: { type: 'string', description: 'Output path (default: /tmp/chromex-heap.heapsnapshot)' },
|
|
433
|
+
}, ['target'], RO),
|
|
434
|
+
|
|
435
|
+
tool('chromex_webauthn',
|
|
436
|
+
'Virtual WebAuthn authenticator for passkey testing.',
|
|
437
|
+
{
|
|
438
|
+
target: P_TARGET,
|
|
439
|
+
action: { type: 'string', enum: ['enable', 'creds', 'disable'], description: 'Enable, list credentials, or disable' },
|
|
440
|
+
}, ['target', 'action'], RW),
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
// ---- Tool name -> daemon {cmd, args} mapping ----
|
|
444
|
+
|
|
445
|
+
function toolToCmd(name, p) {
|
|
446
|
+
switch (name) {
|
|
447
|
+
// Inspect
|
|
448
|
+
case 'chromex_snapshot': return { cmd: 'snap', args: p.refs ? ['--refs'] : [] };
|
|
449
|
+
case 'chromex_html': return { cmd: 'html', args: p.selector ? [p.selector] : [] };
|
|
450
|
+
case 'chromex_screenshot': {
|
|
451
|
+
const a = [];
|
|
452
|
+
if (p.filePath) a.push(p.filePath);
|
|
453
|
+
if (p.fullPage) a.push('--full');
|
|
454
|
+
return { cmd: 'shot', args: a };
|
|
455
|
+
}
|
|
456
|
+
case 'chromex_network': return { cmd: 'net', args: [] };
|
|
457
|
+
case 'chromex_perf': return { cmd: 'perf', args: [] };
|
|
458
|
+
case 'chromex_console': return { cmd: 'console', args: p.duration != null ? [String(p.duration)] : [] };
|
|
459
|
+
case 'chromex_domsnapshot': return { cmd: 'domsnapshot', args: p.styles ? ['--styles'] : [] };
|
|
460
|
+
case 'chromex_highlight': return { cmd: 'highlight', args: [p.selector] };
|
|
461
|
+
|
|
462
|
+
// Evaluate
|
|
463
|
+
case 'chromex_eval': return { cmd: 'eval', args: [p.expression] };
|
|
464
|
+
case 'chromex_evalraw': return { cmd: 'evalraw', args: p.params ? [p.method, p.params] : [p.method] };
|
|
465
|
+
|
|
466
|
+
// Navigate
|
|
467
|
+
case 'chromex_navigate': return { cmd: 'nav', args: [p.url] };
|
|
468
|
+
case 'chromex_waitfor': return { cmd: 'waitfor', args: p.timeout != null ? [p.selector, String(p.timeout)] : [p.selector] };
|
|
469
|
+
case 'chromex_wait': return { cmd: 'wait', args: p.timeout != null ? [p.event, String(p.timeout)] : [p.event] };
|
|
470
|
+
case 'chromex_scroll': return { cmd: 'scroll', args: p.amount != null ? [p.direction, p.amount] : [p.direction] };
|
|
471
|
+
|
|
472
|
+
// Interact
|
|
473
|
+
case 'chromex_click': return { cmd: 'click', args: [p.selector] };
|
|
474
|
+
case 'chromex_clickxy': return { cmd: 'clickxy', args: [String(p.x), String(p.y)] };
|
|
475
|
+
case 'chromex_type': return { cmd: 'type', args: [p.text] };
|
|
476
|
+
case 'chromex_hover': return { cmd: 'hover', args: [p.ref] };
|
|
477
|
+
case 'chromex_drag': return { cmd: 'drag', args: [p.from, p.to] };
|
|
478
|
+
case 'chromex_touch': return { cmd: 'touch', args: [p.gesture, ...(p.args || [])] };
|
|
479
|
+
case 'chromex_dialog': return { cmd: 'dialog', args: p.text ? [p.action, p.text] : [p.action] };
|
|
480
|
+
case 'chromex_loadall': return { cmd: 'loadall', args: p.interval != null ? [p.selector, String(p.interval)] : [p.selector] };
|
|
481
|
+
|
|
482
|
+
// Forms
|
|
483
|
+
case 'chromex_fill': return { cmd: 'fill', args: [p.selector, p.value] };
|
|
484
|
+
case 'chromex_clear': return { cmd: 'clear', args: [p.selector] };
|
|
485
|
+
case 'chromex_select': return { cmd: 'select', args: [p.selector, p.value] };
|
|
486
|
+
case 'chromex_check': return { cmd: 'check', args: p.checked === false ? [p.selector, 'false'] : [p.selector] };
|
|
487
|
+
case 'chromex_form': return { cmd: 'form', args: [p.fields] };
|
|
488
|
+
case 'chromex_upload': return { cmd: 'upload', args: [p.selector, ...(p.files || [])] };
|
|
489
|
+
|
|
490
|
+
// Data
|
|
491
|
+
case 'chromex_cookies': return { cmd: 'cookies', args: [p.action || 'list', ...(p.arg ? [p.arg] : [])] };
|
|
492
|
+
case 'chromex_storage': return { cmd: 'storage', args: [p.action] };
|
|
493
|
+
case 'chromex_pdf': return { cmd: 'pdf', args: p.filePath ? [p.filePath] : [] };
|
|
494
|
+
|
|
495
|
+
// Network
|
|
496
|
+
case 'chromex_throttle': {
|
|
497
|
+
if (p.preset === 'custom') {
|
|
498
|
+
return { cmd: 'throttle', args: ['custom', String(p.latency || 0), String(p.download || 0), String(p.upload || 0)] };
|
|
499
|
+
}
|
|
500
|
+
return { cmd: 'throttle', args: [p.preset] };
|
|
501
|
+
}
|
|
502
|
+
case 'chromex_intercept': return { cmd: 'intercept', args: [p.action, ...(p.pattern ? [p.pattern] : []), ...(p.body ? [p.body] : [])] };
|
|
503
|
+
case 'chromex_har': return { cmd: 'har', args: p.filePath ? [p.action, p.filePath] : [p.action] };
|
|
504
|
+
|
|
505
|
+
// Emulate
|
|
506
|
+
case 'chromex_emulate': return { cmd: 'emulate', args: [p.device] };
|
|
507
|
+
case 'chromex_geo': return { cmd: 'geo', args: p.accuracy != null ? [p.latitude, p.longitude, String(p.accuracy)] : p.longitude ? [p.latitude, p.longitude] : [p.latitude] };
|
|
508
|
+
case 'chromex_timezone': return { cmd: 'timezone', args: [p.timezone] };
|
|
509
|
+
case 'chromex_locale': return { cmd: 'locale', args: [p.locale] };
|
|
510
|
+
case 'chromex_cpu': return { cmd: 'cpu', args: [p.rate] };
|
|
511
|
+
|
|
512
|
+
// Advanced
|
|
513
|
+
case 'chromex_inject': return { cmd: 'inject', args: p.arg ? [p.action, p.arg] : [p.action] };
|
|
514
|
+
case 'chromex_download': return { cmd: 'download', args: p.path ? [p.action, p.path] : [p.action] };
|
|
515
|
+
case 'chromex_coverage': return { cmd: 'coverage', args: [p.action] };
|
|
516
|
+
case 'chromex_trace': return { cmd: 'trace', args: p.arg ? [p.action, p.arg] : [p.action] };
|
|
517
|
+
case 'chromex_heap': return { cmd: 'heap', args: p.filePath ? ['snapshot', p.filePath] : ['snapshot'] };
|
|
518
|
+
case 'chromex_webauthn': return { cmd: 'webauthn', args: [p.action] };
|
|
519
|
+
|
|
520
|
+
default: return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---- Target resolution (with auto-list) ----
|
|
525
|
+
|
|
526
|
+
// Auto-populate page cache if missing
|
|
527
|
+
async function ensurePageCache() {
|
|
528
|
+
if (existsSync(config._pagesCachePath)) return;
|
|
529
|
+
const cdp = new CDP(config.commandTimeout);
|
|
530
|
+
await cdp.connect(getWsUrl());
|
|
531
|
+
const pages = await getPages(cdp);
|
|
532
|
+
cdp.close();
|
|
533
|
+
writeFileSync(config._pagesCachePath, JSON.stringify(pages));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function resolveTarget(prefix) {
|
|
537
|
+
const daemonTargetIds = listDaemonSockets(config._socketDir).map(d => d.targetId);
|
|
538
|
+
const daemonMatches = daemonTargetIds.filter(id => id.toUpperCase().startsWith(prefix.toUpperCase()));
|
|
539
|
+
|
|
540
|
+
if (daemonMatches.length > 0) {
|
|
541
|
+
return resolvePrefix(prefix, daemonTargetIds, 'daemon');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Auto-list: fetch pages if no cache exists
|
|
545
|
+
await ensurePageCache();
|
|
546
|
+
|
|
547
|
+
const pages = JSON.parse(readFileSync(config._pagesCachePath, 'utf8'));
|
|
548
|
+
return resolvePrefix(prefix, pages.map(p => p.targetId), 'target', 'Call chromex_list first.');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---- Tool execution ----
|
|
552
|
+
|
|
553
|
+
// Commands that use direct CDP (no daemon)
|
|
554
|
+
const NO_DAEMON = new Set([
|
|
555
|
+
'chromex_list', 'chromex_launch', 'chromex_open', 'chromex_close',
|
|
556
|
+
'chromex_focus', 'chromex_incognito', 'chromex_stop',
|
|
557
|
+
]);
|
|
558
|
+
|
|
559
|
+
// Helper: connect to browser, execute, disconnect
|
|
560
|
+
async function withBrowser(fn) {
|
|
561
|
+
const cdp = new CDP(config.commandTimeout);
|
|
562
|
+
await cdp.connect(getWsUrl());
|
|
563
|
+
try {
|
|
564
|
+
return await fn(cdp);
|
|
565
|
+
} finally {
|
|
566
|
+
cdp.close();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function executeTool(name, params) {
|
|
571
|
+
params = params || {};
|
|
572
|
+
|
|
573
|
+
// -- No-daemon commands (direct browser CDP) --
|
|
574
|
+
|
|
575
|
+
if (name === 'chromex_list') {
|
|
576
|
+
return withBrowser(async (cdp) => {
|
|
577
|
+
const pages = await getPages(cdp);
|
|
578
|
+
writeFileSync(config._pagesCachePath, JSON.stringify(pages));
|
|
579
|
+
audit('list', null, [], { ok: true }, config);
|
|
580
|
+
return ok(formatPageList(pages, config));
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (name === 'chromex_launch') {
|
|
585
|
+
const result = await launchBrowser(params);
|
|
586
|
+
return ok(result);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (name === 'chromex_open') {
|
|
590
|
+
return withBrowser(async (cdp) => ok(await openTabStr(cdp, params.url)));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (name === 'chromex_close') {
|
|
594
|
+
return withBrowser(async (cdp) => ok(await closeTabStr(cdp, params.target)));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (name === 'chromex_focus') {
|
|
598
|
+
return withBrowser(async (cdp) => ok(await focusTabStr(cdp, params.target)));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (name === 'chromex_incognito') {
|
|
602
|
+
return withBrowser(async (cdp) => {
|
|
603
|
+
const result = await incognitoContext(cdp, params.url);
|
|
604
|
+
return ok(result.message);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (name === 'chromex_stop') {
|
|
609
|
+
await stopDaemons(params.target || null, config);
|
|
610
|
+
audit('stop', params.target || 'all', [], { ok: true }, config);
|
|
611
|
+
return ok('Daemon(s) stopped.');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// -- Daemon commands (via IPC Unix socket) --
|
|
615
|
+
|
|
616
|
+
if (!NO_DAEMON.has(name) && !params.target) {
|
|
617
|
+
return fail('Target ID required. Call chromex_list first to get target prefixes.');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const targetId = await resolveTarget(params.target);
|
|
621
|
+
checkTargetDomain(targetId, config);
|
|
622
|
+
|
|
623
|
+
const mapped = toolToCmd(name, params);
|
|
624
|
+
if (!mapped) return fail(`Unknown tool: ${name}`);
|
|
625
|
+
|
|
626
|
+
const conn = await getOrStartTabDaemon(targetId, config);
|
|
627
|
+
const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
|
|
628
|
+
|
|
629
|
+
if (!response.ok) {
|
|
630
|
+
return fail(response.error || 'Command failed');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Screenshot: return inline image (base64) + text metadata
|
|
634
|
+
if (name === 'chromex_screenshot' && response.result) {
|
|
635
|
+
const screenshotPath = (response.result.split('\n')[0] || '').trim();
|
|
636
|
+
if (screenshotPath && existsSync(screenshotPath)) {
|
|
637
|
+
const imageData = readFileSync(screenshotPath).toString('base64');
|
|
638
|
+
return okWithImage(response.result, imageData);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return ok(response.result || '');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---- MCP protocol handler (JSON-RPC 2.0 over stdio) ----
|
|
646
|
+
|
|
647
|
+
async function handleMessage(msg) {
|
|
648
|
+
const { id, method, params } = msg;
|
|
649
|
+
|
|
650
|
+
// Notifications (no id) -- ignore
|
|
651
|
+
if (id == null) return null;
|
|
652
|
+
|
|
653
|
+
switch (method) {
|
|
654
|
+
case 'initialize':
|
|
655
|
+
return {
|
|
656
|
+
result: {
|
|
657
|
+
protocolVersion: params?.protocolVersion || '2025-03-26',
|
|
658
|
+
capabilities: { tools: {} },
|
|
659
|
+
serverInfo: SERVER_INFO,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
case 'tools/list':
|
|
664
|
+
return { result: { tools: TOOLS } };
|
|
665
|
+
|
|
666
|
+
case 'tools/call': {
|
|
667
|
+
const toolName = params?.name;
|
|
668
|
+
if (!toolName || !TOOLS.find(t => t.name === toolName)) {
|
|
669
|
+
return { error: { code: -32602, message: `Unknown tool: ${toolName}` } };
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
const result = await executeTool(toolName, params.arguments);
|
|
673
|
+
return { result };
|
|
674
|
+
} catch (err) {
|
|
675
|
+
return { result: fail(err.message) };
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
case 'ping':
|
|
680
|
+
return { result: {} };
|
|
681
|
+
|
|
682
|
+
default:
|
|
683
|
+
return { error: { code: -32601, message: `Method not found: ${method}` } };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Stdin line reader with proper lifecycle
|
|
688
|
+
// (waits for pending async operations before exiting when stdin closes)
|
|
689
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
690
|
+
let pending = 0;
|
|
691
|
+
let closing = false;
|
|
692
|
+
|
|
693
|
+
function maybeExit() {
|
|
694
|
+
if (closing && pending === 0) process.exit(0);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
rl.on('line', async (line) => {
|
|
698
|
+
if (!line.trim()) return;
|
|
699
|
+
let msg;
|
|
700
|
+
try {
|
|
701
|
+
msg = JSON.parse(line);
|
|
702
|
+
} catch {
|
|
703
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
pending++;
|
|
708
|
+
try {
|
|
709
|
+
const response = await handleMessage(msg);
|
|
710
|
+
if (response !== null) {
|
|
711
|
+
send({ jsonrpc: '2.0', id: msg.id, ...response });
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32603, message: err.message } });
|
|
715
|
+
} finally {
|
|
716
|
+
pending--;
|
|
717
|
+
maybeExit();
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
rl.on('close', () => {
|
|
722
|
+
closing = true;
|
|
723
|
+
maybeExit();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
process.stderr.write('chromex MCP server started\n');
|