auto-webmcp 0.2.1 → 0.2.2

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  **Automatically make any HTML form WebMCP-ready — zero explicit coding required.**
4
4
 
5
+ [Read the article on dev.to](https://dev.to/prasannagyde/every-web-form-should-be-callable-by-ai-agents-and-yours-can-be-today-228)  ·  [Live demo](https://autowebmcp.dev/demo)  ·  [Platform guides](https://autowebmcp.dev/platforms)
6
+
5
7
  Drop in one script tag (or one `import`) and every `<form>` on your page is
6
8
  instantly registered as a structured tool that in-browser AI agents can
7
9
  discover and use via Chrome's
@@ -7,6 +7,8 @@ export interface ToolMetadata {
7
7
  name: string;
8
8
  description: string;
9
9
  inputSchema: JsonSchema;
10
+ /** Key → DOM element for fields not addressable by name (id-keyed or ARIA-role controls). */
11
+ fieldElements?: Map<string, Element>;
10
12
  }
11
13
  /** Reset form index counter (useful in tests) */
12
14
  export declare function resetFormIndex(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAA8E,MAAM,aAAa,CAAC;AACrH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;CACzB;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAMxF"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,UAAU,EAA8H,MAAM,aAAa,CAAC;AACrK,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE3C,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,UAAU,CAAC;IACxB,6FAA6F;IAC7F,aAAa,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACtC;AAKD,iDAAiD;AACjD,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,EAAE,QAAQ,CAAC,EAAE,YAAY,GAAG,YAAY,CAMxF"}
@@ -110,6 +110,16 @@ function resolveConfig(userConfig) {
110
110
  }
111
111
 
112
112
  // src/schema.ts
113
+ var ARIA_ROLES_TO_SCAN = [
114
+ "textbox",
115
+ "combobox",
116
+ "checkbox",
117
+ "radio",
118
+ "switch",
119
+ "spinbutton",
120
+ "searchbox",
121
+ "slider"
122
+ ];
113
123
  function inputTypeToSchema(input) {
114
124
  if (input instanceof HTMLInputElement) {
115
125
  return mapInputElement(input);
@@ -205,6 +215,49 @@ function collectRadioOneOf(form, name) {
205
215
  return { const: r.value, title: title || r.value };
206
216
  });
207
217
  }
218
+ function ariaRoleToSchema(el, role) {
219
+ switch (role) {
220
+ case "checkbox":
221
+ case "switch":
222
+ return { type: "boolean" };
223
+ case "spinbutton":
224
+ case "slider": {
225
+ const prop = { type: "number" };
226
+ const min = el.getAttribute("aria-valuemin");
227
+ const max = el.getAttribute("aria-valuemax");
228
+ if (min !== null)
229
+ prop.minimum = parseFloat(min);
230
+ if (max !== null)
231
+ prop.maximum = parseFloat(max);
232
+ return prop;
233
+ }
234
+ case "combobox": {
235
+ const ownedId = el.getAttribute("aria-owns") ?? el.getAttribute("aria-controls");
236
+ if (ownedId) {
237
+ const listbox = document.getElementById(ownedId);
238
+ if (listbox) {
239
+ const options = Array.from(listbox.querySelectorAll('[role="option"]')).filter(
240
+ (o) => o.getAttribute("aria-disabled") !== "true"
241
+ );
242
+ if (options.length > 0) {
243
+ const enumValues = options.map((o) => (o.getAttribute("data-value") ?? o.textContent ?? "").trim()).filter(Boolean);
244
+ const oneOf = options.map((o) => ({
245
+ const: (o.getAttribute("data-value") ?? o.textContent ?? "").trim(),
246
+ title: (o.textContent ?? "").trim()
247
+ }));
248
+ return { type: "string", enum: enumValues, oneOf };
249
+ }
250
+ }
251
+ }
252
+ return { type: "string" };
253
+ }
254
+ case "textbox":
255
+ case "searchbox":
256
+ case "radio":
257
+ default:
258
+ return { type: "string" };
259
+ }
260
+ }
208
261
  function getRadioLabelText(radio) {
209
262
  const parent = radio.closest("label");
210
263
  if (parent) {
@@ -230,8 +283,8 @@ var formIndex = 0;
230
283
  function analyzeForm(form, override) {
231
284
  const name = override?.name ?? inferToolName(form);
232
285
  const description = override?.description ?? inferToolDescription(form);
233
- const inputSchema = buildSchema(form);
234
- return { name, description, inputSchema };
286
+ const { schema: inputSchema, fieldElements } = buildSchema(form);
287
+ return { name, description, inputSchema, fieldElements };
235
288
  }
236
289
  function inferToolName(form) {
237
290
  const nativeName = form.getAttribute("toolname");
@@ -331,6 +384,7 @@ function inferToolDescription(form) {
331
384
  function buildSchema(form) {
332
385
  const properties = {};
333
386
  const required = [];
387
+ const fieldElements = /* @__PURE__ */ new Map();
334
388
  const processedRadioGroups = /* @__PURE__ */ new Set();
335
389
  const controls = Array.from(
336
390
  form.querySelectorAll(
@@ -339,12 +393,13 @@ function buildSchema(form) {
339
393
  );
340
394
  for (const control of controls) {
341
395
  const name = control.name;
342
- if (!name)
396
+ const fieldKey = name || resolveNativeControlFallbackKey(control);
397
+ if (!fieldKey)
343
398
  continue;
344
399
  if (control instanceof HTMLInputElement && control.type === "radio") {
345
- if (processedRadioGroups.has(name))
400
+ if (processedRadioGroups.has(fieldKey))
346
401
  continue;
347
- processedRadioGroups.add(name);
402
+ processedRadioGroups.add(fieldKey);
348
403
  }
349
404
  const schemaProp = inputTypeToSchema(control);
350
405
  if (!schemaProp)
@@ -354,17 +409,123 @@ function buildSchema(form) {
354
409
  if (desc)
355
410
  schemaProp.description = desc;
356
411
  if (control instanceof HTMLInputElement && control.type === "radio") {
357
- schemaProp.enum = collectRadioEnum(form, name);
358
- const radioOneOf = collectRadioOneOf(form, name);
412
+ schemaProp.enum = collectRadioEnum(form, fieldKey);
413
+ const radioOneOf = collectRadioOneOf(form, fieldKey);
359
414
  if (radioOneOf.length > 0)
360
415
  schemaProp.oneOf = radioOneOf;
361
416
  }
362
- properties[name] = schemaProp;
417
+ properties[fieldKey] = schemaProp;
418
+ if (!name) {
419
+ fieldElements.set(fieldKey, control);
420
+ }
363
421
  if (control.required) {
364
- required.push(name);
422
+ required.push(fieldKey);
365
423
  }
366
424
  }
367
- return { type: "object", properties, required };
425
+ const ariaControls = collectAriaControls(form);
426
+ const processedAriaRadioGroups = /* @__PURE__ */ new Set();
427
+ for (const { el, role, key } of ariaControls) {
428
+ if (properties[key])
429
+ continue;
430
+ if (role === "radio") {
431
+ if (processedAriaRadioGroups.has(key))
432
+ continue;
433
+ processedAriaRadioGroups.add(key);
434
+ }
435
+ const schemaProp = ariaRoleToSchema(el, role);
436
+ schemaProp.title = inferAriaFieldTitle(el);
437
+ const desc = inferAriaFieldDescription(el);
438
+ if (desc)
439
+ schemaProp.description = desc;
440
+ properties[key] = schemaProp;
441
+ fieldElements.set(key, el);
442
+ if (el.getAttribute("aria-required") === "true") {
443
+ required.push(key);
444
+ }
445
+ }
446
+ return { schema: { type: "object", properties, required }, fieldElements };
447
+ }
448
+ function resolveNativeControlFallbackKey(control) {
449
+ const el = control;
450
+ if (el.dataset["webmcpName"])
451
+ return sanitizeName(el.dataset["webmcpName"]);
452
+ if (control.id)
453
+ return sanitizeName(control.id);
454
+ const label = control.getAttribute("aria-label");
455
+ if (label)
456
+ return sanitizeName(label);
457
+ return null;
458
+ }
459
+ function collectAriaControls(form) {
460
+ const selector = ARIA_ROLES_TO_SCAN.map((r) => `[role="${r}"]`).join(", ");
461
+ const results = [];
462
+ for (const el of Array.from(form.querySelectorAll(selector))) {
463
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement)
464
+ continue;
465
+ if (el.getAttribute("aria-hidden") === "true" || el.hidden)
466
+ continue;
467
+ const role = el.getAttribute("role");
468
+ const key = resolveAriaFieldKey(el);
469
+ if (!key)
470
+ continue;
471
+ results.push({ el, role, key });
472
+ }
473
+ return results;
474
+ }
475
+ function resolveAriaFieldKey(el) {
476
+ const htmlEl = el;
477
+ if (htmlEl.dataset?.["webmcpName"])
478
+ return sanitizeName(htmlEl.dataset["webmcpName"]);
479
+ if (el.id)
480
+ return sanitizeName(el.id);
481
+ const label = el.getAttribute("aria-label");
482
+ if (label)
483
+ return sanitizeName(label);
484
+ const labelledById = el.getAttribute("aria-labelledby");
485
+ if (labelledById) {
486
+ const text = document.getElementById(labelledById)?.textContent?.trim();
487
+ if (text)
488
+ return sanitizeName(text);
489
+ }
490
+ return null;
491
+ }
492
+ function inferAriaFieldTitle(el) {
493
+ const htmlEl = el;
494
+ if (htmlEl.dataset?.["webmcpTitle"])
495
+ return htmlEl.dataset["webmcpTitle"];
496
+ const label = el.getAttribute("aria-label");
497
+ if (label)
498
+ return label.trim();
499
+ const labelledById = el.getAttribute("aria-labelledby");
500
+ if (labelledById) {
501
+ const text = document.getElementById(labelledById)?.textContent?.trim();
502
+ if (text)
503
+ return text;
504
+ }
505
+ if (el.id)
506
+ return humanizeName(el.id);
507
+ return "";
508
+ }
509
+ function inferAriaFieldDescription(el) {
510
+ const nativeParamDesc = el.getAttribute("toolparamdescription");
511
+ if (nativeParamDesc)
512
+ return nativeParamDesc.trim();
513
+ const htmlEl = el;
514
+ if (htmlEl.dataset?.["webmcpDescription"])
515
+ return htmlEl.dataset["webmcpDescription"];
516
+ const ariaDesc = el.getAttribute("aria-description");
517
+ if (ariaDesc)
518
+ return ariaDesc;
519
+ const describedById = el.getAttribute("aria-describedby");
520
+ if (describedById) {
521
+ const text = document.getElementById(describedById)?.textContent?.trim();
522
+ if (text)
523
+ return text;
524
+ }
525
+ const placeholder = el.getAttribute("placeholder") ?? el.dataset?.["placeholder"];
526
+ if (placeholder)
527
+ return placeholder.trim();
528
+ return "";
368
529
  }
369
530
  function inferFieldTitle(control) {
370
531
  if ("dataset" in control && control.dataset["webmcpTitle"]) {
@@ -433,7 +594,15 @@ init_registry();
433
594
 
434
595
  // src/interceptor.ts
435
596
  var pendingExecutions = /* @__PURE__ */ new WeakMap();
436
- function buildExecuteHandler(form, config, toolName) {
597
+ var lastParams = /* @__PURE__ */ new WeakMap();
598
+ var formFieldElements = /* @__PURE__ */ new WeakMap();
599
+ var _inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
600
+ var _textareaValueSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
601
+ var _checkedSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set;
602
+ function buildExecuteHandler(form, config, toolName, metadata) {
603
+ if (metadata?.fieldElements) {
604
+ formFieldElements.set(form, metadata.fieldElements);
605
+ }
437
606
  attachSubmitInterceptor(form, toolName);
438
607
  return async (params) => {
439
608
  fillFormFields(form, params);
@@ -456,7 +625,7 @@ function attachSubmitInterceptor(form, toolName) {
456
625
  return;
457
626
  const { resolve } = pending;
458
627
  pendingExecutions.delete(form);
459
- const formData = serializeFormData(form);
628
+ const formData = serializeFormData(form, lastParams.get(form), formFieldElements.get(form));
460
629
  const text = JSON.stringify(formData);
461
630
  const result = { content: [{ type: "text", text }] };
462
631
  if (e.agentInvoked && typeof e.respondWith === "function") {
@@ -469,52 +638,98 @@ function attachSubmitInterceptor(form, toolName) {
469
638
  window.dispatchEvent(new CustomEvent("toolcancel", { detail: { toolName } }));
470
639
  });
471
640
  }
641
+ function setReactValue(el, v) {
642
+ const setter = el instanceof HTMLTextAreaElement ? _textareaValueSetter : _inputValueSetter;
643
+ if (setter) {
644
+ setter.call(el, v);
645
+ } else {
646
+ el.value = v;
647
+ }
648
+ el.dispatchEvent(new Event("input", { bubbles: true }));
649
+ el.dispatchEvent(new Event("change", { bubbles: true }));
650
+ }
651
+ function setReactChecked(el, checked) {
652
+ if (_checkedSetter) {
653
+ _checkedSetter.call(el, checked);
654
+ } else {
655
+ el.checked = checked;
656
+ }
657
+ el.dispatchEvent(new Event("change", { bubbles: true }));
658
+ }
659
+ function findNativeField(form, key) {
660
+ const esc = CSS.escape(key);
661
+ return form.querySelector(`[name="${esc}"]`) ?? form.querySelector(
662
+ `input#${esc}, textarea#${esc}, select#${esc}`
663
+ );
664
+ }
472
665
  function fillFormFields(form, params) {
473
- for (const [name, value] of Object.entries(params)) {
474
- const escapedName = CSS.escape(name);
475
- const input = form.querySelector(
476
- `[name="${escapedName}"]`
477
- );
478
- if (!input)
666
+ lastParams.set(form, params);
667
+ const fieldEls = formFieldElements.get(form);
668
+ for (const [key, value] of Object.entries(params)) {
669
+ const input = findNativeField(form, key);
670
+ if (input) {
671
+ if (input instanceof HTMLInputElement) {
672
+ fillInput(input, form, key, value);
673
+ } else if (input instanceof HTMLTextAreaElement) {
674
+ setReactValue(input, String(value ?? ""));
675
+ } else if (input instanceof HTMLSelectElement) {
676
+ input.value = String(value ?? "");
677
+ input.dispatchEvent(new Event("change", { bubbles: true }));
678
+ }
479
679
  continue;
480
- if (input instanceof HTMLInputElement) {
481
- fillInput(input, form, name, value);
482
- } else if (input instanceof HTMLTextAreaElement) {
483
- input.value = String(value ?? "");
484
- input.dispatchEvent(new Event("input", { bubbles: true }));
485
- input.dispatchEvent(new Event("change", { bubbles: true }));
486
- } else if (input instanceof HTMLSelectElement) {
487
- input.value = String(value ?? "");
488
- input.dispatchEvent(new Event("change", { bubbles: true }));
680
+ }
681
+ const ariaEl = fieldEls?.get(key);
682
+ if (ariaEl) {
683
+ fillAriaField(ariaEl, value);
489
684
  }
490
685
  }
491
686
  }
492
- function fillInput(input, form, name, value) {
687
+ function fillInput(input, form, key, value) {
493
688
  const type = input.type.toLowerCase();
494
689
  if (type === "checkbox") {
495
- input.checked = Boolean(value);
496
- input.dispatchEvent(new Event("change", { bubbles: true }));
690
+ setReactChecked(input, Boolean(value));
497
691
  return;
498
692
  }
499
693
  if (type === "radio") {
500
- const escapedName = CSS.escape(name);
694
+ const esc = CSS.escape(key);
501
695
  const radios = form.querySelectorAll(
502
- `input[type="radio"][name="${escapedName}"]`
696
+ `input[type="radio"][name="${esc}"]`
503
697
  );
504
698
  for (const radio of radios) {
505
699
  if (radio.value === String(value)) {
506
- radio.checked = true;
700
+ if (_checkedSetter) {
701
+ _checkedSetter.call(radio, true);
702
+ } else {
703
+ radio.checked = true;
704
+ }
507
705
  radio.dispatchEvent(new Event("change", { bubbles: true }));
508
706
  break;
509
707
  }
510
708
  }
511
709
  return;
512
710
  }
513
- input.value = String(value ?? "");
514
- input.dispatchEvent(new Event("input", { bubbles: true }));
515
- input.dispatchEvent(new Event("change", { bubbles: true }));
711
+ setReactValue(input, String(value ?? ""));
712
+ }
713
+ function fillAriaField(el, value) {
714
+ const role = el.getAttribute("role");
715
+ if (role === "checkbox" || role === "switch") {
716
+ el.setAttribute("aria-checked", String(Boolean(value)));
717
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
718
+ return;
719
+ }
720
+ if (role === "radio") {
721
+ el.setAttribute("aria-checked", "true");
722
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
723
+ return;
724
+ }
725
+ const htmlEl = el;
726
+ if (htmlEl.isContentEditable) {
727
+ htmlEl.textContent = String(value ?? "");
728
+ }
729
+ el.dispatchEvent(new Event("input", { bubbles: true }));
730
+ el.dispatchEvent(new Event("change", { bubbles: true }));
516
731
  }
517
- function serializeFormData(form) {
732
+ function serializeFormData(form, params, fieldEls) {
518
733
  const result = {};
519
734
  const data = new FormData(form);
520
735
  for (const [key, val] of data.entries()) {
@@ -529,6 +744,27 @@ function serializeFormData(form) {
529
744
  result[key] = val;
530
745
  }
531
746
  }
747
+ if (params) {
748
+ for (const key of Object.keys(params)) {
749
+ if (key in result)
750
+ continue;
751
+ const el = findNativeField(form, key) ?? fieldEls?.get(key) ?? null;
752
+ if (!el)
753
+ continue;
754
+ if (el instanceof HTMLInputElement && el.type === "checkbox") {
755
+ result[key] = el.checked;
756
+ } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
757
+ result[key] = el.value;
758
+ } else {
759
+ const role = el.getAttribute("role");
760
+ if (role === "checkbox" || role === "switch") {
761
+ result[key] = el.getAttribute("aria-checked") === "true";
762
+ } else {
763
+ result[key] = el.textContent?.trim() ?? "";
764
+ }
765
+ }
766
+ }
767
+ }
532
768
  return result;
533
769
  }
534
770
 
@@ -640,8 +876,9 @@ async function registerForm(form, config) {
640
876
  if (config.debug) {
641
877
  warnToolQuality(metadata.name, metadata.description);
642
878
  }
643
- const execute = buildExecuteHandler(form, config, metadata.name);
879
+ const execute = buildExecuteHandler(form, config, metadata.name, metadata);
644
880
  await registerFormTool(form, metadata, execute);
881
+ registeredForms.add(form);
645
882
  if (config.debug) {
646
883
  console.debug(`[auto-webmcp] Registered: ${metadata.name}`, metadata);
647
884
  }
@@ -653,12 +890,43 @@ async function unregisterForm(form, config) {
653
890
  if (!name)
654
891
  return;
655
892
  await unregisterFormTool(form);
893
+ registeredForms.delete(form);
656
894
  if (config.debug) {
657
895
  console.debug(`[auto-webmcp] Unregistered: ${name}`);
658
896
  }
659
897
  emit("form:unregistered", form, name);
660
898
  }
661
899
  var observer = null;
900
+ var registeredForms = /* @__PURE__ */ new WeakSet();
901
+ var reAnalysisTimers = /* @__PURE__ */ new Map();
902
+ var RE_ANALYSIS_DEBOUNCE_MS = 300;
903
+ function isInterestingNode(node) {
904
+ const tag = node.tagName.toLowerCase();
905
+ if (tag === "input" || tag === "textarea" || tag === "select")
906
+ return true;
907
+ const role = node.getAttribute("role");
908
+ if (role && ARIA_ROLES_TO_SCAN.includes(role))
909
+ return true;
910
+ if (node.querySelector("input, textarea, select"))
911
+ return true;
912
+ for (const r of ARIA_ROLES_TO_SCAN) {
913
+ if (node.querySelector(`[role="${r}"]`))
914
+ return true;
915
+ }
916
+ return false;
917
+ }
918
+ function scheduleReAnalysis(form, config) {
919
+ const existing = reAnalysisTimers.get(form);
920
+ if (existing)
921
+ clearTimeout(existing);
922
+ reAnalysisTimers.set(
923
+ form,
924
+ setTimeout(() => {
925
+ reAnalysisTimers.delete(form);
926
+ void registerForm(form, config);
927
+ }, RE_ANALYSIS_DEBOUNCE_MS)
928
+ );
929
+ }
662
930
  function startObserver(config) {
663
931
  if (observer)
664
932
  return;
@@ -667,8 +935,15 @@ function startObserver(config) {
667
935
  for (const node of mutation.addedNodes) {
668
936
  if (!(node instanceof Element))
669
937
  continue;
670
- const forms = node instanceof HTMLFormElement ? [node] : Array.from(node.querySelectorAll("form"));
671
- for (const form of forms) {
938
+ if (node instanceof HTMLFormElement) {
939
+ void registerForm(node, config);
940
+ continue;
941
+ }
942
+ const parentForm = node.closest("form");
943
+ if (parentForm instanceof HTMLFormElement && registeredForms.has(parentForm) && isInterestingNode(node)) {
944
+ scheduleReAnalysis(parentForm, config);
945
+ }
946
+ for (const form of Array.from(node.querySelectorAll("form"))) {
672
947
  void registerForm(form, config);
673
948
  }
674
949
  }