@geometra/mcp 1.20.0 → 1.22.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/dist/server.js CHANGED
@@ -617,6 +617,73 @@ Equivalent to \`geometra_wait_for\` with \`present: false\` and \`text\` set to
617
617
  return err(waited.error);
618
618
  return ok(waitConditionSuccessLine(waited.value));
619
619
  });
620
+ server.tool('geometra_wait_for_navigation', `Wait until the page URL changes and the new page's DOM stabilizes. Use this after clicking "Next", "Submit", or "Continue" on multi-page forms.
621
+
622
+ Captures the current URL, then polls until the URL changes and a stable UI tree is available. Returns the new page URL and a page model summary.`, {
623
+ timeoutMs: z
624
+ .number()
625
+ .int()
626
+ .min(500)
627
+ .max(60_000)
628
+ .optional()
629
+ .default(10_000)
630
+ .describe('Max time to wait for navigation + DOM stabilization (default 10s)'),
631
+ expectedUrl: z.string().optional().describe('Optional URL substring to match — keeps waiting if the URL changes to something else (e.g. intermediate redirects)'),
632
+ }, async ({ timeoutMs, expectedUrl }) => {
633
+ const session = getSession();
634
+ if (!session)
635
+ return err('Not connected. Call geometra_connect first.');
636
+ const beforeA11y = sessionA11y(session);
637
+ const beforeUrl = beforeA11y?.meta?.pageUrl ?? session.url;
638
+ const startedAt = performance.now();
639
+ let navigated = false;
640
+ while (performance.now() - startedAt < timeoutMs) {
641
+ await waitForUiCondition(session, () => {
642
+ const a = sessionA11y(session);
643
+ if (!a?.meta?.pageUrl)
644
+ return false;
645
+ const currentUrl = a.meta.pageUrl;
646
+ if (currentUrl === beforeUrl)
647
+ return false;
648
+ if (expectedUrl && !currentUrl.includes(expectedUrl))
649
+ return false;
650
+ return true;
651
+ }, Math.max(500, timeoutMs - (performance.now() - startedAt)));
652
+ const afterA11y = sessionA11y(session);
653
+ const afterUrl = afterA11y?.meta?.pageUrl;
654
+ if (afterUrl && afterUrl !== beforeUrl) {
655
+ if (!expectedUrl || afterUrl.includes(expectedUrl)) {
656
+ navigated = true;
657
+ break;
658
+ }
659
+ }
660
+ }
661
+ const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
662
+ const afterA11y = await sessionA11yWhenReady(session);
663
+ const afterUrl = afterA11y?.meta?.pageUrl;
664
+ if (!navigated) {
665
+ return err(JSON.stringify({
666
+ navigated: false,
667
+ beforeUrl,
668
+ currentUrl: afterUrl,
669
+ elapsedMs,
670
+ message: `URL did not change within ${timeoutMs}ms`,
671
+ }));
672
+ }
673
+ const model = afterA11y ? buildPageModel(afterA11y) : undefined;
674
+ return ok(JSON.stringify({
675
+ navigated: true,
676
+ beforeUrl,
677
+ afterUrl,
678
+ elapsedMs,
679
+ ...(model ? {
680
+ summary: model.summary,
681
+ archetypes: model.archetypes,
682
+ formCount: model.forms.length,
683
+ ...(model.captcha ? { captcha: model.captcha } : {}),
684
+ } : {}),
685
+ }));
686
+ });
620
687
  server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
621
688
 
622
689
  Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads. When \`fieldId\` from \`geometra_form_schema\` is present, MCP can resolve the current label server-side so you do not need to duplicate \`fieldLabel\` / \`label\` for text, choice, and toggle fields.`, {
@@ -676,7 +743,8 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
676
743
  }
677
744
  catch (e) {
678
745
  const message = e instanceof Error ? e.message : String(e);
679
- steps.push({ index, kind: field.kind, ok: false, error: message });
746
+ const suggestion = isResolvedFillFieldInput(field) ? suggestRecovery(field, message) : undefined;
747
+ steps.push({ index, kind: field.kind, ok: false, error: message, ...(suggestion ? { suggestion } : {}) });
680
748
  if (stopOnError) {
681
749
  stoppedAt = index;
682
750
  break;
@@ -732,8 +800,18 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
732
800
  .min(0)
733
801
  .optional()
734
802
  .describe('Resume a partial fill from this field index (from a previous stoppedAt + 1). Skips already-filled fields.'),
803
+ verifyFills: z
804
+ .boolean()
805
+ .optional()
806
+ .default(false)
807
+ .describe('After filling, read back each field value and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a verification array to the response.'),
808
+ skipPreFilled: z
809
+ .boolean()
810
+ .optional()
811
+ .default(false)
812
+ .describe('Skip fields that already contain a matching value. Avoids overwriting good data from resume parsing or previous fills.'),
735
813
  detail: detailInput(),
736
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, detail }) => {
814
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, detail }) => {
737
815
  const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
738
816
  ? directLabelBatchFields(valuesByLabel)
739
817
  : null;
@@ -761,6 +839,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
761
839
  const wait = await sendFillFields(session, directFields);
762
840
  const ackResult = parseProxyFillAckResult(wait.result);
763
841
  if (ackResult && ackResult.invalidCount === 0) {
842
+ recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
764
843
  return ok(JSON.stringify({
765
844
  ...connection,
766
845
  completed: true,
@@ -777,6 +856,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
777
856
  const afterDirect = sessionA11y(session);
778
857
  const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
779
858
  if (directSignals && directSignals.invalidFields.length === 0) {
859
+ recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
780
860
  return ok(JSON.stringify({
781
861
  ...connection,
782
862
  completed: true,
@@ -816,6 +896,34 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
816
896
  const planned = planFormFill(schema, { valuesById, valuesByLabel });
817
897
  if (!planned.ok)
818
898
  return err(planned.error);
899
+ let skippedCount = 0;
900
+ if (skipPreFilled) {
901
+ const schemaFieldById = new Map(schema.fields.map(f => [f.id, f]));
902
+ const indicesToRemove = new Set();
903
+ for (let i = 0; i < planned.planned.length; i++) {
904
+ const p = planned.planned[i];
905
+ const fieldId = p.field.fieldId;
906
+ if (!fieldId)
907
+ continue;
908
+ const schemaField = schemaFieldById.get(fieldId);
909
+ if (!schemaField?.value)
910
+ continue;
911
+ const currentVal = schemaField.value.toLowerCase().trim();
912
+ let intendedVal;
913
+ if (p.field.kind === 'text')
914
+ intendedVal = p.field.value;
915
+ else if (p.field.kind === 'choice')
916
+ intendedVal = p.field.value;
917
+ if (intendedVal && currentVal === intendedVal.toLowerCase().trim()) {
918
+ indicesToRemove.add(i);
919
+ }
920
+ }
921
+ if (indicesToRemove.size > 0) {
922
+ skippedCount = indicesToRemove.size;
923
+ planned.planned = planned.planned.filter((_, i) => !indicesToRemove.has(i));
924
+ planned.fields = planned.planned.map(p => p.field);
925
+ }
926
+ }
819
927
  if (!includeSteps) {
820
928
  let usedBatch = false;
821
929
  let batchAckResult;
@@ -872,8 +980,12 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
872
980
  fieldCount: planned.fields.length,
873
981
  successCount: planned.fields.length,
874
982
  errorCount: 0,
983
+ minConfidence: planned.planned.length > 0
984
+ ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
985
+ : undefined,
875
986
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
876
987
  };
988
+ recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
877
989
  if (failOnInvalid && invalidRemaining > 0) {
878
990
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
879
991
  }
@@ -885,15 +997,19 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
885
997
  const startIndex = resumeFromIndex ?? 0;
886
998
  for (let index = startIndex; index < planned.fields.length; index++) {
887
999
  const field = planned.fields[index];
1000
+ const plan = planned.planned[index];
1001
+ const confidence = plan?.confidence;
1002
+ const matchMethod = plan?.matchMethod;
888
1003
  try {
889
1004
  const result = await executeFillField(session, field, detail);
890
1005
  steps.push(detail === 'verbose'
891
- ? { index, kind: field.kind, ok: true, summary: result.summary }
892
- : { index, kind: field.kind, ok: true, ...result.compact });
1006
+ ? { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), summary: result.summary }
1007
+ : { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), ...result.compact });
893
1008
  }
894
1009
  catch (e) {
895
1010
  const message = e instanceof Error ? e.message : String(e);
896
- steps.push({ index, kind: field.kind, ok: false, error: message });
1011
+ const suggestion = suggestRecovery(field, message);
1012
+ steps.push({ index, kind: field.kind, ok: false, ...(confidence !== undefined ? { confidence, matchMethod } : {}), error: message, ...(suggestion ? { suggestion } : {}) });
897
1013
  if (stopOnError) {
898
1014
  stoppedAt = index;
899
1015
  break;
@@ -905,6 +1021,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
905
1021
  const invalidRemaining = signals?.invalidFields.length ?? 0;
906
1022
  const successCount = steps.filter(step => step.ok === true).length;
907
1023
  const errorCount = steps.length - successCount;
1024
+ const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
908
1025
  const payload = {
909
1026
  ...connection,
910
1027
  completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
@@ -914,11 +1031,17 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
914
1031
  fieldCount: planned.fields.length,
915
1032
  successCount,
916
1033
  errorCount,
1034
+ ...(skippedCount > 0 ? { skippedPreFilled: skippedCount } : {}),
1035
+ minConfidence: planned.planned.length > 0
1036
+ ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
1037
+ : undefined,
917
1038
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
918
1039
  ...(includeSteps ? { steps } : {}),
919
1040
  ...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
1041
+ ...(verification ? { verification } : {}),
920
1042
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
921
1043
  };
1044
+ recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
922
1045
  if (failOnInvalid && invalidRemaining > 0) {
923
1046
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
924
1047
  }
@@ -1713,6 +1836,43 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
1713
1836
  return err('Not connected. Call geometra_connect first.');
1714
1837
  return ok(JSON.stringify(session.layout, null, 2));
1715
1838
  });
1839
+ // ── workflow state ───────────────────────────────────────────
1840
+ server.tool('geometra_workflow_state', `Get the accumulated workflow state across page navigations. Shows which pages/forms have been filled, what values were submitted, and the fill status per page.
1841
+
1842
+ Use this after navigating to a new page in a multi-step flow (e.g. job applications) to understand what has been completed so far. Pass \`clear: true\` to reset the workflow state.`, {
1843
+ clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
1844
+ }, async ({ clear }) => {
1845
+ const session = getSession();
1846
+ if (!session)
1847
+ return err('Not connected. Call geometra_connect first.');
1848
+ if (clear) {
1849
+ session.workflowState = undefined;
1850
+ return ok(JSON.stringify({ cleared: true }));
1851
+ }
1852
+ if (!session.workflowState || session.workflowState.pages.length === 0) {
1853
+ return ok(JSON.stringify({
1854
+ pageCount: 0,
1855
+ message: 'No workflow state recorded yet. Fill a form with geometra_fill_form to start tracking.',
1856
+ }));
1857
+ }
1858
+ const state = session.workflowState;
1859
+ const totalFields = state.pages.reduce((sum, p) => sum + p.fieldCount, 0);
1860
+ const totalInvalid = state.pages.reduce((sum, p) => sum + p.invalidCount, 0);
1861
+ return ok(JSON.stringify({
1862
+ pageCount: state.pages.length,
1863
+ totalFieldsFilled: totalFields,
1864
+ totalInvalidRemaining: totalInvalid,
1865
+ elapsedMs: Date.now() - state.startedAt,
1866
+ pages: state.pages.map(p => ({
1867
+ pageUrl: p.pageUrl,
1868
+ ...(p.formId ? { formId: p.formId } : {}),
1869
+ ...(p.formName ? { formName: p.formName } : {}),
1870
+ fieldCount: p.fieldCount,
1871
+ invalidCount: p.invalidCount,
1872
+ filledValues: p.filledValues,
1873
+ })),
1874
+ }));
1875
+ });
1716
1876
  // ── disconnect ───────────────────────────────────────────────
1717
1877
  server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep compatible browsers alive by default so the next geometra_connect can reuse them quickly; pass closeBrowser=true to fully tear down the warm proxy/browser pool.`, {
1718
1878
  closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
@@ -1815,6 +1975,7 @@ function packedFormSchemas(forms) {
1815
1975
  ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
1816
1976
  ...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
1817
1977
  ...(field.aliases ? { al: field.aliases } : {}),
1978
+ ...(field.format ? { fmt: field.format } : {}),
1818
1979
  ...(field.context ? { x: field.context } : {}),
1819
1980
  })),
1820
1981
  ...(form.sections ? { s: form.sections.map(s => ({ n: s.name, fi: s.fieldIds })) } : {}),
@@ -2031,6 +2192,12 @@ function collectSessionSignals(root) {
2031
2192
  };
2032
2193
  const seenAlerts = new Set();
2033
2194
  const seenInvalidIds = new Set();
2195
+ const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
2196
+ const captchaTypes = {
2197
+ recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
2198
+ hcaptcha: 'hcaptcha', 'h-captcha': 'hcaptcha',
2199
+ turnstile: 'turnstile', 'cf-turnstile': 'turnstile',
2200
+ };
2034
2201
  function walk(node) {
2035
2202
  if (!signals.focus && node.state?.focused) {
2036
2203
  signals.focus = {
@@ -2063,6 +2230,14 @@ function collectSessionSignals(root) {
2063
2230
  });
2064
2231
  }
2065
2232
  }
2233
+ if (!signals.captchaDetected) {
2234
+ const text = [node.name, node.value].filter(Boolean).join(' ');
2235
+ if (captchaPattern.test(text)) {
2236
+ signals.captchaDetected = true;
2237
+ const match = text.toLowerCase().match(/recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile/);
2238
+ signals.captchaType = match ? (captchaTypes[match[0]] ?? 'unknown') : 'unknown';
2239
+ }
2240
+ }
2066
2241
  for (const child of node.children)
2067
2242
  walk(child);
2068
2243
  }
@@ -2124,6 +2299,7 @@ function sessionSignalsPayload(signals, detail = 'minimal') {
2124
2299
  ? { scroll: { x: signals.scrollX ?? 0, y: signals.scrollY ?? 0 } }
2125
2300
  : {}),
2126
2301
  ...(signals.focus ? { focus: signals.focus } : {}),
2302
+ ...(signals.captchaDetected ? { captchaDetected: true, captchaType: signals.captchaType ?? 'unknown' } : {}),
2127
2303
  dialogCount: signals.dialogCount,
2128
2304
  busyCount: signals.busyCount,
2129
2305
  alertCount: signals.alerts.length,
@@ -2393,11 +2569,86 @@ function coerceChoiceValue(field, value) {
2393
2569
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
2394
2570
  return option ?? (value ? 'Yes' : 'No');
2395
2571
  }
2572
+ /**
2573
+ * Normalize a text value based on field format hints (placeholder, inputType, pattern).
2574
+ * Handles common date and phone format conversions.
2575
+ */
2576
+ function normalizeFieldValue(value, format) {
2577
+ if (!format)
2578
+ return value;
2579
+ // Date normalization: detect ISO dates and convert to placeholder format
2580
+ if (format.inputType === 'date')
2581
+ return value; // native date inputs handle ISO fine
2582
+ const isDateLike = /^\d{4}-\d{2}-\d{2}$/.test(value) || /^\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}$/.test(value);
2583
+ if (isDateLike && format.placeholder) {
2584
+ const parsed = parseDateLoose(value);
2585
+ if (parsed) {
2586
+ const ph = format.placeholder.toLowerCase();
2587
+ if (ph.includes('mm/dd/yyyy') || ph.includes('mm/dd/yy')) {
2588
+ return `${pad2(parsed.month)}/${pad2(parsed.day)}/${parsed.year}`;
2589
+ }
2590
+ if (ph.includes('dd/mm/yyyy') || ph.includes('dd/mm/yy')) {
2591
+ return `${pad2(parsed.day)}/${pad2(parsed.month)}/${parsed.year}`;
2592
+ }
2593
+ if (ph.includes('yyyy-mm-dd')) {
2594
+ return `${parsed.year}-${pad2(parsed.month)}-${pad2(parsed.day)}`;
2595
+ }
2596
+ if (ph.includes('mm-dd-yyyy')) {
2597
+ return `${pad2(parsed.month)}-${pad2(parsed.day)}-${parsed.year}`;
2598
+ }
2599
+ }
2600
+ }
2601
+ // Phone normalization
2602
+ if (format.inputType === 'tel' || format.autocomplete?.includes('tel')) {
2603
+ const digits = value.replace(/\D/g, '');
2604
+ if (digits.length === 10 && format.placeholder) {
2605
+ const ph = format.placeholder;
2606
+ if (ph.includes('(') && ph.includes(')')) {
2607
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
2608
+ }
2609
+ if (ph.includes('-') && !ph.includes('(')) {
2610
+ return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
2611
+ }
2612
+ if (ph.includes('.')) {
2613
+ return `${digits.slice(0, 3)}.${digits.slice(3, 6)}.${digits.slice(6)}`;
2614
+ }
2615
+ }
2616
+ if (digits.length === 11 && digits.startsWith('1') && format.placeholder) {
2617
+ const core = digits.slice(1);
2618
+ const ph = format.placeholder;
2619
+ if (ph.startsWith('+1') || ph.startsWith('1')) {
2620
+ return `+1 (${core.slice(0, 3)}) ${core.slice(3, 6)}-${core.slice(6)}`;
2621
+ }
2622
+ }
2623
+ }
2624
+ return value;
2625
+ }
2626
+ function parseDateLoose(value) {
2627
+ // YYYY-MM-DD
2628
+ const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
2629
+ if (iso)
2630
+ return { year: +iso[1], month: +iso[2], day: +iso[3] };
2631
+ // MM/DD/YYYY or MM-DD-YYYY or MM.DD.YYYY
2632
+ const mdy = value.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{4})$/);
2633
+ if (mdy)
2634
+ return { year: +mdy[3], month: +mdy[1], day: +mdy[2] };
2635
+ // MM/DD/YY
2636
+ const mdy2 = value.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2})$/);
2637
+ if (mdy2) {
2638
+ const yr = +mdy2[3];
2639
+ return { year: yr < 50 ? 2000 + yr : 1900 + yr, month: +mdy2[1], day: +mdy2[2] };
2640
+ }
2641
+ return null;
2642
+ }
2643
+ function pad2(n) {
2644
+ return n < 10 ? `0${n}` : String(n);
2645
+ }
2396
2646
  function plannedFillInputsForField(field, value) {
2397
2647
  if (field.kind === 'text') {
2398
2648
  if (typeof value !== 'string')
2399
2649
  return { error: `Field "${field.label}" expects a string value` };
2400
- return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value }];
2650
+ const normalized = normalizeFieldValue(value, field.format);
2651
+ return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value: normalized }];
2401
2652
  }
2402
2653
  if (field.kind === 'choice') {
2403
2654
  const coerced = coerceChoiceValue(field, value);
@@ -2442,7 +2693,7 @@ function planFormFill(schema, opts) {
2442
2693
  else
2443
2694
  fieldsByLabel.set(key, [field]);
2444
2695
  }
2445
- const planned = [];
2696
+ const allPlanned = [];
2446
2697
  const seenFieldIds = new Set();
2447
2698
  for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
2448
2699
  const field = fieldById.get(fieldId);
@@ -2451,7 +2702,8 @@ function planFormFill(schema, opts) {
2451
2702
  const next = plannedFillInputsForField(field, value);
2452
2703
  if ('error' in next)
2453
2704
  return { ok: false, error: next.error };
2454
- planned.push(...next);
2705
+ for (const n of next)
2706
+ allPlanned.push({ field: n, confidence: 1.0, matchMethod: 'id' });
2455
2707
  seenFieldIds.add(field.id);
2456
2708
  }
2457
2709
  for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
@@ -2468,10 +2720,14 @@ function planFormFill(schema, opts) {
2468
2720
  const next = plannedFillInputsForField(field, value);
2469
2721
  if ('error' in next)
2470
2722
  return { ok: false, error: next.error };
2471
- planned.push(...next);
2723
+ const isExact = field.label === label;
2724
+ const confidence = isExact ? 0.95 : 0.8;
2725
+ const matchMethod = isExact ? 'label-exact' : 'label-normalized';
2726
+ for (const n of next)
2727
+ allPlanned.push({ field: n, confidence, matchMethod });
2472
2728
  seenFieldIds.add(field.id);
2473
2729
  }
2474
- return { ok: true, fields: planned };
2730
+ return { ok: true, fields: allPlanned.map(p => p.field), planned: allPlanned };
2475
2731
  }
2476
2732
  function isResolvedFillFieldInput(field) {
2477
2733
  if (field.kind === 'toggle')
@@ -2990,6 +3246,71 @@ async function executeBatchAction(session, action, detail, includeSteps) {
2990
3246
  }
2991
3247
  }
2992
3248
  }
3249
+ function suggestRecovery(field, error) {
3250
+ const lowerError = error.toLowerCase();
3251
+ if (field.kind === 'choice') {
3252
+ if (lowerError.includes('no option') || lowerError.includes('not found') || lowerError.includes('visible')) {
3253
+ return `Try geometra_pick_listbox_option with fieldLabel="${field.fieldLabel}" and label="${field.value}" for custom dropdowns.`;
3254
+ }
3255
+ if (lowerError.includes('timeout')) {
3256
+ return `Dropdown may load options asynchronously. Retry with a higher timeoutMs, or use geometra_pick_listbox_option with a query parameter.`;
3257
+ }
3258
+ }
3259
+ if (field.kind === 'text') {
3260
+ if (lowerError.includes('format') || lowerError.includes('pattern') || lowerError.includes('invalid')) {
3261
+ return `Field may require a specific format (e.g. MM/DD/YYYY for dates, (555) 123-4567 for phone). Check the field's placeholder or aria-describedby for format hints.`;
3262
+ }
3263
+ if (lowerError.includes('not found') || lowerError.includes('no match')) {
3264
+ return `Field label "${field.fieldLabel}" may have changed after page update. Refresh with geometra_form_schema and retry.`;
3265
+ }
3266
+ if (lowerError.includes('timeout')) {
3267
+ return `Field may be disabled or obscured. Check geometra_snapshot for the field's current state.`;
3268
+ }
3269
+ }
3270
+ if (field.kind === 'toggle') {
3271
+ if (lowerError.includes('not found') || lowerError.includes('no match')) {
3272
+ return `Checkbox/radio "${field.label}" not found. The label may be dynamic — try geometra_set_checked with exact=false.`;
3273
+ }
3274
+ }
3275
+ if (field.kind === 'file') {
3276
+ if (lowerError.includes('no file') || lowerError.includes('not found')) {
3277
+ return `File input not found by label. Try geometra_upload_files with strategy="hidden" or provide click coordinates.`;
3278
+ }
3279
+ }
3280
+ if (lowerError.includes('timeout')) {
3281
+ return `Action timed out. The page may still be loading. Try geometra_wait_for with a loading indicator, then retry.`;
3282
+ }
3283
+ return undefined;
3284
+ }
3285
+ function verifyFormFills(session, planned) {
3286
+ const a11y = sessionA11y(session);
3287
+ if (!a11y)
3288
+ return { verified: 0, mismatches: [] };
3289
+ const mismatches = [];
3290
+ let verified = 0;
3291
+ for (const p of planned) {
3292
+ if (p.field.kind === 'toggle' || p.field.kind === 'file')
3293
+ continue;
3294
+ const label = p.field.fieldLabel;
3295
+ const expected = p.field.kind === 'text' ? p.field.value : p.field.value;
3296
+ const matches = [
3297
+ ...findNodes(a11y, { name: label, role: 'textbox' }),
3298
+ ...findNodes(a11y, { name: label, role: 'combobox' }),
3299
+ ];
3300
+ const match = matches[0];
3301
+ const actual = match?.value?.trim();
3302
+ if (!actual || !expected) {
3303
+ mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
3304
+ }
3305
+ else if (actual.toLowerCase() !== expected.toLowerCase()) {
3306
+ mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
3307
+ }
3308
+ else {
3309
+ verified++;
3310
+ }
3311
+ }
3312
+ return { verified, mismatches };
3313
+ }
2993
3314
  async function executeFillField(session, field, detail) {
2994
3315
  switch (field.kind) {
2995
3316
  case 'text': {
@@ -3073,6 +3394,31 @@ function ok(text, screenshot) {
3073
3394
  }
3074
3395
  return { content };
3075
3396
  }
3397
+ function recordWorkflowFill(session, formId, formName, valuesById, valuesByLabel, invalidCount, fieldCount) {
3398
+ if (!session.workflowState) {
3399
+ session.workflowState = { pages: [], startedAt: Date.now() };
3400
+ }
3401
+ const filledValues = {};
3402
+ for (const [k, v] of Object.entries(valuesById ?? {})) {
3403
+ if (typeof v === 'string' || typeof v === 'boolean')
3404
+ filledValues[k] = v;
3405
+ }
3406
+ for (const [k, v] of Object.entries(valuesByLabel ?? {})) {
3407
+ if (typeof v === 'string' || typeof v === 'boolean')
3408
+ filledValues[k] = v;
3409
+ }
3410
+ const a11y = sessionA11y(session);
3411
+ const pageUrl = a11y?.meta?.pageUrl ?? session.url;
3412
+ session.workflowState.pages.push({
3413
+ pageUrl,
3414
+ formId,
3415
+ formName,
3416
+ filledValues,
3417
+ filledAt: Date.now(),
3418
+ fieldCount,
3419
+ invalidCount,
3420
+ });
3421
+ }
3076
3422
  async function captureScreenshotBase64(session) {
3077
3423
  try {
3078
3424
  const wait = await sendScreenshot(session);
package/dist/session.d.ts CHANGED
@@ -29,6 +29,10 @@ export interface A11yNode {
29
29
  scrollX?: number;
30
30
  scrollY?: number;
31
31
  controlTag?: string;
32
+ placeholder?: string;
33
+ inputPattern?: string;
34
+ inputType?: string;
35
+ autocomplete?: string;
32
36
  };
33
37
  bounds: {
34
38
  x: number;
@@ -121,6 +125,11 @@ export interface PageDialogModel extends PageSectionSummaryBase {
121
125
  export interface PageListModel extends PageSectionSummaryBase {
122
126
  itemCount: number;
123
127
  }
128
+ export interface CaptchaDetection {
129
+ detected: boolean;
130
+ type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
131
+ hint?: string;
132
+ }
124
133
  export interface PageModel {
125
134
  viewport: {
126
135
  width: number;
@@ -134,6 +143,7 @@ export interface PageModel {
134
143
  listCount: number;
135
144
  focusableCount: number;
136
145
  };
146
+ captcha?: CaptchaDetection;
137
147
  primaryActions: PagePrimaryAction[];
138
148
  landmarks: PageLandmark[];
139
149
  forms: PageFormModel[];
@@ -264,6 +274,12 @@ export interface FormSchemaField {
264
274
  optionCount?: number;
265
275
  options?: string[];
266
276
  aliases?: Record<string, string[]>;
277
+ format?: {
278
+ placeholder?: string;
279
+ pattern?: string;
280
+ inputType?: string;
281
+ autocomplete?: string;
282
+ };
267
283
  context?: NodeContextModel;
268
284
  }
269
285
  export interface FormSchemaSection {
@@ -343,6 +359,19 @@ export interface UiDelta {
343
359
  viewport?: UiViewportChange;
344
360
  focus?: UiFocusChange;
345
361
  }
362
+ export interface WorkflowPageEntry {
363
+ pageUrl: string;
364
+ formId?: string;
365
+ formName?: string;
366
+ filledValues: Record<string, string | boolean>;
367
+ filledAt: number;
368
+ fieldCount: number;
369
+ invalidCount: number;
370
+ }
371
+ export interface WorkflowState {
372
+ pages: WorkflowPageEntry[];
373
+ startedAt: number;
374
+ }
346
375
  export interface Session {
347
376
  ws: WebSocket;
348
377
  layout: Record<string, unknown> | null;
@@ -360,6 +389,7 @@ export interface Session {
360
389
  revision: number;
361
390
  forms: FormSchemaModel[];
362
391
  }>;
392
+ workflowState?: WorkflowState;
363
393
  }
364
394
  export interface SessionConnectTrace {
365
395
  mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
@@ -563,10 +593,6 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
563
593
  };
564
594
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
565
595
  export declare function nodeContextForNode(root: A11yNode, node: A11yNode): NodeContextModel | undefined;
566
- /**
567
- * Build a summary-first, stable-ID webpage model from the accessibility tree.
568
- * Use {@link expandPageSection} to fetch details for a specific section on demand.
569
- */
570
596
  export declare function buildPageModel(root: A11yNode, options?: {
571
597
  maxPrimaryActions?: number;
572
598
  maxSectionsPerKind?: number;
package/dist/session.js CHANGED
@@ -1597,6 +1597,21 @@ function computeOptionAliases(options) {
1597
1597
  }
1598
1598
  return Object.keys(result).length > 0 ? result : undefined;
1599
1599
  }
1600
+ function buildFieldFormat(node) {
1601
+ const m = node.meta;
1602
+ if (!m)
1603
+ return undefined;
1604
+ const format = {};
1605
+ if (m.placeholder)
1606
+ format.placeholder = m.placeholder;
1607
+ if (m.inputPattern)
1608
+ format.pattern = m.inputPattern;
1609
+ if (m.inputType)
1610
+ format.inputType = m.inputType;
1611
+ if (m.autocomplete)
1612
+ format.autocomplete = m.autocomplete;
1613
+ return Object.keys(format).length > 0 ? format : undefined;
1614
+ }
1600
1615
  function simpleSchemaField(root, node) {
1601
1616
  const context = nodeContextForNode(root, node);
1602
1617
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
@@ -1607,6 +1622,7 @@ function simpleSchemaField(root, node) {
1607
1622
  ? 'select'
1608
1623
  : 'listbox'
1609
1624
  : undefined;
1625
+ const format = buildFieldFormat(node);
1610
1626
  return {
1611
1627
  id: formFieldIdForPath(node.path),
1612
1628
  kind: node.role === 'combobox' ? 'choice' : 'text',
@@ -1615,6 +1631,7 @@ function simpleSchemaField(root, node) {
1615
1631
  ...(node.state?.required ? { required: true } : {}),
1616
1632
  ...(node.state?.invalid ? { invalid: true } : {}),
1617
1633
  ...compactSchemaValue(node.value, 72),
1634
+ ...(format ? { format } : {}),
1618
1635
  ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
1619
1636
  };
1620
1637
  }
@@ -1845,6 +1862,47 @@ function inferPageArchetypes(model) {
1845
1862
  * Build a summary-first, stable-ID webpage model from the accessibility tree.
1846
1863
  * Use {@link expandPageSection} to fetch details for a specific section on demand.
1847
1864
  */
1865
+ const CAPTCHA_PATTERNS = [
1866
+ { pattern: /recaptcha|g-recaptcha/i, type: 'recaptcha', hint: 'Google reCAPTCHA detected' },
1867
+ { pattern: /hcaptcha|h-captcha/i, type: 'hcaptcha', hint: 'hCaptcha detected' },
1868
+ { pattern: /turnstile|cf-turnstile/i, type: 'turnstile', hint: 'Cloudflare Turnstile detected' },
1869
+ { pattern: /cloudflare.*challenge|challenge-platform|just a moment/i, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page detected' },
1870
+ { pattern: /captcha/i, type: 'unknown', hint: 'CAPTCHA element detected' },
1871
+ ];
1872
+ function detectCaptcha(root) {
1873
+ let found;
1874
+ function walk(node) {
1875
+ if (found)
1876
+ return;
1877
+ const text = [node.name, node.value, node.role].filter(Boolean).join(' ');
1878
+ for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
1879
+ if (pattern.test(text)) {
1880
+ found = { detected: true, type, hint };
1881
+ return;
1882
+ }
1883
+ }
1884
+ // Check iframe placeholders (common for reCAPTCHA/hCaptcha/Turnstile)
1885
+ if (node.meta && typeof node.meta.frameUrl === 'string') {
1886
+ const frameUrl = node.meta.frameUrl;
1887
+ for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
1888
+ if (pattern.test(frameUrl)) {
1889
+ found = { detected: true, type, hint };
1890
+ return;
1891
+ }
1892
+ }
1893
+ }
1894
+ for (const child of node.children)
1895
+ walk(child);
1896
+ }
1897
+ walk(root);
1898
+ // Also check the page URL for Cloudflare challenge pages
1899
+ if (!found && root.meta?.pageUrl) {
1900
+ if (/challenge|cdn-cgi.*challenge/i.test(root.meta.pageUrl)) {
1901
+ found = { detected: true, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page URL detected' };
1902
+ }
1903
+ }
1904
+ return found ?? { detected: false };
1905
+ }
1848
1906
  export function buildPageModel(root, options) {
1849
1907
  const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
1850
1908
  const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
@@ -1928,8 +1986,10 @@ export function buildPageModel(root, options) {
1928
1986
  dialogs: sortByBounds(dialogs).slice(0, maxSectionsPerKind),
1929
1987
  lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
1930
1988
  };
1989
+ const captcha = detectCaptcha(root);
1931
1990
  return {
1932
1991
  ...baseModel,
1992
+ ...(captcha.detected ? { captcha } : {}),
1933
1993
  archetypes: inferPageArchetypes(baseModel),
1934
1994
  };
1935
1995
  }
@@ -2430,6 +2490,14 @@ function walkNode(element, layout, path) {
2430
2490
  meta.scrollY = semantic.scrollY;
2431
2491
  if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
2432
2492
  meta.controlTag = semantic.tag;
2493
+ if (typeof semantic?.placeholder === 'string')
2494
+ meta.placeholder = semantic.placeholder;
2495
+ if (typeof semantic?.inputPattern === 'string')
2496
+ meta.inputPattern = semantic.inputPattern;
2497
+ if (typeof semantic?.inputType === 'string')
2498
+ meta.inputType = semantic.inputType;
2499
+ if (typeof semantic?.autocomplete === 'string')
2500
+ meta.autocomplete = semantic.autocomplete;
2433
2501
  const children = [];
2434
2502
  const elementChildren = element.children;
2435
2503
  const layoutChildren = layout.children;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.20.0",
3
+ "version": "1.22.0",
4
4
  "description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
5
5
  "license": "MIT",
6
6
  "type": "module",