machine-bridge-mcp 0.14.0 → 0.15.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.
@@ -1,30 +1,94 @@
1
1
  (() => {
2
2
  if (globalThis.__machineBridgePageAutomation) return;
3
3
 
4
- const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable='true'],summary";
4
+ const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable]:not([contenteditable='false']),summary";
5
5
  const MAX_SHADOW_ROOTS = 200;
6
+ const MAX_SCAN_NODES = 100000;
7
+ const MAX_QUERY_MATCHES = 10001;
8
+ const MAX_PAGE_FIELD_CHARS = 2000;
9
+ const MAX_PAGE_URL_CHARS = 8192;
10
+ const MAX_WAIT_TEXT_CHARS = 2 * 1024 * 1024;
11
+ const MAX_ELEMENT_REFS = 10000;
6
12
  const elementRefs = new WeakMap();
7
13
  const refElements = new Map();
8
14
  let nextRef = 1;
15
+ let evictedRefs = 0;
9
16
 
10
- function collectRoots() {
17
+ function scanPageElements(maxNodes = MAX_SCAN_NODES) {
11
18
  const roots = [document];
12
- for (let index = 0; index < roots.length && roots.length <= MAX_SHADOW_ROOTS; index += 1) {
13
- const root = roots[index];
14
- for (const element of root.querySelectorAll("*")) {
15
- if (element.shadowRoot && !roots.includes(element.shadowRoot)) roots.push(element.shadowRoot);
16
- if (roots.length > MAX_SHADOW_ROOTS) break;
19
+ const seenRoots = new Set(roots);
20
+ const entries = [];
21
+ let visitedNodes = 0;
22
+ let truncated = false;
23
+ for (let rootIndex = 0; rootIndex < roots.length; rootIndex += 1) {
24
+ const root = roots[rootIndex];
25
+ const elements = [];
26
+ const source = iterateElements(root);
27
+ for (const element of source) {
28
+ if (visitedNodes >= maxNodes) {
29
+ truncated = true;
30
+ break;
31
+ }
32
+ visitedNodes += 1;
33
+ elements.push(element);
34
+ if (element.shadowRoot && !seenRoots.has(element.shadowRoot)) {
35
+ if (roots.length >= MAX_SHADOW_ROOTS + 1) truncated = true;
36
+ else {
37
+ seenRoots.add(element.shadowRoot);
38
+ roots.push(element.shadowRoot);
39
+ }
40
+ }
17
41
  }
42
+ entries.push({ root, elements });
43
+ if (visitedNodes >= maxNodes) break;
18
44
  }
19
- return roots;
45
+ return { roots, entries, visitedNodes, truncated };
46
+ }
47
+
48
+ function iterateElements(root) {
49
+ if (typeof document.createTreeWalker === "function") {
50
+ const walker = document.createTreeWalker(root, globalThis.NodeFilter?.SHOW_ELEMENT || 1);
51
+ return {
52
+ [Symbol.iterator]() {
53
+ return {
54
+ next() {
55
+ const value = walker.nextNode();
56
+ return value ? { value, done: false } : { value: undefined, done: true };
57
+ },
58
+ };
59
+ },
60
+ };
61
+ }
62
+ return root.querySelectorAll("*");
20
63
  }
21
64
 
22
- function deepQuerySelectorAll(selector) {
65
+ function deepQuerySelectorAll(selector, limit = MAX_QUERY_MATCHES) {
66
+ if (!selector || limit < 1) return [];
67
+ const scan = scanPageElements();
23
68
  const results = [];
24
- for (const root of collectRoots()) results.push(...root.querySelectorAll(selector));
69
+ try {
70
+ if (typeof document.documentElement?.matches === "function") document.documentElement.matches(selector);
71
+ for (const entry of scan.entries) {
72
+ for (const element of entry.elements) {
73
+ if (element.matches?.(selector)) {
74
+ results.push(element);
75
+ if (results.length >= limit) return results;
76
+ }
77
+ }
78
+ }
79
+ } catch (error) {
80
+ throw new Error(`invalid CSS selector: ${String(error?.message || error).slice(0, 300)}`);
81
+ }
25
82
  return results;
26
83
  }
27
84
 
85
+ function isInteractiveElement(element) {
86
+ const tag = String(element.tagName || "").toLowerCase();
87
+ return ["a", "button", "input", "select", "textarea", "summary"].includes(tag)
88
+ || element.hasAttribute?.("role")
89
+ || (element.hasAttribute?.("contenteditable") && String(element.getAttribute?.("contenteditable") || "").toLowerCase() !== "false");
90
+ }
91
+
28
92
  function rootById(element, id) {
29
93
  const root = element.getRootNode?.();
30
94
  if (root && typeof root.getElementById === "function") return root.getElementById(id);
@@ -33,16 +97,25 @@
33
97
 
34
98
  function refFor(element) {
35
99
  let ref = elementRefs.get(element);
36
- if (ref) {
37
- refElements.set(ref, element);
38
- return ref;
100
+ if (!ref) {
101
+ ref = `e${nextRef++}`;
102
+ elementRefs.set(element, ref);
39
103
  }
40
- ref = `e${nextRef++}`;
41
- elementRefs.set(element, ref);
42
- refElements.set(ref, element);
104
+ rememberRef(ref, element);
43
105
  return ref;
44
106
  }
45
107
 
108
+ function rememberRef(ref, element) {
109
+ if (refElements.has(ref)) refElements.delete(ref);
110
+ while (refElements.size >= MAX_ELEMENT_REFS) {
111
+ const oldest = refElements.keys().next().value;
112
+ if (!oldest) break;
113
+ refElements.delete(oldest);
114
+ evictedRefs += 1;
115
+ }
116
+ refElements.set(ref, element);
117
+ }
118
+
46
119
  function pruneDetachedRefs() {
47
120
  for (const [ref, element] of refElements) {
48
121
  if (!element?.isConnected) refElements.delete(ref);
@@ -51,22 +124,39 @@
51
124
 
52
125
  function inspect(params) {
53
126
  pruneDetachedRefs();
54
- const maxElements = Number(params.maxElements) || 300;
127
+ const maxElements = Math.max(0, Number(params.maxElements) || 0);
55
128
  const includeValues = params.includeValues === true;
56
- const all = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
57
- const candidates = all.slice(0, maxElements);
129
+ const scan = scanPageElements();
130
+ const candidates = [];
131
+ let interactiveCount = 0;
132
+ let formCount = 0;
133
+ for (const entry of scan.entries) {
134
+ for (const element of entry.elements) {
135
+ if (String(element.tagName || "").toLowerCase() === "form") formCount += 1;
136
+ if (!isInteractiveElement(element)) continue;
137
+ interactiveCount += 1;
138
+ if (candidates.length < maxElements) candidates.push(element);
139
+ }
140
+ }
141
+ const describedElements = candidates.map((element, index) => describeElement(element, index, includeValues));
58
142
  return {
59
- snapshot_version: 1,
143
+ snapshot_version: 2,
60
144
  document: {
61
- title: document.title,
62
- url: location.href,
63
- language: document.documentElement.lang || "",
145
+ title: boundedPageText(document.title, 1000),
146
+ url: safePageUrl(location.href),
147
+ language: boundedPageText(document.documentElement.lang, 100),
64
148
  ready_state: document.readyState,
65
- forms: deepQuerySelectorAll("form").length,
66
- open_shadow_roots: Math.max(0, collectRoots().length - 1),
149
+ forms: formCount,
150
+ open_shadow_roots: Math.max(0, scan.roots.length - 1),
151
+ scanned_nodes: scan.visitedNodes,
152
+ scan_limit: MAX_SCAN_NODES,
153
+ scan_truncated: scan.truncated,
154
+ tracked_refs: refElements.size,
155
+ ref_limit: MAX_ELEMENT_REFS,
156
+ refs_evicted: evictedRefs,
67
157
  },
68
- elements: candidates.map((element, index) => describeElement(element, index, includeValues)),
69
- truncated: all.length > candidates.length,
158
+ elements: describedElements,
159
+ truncated: scan.truncated || interactiveCount > describedElements.length,
70
160
  };
71
161
  }
72
162
 
@@ -101,19 +191,30 @@
101
191
  const timeoutMs = Number(params.elementTimeoutMs) || 10000;
102
192
  for (let index = 0; index < params.fields.length; index += 1) {
103
193
  const field = params.fields[index];
104
- const element = await waitForActionable(field.selector, field.action, timeoutMs);
105
- await applyOne(element, field.action, field.value, "");
106
- results.push({ index, action: field.action, sensitive: field.sensitive === true, element: describeElement(element, index, false) });
194
+ try {
195
+ const element = await waitForActionable(field.selector, field.action, timeoutMs);
196
+ await applyOne(element, field.action, field.value, "");
197
+ results.push({ index, action: field.action, sensitive: field.sensitive === true, element: describeElement(element, index, false) });
198
+ } catch (error) {
199
+ const prefix = index > 0
200
+ ? `form field ${index} (${field.action}) failed after ${index} earlier field(s) may have changed`
201
+ : `form field 0 (${field.action}) failed before any earlier field changed`;
202
+ throw new Error(`${prefix}: ${boundedPageText(error?.message || error, 500)}`);
203
+ }
107
204
  }
108
205
  if (params.submit) {
109
- const submitter = params.submitSelector ? await waitForActionable(params.submitSelector, "click", timeoutMs) : deepQuerySelectorAll("button[type='submit'],input[type='submit']")[0];
110
- if (submitter) submitter.click();
111
- else {
112
- const field = params.fields.length ? findOne(params.fields[0].selector) : null;
113
- const form = field?.form || field?.closest?.("form") || deepQuerySelectorAll("form")[0];
114
- if (!form) throw new Error("no form or submit control found");
115
- if (typeof form.requestSubmit === "function") form.requestSubmit();
116
- else form.submit();
206
+ try {
207
+ const submitter = params.submitSelector ? await waitForActionable(params.submitSelector, "click", timeoutMs) : deepQuerySelectorAll("button[type='submit'],input[type='submit']", 1)[0];
208
+ if (submitter) submitter.click();
209
+ else {
210
+ const field = params.fields.length ? findOne(params.fields[0].selector) : null;
211
+ const form = field?.form || field?.closest?.("form") || deepQuerySelectorAll("form", 1)[0];
212
+ if (!form) throw new Error("no form or submit control found");
213
+ if (typeof form.requestSubmit === "function") form.requestSubmit();
214
+ else form.submit();
215
+ }
216
+ } catch (error) {
217
+ throw new Error(`form submission failed after ${results.length} field(s) changed: ${boundedPageText(error?.message || error, 500)}`);
117
218
  }
118
219
  }
119
220
  return { ok: true, fields: results, submitted: params.submit === true, values_exposed: false };
@@ -148,8 +249,8 @@
148
249
  || (params.state === "editable" && editable.length > 0)
149
250
  || (params.state === "checked" && elements.some((element) => "checked" in element && element.checked === true))
150
251
  || (params.state === "unchecked" && elements.some((element) => "checked" in element && element.checked === false));
151
- const pageText = params.text ? normalizedText(document.body?.innerText || document.body?.textContent || "") : "";
152
- const textMatched = !params.text || pageText.includes(normalizedText(params.text));
252
+ const textSearch = params.text ? pageContainsText(params.text) : { found: true, truncated: false, scanned_chars: 0 };
253
+ const textMatched = textSearch.found;
153
254
  const loadMatched = !params.loadState
154
255
  || (params.loadState === "domcontentloaded" && ["interactive", "complete"].includes(document.readyState))
155
256
  || (params.loadState === "complete" && document.readyState === "complete");
@@ -159,13 +260,15 @@
159
260
  visible_count: visible.length,
160
261
  state: params.state || "",
161
262
  text_found: textMatched,
263
+ text_scan_truncated: textSearch.truncated,
264
+ text_scanned_chars: textSearch.scanned_chars,
162
265
  ready_state: document.readyState,
163
266
  };
164
267
  }
165
268
 
166
269
  function describeElement(element, index, includeValues) {
167
- const tag = element.tagName.toLowerCase();
168
- const type = String(element.getAttribute("type") || "").toLowerCase();
270
+ const tag = String(element.tagName || "").toLowerCase();
271
+ const type = boundedPageText(element.getAttribute("type"), 100).toLowerCase();
169
272
  const sensitive = isSensitiveElement(element, tag, type);
170
273
  const visible = isVisible(element);
171
274
  const item = {
@@ -173,33 +276,36 @@
173
276
  index,
174
277
  tag,
175
278
  type,
176
- role: element.getAttribute("role") || implicitRole(element),
279
+ role: boundedPageText(element.getAttribute("role") || implicitRole(element), 200),
177
280
  name: accessibleName(element),
178
- id: element.id || "",
179
- field_name: element.getAttribute("name") || "",
281
+ id: boundedPageText(element.id, MAX_PAGE_FIELD_CHARS),
282
+ field_name: boundedPageText(element.getAttribute("name"), MAX_PAGE_FIELD_CHARS),
180
283
  label: labelText(element),
181
- placeholder: element.getAttribute("placeholder") || "",
284
+ placeholder: boundedPageText(element.getAttribute("placeholder"), MAX_PAGE_FIELD_CHARS),
182
285
  visible,
183
286
  enabled: isEnabled(element),
184
287
  editable: isEditable(element),
185
288
  disabled: Boolean(element.disabled),
186
289
  checked: "checked" in element ? Boolean(element.checked) : null,
187
- selected: tag === "select" ? element.value : null,
188
- href: tag === "a" ? element.href : "",
290
+ selected: tag === "select" ? boundedPageText(element.value, MAX_PAGE_FIELD_CHARS) : null,
291
+ href: tag === "a" ? safePageUrl(element.href) : "",
189
292
  sensitive,
190
293
  in_shadow_dom: element.getRootNode?.() instanceof ShadowRoot,
191
294
  bounding_box: visible ? boundingBox(element) : null,
192
295
  };
193
- if (includeValues && !sensitive && "value" in element) item.value = String(element.value || "").slice(0, 2000);
296
+ if (includeValues && !sensitive && "value" in element) item.value = boundedPageText(element.value, MAX_PAGE_FIELD_CHARS);
194
297
  return item;
195
298
  }
196
299
 
197
300
  function isSensitiveElement(element, tag, type) {
198
- if (tag !== "input" && tag !== "textarea") return false;
301
+ if (tag !== "input" && tag !== "textarea" && !element.isContentEditable) return false;
199
302
  if (["password", "hidden"].includes(type)) return true;
200
303
  const autocomplete = String(element.getAttribute("autocomplete") || "").toLowerCase();
201
304
  if (/(?:password|one-time-code|cc-number|cc-csc|cc-exp)/.test(autocomplete)) return true;
202
- const identity = `${element.id || ""} ${element.getAttribute("name") || ""} ${element.getAttribute("aria-label") || ""} ${element.getAttribute("placeholder") || ""}`.toLowerCase();
305
+ const identity = [element.id, element.getAttribute("name"), element.getAttribute("aria-label"), element.getAttribute("placeholder")]
306
+ .map((value) => boundedPageText(value, 500))
307
+ .join(" ")
308
+ .toLowerCase();
203
309
  return /(?:password|passwd|secret|token|api[-_ ]?key|otp|one[-_ ]?time|verification|cvc|cvv|security[-_ ]?code|card[-_ ]?number)/.test(identity);
204
310
  }
205
311
 
@@ -312,13 +418,14 @@
312
418
  if (operation === "check" || operation === "uncheck") {
313
419
  const wanted = operation === "check";
314
420
  if (Boolean(element.checked) !== wanted) element.click();
421
+ if (Boolean(element.checked) !== wanted) throw new Error(`checkable control did not reach the requested ${wanted ? "checked" : "unchecked"} state`);
315
422
  return;
316
423
  }
317
424
  if (operation === "select") {
318
425
  const text = String(value ?? "");
319
426
  const option = [...element.options].find((item) => item.value === text || item.text.trim() === text);
320
427
  if (!option) throw new Error("select option was not found");
321
- element.value = option.value;
428
+ setNativeValue(element, option.value);
322
429
  dispatchValueEvents(element);
323
430
  return;
324
431
  }
@@ -418,7 +525,7 @@
418
525
  elements = elements.filter((element) => {
419
526
  if (selector.name && element.getAttribute("name") !== selector.name) return false;
420
527
  if (selector.label && !sameText(labelText(element), selector.label)) return false;
421
- if (selector.text && !sameText(element.innerText || element.textContent || "", selector.text)) return false;
528
+ if (selector.text && !sameText(boundedNodeText(element, MAX_PAGE_FIELD_CHARS + 1), selector.text)) return false;
422
529
  if (selector.role && !sameText(element.getAttribute("role") || implicitRole(element), selector.role)) return false;
423
530
  if (selector.placeholder && !sameText(element.getAttribute("placeholder") || "", selector.placeholder)) return false;
424
531
  return true;
@@ -478,7 +585,7 @@
478
585
  }
479
586
 
480
587
  function normalizedText(value) {
481
- return String(value || "").trim().replace(/\s+/g, " ").toLowerCase();
588
+ return boundedPageText(value, MAX_WAIT_TEXT_CHARS).trim().replace(/\s+/g, " ").toLowerCase();
482
589
  }
483
590
 
484
591
  function sameText(left, right) {
@@ -486,17 +593,95 @@
486
593
  }
487
594
 
488
595
  function labelText(element) {
489
- if (element.labels?.length) return [...element.labels].map((label) => label.innerText || label.textContent || "").join(" ").trim();
490
- const parent = element.closest("label");
491
- if (parent) return (parent.innerText || parent.textContent || "").trim();
596
+ if (element.labels?.length) {
597
+ let text = "";
598
+ for (const label of [...element.labels].slice(0, 100)) {
599
+ text += ` ${boundedNodeText(label, Math.max(0, MAX_PAGE_FIELD_CHARS - text.length))}`;
600
+ if (text.length >= MAX_PAGE_FIELD_CHARS) break;
601
+ }
602
+ return boundedPageText(text.trim(), MAX_PAGE_FIELD_CHARS);
603
+ }
604
+ const parent = element.closest?.("label");
605
+ if (parent) return boundedNodeText(parent, MAX_PAGE_FIELD_CHARS);
492
606
  const labelledBy = element.getAttribute("aria-labelledby");
493
- if (labelledBy) return labelledBy.split(/\s+/).map((id) => rootById(element, id)?.textContent || "").join(" ").trim();
607
+ if (labelledBy) {
608
+ let text = "";
609
+ for (const id of labelledBy.split(/\s+/).slice(0, 100)) {
610
+ const labelled = rootById(element, id);
611
+ text += ` ${boundedNodeText(labelled, Math.max(0, MAX_PAGE_FIELD_CHARS - text.length))}`;
612
+ if (text.length >= MAX_PAGE_FIELD_CHARS) break;
613
+ }
614
+ return boundedPageText(text.trim(), MAX_PAGE_FIELD_CHARS);
615
+ }
494
616
  return "";
495
617
  }
496
618
 
497
619
  function accessibleName(element) {
498
- return (element.getAttribute("aria-label") || labelText(element) || element.getAttribute("title") || element.getAttribute("alt") || element.innerText || element.textContent || "")
499
- .trim().replace(/\s+/g, " ").slice(0, 500);
620
+ const direct = element.getAttribute("aria-label") || labelText(element) || element.getAttribute("title") || element.getAttribute("alt");
621
+ return boundedPageText(direct || boundedNodeText(element, 500), 500).trim().replace(/\s+/g, " ");
622
+ }
623
+
624
+ function boundedNodeText(node, maxChars) {
625
+ const limit = Math.max(0, Number(maxChars) || 0);
626
+ if (!node || limit === 0) return "";
627
+ if (typeof document.createTreeWalker !== "function") return boundedPageText(node.textContent || node.innerText, limit);
628
+ const walker = document.createTreeWalker(node, globalThis.NodeFilter?.SHOW_TEXT || 4);
629
+ let text = "";
630
+ let current;
631
+ while ((current = walker.nextNode()) && text.length < limit) {
632
+ const remaining = limit - text.length;
633
+ text += boundedPageText(current.data, remaining);
634
+ }
635
+ return text;
636
+ }
637
+
638
+ function pageContainsText(value) {
639
+ const needle = normalizedText(value);
640
+ if (!needle) return { found: true, truncated: false, scanned_chars: 0 };
641
+ const root = document.body || document.documentElement;
642
+ if (!root) return { found: false, truncated: false, scanned_chars: 0 };
643
+ if (typeof document.createTreeWalker !== "function") {
644
+ const text = boundedPageText(root.textContent || root.innerText, MAX_WAIT_TEXT_CHARS);
645
+ return { found: normalizedText(text).includes(needle), truncated: String(root.textContent || root.innerText || "").length > text.length, scanned_chars: text.length };
646
+ }
647
+ const walker = document.createTreeWalker(root, globalThis.NodeFilter?.SHOW_TEXT || 4);
648
+ let haystack = "";
649
+ let truncated = false;
650
+ let scannedChars = 0;
651
+ let node;
652
+ while ((node = walker.nextNode())) {
653
+ const remaining = MAX_WAIT_TEXT_CHARS - scannedChars;
654
+ if (remaining <= 0) { truncated = true; break; }
655
+ const raw = String(node.data || "");
656
+ const boundedRaw = raw.slice(0, remaining);
657
+ scannedChars += boundedRaw.length;
658
+ const chunk = normalizedText(boundedRaw);
659
+ if (chunk) {
660
+ haystack = haystack ? `${haystack} ${chunk}` : chunk;
661
+ if (haystack.includes(needle)) return { found: true, truncated: raw.length > boundedRaw.length, scanned_chars: scannedChars };
662
+ if (haystack.length > needle.length * 2 + 4096) haystack = haystack.slice(-(needle.length + 4096));
663
+ }
664
+ if (raw.length > boundedRaw.length) { truncated = true; break; }
665
+ }
666
+ return { found: haystack.includes(needle), truncated, scanned_chars: Math.min(MAX_WAIT_TEXT_CHARS, scannedChars) };
667
+ }
668
+
669
+ function boundedPageText(value, maxChars) {
670
+ return String(value || "").slice(0, Math.max(0, maxChars));
671
+ }
672
+
673
+ function safePageUrl(value) {
674
+ const text = boundedPageText(value, MAX_PAGE_URL_CHARS);
675
+ try {
676
+ const parsed = new URL(text, location.href);
677
+ if (parsed.username || parsed.password) {
678
+ parsed.username = "";
679
+ parsed.password = "";
680
+ }
681
+ return boundedPageText(parsed.href, MAX_PAGE_URL_CHARS);
682
+ } catch {
683
+ return text;
684
+ }
500
685
  }
501
686
 
502
687
  function implicitRole(element) {
@@ -2,7 +2,7 @@
2
2
  const marker = document.querySelector('meta[name="machine-bridge-browser-pair"]');
3
3
  const portMeta = document.querySelector('meta[name="machine-bridge-browser-port"]');
4
4
  const tokenMeta = document.querySelector('meta[name="machine-bridge-browser-token"]');
5
- if (!marker || !portMeta || !tokenMeta) return;
5
+ if (marker?.content !== "1" || !portMeta || !tokenMeta) return;
6
6
  const token = tokenMeta.content;
7
7
  const port = Number(portMeta.content);
8
8
  if (!/^[A-Za-z0-9_-]{32,100}$/.test(token) || !Number.isInteger(port) || port < 1024 || port > 65535) return;