auto-webmcp 0.3.13 → 0.3.15

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.
@@ -490,14 +490,15 @@ function collectShadowControls(root, visited = /* @__PURE__ */ new Set()) {
490
490
  const results = [];
491
491
  for (const el of Array.from(root.querySelectorAll("*"))) {
492
492
  if (el.shadowRoot) {
493
- results.push(
494
- ...Array.from(
495
- el.shadowRoot.querySelectorAll(
496
- "input, textarea, select"
497
- )
498
- ),
499
- ...collectShadowControls(el.shadowRoot, visited)
493
+ const found = Array.from(
494
+ el.shadowRoot.querySelectorAll(
495
+ "input, textarea, select"
496
+ )
500
497
  );
498
+ if (found.length > 0) {
499
+ console.log(`[auto-webmcp] shadow: found ${found.length} control(s) in ${el.tagName.toLowerCase()} shadow root:`, found.map((f) => `${f.tagName.toLowerCase()}[type=${f.type ?? "?"}][name="${f.name}"][id="${f.id}"]`));
500
+ }
501
+ results.push(...found, ...collectShadowControls(el.shadowRoot, visited));
501
502
  }
502
503
  }
503
504
  return results;
@@ -581,9 +582,23 @@ function buildSchema(form) {
581
582
  if (!name) {
582
583
  fieldElements.set(fieldKey, control);
583
584
  }
584
- if (control.required) {
585
- required.push(fieldKey);
585
+ let isRequired = control.required;
586
+ if (!isRequired) {
587
+ let hostNode = control;
588
+ while (true) {
589
+ const root = hostNode.getRootNode();
590
+ if (!(root instanceof ShadowRoot))
591
+ break;
592
+ const host = root.host;
593
+ if (host.hasAttribute("required") || host.getAttribute("aria-required") === "true") {
594
+ isRequired = true;
595
+ break;
596
+ }
597
+ hostNode = host;
598
+ }
586
599
  }
600
+ if (isRequired)
601
+ required.push(fieldKey);
587
602
  }
588
603
  const ariaControls = collectAriaControls(form);
589
604
  const processedAriaRadioGroups = /* @__PURE__ */ new Set();
@@ -626,11 +641,40 @@ function resolveNativeControlFallbackKey(control) {
626
641
  if ((control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement) && control.placeholder?.trim()) {
627
642
  return sanitizeName(control.placeholder.trim());
628
643
  }
644
+ const hostKey = resolveShadowHostKey(control);
645
+ if (hostKey)
646
+ return hostKey;
629
647
  if (control instanceof HTMLInputElement && control.type !== "text") {
630
648
  return control.type;
631
649
  }
632
650
  return null;
633
651
  }
652
+ function resolveShadowHostKey(el) {
653
+ let node = el;
654
+ while (true) {
655
+ const root = node.getRootNode();
656
+ if (!(root instanceof ShadowRoot))
657
+ break;
658
+ const host = root.host;
659
+ const fieldName = host.getAttribute("field-name");
660
+ if (fieldName) {
661
+ console.log("[auto-webmcp] shadow host key: field-name=", fieldName);
662
+ return sanitizeName(fieldName);
663
+ }
664
+ const hostLabel = host.getAttribute("label") || host.getAttribute("aria-label");
665
+ if (hostLabel) {
666
+ console.log("[auto-webmcp] shadow host key: label=", hostLabel);
667
+ return sanitizeName(hostLabel);
668
+ }
669
+ const hostName = host.getAttribute("name");
670
+ if (hostName) {
671
+ console.log("[auto-webmcp] shadow host key: name=", hostName);
672
+ return sanitizeName(hostName);
673
+ }
674
+ node = host;
675
+ }
676
+ return null;
677
+ }
634
678
  function resolveAriaElementKey(el) {
635
679
  if (el.dataset["webmcpName"])
636
680
  return sanitizeName(el.dataset["webmcpName"]);
@@ -792,12 +836,40 @@ function getAssociatedLabelText(control) {
792
836
  return text;
793
837
  }
794
838
  }
839
+ const ownRoot = control.getRootNode();
840
+ if (ownRoot instanceof ShadowRoot) {
841
+ if (control.id) {
842
+ const shadowLabel = ownRoot.querySelector(`label[for="${CSS.escape(control.id)}"]`);
843
+ if (shadowLabel) {
844
+ const text = labelTextWithoutNested(shadowLabel);
845
+ if (text)
846
+ return text;
847
+ }
848
+ }
849
+ const anyLabel = ownRoot.querySelector("label");
850
+ if (anyLabel) {
851
+ const text = labelTextWithoutNested(anyLabel);
852
+ if (text)
853
+ return text;
854
+ }
855
+ }
795
856
  const parent = control.closest("label");
796
857
  if (parent) {
797
858
  const text = labelTextWithoutNested(parent);
798
859
  if (text)
799
860
  return text;
800
861
  }
862
+ let node = control;
863
+ while (true) {
864
+ const root = node.getRootNode();
865
+ if (!(root instanceof ShadowRoot))
866
+ break;
867
+ const host = root.host;
868
+ const hostLabel = host.getAttribute("label") || host.getAttribute("aria-label");
869
+ if (hostLabel)
870
+ return hostLabel;
871
+ node = host;
872
+ }
801
873
  return "";
802
874
  }
803
875
  function labelTextWithoutNested(label) {
@@ -1497,6 +1569,51 @@ function getMissingRequired(metadata, params) {
1497
1569
  return [];
1498
1570
  return metadata.inputSchema.required.filter((fieldKey) => !(fieldKey in params));
1499
1571
  }
1572
+ async function fillComboboxButton(el, value) {
1573
+ const text = String(value ?? "").trim();
1574
+ console.log("[auto-webmcp] fillComboboxButton: clicking button, value=", JSON.stringify(text));
1575
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
1576
+ const listbox = await new Promise((resolve) => {
1577
+ const deadline = Date.now() + 1e3;
1578
+ const poll = () => {
1579
+ const candidate = document.querySelector('[role="listbox"]') ?? document.querySelector('[role="option"]')?.closest('[role="listbox"]') ?? null;
1580
+ if (candidate) {
1581
+ resolve(candidate);
1582
+ return;
1583
+ }
1584
+ if (Date.now() >= deadline) {
1585
+ resolve(null);
1586
+ return;
1587
+ }
1588
+ setTimeout(poll, 50);
1589
+ };
1590
+ poll();
1591
+ });
1592
+ if (!listbox) {
1593
+ console.warn("[auto-webmcp] fillComboboxButton: listbox did not appear after 1s");
1594
+ return;
1595
+ }
1596
+ const options = Array.from(listbox.querySelectorAll('[role="option"]'));
1597
+ console.log("[auto-webmcp] fillComboboxButton: listbox has", options.length, "options");
1598
+ const lowerValue = text.toLowerCase();
1599
+ const match = options.find((opt) => {
1600
+ const dataValue = (opt.getAttribute("data-value") ?? "").toLowerCase();
1601
+ const ariaLabel = (opt.getAttribute("aria-label") ?? "").toLowerCase();
1602
+ const optText = (opt.textContent ?? "").trim().toLowerCase();
1603
+ return dataValue === lowerValue || ariaLabel === lowerValue || optText === lowerValue;
1604
+ });
1605
+ if (match) {
1606
+ console.log("[auto-webmcp] fillComboboxButton: clicking option", match.textContent?.trim());
1607
+ match.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
1608
+ } else {
1609
+ console.warn(
1610
+ "[auto-webmcp] fillComboboxButton: no option matched",
1611
+ JSON.stringify(text),
1612
+ "available:",
1613
+ options.map((o) => o.textContent?.trim())
1614
+ );
1615
+ }
1616
+ }
1500
1617
 
1501
1618
  // src/discovery.ts
1502
1619
  function emit(type, form, toolName) {
@@ -1564,6 +1681,17 @@ var registeredForms = /* @__PURE__ */ new WeakSet();
1564
1681
  var registeredFormCount = 0;
1565
1682
  var reAnalysisTimers = /* @__PURE__ */ new Map();
1566
1683
  var RE_ANALYSIS_DEBOUNCE_MS = 300;
1684
+ var orphanRescanTimer = null;
1685
+ var ORPHAN_RESCAN_DEBOUNCE_MS = 500;
1686
+ var registeredOrphanToolNames = /* @__PURE__ */ new Set();
1687
+ function scheduleOrphanRescan(config) {
1688
+ if (orphanRescanTimer)
1689
+ clearTimeout(orphanRescanTimer);
1690
+ orphanRescanTimer = setTimeout(() => {
1691
+ orphanRescanTimer = null;
1692
+ void scanOrphanInputs(config);
1693
+ }, ORPHAN_RESCAN_DEBOUNCE_MS);
1694
+ }
1567
1695
  function isInterestingNode(node) {
1568
1696
  const tag = node.tagName.toLowerCase();
1569
1697
  if (tag === "input" || tag === "textarea" || tag === "select")
@@ -1610,6 +1738,9 @@ function startObserver(config) {
1610
1738
  for (const form of Array.from(node.querySelectorAll("form"))) {
1611
1739
  void registerForm(form, config);
1612
1740
  }
1741
+ if (isInterestingNode(node) && !node.closest("form")) {
1742
+ scheduleOrphanRescan(config);
1743
+ }
1613
1744
  }
1614
1745
  for (const node of mutation.removedNodes) {
1615
1746
  if (!(node instanceof Element))
@@ -1657,10 +1788,10 @@ async function scanOrphanInputs(config) {
1657
1788
  return;
1658
1789
  const SUBMIT_BTN_SELECTOR = '[type="submit"]:not([disabled]), button[data-variant="primary"]:not([disabled])';
1659
1790
  const SUBMIT_BTN_GROUPING_SELECTOR = '[type="submit"], button[data-variant="primary"]';
1660
- const SUBMIT_TEXT_RE = /subscribe|submit|sign[\s-]?up|send|join|go|search|post|tweet|publish/i;
1791
+ const SUBMIT_TEXT_RE = /subscribe|submit|sign[\s-]?up|send|join|go|search|post|tweet|publish|save/i;
1661
1792
  const orphanInputs = Array.from(
1662
1793
  document.querySelectorAll(
1663
- 'input:not(form input), textarea:not(form textarea), select:not(form select), [role="textbox"]:not(form [role="textbox"]):not(input):not(textarea), [role="searchbox"]:not(form [role="searchbox"]):not(input):not(textarea), [contenteditable="true"]:not(form [contenteditable="true"]):not(input):not(textarea)'
1794
+ 'input:not(form input), textarea:not(form textarea), select:not(form select), [role="textbox"]:not(form [role="textbox"]):not(input):not(textarea), [role="searchbox"]:not(form [role="searchbox"]):not(input):not(textarea), [contenteditable="true"]:not(form [contenteditable="true"]):not(input):not(textarea), button[role="combobox"]:not(form button[role="combobox"])'
1664
1795
  )
1665
1796
  ).filter((el) => {
1666
1797
  if (el instanceof HTMLInputElement && ORPHAN_EXCLUDED_TYPES.has(el.type.toLowerCase())) {
@@ -1791,7 +1922,11 @@ async function scanOrphanInputs(config) {
1791
1922
  for (const { key, el } of inputPairs) {
1792
1923
  if (params[key] !== void 0) {
1793
1924
  console.log(`[auto-webmcp] orphan execute: filling key="${key}" value=`, params[key], "element=", el);
1794
- fillElement(el, params[key]);
1925
+ if (el.getAttribute("role") === "combobox" && el.tagName.toLowerCase() === "button") {
1926
+ await fillComboboxButton(el, params[key]);
1927
+ } else {
1928
+ fillElement(el, params[key]);
1929
+ }
1795
1930
  console.log(`[auto-webmcp] orphan execute: after fill, element value=`, el.value);
1796
1931
  } else {
1797
1932
  console.log(`[auto-webmcp] orphan execute: key="${key}" not in params, skipping`);
@@ -1803,22 +1938,43 @@ async function scanOrphanInputs(config) {
1803
1938
  console.log(`[auto-webmcp] orphan execute: autoSubmit=false, returning without clicking submit`);
1804
1939
  return { content: [{ type: "text", text: "Fields filled. Ready to submit." }] };
1805
1940
  }
1806
- console.log(`[auto-webmcp] orphan execute: polling for enabled submit button (up to 2s)...`);
1941
+ console.log(`[auto-webmcp] orphan execute: resolving submit button (up to 2s)...`);
1807
1942
  let btn = null;
1808
- const deadline = Date.now() + 2e3;
1809
- while (Date.now() < deadline) {
1810
- const candidates = Array.from(
1811
- container.querySelectorAll(SUBMIT_BTN_SELECTOR)
1943
+ if (submitBtn && document.contains(submitBtn)) {
1944
+ const isEnabled = !submitBtn.disabled && submitBtn.getAttribute("aria-disabled") !== "true";
1945
+ const r = submitBtn.getBoundingClientRect();
1946
+ if (isEnabled && r.width > 0 && r.height > 0) {
1947
+ btn = submitBtn;
1948
+ console.log(`[auto-webmcp] orphan execute: using captured submit button "${btn.textContent?.trim()}"`);
1949
+ }
1950
+ }
1951
+ if (!btn) {
1952
+ const deadline = Date.now() + 2e3;
1953
+ while (Date.now() < deadline) {
1954
+ const candidates = Array.from(
1955
+ container.querySelectorAll(SUBMIT_BTN_SELECTOR)
1956
+ ).filter((b) => {
1957
+ const r = b.getBoundingClientRect();
1958
+ return r.width > 0 && r.height > 0;
1959
+ });
1960
+ const last = candidates[candidates.length - 1] ?? null;
1961
+ if (last) {
1962
+ btn = last;
1963
+ break;
1964
+ }
1965
+ await new Promise((r) => setTimeout(r, 100));
1966
+ }
1967
+ }
1968
+ if (!btn) {
1969
+ const textBtns = Array.from(
1970
+ (container !== document.body ? container : document).querySelectorAll('button, [role="button"]')
1812
1971
  ).filter((b) => {
1813
1972
  const r = b.getBoundingClientRect();
1814
- return r.width > 0 && r.height > 0;
1973
+ return r.width > 0 && r.height > 0 && !b.disabled && b.getAttribute("aria-disabled") !== "true" && SUBMIT_TEXT_RE.test(b.textContent ?? "");
1815
1974
  });
1816
- const last = candidates[candidates.length - 1] ?? null;
1817
- if (last) {
1818
- btn = last;
1819
- break;
1820
- }
1821
- await new Promise((r) => setTimeout(r, 100));
1975
+ btn = textBtns[textBtns.length - 1] ?? null;
1976
+ if (btn)
1977
+ console.log(`[auto-webmcp] orphan execute: using text-matched fallback button "${btn.textContent?.trim()}"`);
1822
1978
  }
1823
1979
  if (!btn) {
1824
1980
  console.warn(`[auto-webmcp] orphan execute: submit button still disabled after 2s`);
@@ -1829,6 +1985,10 @@ async function scanOrphanInputs(config) {
1829
1985
  return { content: [{ type: "text", text: "Fields filled and form submitted." }] };
1830
1986
  };
1831
1987
  try {
1988
+ if (registeredOrphanToolNames.has(metadata.name)) {
1989
+ console.log(`[auto-webmcp] orphan: "${metadata.name}" already registered, skipping`);
1990
+ continue;
1991
+ }
1832
1992
  const toolDef = {
1833
1993
  name: metadata.name,
1834
1994
  description: metadata.description,
@@ -1839,6 +1999,7 @@ async function scanOrphanInputs(config) {
1839
1999
  toolDef.annotations = metadata.annotations;
1840
2000
  }
1841
2001
  await navigator.modelContext.registerTool(toolDef);
2002
+ registeredOrphanToolNames.add(metadata.name);
1842
2003
  const pendingBtns = window["__pendingSubmitBtns"] ??= {};
1843
2004
  pendingBtns[metadata.name] = submitBtn;
1844
2005
  if (config.debug) {
@@ -1866,6 +2027,7 @@ async function startDiscovery(config) {
1866
2027
  );
1867
2028
  }
1868
2029
  registeredFormCount = 0;
2030
+ registeredOrphanToolNames.clear();
1869
2031
  startObserver(config);
1870
2032
  listenForRouteChanges(config);
1871
2033
  await scanForms(config);