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 +4 -4
- package/bin/chromex-cli.mjs +2 -0
- package/bin/chromex-mcp.mjs +2 -0
- package/package.json +4 -3
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +134 -15
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +8 -2
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +10 -2
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
|
-
#
|
|
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
|
|
296
|
-
claude mcp add chromex
|
|
295
|
+
# Or project-only
|
|
296
|
+
claude mcp add chromex npx chromex-mcp@latest
|
|
297
297
|
```
|
|
298
298
|
|
|
299
299
|
### Auto-Approve
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chromex-mcp",
|
|
3
|
-
"version": "1.
|
|
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": "./
|
|
8
|
-
"chromex-cli": "./
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
const show = shouldShowAxNode(node, compact);
|
|
79
140
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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':
|
|
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 = [];
|