@geometra/mcp 1.21.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;
@@ -818,6 +896,34 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
818
896
  const planned = planFormFill(schema, { valuesById, valuesByLabel });
819
897
  if (!planned.ok)
820
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
+ }
821
927
  if (!includeSteps) {
822
928
  let usedBatch = false;
823
929
  let batchAckResult;
@@ -902,7 +1008,8 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
902
1008
  }
903
1009
  catch (e) {
904
1010
  const message = e instanceof Error ? e.message : String(e);
905
- steps.push({ index, kind: field.kind, ok: false, ...(confidence !== undefined ? { confidence, matchMethod } : {}), 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 } : {}) });
906
1013
  if (stopOnError) {
907
1014
  stoppedAt = index;
908
1015
  break;
@@ -914,6 +1021,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
914
1021
  const invalidRemaining = signals?.invalidFields.length ?? 0;
915
1022
  const successCount = steps.filter(step => step.ok === true).length;
916
1023
  const errorCount = steps.length - successCount;
1024
+ const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
917
1025
  const payload = {
918
1026
  ...connection,
919
1027
  completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
@@ -923,12 +1031,14 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
923
1031
  fieldCount: planned.fields.length,
924
1032
  successCount,
925
1033
  errorCount,
1034
+ ...(skippedCount > 0 ? { skippedPreFilled: skippedCount } : {}),
926
1035
  minConfidence: planned.planned.length > 0
927
1036
  ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
928
1037
  : undefined,
929
1038
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
930
1039
  ...(includeSteps ? { steps } : {}),
931
1040
  ...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
1041
+ ...(verification ? { verification } : {}),
932
1042
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
933
1043
  };
934
1044
  recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
@@ -1865,6 +1975,7 @@ function packedFormSchemas(forms) {
1865
1975
  ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
1866
1976
  ...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
1867
1977
  ...(field.aliases ? { al: field.aliases } : {}),
1978
+ ...(field.format ? { fmt: field.format } : {}),
1868
1979
  ...(field.context ? { x: field.context } : {}),
1869
1980
  })),
1870
1981
  ...(form.sections ? { s: form.sections.map(s => ({ n: s.name, fi: s.fieldIds })) } : {}),
@@ -2458,11 +2569,86 @@ function coerceChoiceValue(field, value) {
2458
2569
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
2459
2570
  return option ?? (value ? 'Yes' : 'No');
2460
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
+ }
2461
2646
  function plannedFillInputsForField(field, value) {
2462
2647
  if (field.kind === 'text') {
2463
2648
  if (typeof value !== 'string')
2464
2649
  return { error: `Field "${field.label}" expects a string value` };
2465
- 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 }];
2466
2652
  }
2467
2653
  if (field.kind === 'choice') {
2468
2654
  const coerced = coerceChoiceValue(field, value);
@@ -3060,6 +3246,71 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3060
3246
  }
3061
3247
  }
3062
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
+ }
3063
3314
  async function executeFillField(session, field, detail) {
3064
3315
  switch (field.kind) {
3065
3316
  case 'text': {
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;
@@ -270,6 +274,12 @@ export interface FormSchemaField {
270
274
  optionCount?: number;
271
275
  options?: string[];
272
276
  aliases?: Record<string, string[]>;
277
+ format?: {
278
+ placeholder?: string;
279
+ pattern?: string;
280
+ inputType?: string;
281
+ autocomplete?: string;
282
+ };
273
283
  context?: NodeContextModel;
274
284
  }
275
285
  export interface FormSchemaSection {
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
  }
@@ -2473,6 +2490,14 @@ function walkNode(element, layout, path) {
2473
2490
  meta.scrollY = semantic.scrollY;
2474
2491
  if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
2475
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;
2476
2501
  const children = [];
2477
2502
  const elementChildren = element.children;
2478
2503
  const layoutChildren = layout.children;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.21.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",