chromex-mcp 1.0.0 → 1.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 CHANGED
@@ -289,11 +289,11 @@ Chromex also ships as an MCP server -- typed tools, auto-approve with one line,
289
289
  ### Setup
290
290
 
291
291
  ```bash
292
- # Via npx (recommended)
293
- claude mcp add chromex npx chromex-mcp@latest
292
+ # Global (all projects)
293
+ claude mcp add chromex -s user npx chromex-mcp@latest
294
294
 
295
- # Or with absolute path (no npm needed)
296
- claude mcp add chromex node /path/to/plugins/chromex/skills/chromex/scripts/mcp-server.mjs
295
+ # Or project-only
296
+ claude mcp add chromex npx chromex-mcp@latest
297
297
  ```
298
298
 
299
299
  ### Auto-Approve
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../plugins/chromex/skills/chromex/scripts/chromex.mjs';
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../plugins/chromex/skills/chromex/scripts/mcp-server.mjs';
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "chromex-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": {
7
- "chromex-mcp": "./plugins/chromex/skills/chromex/scripts/mcp-server.mjs",
8
- "chromex-cli": "./plugins/chromex/skills/chromex/scripts/chromex.mjs"
7
+ "chromex-mcp": "./bin/chromex-mcp.mjs",
8
+ "chromex-cli": "./bin/chromex-cli.mjs"
9
9
  },
10
10
  "files": [
11
+ "bin/",
11
12
  "plugins/chromex/skills/chromex/scripts/",
12
13
  "LICENSE",
13
14
  "README.md"
@@ -1,4 +1,4 @@
1
- // Accessibility tree snapshot with optional interactive refs (@e1, @e2...)
1
+ // Accessibility tree snapshot with incremental diff and interactive refs (@e1, @e2...)
2
2
 
3
3
  const INTERACTIVE_ROLES = new Set([
4
4
  'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
@@ -6,7 +6,17 @@ const INTERACTIVE_ROLES = new Set([
6
6
  'option', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
7
7
  ]);
8
8
 
9
+ // Check if AX node is marked as ignored/hidden by the browser
10
+ function isAxNodeHidden(node) {
11
+ if (node.ignored) return true;
12
+ // CDP includes boolean properties like 'hidden' in the properties array
13
+ const hidden = node.properties?.find(p => p.name === 'hidden');
14
+ if (hidden?.value?.value === true) return true;
15
+ return false;
16
+ }
17
+
9
18
  function shouldShowAxNode(node, compact = false) {
19
+ if (isAxNodeHidden(node)) return false;
10
20
  const role = node.role?.value || '';
11
21
  const name = node.name?.value ?? '';
12
22
  const value = node.value?.value;
@@ -14,21 +24,35 @@ function shouldShowAxNode(node, compact = false) {
14
24
  return role !== 'none' && role !== 'generic' && !(name === '' && (value === '' || value == null));
15
25
  }
16
26
 
27
+ // Check if element is focusable/clickable (not disabled, not aria-disabled)
28
+ function isAxNodeInteractable(node) {
29
+ const disabled = node.properties?.find(p => p.name === 'disabled');
30
+ if (disabled?.value?.value === true) return false;
31
+ return true;
32
+ }
33
+
34
+ const MAX_NAME_LENGTH = 200;
35
+
36
+ function truncate(str, max = MAX_NAME_LENGTH) {
37
+ if (!str || str.length <= max) return str;
38
+ return str.slice(0, max) + '...';
39
+ }
40
+
17
41
  function formatAxNode(node, depth, refIndex, refs) {
18
42
  const role = node.role?.value || '';
19
- const name = node.name?.value ?? '';
43
+ const name = truncate(node.name?.value ?? '');
20
44
  const value = node.value?.value;
21
45
  const indent = ' '.repeat(Math.min(depth, 10));
22
46
 
23
47
  let refTag = '';
24
- if (refs && INTERACTIVE_ROLES.has(role.toLowerCase())) {
48
+ if (refs && INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
25
49
  refTag = `@e${refIndex.value} `;
26
50
  refIndex.value++;
27
51
  }
28
52
 
29
53
  let line = `${indent}${refTag}[${role}]`;
30
54
  if (name !== '') line += ` ${name}`;
31
- if (!(value === '' || value == null)) line += ` = ${JSON.stringify(value)}`;
55
+ if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
32
56
  return line;
33
57
  }
34
58
 
@@ -51,9 +75,28 @@ function orderedAxChildren(node, nodesById, childrenByParent) {
51
75
  return children;
52
76
  }
53
77
 
78
+ // Build a fingerprint for each visible node: role + name + value + childCount
79
+ // Used for incremental diff to detect changes
80
+ function buildFingerprints(nodes, nodesById, childrenByParent, compact) {
81
+ const fingerprints = new Map();
82
+ for (const node of nodes) {
83
+ if (!shouldShowAxNode(node, compact)) continue;
84
+ const role = node.role?.value || '';
85
+ const name = node.name?.value ?? '';
86
+ const value = node.value?.value ?? '';
87
+ const children = orderedAxChildren(node, nodesById, childrenByParent);
88
+ const childIds = children.filter(c => shouldShowAxNode(c, compact)).map(c => c.nodeId).join(',');
89
+ fingerprints.set(node.nodeId, `${role}|${name}|${value}|${childIds}`);
90
+ }
91
+ return fingerprints;
92
+ }
93
+
54
94
  // refMap is populated when refs=true: { refNumber -> { backendNodeId, role, name } }
55
95
  // The caller (daemon) stores this map for later ref resolution.
56
- export async function snapshotStr(cdp, sid, compact = true, refs = false) {
96
+ // previousFingerprints: Map from prior snapshot for incremental diff.
97
+ // maxDepth: limit tree depth (0 = unlimited). Nodes at the limit render as leaves.
98
+ // Returns { text, refMap, fingerprints } -- caller stores fingerprints for next diff.
99
+ export async function snapshotStr(cdp, sid, compact = true, refs = false, previousFingerprints = null, maxDepth = 0) {
57
100
  const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
58
101
  const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
59
102
  const childrenByParent = new Map();
@@ -63,38 +106,114 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false) {
63
106
  childrenByParent.get(node.parentId).push(node);
64
107
  }
65
108
 
109
+ const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
110
+ const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
111
+
66
112
  const refIndex = { value: 1 };
67
113
  const refMap = new Map();
68
114
  const lines = [];
69
115
  const visited = new Set();
70
116
 
117
+ // Track unchanged subtree roots for diff output
118
+ let unchangedCount = 0;
119
+
120
+ function isSubtreeUnchanged(node) {
121
+ if (!previousFingerprints) return false;
122
+ const nodeId = node.nodeId;
123
+ const curr = currentFingerprints.get(nodeId);
124
+ const prev = previousFingerprints.get(nodeId);
125
+ if (!curr || !prev || curr !== prev) return false;
126
+ // Node itself matches -- check all visible children recursively
127
+ const children = orderedAxChildren(node, nodesById, childrenByParent);
128
+ for (const child of children) {
129
+ if (!shouldShowAxNode(child, compact)) continue;
130
+ if (!isSubtreeUnchanged(child)) return false;
131
+ }
132
+ return true;
133
+ }
134
+
71
135
  function visit(node, depth) {
72
136
  if (!node || visited.has(node.nodeId)) return;
73
137
  visited.add(node.nodeId);
74
- if (shouldShowAxNode(node, compact)) {
75
- const role = node.role?.value || '';
76
- const currentRef = refIndex.value;
77
138
 
78
- lines.push(formatAxNode(node, depth, refIndex, refs));
139
+ const show = shouldShowAxNode(node, compact);
79
140
 
80
- // If ref was assigned (refIndex advanced), record the mapping
81
- if (refs && refIndex.value > currentRef) {
82
- refMap.set(currentRef, {
141
+ if (!show) {
142
+ // Generic/none node: collapse by visiting children at SAME depth (no indentation increase)
143
+ for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
144
+ visit(child, depth);
145
+ }
146
+ return;
147
+ }
148
+
149
+ // Incremental diff: if this subtree is unchanged, collapse it
150
+ if (isDiff && isSubtreeUnchanged(node)) {
151
+ unchangedCount++;
152
+ if (refs) {
153
+ advanceRefsForSubtree(node);
154
+ }
155
+ return;
156
+ }
157
+
158
+ const role = node.role?.value || '';
159
+ const currentRef = refIndex.value;
160
+
161
+ lines.push(formatAxNode(node, depth, refIndex, refs));
162
+
163
+ if (refs && refIndex.value > currentRef) {
164
+ refMap.set(currentRef, {
165
+ backendNodeId: node.backendDOMNodeId,
166
+ nodeId: node.nodeId,
167
+ role,
168
+ name: node.name?.value ?? '',
169
+ });
170
+ }
171
+
172
+ // Depth limiting: at the limit, render as leaf (no children)
173
+ if (maxDepth > 0 && depth >= maxDepth) return;
174
+
175
+ for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
176
+ visit(child, depth + 1);
177
+ }
178
+ }
179
+
180
+ // Advance ref counter for unchanged subtrees to keep ref numbers stable
181
+ // Uses its own visited set because visit() already marked nodes before calling this
182
+ const refAdvanced = new Set();
183
+ function advanceRefsForSubtree(node) {
184
+ if (!node || refAdvanced.has(node.nodeId)) return;
185
+ refAdvanced.add(node.nodeId);
186
+ if (shouldShowAxNode(node, compact)) {
187
+ const role = node.role?.value || '';
188
+ if (INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
189
+ refMap.set(refIndex.value, {
83
190
  backendNodeId: node.backendDOMNodeId,
84
191
  nodeId: node.nodeId,
85
192
  role,
86
193
  name: node.name?.value ?? '',
87
194
  });
195
+ refIndex.value++;
88
196
  }
89
197
  }
90
198
  for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
91
- visit(child, depth + 1);
199
+ advanceRefsForSubtree(child);
92
200
  }
93
201
  }
94
202
 
95
203
  const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
96
204
  for (const root of roots) visit(root, 0);
97
- for (const node of nodes) visit(node, 0);
205
+ // Second pass: catch disconnected nodes (skip when depth-limited to avoid false depth=0)
206
+ if (!maxDepth) {
207
+ for (const node of nodes) visit(node, 0);
208
+ }
209
+
210
+ // Add diff summary header when in incremental mode
211
+ let text = lines.join('\n');
212
+ if (isDiff && unchangedCount > 0) {
213
+ const totalVisible = currentFingerprints.size;
214
+ const changedCount = totalVisible - unchangedCount;
215
+ text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
216
+ }
98
217
 
99
- return { text: lines.join('\n'), refMap };
218
+ return { text, refMap, fingerprints: currentFingerprints };
100
219
  }
@@ -106,8 +106,9 @@ export async function runDaemon(targetId, config) {
106
106
  idleTimer = setTimeout(shutdown, config.idleTimeout);
107
107
  }
108
108
 
109
- // Ref-based selection state: stores mapping from @eN -> {backendNodeId, role, name}
109
+ // Per-tab state: ref map for @eN resolution + fingerprints for incremental diff
110
110
  let currentRefMap = new Map();
111
+ let previousFingerprints = null;
111
112
 
112
113
  async function handleCommand({ cmd, args }) {
113
114
  resetIdle();
@@ -145,8 +146,13 @@ export async function runDaemon(targetId, config) {
145
146
  }
146
147
  case 'snap': case 'snapshot': {
147
148
  const useRefs = args.includes('--refs') || args.includes('-i');
148
- const snapResult = await snapshotStr(cdp, sessionId, true, useRefs);
149
+ const forceFull = args.includes('--full');
150
+ const depthArg = args.find(a => a.startsWith('--depth='));
151
+ const maxDepth = depthArg ? parseInt(depthArg.split('=')[1]) || 0 : 0;
152
+ const prevFp = forceFull ? null : previousFingerprints;
153
+ const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth);
149
154
  result = snapResult.text;
155
+ previousFingerprints = snapResult.fingerprints;
150
156
  if (useRefs && snapResult.refMap.size > 0) {
151
157
  currentRefMap = snapResult.refMap;
152
158
  }
@@ -97,10 +97,12 @@ const TOOLS = [
97
97
 
98
98
  // == INSPECT (readOnly) ==
99
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.',
100
+ 'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover.',
101
101
  {
102
102
  target: P_TARGET,
103
103
  refs: { type: 'boolean', description: 'Assign @eN refs to interactive elements', default: false },
104
+ full: { type: 'boolean', description: 'Force full snapshot (skip incremental diff)', default: false },
105
+ depth: { type: 'number', description: 'Max tree depth (0 = unlimited)' },
104
106
  }, ['target'], RO),
105
107
 
106
108
  tool('chromex_html',
@@ -445,7 +447,13 @@ const TOOLS = [
445
447
  function toolToCmd(name, p) {
446
448
  switch (name) {
447
449
  // Inspect
448
- case 'chromex_snapshot': return { cmd: 'snap', args: p.refs ? ['--refs'] : [] };
450
+ case 'chromex_snapshot': {
451
+ const a = [];
452
+ if (p.refs) a.push('--refs');
453
+ if (p.full) a.push('--full');
454
+ if (p.depth) a.push(`--depth=${p.depth}`);
455
+ return { cmd: 'snap', args: a };
456
+ }
449
457
  case 'chromex_html': return { cmd: 'html', args: p.selector ? [p.selector] : [] };
450
458
  case 'chromex_screenshot': {
451
459
  const a = [];