chromex-mcp 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chromex-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 52 typed tools, per-tab daemons, security hardened.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -244,7 +244,8 @@ async function main() {
244
244
 
245
245
  const conn = await getOrStartTabDaemon(targetId, config);
246
246
 
247
- const cmdArgs = args.slice(1);
247
+ const noSnap = args.includes('--no-snap');
248
+ const cmdArgs = args.slice(1).filter(a => a !== '--no-snap');
248
249
 
249
250
  // Juntar argumentos para comandos que aceitam texto livre
250
251
  if (cmd === 'eval') {
@@ -278,6 +279,7 @@ async function main() {
278
279
  process.exit(1);
279
280
  }
280
281
 
282
+ if (noSnap) cmdArgs.push('--no-snap');
281
283
  const response = await sendCommand(conn, { cmd, args: cmdArgs });
282
284
 
283
285
  if (response.ok) {
@@ -38,7 +38,7 @@ function truncate(str, max = MAX_NAME_LENGTH) {
38
38
  return str.slice(0, max) + '...';
39
39
  }
40
40
 
41
- function formatAxNode(node, depth, refIndex, refs) {
41
+ function formatAxNode(node, depth, refIndex, refs, isNew = false) {
42
42
  const role = node.role?.value || '';
43
43
  const name = truncate(node.name?.value ?? '');
44
44
  const value = node.value?.value;
@@ -50,7 +50,8 @@ function formatAxNode(node, depth, refIndex, refs) {
50
50
  refIndex.value++;
51
51
  }
52
52
 
53
- let line = `${indent}${refTag}[${role}]`;
53
+ const newTag = isNew ? '*' : '';
54
+ let line = `${indent}${newTag}${refTag}[${role}]`;
54
55
  if (name !== '') line += ` ${name}`;
55
56
  if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
56
57
  return line;
@@ -91,6 +92,54 @@ function buildFingerprints(nodes, nodesById, childrenByParent, compact) {
91
92
  return fingerprints;
92
93
  }
93
94
 
95
+ // Detect scrollable containers. Returns human-readable summary lines.
96
+ // Only reports containers with overflow:auto|scroll and >50px hidden content.
97
+ async function detectScrollables(cdp, sid) {
98
+ try {
99
+ const { result } = await cdp.send('Runtime.evaluate', {
100
+ expression: `(() => {
101
+ const MIN = 50;
102
+ const out = [];
103
+ const walk = (el, path) => {
104
+ if (el.nodeType !== 1 || out.length >= 10) return;
105
+ const sh = el.scrollHeight, sw = el.scrollWidth;
106
+ const ch = el.clientHeight, cw = el.clientWidth;
107
+ const isRoot = el === document.documentElement || el === document.body;
108
+ const style = isRoot ? null : getComputedStyle(el);
109
+ const oy = isRoot ? 'auto' : style.overflowY;
110
+ const ox = isRoot ? 'auto' : style.overflowX;
111
+ const scrollableY = isRoot || oy === 'auto' || oy === 'scroll';
112
+ const scrollableX = isRoot || ox === 'auto' || ox === 'scroll';
113
+ if (((scrollableY && sh > ch + MIN) || (scrollableX && sw > cw + MIN)) && ch > 0) {
114
+ const dirs = [];
115
+ if (scrollableY && sh > ch + MIN) {
116
+ const down = sh - ch - el.scrollTop;
117
+ const up = el.scrollTop;
118
+ if (up > MIN) dirs.push('up:' + Math.round(up) + 'px');
119
+ if (down > MIN) dirs.push('down:' + Math.round(down) + 'px');
120
+ }
121
+ if (scrollableX && sw > cw + MIN) {
122
+ const right = sw - cw - el.scrollLeft;
123
+ const left = el.scrollLeft;
124
+ if (left > MIN) dirs.push('left:' + Math.round(left) + 'px');
125
+ if (right > MIN) dirs.push('right:' + Math.round(right) + 'px');
126
+ }
127
+ if (dirs.length) {
128
+ const label = isRoot ? 'page' : (el.getAttribute('aria-label') || el.getAttribute('role') || el.id || el.tagName.toLowerCase());
129
+ out.push(label + ': ' + dirs.join(', '));
130
+ }
131
+ }
132
+ for (const c of el.children) walk(c);
133
+ };
134
+ walk(document.documentElement);
135
+ return out;
136
+ })()`,
137
+ returnByValue: true,
138
+ }, sid);
139
+ return result?.value || [];
140
+ } catch { return []; }
141
+ }
142
+
94
143
  // refMap is populated when refs=true: { refNumber -> { backendNodeId, role, name } }
95
144
  // The caller (daemon) stores this map for later ref resolution.
96
145
  // previousFingerprints: Map from prior snapshot for incremental diff.
@@ -108,6 +157,7 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
108
157
 
109
158
  const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
110
159
  const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
160
+ const scrollables = await detectScrollables(cdp, sid);
111
161
 
112
162
  const refIndex = { value: 1 };
113
163
  const refMap = new Map();
@@ -157,8 +207,9 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
157
207
 
158
208
  const role = node.role?.value || '';
159
209
  const currentRef = refIndex.value;
210
+ const isNew = isDiff && !previousFingerprints.has(node.nodeId);
160
211
 
161
- lines.push(formatAxNode(node, depth, refIndex, refs));
212
+ lines.push(formatAxNode(node, depth, refIndex, refs, isNew));
162
213
 
163
214
  if (refs && refIndex.value > currentRef) {
164
215
  refMap.set(currentRef, {
@@ -214,6 +265,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
214
265
  const changedCount = totalVisible - unchangedCount;
215
266
  text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
216
267
  }
268
+ // Append scroll info footer when scrollable containers exist
269
+ if (scrollables.length > 0) {
270
+ text += `\n[scroll: ${scrollables.join(' | ')}]`;
271
+ }
217
272
 
218
273
  return { text, refMap, fingerprints: currentFingerprints };
219
274
  }
@@ -46,6 +46,15 @@ import { touchStr } from './commands/touch.mjs';
46
46
  import { domsnapshotStr } from './commands/domsnapshot.mjs';
47
47
  import { parseRef, clickRefStr, hoverRefStr, fillRefStr } from './commands/refs.mjs';
48
48
  import { highlightStr } from './commands/highlight.mjs';
49
+ import { sleep } from './utils.mjs';
50
+
51
+ // Commands that modify visible DOM and should trigger automatic post-action snapshot.
52
+ // After these commands, an incremental snapshot with refs is appended to the result,
53
+ // so the AI agent sees the page state without needing a separate snapshot call.
54
+ const AUTO_SNAP_CMDS = new Set([
55
+ 'click', 'clickxy', 'type', 'fill', 'clear', 'select', 'check', 'form',
56
+ 'nav', 'navigate', 'dialog', 'loadall', 'drag', 'touch', 'upload',
57
+ ]);
49
58
 
50
59
  export function getOrCreateToken(config) {
51
60
  if (!config.socketAuth) return null;
@@ -114,10 +123,18 @@ export async function runDaemon(targetId, config) {
114
123
  resetIdle();
115
124
  const auditResult = { ok: true };
116
125
  try {
126
+ // Strip --no-snap before dispatch so it doesn't contaminate command args
127
+ // (e.g. fill would type "--no-snap" into the input field).
128
+ const noSnap = args.includes('--no-snap');
129
+ if (noSnap) args = args.filter(a => a !== '--no-snap');
130
+
131
+ let result;
132
+ let isRefCmd = false;
133
+
117
134
  // Ref-based dispatch: click @e5, fill @e3 "value", hover @e12
118
135
  if (args[0] && parseRef(args[0]) !== null) {
136
+ isRefCmd = true;
119
137
  const refNum = parseRef(args[0]);
120
- let result;
121
138
  if (cmd === 'click') {
122
139
  result = await clickRefStr(cdp, sessionId, currentRefMap, refNum);
123
140
  } else if (cmd === 'fill') {
@@ -127,12 +144,9 @@ export async function runDaemon(targetId, config) {
127
144
  } else {
128
145
  throw new Error(`Ref @e${refNum} not supported for command "${cmd}". Use with: click, fill, hover.`);
129
146
  }
130
- audit(cmd, targetId, args, auditResult, config);
131
- return { ok: true, result };
132
147
  }
133
148
 
134
- let result;
135
- switch (cmd) {
149
+ if (!isRefCmd) switch (cmd) {
136
150
  // --- Comandos originais ---
137
151
  case 'list': {
138
152
  const pages = await getPages(cdp);
@@ -172,6 +186,7 @@ export async function runDaemon(targetId, config) {
172
186
  break;
173
187
  case 'nav': case 'navigate':
174
188
  result = await navStr(cdp, sessionId, args[0], config);
189
+ previousFingerprints = null; // Reset: new page needs full snapshot
175
190
  break;
176
191
  case 'net': case 'network':
177
192
  result = await netStr(cdp, sessionId);
@@ -322,6 +337,29 @@ export async function runDaemon(targetId, config) {
322
337
  }
323
338
  }
324
339
  audit(cmd, targetId, args, auditResult, config);
340
+
341
+ // Auto-snapshot: append incremental snapshot with refs after DOM-modifying actions.
342
+ // This lets the AI agent see the resulting page state in a single round-trip.
343
+ // Opt-out with --no-snap for scripts doing rapid sequential actions.
344
+ const shouldSnap = isRefCmd
345
+ ? (cmd === 'click' || cmd === 'fill') // hover doesn't change DOM
346
+ : AUTO_SNAP_CMDS.has(cmd);
347
+
348
+ if (shouldSnap && !noSnap) {
349
+ try {
350
+ // Navigate already waits for load+readyState; shorter settle for it.
351
+ // Other actions (click, fill) need more time for SPA re-renders.
352
+ const settleMs = (cmd === 'nav' || cmd === 'navigate') ? 100 : 300;
353
+ await sleep(settleMs);
354
+ const snapResult = await snapshotStr(cdp, sessionId, true, true, previousFingerprints);
355
+ previousFingerprints = snapResult.fingerprints;
356
+ if (snapResult.refMap.size > 0) {
357
+ currentRefMap = snapResult.refMap;
358
+ }
359
+ result = (result ?? '') + '\n\n' + snapResult.text;
360
+ } catch (e) { process.stderr.write(`[auto-snap] ${e.message}\n`); }
361
+ }
362
+
325
363
  return { ok: true, result: result ?? '' };
326
364
  } catch (e) {
327
365
  auditResult.ok = false;
@@ -54,6 +54,7 @@ function tool(name, description, properties, required, annotations) {
54
54
  }
55
55
 
56
56
  const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
57
+ const P_NO_SNAP = { type: 'boolean', description: 'Skip auto-snapshot after action' };
57
58
 
58
59
  // ---- Tool definitions (52 tools) ----
59
60
 
@@ -167,10 +168,11 @@ const TOOLS = [
167
168
 
168
169
  // == NAVIGATE ==
169
170
  tool('chromex_navigate',
170
- 'Navigate to URL and wait for page load.',
171
+ 'Navigate to URL and wait for page load. Returns full snapshot with refs of the new page.',
171
172
  {
172
173
  target: P_TARGET,
173
174
  url: { type: 'string', description: 'URL to navigate to' },
175
+ noSnap: P_NO_SNAP,
174
176
  }, ['target', 'url'], RW),
175
177
 
176
178
  tool('chromex_waitfor',
@@ -199,25 +201,28 @@ const TOOLS = [
199
201
 
200
202
  // == INTERACT ==
201
203
  tool('chromex_click',
202
- 'Click element by CSS selector or @eN ref from snapshot.',
204
+ 'Click element by CSS selector or @eN ref from snapshot. Returns auto-snapshot with updated refs.',
203
205
  {
204
206
  target: P_TARGET,
205
207
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
208
+ noSnap: P_NO_SNAP,
206
209
  }, ['target', 'selector'], RW),
207
210
 
208
211
  tool('chromex_clickxy',
209
- 'Click at CSS pixel coordinates.',
212
+ 'Click at CSS pixel coordinates. Returns auto-snapshot with updated refs.',
210
213
  {
211
214
  target: P_TARGET,
212
215
  x: { type: 'number', description: 'X in CSS pixels' },
213
216
  y: { type: 'number', description: 'Y in CSS pixels' },
217
+ noSnap: P_NO_SNAP,
214
218
  }, ['target', 'x', 'y'], RW),
215
219
 
216
220
  tool('chromex_type',
217
- 'Type text at currently focused element.',
221
+ 'Type text at currently focused element. Returns auto-snapshot with updated refs.',
218
222
  {
219
223
  target: P_TARGET,
220
224
  text: { type: 'string', description: 'Text to type' },
225
+ noSnap: P_NO_SNAP,
221
226
  }, ['target', 'text'], RW),
222
227
 
223
228
  tool('chromex_hover',
@@ -228,82 +233,92 @@ const TOOLS = [
228
233
  }, ['target', 'ref'], RW),
229
234
 
230
235
  tool('chromex_drag',
231
- 'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2).',
236
+ 'Drag and drop between selectors or coordinate pairs (x1,y1 x2,y2). Returns auto-snapshot with updated refs.',
232
237
  {
233
238
  target: P_TARGET,
234
239
  from: { type: 'string', description: 'Source selector or x,y' },
235
240
  to: { type: 'string', description: 'Destination selector or x,y' },
241
+ noSnap: P_NO_SNAP,
236
242
  }, ['target', 'from', 'to'], RW),
237
243
 
238
244
  tool('chromex_touch',
239
- 'Touch gesture: tap, swipe, pinch, longpress.',
245
+ 'Touch gesture: tap, swipe, pinch, longpress. Returns auto-snapshot with updated refs.',
240
246
  {
241
247
  target: P_TARGET,
242
248
  gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
243
249
  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])' },
250
+ noSnap: P_NO_SNAP,
244
251
  }, ['target', 'gesture'], RW),
245
252
 
246
253
  tool('chromex_dialog',
247
- 'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all.',
254
+ 'Handle JS dialogs (alert/confirm/prompt). Use "auto" to auto-accept all. Returns auto-snapshot with updated refs.',
248
255
  {
249
256
  target: P_TARGET,
250
257
  action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
251
258
  text: { type: 'string', description: 'Text for prompt (only with accept)' },
259
+ noSnap: P_NO_SNAP,
252
260
  }, ['target', 'action'], RW),
253
261
 
254
262
  tool('chromex_loadall',
255
- 'Click "load more" button repeatedly until it disappears.',
263
+ 'Click "load more" button repeatedly until it disappears. Returns auto-snapshot with updated refs.',
256
264
  {
257
265
  target: P_TARGET,
258
266
  selector: { type: 'string', description: 'CSS selector of load-more button' },
259
267
  interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
268
+ noSnap: P_NO_SNAP,
260
269
  }, ['target', 'selector'], RW),
261
270
 
262
271
  // == FORMS ==
263
272
  tool('chromex_fill',
264
- 'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref.',
273
+ 'Fill input/textarea. Handles React/Vue/Angular controlled inputs. Accepts @eN ref. Returns auto-snapshot with updated refs.',
265
274
  {
266
275
  target: P_TARGET,
267
276
  selector: { type: 'string', description: 'CSS selector or @eN ref' },
268
277
  value: { type: 'string', description: 'Value to fill' },
278
+ noSnap: P_NO_SNAP,
269
279
  }, ['target', 'selector', 'value'], RW),
270
280
 
271
281
  tool('chromex_clear',
272
- 'Clear input field.',
282
+ 'Clear input field. Returns auto-snapshot with updated refs.',
273
283
  {
274
284
  target: P_TARGET,
275
285
  selector: { type: 'string', description: 'CSS selector' },
286
+ noSnap: P_NO_SNAP,
276
287
  }, ['target', 'selector'], RW),
277
288
 
278
289
  tool('chromex_select',
279
- 'Select option in dropdown.',
290
+ 'Select option in dropdown. Returns auto-snapshot with updated refs.',
280
291
  {
281
292
  target: P_TARGET,
282
293
  selector: { type: 'string', description: 'CSS selector of select element' },
283
294
  value: { type: 'string', description: 'Option value or visible text' },
295
+ noSnap: P_NO_SNAP,
284
296
  }, ['target', 'selector', 'value'], RW),
285
297
 
286
298
  tool('chromex_check',
287
- 'Toggle checkbox or radio button.',
299
+ 'Toggle checkbox or radio button. Returns auto-snapshot with updated refs.',
288
300
  {
289
301
  target: P_TARGET,
290
302
  selector: { type: 'string', description: 'CSS selector' },
291
303
  checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
304
+ noSnap: P_NO_SNAP,
292
305
  }, ['target', 'selector'], RW),
293
306
 
294
307
  tool('chromex_form',
295
- 'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes.',
308
+ 'Batch fill form. JSON maps selectors to values. Booleans toggle checkboxes. Returns auto-snapshot with updated refs.',
296
309
  {
297
310
  target: P_TARGET,
298
311
  fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
312
+ noSnap: P_NO_SNAP,
299
313
  }, ['target', 'fields'], RW),
300
314
 
301
315
  tool('chromex_upload',
302
- 'Upload file(s) to input[type=file].',
316
+ 'Upload file(s) to input[type=file]. Returns auto-snapshot with updated refs.',
303
317
  {
304
318
  target: P_TARGET,
305
319
  selector: { type: 'string', description: 'CSS selector of file input' },
306
320
  files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
321
+ noSnap: P_NO_SNAP,
307
322
  }, ['target', 'selector', 'files'], RW),
308
323
 
309
324
  // == DATA ==
@@ -631,6 +646,8 @@ async function executeTool(name, params) {
631
646
  const mapped = toolToCmd(name, params);
632
647
  if (!mapped) return fail(`Unknown tool: ${name}`);
633
648
 
649
+ if (params.noSnap) mapped.args.push('--no-snap');
650
+
634
651
  const conn = await getOrStartTabDaemon(targetId, config);
635
652
  const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
636
653