@geometra/mcp 1.19.23 → 1.21.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.
@@ -281,7 +281,7 @@ describe('batch MCP result shaping', () => {
281
281
  alertCount: 1,
282
282
  invalidCount: 5,
283
283
  });
284
- expect(final.invalidFields.length).toBe(4);
284
+ expect(final.invalidFields.length).toBe(5);
285
285
  expect(final.alerts.length).toBe(1);
286
286
  });
287
287
  it('uses the proxy batch path for fill_fields when step output is omitted', async () => {
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
3
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { z } from 'zod';
5
5
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
6
- import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
6
+ import { connect, connectThroughProxy, disconnect, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
7
7
  function checkedStateInput() {
8
8
  return z
9
9
  .union([z.boolean(), z.literal('mixed')])
@@ -726,8 +726,14 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
726
726
  .optional()
727
727
  .default(false)
728
728
  .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
729
+ resumeFromIndex: z
730
+ .number()
731
+ .int()
732
+ .min(0)
733
+ .optional()
734
+ .describe('Resume a partial fill from this field index (from a previous stoppedAt + 1). Skips already-filled fields.'),
729
735
  detail: detailInput(),
730
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
736
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, detail }) => {
731
737
  const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
732
738
  ? directLabelBatchFields(valuesByLabel)
733
739
  : null;
@@ -755,6 +761,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
755
761
  const wait = await sendFillFields(session, directFields);
756
762
  const ackResult = parseProxyFillAckResult(wait.result);
757
763
  if (ackResult && ackResult.invalidCount === 0) {
764
+ recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
758
765
  return ok(JSON.stringify({
759
766
  ...connection,
760
767
  completed: true,
@@ -771,6 +778,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
771
778
  const afterDirect = sessionA11y(session);
772
779
  const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
773
780
  if (directSignals && directSignals.invalidFields.length === 0) {
781
+ recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
774
782
  return ok(JSON.stringify({
775
783
  ...connection,
776
784
  completed: true,
@@ -866,8 +874,12 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
866
874
  fieldCount: planned.fields.length,
867
875
  successCount: planned.fields.length,
868
876
  errorCount: 0,
877
+ minConfidence: planned.planned.length > 0
878
+ ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
879
+ : undefined,
869
880
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
870
881
  };
882
+ recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
871
883
  if (failOnInvalid && invalidRemaining > 0) {
872
884
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
873
885
  }
@@ -876,17 +888,21 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
876
888
  }
877
889
  const steps = [];
878
890
  let stoppedAt;
879
- for (let index = 0; index < planned.fields.length; index++) {
891
+ const startIndex = resumeFromIndex ?? 0;
892
+ for (let index = startIndex; index < planned.fields.length; index++) {
880
893
  const field = planned.fields[index];
894
+ const plan = planned.planned[index];
895
+ const confidence = plan?.confidence;
896
+ const matchMethod = plan?.matchMethod;
881
897
  try {
882
898
  const result = await executeFillField(session, field, detail);
883
899
  steps.push(detail === 'verbose'
884
- ? { index, kind: field.kind, ok: true, summary: result.summary }
885
- : { index, kind: field.kind, ok: true, ...result.compact });
900
+ ? { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), summary: result.summary }
901
+ : { index, kind: field.kind, ok: true, ...(confidence !== undefined ? { confidence, matchMethod } : {}), ...result.compact });
886
902
  }
887
903
  catch (e) {
888
904
  const message = e instanceof Error ? e.message : String(e);
889
- steps.push({ index, kind: field.kind, ok: false, error: message });
905
+ steps.push({ index, kind: field.kind, ok: false, ...(confidence !== undefined ? { confidence, matchMethod } : {}), error: message });
890
906
  if (stopOnError) {
891
907
  stoppedAt = index;
892
908
  break;
@@ -900,17 +916,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
900
916
  const errorCount = steps.length - successCount;
901
917
  const payload = {
902
918
  ...connection,
903
- completed: stoppedAt === undefined && steps.length === planned.fields.length,
919
+ completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
904
920
  execution: 'sequential',
905
921
  formId: schema.formId,
906
922
  requestedValueCount: entryCount,
907
923
  fieldCount: planned.fields.length,
908
924
  successCount,
909
925
  errorCount,
926
+ minConfidence: planned.planned.length > 0
927
+ ? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
928
+ : undefined,
929
+ ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
910
930
  ...(includeSteps ? { steps } : {}),
911
- ...(stoppedAt !== undefined ? { stoppedAt } : {}),
931
+ ...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
912
932
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
913
933
  };
934
+ recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
914
935
  if (failOnInvalid && invalidRemaining > 0) {
915
936
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
916
937
  }
@@ -952,6 +973,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
952
973
  const connection = autoConnectionPayload(resolved);
953
974
  const steps = [];
954
975
  let stoppedAt;
976
+ const batchStartedAt = performance.now();
955
977
  for (let index = 0; index < actions.length; index++) {
956
978
  const action = actions[index];
957
979
  const startedAt = performance.now();
@@ -964,32 +986,46 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
964
986
  }
965
987
  const result = await executeBatchAction(session, action, detail, includeSteps);
966
988
  const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
989
+ const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
990
+ const stepSignals = includeSteps ? (() => {
991
+ const a = sessionA11y(session);
992
+ if (!a)
993
+ return undefined;
994
+ const s = collectSessionSignals(a);
995
+ return { invalidCount: s.invalidFields.length, alertCount: s.alerts.length, dialogCount: s.dialogCount, busyCount: s.busyCount };
996
+ })() : undefined;
967
997
  steps.push(detail === 'verbose'
968
998
  ? {
969
999
  index,
970
1000
  type: action.type,
971
1001
  ok: true,
972
1002
  elapsedMs,
1003
+ cumulativeMs,
973
1004
  ...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
974
1005
  summary: result.summary,
1006
+ ...(stepSignals ? { signals: stepSignals } : {}),
975
1007
  }
976
1008
  : {
977
1009
  index,
978
1010
  type: action.type,
979
1011
  ok: true,
980
1012
  elapsedMs,
1013
+ cumulativeMs,
981
1014
  ...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
982
1015
  ...result.compact,
1016
+ ...(stepSignals ? { signals: stepSignals } : {}),
983
1017
  });
984
1018
  }
985
1019
  catch (e) {
986
1020
  const message = e instanceof Error ? e.message : String(e);
987
1021
  const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
1022
+ const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
988
1023
  steps.push({
989
1024
  index,
990
1025
  type: action.type,
991
1026
  ok: false,
992
1027
  elapsedMs,
1028
+ cumulativeMs,
993
1029
  ...(uiTreeWaitMs > 0 ? { uiTreeWaitMs: Number(uiTreeWaitMs.toFixed(1)) } : {}),
994
1030
  error: message,
995
1031
  });
@@ -1041,7 +1077,12 @@ Use this first on normal HTML pages when you want to understand the page shape w
1041
1077
  .optional()
1042
1078
  .default(8)
1043
1079
  .describe('Cap returned landmarks/forms/dialogs/lists per kind (default 8).'),
1044
- }, async ({ maxPrimaryActions, maxSectionsPerKind }) => {
1080
+ includeScreenshot: z
1081
+ .boolean()
1082
+ .optional()
1083
+ .default(false)
1084
+ .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
1085
+ }, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot }) => {
1045
1086
  const session = getSession();
1046
1087
  if (!session)
1047
1088
  return err('Not connected. Call geometra_connect first.');
@@ -1049,7 +1090,8 @@ Use this first on normal HTML pages when you want to understand the page shape w
1049
1090
  if (!a11y)
1050
1091
  return err('No UI tree available');
1051
1092
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
1052
- return ok(JSON.stringify(model));
1093
+ const screenshot = includeScreenshot ? await captureScreenshotBase64(session) : undefined;
1094
+ return ok(JSON.stringify(model), screenshot);
1053
1095
  });
1054
1096
  server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
1055
1097
 
@@ -1369,6 +1411,8 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
1369
1411
  .describe('Upload strategy (default auto)'),
1370
1412
  dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
1371
1413
  dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
1414
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated file inputs'),
1415
+ sectionText: z.string().optional().describe('Containing section text to disambiguate repeated file inputs'),
1372
1416
  timeoutMs: z
1373
1417
  .number()
1374
1418
  .int()
@@ -1377,7 +1421,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
1377
1421
  .optional()
1378
1422
  .describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
1379
1423
  detail: detailInput(),
1380
- }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, timeoutMs, detail }) => {
1424
+ }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1381
1425
  const session = getSession();
1382
1426
  if (!session)
1383
1427
  return err('Not connected. Call geometra_connect first.');
@@ -1411,6 +1455,8 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
1411
1455
  openX: z.number().optional().describe('Click to open dropdown'),
1412
1456
  openY: z.number().optional().describe('Click to open dropdown'),
1413
1457
  fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
1458
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated dropdowns with the same label'),
1459
+ sectionText: z.string().optional().describe('Containing section text to disambiguate repeated dropdowns'),
1414
1460
  query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
1415
1461
  timeoutMs: z
1416
1462
  .number()
@@ -1420,7 +1466,7 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
1420
1466
  .optional()
1421
1467
  .describe('Optional action wait timeout for slow dropdowns / remote search results'),
1422
1468
  detail: detailInput(),
1423
- }, async ({ label, exact, openX, openY, fieldLabel, query, timeoutMs, detail }) => {
1469
+ }, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail }) => {
1424
1470
  const session = getSession();
1425
1471
  if (!session)
1426
1472
  return err('Not connected. Call geometra_connect first.');
@@ -1459,6 +1505,8 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
1459
1505
  value: z.string().optional().describe('Option value= attribute'),
1460
1506
  label: z.string().optional().describe('Visible option label (substring match)'),
1461
1507
  index: z.number().int().min(0).optional().describe('Zero-based option index'),
1508
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated selects'),
1509
+ sectionText: z.string().optional().describe('Containing section text to disambiguate repeated selects'),
1462
1510
  timeoutMs: z
1463
1511
  .number()
1464
1512
  .int()
@@ -1467,7 +1515,7 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
1467
1515
  .optional()
1468
1516
  .describe('Optional action wait timeout'),
1469
1517
  detail: detailInput(),
1470
- }, async ({ x, y, value, label, index, timeoutMs, detail }) => {
1518
+ }, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1471
1519
  const session = getSession();
1472
1520
  if (!session)
1473
1521
  return err('Not connected. Call geometra_connect first.');
@@ -1497,6 +1545,8 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
1497
1545
  checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
1498
1546
  exact: z.boolean().optional().describe('Exact label match'),
1499
1547
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
1548
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
1549
+ sectionText: z.string().optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
1500
1550
  timeoutMs: z
1501
1551
  .number()
1502
1552
  .int()
@@ -1505,7 +1555,7 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
1505
1555
  .optional()
1506
1556
  .describe('Optional action wait timeout'),
1507
1557
  detail: detailInput(),
1508
- }, async ({ label, checked, exact, controlType, timeoutMs, detail }) => {
1558
+ }, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1509
1559
  const session = getSession();
1510
1560
  if (!session)
1511
1561
  return err('Not connected. Call geometra_connect first.');
@@ -1557,6 +1607,57 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
1557
1607
  return err(e.message);
1558
1608
  }
1559
1609
  });
1610
+ // ── list items (virtualized list pagination) ─────────────────
1611
+ server.tool('geometra_list_items', `Auto-scroll a virtualized or long list and collect all visible items across scroll positions. Requires \`@geometra/proxy\`.
1612
+
1613
+ Use this for dropdowns, location pickers, or any scrollable list where items are rendered on demand. Scrolls down in steps, collecting new items each time, until no new items appear or the cap is reached.`, {
1614
+ listId: z.string().optional().describe('Stable section id from geometra_page_model (e.g. ls:2.1) to scope item collection'),
1615
+ role: z.string().optional().describe('Role filter for list items (default: listitem)'),
1616
+ scrollX: z.number().optional().describe('X coordinate to position mouse for scrolling (default: viewport center)'),
1617
+ scrollY: z.number().optional().describe('Y coordinate to position mouse for scrolling (default: viewport center)'),
1618
+ maxItems: z.number().int().min(1).max(500).optional().default(100).describe('Cap collected items (default 100)'),
1619
+ maxScrollSteps: z.number().int().min(1).max(50).optional().default(20).describe('Max scroll steps before stopping (default 20)'),
1620
+ scrollDelta: z.number().optional().default(300).describe('Vertical scroll delta per step (default 300)'),
1621
+ }, async ({ listId: _listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta }) => {
1622
+ const session = getSession();
1623
+ if (!session)
1624
+ return err('Not connected. Call geometra_connect first.');
1625
+ const itemRole = role ?? 'listitem';
1626
+ const collected = new Map();
1627
+ const cx = scrollX ?? 400;
1628
+ const cy = scrollY ?? 400;
1629
+ for (let step = 0; step < maxScrollSteps; step++) {
1630
+ const a11y = await sessionA11yWhenReady(session);
1631
+ if (!a11y)
1632
+ break;
1633
+ const items = findNodes(a11y, { role: itemRole });
1634
+ let newCount = 0;
1635
+ for (const item of items) {
1636
+ const id = nodeIdForPath(item.path);
1637
+ if (!collected.has(id)) {
1638
+ collected.set(id, {
1639
+ ...(item.name ? { name: item.name } : {}),
1640
+ ...(item.value ? { value: item.value } : {}),
1641
+ });
1642
+ newCount++;
1643
+ }
1644
+ }
1645
+ if (collected.size >= maxItems || newCount === 0)
1646
+ break;
1647
+ try {
1648
+ await sendWheel(session, scrollDelta, { x: cx, y: cy }, 1_000);
1649
+ }
1650
+ catch {
1651
+ break;
1652
+ }
1653
+ }
1654
+ const items = [...collected.entries()].slice(0, maxItems).map(([id, data]) => ({ id, ...data }));
1655
+ return ok(JSON.stringify({
1656
+ itemCount: items.length,
1657
+ items,
1658
+ truncated: collected.size > maxItems,
1659
+ }));
1660
+ });
1560
1661
  // ── snapshot ─────────────────────────────────────────────────
1561
1662
  server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes plus a few pinned context anchors (for example tab strips / form roots) and root context like URL, scroll, and focus — far fewer tokens than a full nested tree. Use **full** for complete nested a11y + every wrapper when debugging layout. Use **form-required** to list required fields across forms, including offscreen ones, with bounds + visibility + scroll hints for long application flows.
1562
1663
 
@@ -1577,15 +1678,21 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1577
1678
  formId: z.string().optional().describe('Optional form id from geometra_form_schema / geometra_page_model when view=form-required'),
1578
1679
  maxFields: z.number().int().min(1).max(200).optional().default(80).describe('Per-form field cap when view=form-required'),
1579
1680
  includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels when view=form-required'),
1580
- }, async ({ view, maxNodes, formId, maxFields, includeOptions }) => {
1681
+ includeScreenshot: z
1682
+ .boolean()
1683
+ .optional()
1684
+ .default(false)
1685
+ .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
1686
+ }, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot }) => {
1581
1687
  const session = getSession();
1582
1688
  if (!session)
1583
1689
  return err('Not connected. Call geometra_connect first.');
1584
1690
  const a11y = await sessionA11yWhenReady(session);
1585
1691
  if (!a11y)
1586
1692
  return err('No UI tree available');
1693
+ const screenshot = includeScreenshot ? await captureScreenshotBase64(session) : undefined;
1587
1694
  if (view === 'full') {
1588
- return ok(JSON.stringify(a11y, null, 2));
1695
+ return ok(JSON.stringify(a11y, null, 2), screenshot);
1589
1696
  }
1590
1697
  if (view === 'form-required') {
1591
1698
  const payload = {
@@ -1598,7 +1705,7 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1598
1705
  includeContext: 'auto',
1599
1706
  }),
1600
1707
  };
1601
- return ok(JSON.stringify(payload));
1708
+ return ok(JSON.stringify(payload), screenshot);
1602
1709
  }
1603
1710
  const { nodes, truncated, context } = buildCompactUiIndex(a11y, { maxNodes });
1604
1711
  const payload = {
@@ -1608,7 +1715,7 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1608
1715
  nodes,
1609
1716
  truncated,
1610
1717
  };
1611
- return ok(JSON.stringify(payload));
1718
+ return ok(JSON.stringify(payload), screenshot);
1612
1719
  });
1613
1720
  // ── layout ───────────────────────────────────────────────────
1614
1721
  server.tool('geometra_layout', `Get the raw computed layout geometry — the exact {x, y, width, height} for every node in the UI tree. This is the lowest-level view, useful for pixel-precise assertions in tests.
@@ -1619,6 +1726,43 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
1619
1726
  return err('Not connected. Call geometra_connect first.');
1620
1727
  return ok(JSON.stringify(session.layout, null, 2));
1621
1728
  });
1729
+ // ── workflow state ───────────────────────────────────────────
1730
+ 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.
1731
+
1732
+ 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.`, {
1733
+ clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
1734
+ }, async ({ clear }) => {
1735
+ const session = getSession();
1736
+ if (!session)
1737
+ return err('Not connected. Call geometra_connect first.');
1738
+ if (clear) {
1739
+ session.workflowState = undefined;
1740
+ return ok(JSON.stringify({ cleared: true }));
1741
+ }
1742
+ if (!session.workflowState || session.workflowState.pages.length === 0) {
1743
+ return ok(JSON.stringify({
1744
+ pageCount: 0,
1745
+ message: 'No workflow state recorded yet. Fill a form with geometra_fill_form to start tracking.',
1746
+ }));
1747
+ }
1748
+ const state = session.workflowState;
1749
+ const totalFields = state.pages.reduce((sum, p) => sum + p.fieldCount, 0);
1750
+ const totalInvalid = state.pages.reduce((sum, p) => sum + p.invalidCount, 0);
1751
+ return ok(JSON.stringify({
1752
+ pageCount: state.pages.length,
1753
+ totalFieldsFilled: totalFields,
1754
+ totalInvalidRemaining: totalInvalid,
1755
+ elapsedMs: Date.now() - state.startedAt,
1756
+ pages: state.pages.map(p => ({
1757
+ pageUrl: p.pageUrl,
1758
+ ...(p.formId ? { formId: p.formId } : {}),
1759
+ ...(p.formName ? { formName: p.formName } : {}),
1760
+ fieldCount: p.fieldCount,
1761
+ invalidCount: p.invalidCount,
1762
+ filledValues: p.filledValues,
1763
+ })),
1764
+ }));
1765
+ });
1622
1766
  // ── disconnect ───────────────────────────────────────────────
1623
1767
  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.`, {
1624
1768
  closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
@@ -1720,8 +1864,10 @@ function packedFormSchemas(forms) {
1720
1864
  ...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
1721
1865
  ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
1722
1866
  ...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
1867
+ ...(field.aliases ? { al: field.aliases } : {}),
1723
1868
  ...(field.context ? { x: field.context } : {}),
1724
1869
  })),
1870
+ ...(form.sections ? { s: form.sections.map(s => ({ n: s.name, fi: s.fieldIds })) } : {}),
1725
1871
  }));
1726
1872
  }
1727
1873
  function formSchemaResponsePayload(session, opts) {
@@ -1935,6 +2081,12 @@ function collectSessionSignals(root) {
1935
2081
  };
1936
2082
  const seenAlerts = new Set();
1937
2083
  const seenInvalidIds = new Set();
2084
+ const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
2085
+ const captchaTypes = {
2086
+ recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
2087
+ hcaptcha: 'hcaptcha', 'h-captcha': 'hcaptcha',
2088
+ turnstile: 'turnstile', 'cf-turnstile': 'turnstile',
2089
+ };
1938
2090
  function walk(node) {
1939
2091
  if (!signals.focus && node.state?.focused) {
1940
2092
  signals.focus = {
@@ -1967,6 +2119,14 @@ function collectSessionSignals(root) {
1967
2119
  });
1968
2120
  }
1969
2121
  }
2122
+ if (!signals.captchaDetected) {
2123
+ const text = [node.name, node.value].filter(Boolean).join(' ');
2124
+ if (captchaPattern.test(text)) {
2125
+ signals.captchaDetected = true;
2126
+ const match = text.toLowerCase().match(/recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile/);
2127
+ signals.captchaType = match ? (captchaTypes[match[0]] ?? 'unknown') : 'unknown';
2128
+ }
2129
+ }
1970
2130
  for (const child of node.children)
1971
2131
  walk(child);
1972
2132
  }
@@ -2028,6 +2188,7 @@ function sessionSignalsPayload(signals, detail = 'minimal') {
2028
2188
  ? { scroll: { x: signals.scrollX ?? 0, y: signals.scrollY ?? 0 } }
2029
2189
  : {}),
2030
2190
  ...(signals.focus ? { focus: signals.focus } : {}),
2191
+ ...(signals.captchaDetected ? { captchaDetected: true, captchaType: signals.captchaType ?? 'unknown' } : {}),
2031
2192
  dialogCount: signals.dialogCount,
2032
2193
  busyCount: signals.busyCount,
2033
2194
  alertCount: signals.alerts.length,
@@ -2037,12 +2198,10 @@ function sessionSignalsPayload(signals, detail = 'minimal') {
2037
2198
  alerts: signals.alerts,
2038
2199
  invalidFields: signals.invalidFields,
2039
2200
  }
2040
- : detail === 'minimal'
2041
- ? {
2042
- alerts: signals.alerts.slice(0, 2),
2043
- invalidFields: signals.invalidFields.slice(0, 4),
2044
- }
2045
- : {}),
2201
+ : {
2202
+ alerts: signals.alerts.slice(0, 2),
2203
+ invalidFields: signals.invalidFields.slice(0, 6),
2204
+ }),
2046
2205
  };
2047
2206
  }
2048
2207
  function compactTextValue(value, inlineLimit = 48) {
@@ -2348,7 +2507,7 @@ function planFormFill(schema, opts) {
2348
2507
  else
2349
2508
  fieldsByLabel.set(key, [field]);
2350
2509
  }
2351
- const planned = [];
2510
+ const allPlanned = [];
2352
2511
  const seenFieldIds = new Set();
2353
2512
  for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
2354
2513
  const field = fieldById.get(fieldId);
@@ -2357,7 +2516,8 @@ function planFormFill(schema, opts) {
2357
2516
  const next = plannedFillInputsForField(field, value);
2358
2517
  if ('error' in next)
2359
2518
  return { ok: false, error: next.error };
2360
- planned.push(...next);
2519
+ for (const n of next)
2520
+ allPlanned.push({ field: n, confidence: 1.0, matchMethod: 'id' });
2361
2521
  seenFieldIds.add(field.id);
2362
2522
  }
2363
2523
  for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
@@ -2374,10 +2534,14 @@ function planFormFill(schema, opts) {
2374
2534
  const next = plannedFillInputsForField(field, value);
2375
2535
  if ('error' in next)
2376
2536
  return { ok: false, error: next.error };
2377
- planned.push(...next);
2537
+ const isExact = field.label === label;
2538
+ const confidence = isExact ? 0.95 : 0.8;
2539
+ const matchMethod = isExact ? 'label-exact' : 'label-normalized';
2540
+ for (const n of next)
2541
+ allPlanned.push({ field: n, confidence, matchMethod });
2378
2542
  seenFieldIds.add(field.id);
2379
2543
  }
2380
- return { ok: true, fields: planned };
2544
+ return { ok: true, fields: allPlanned.map(p => p.field), planned: allPlanned };
2381
2545
  }
2382
2546
  function isResolvedFillFieldInput(field) {
2383
2547
  if (field.kind === 'toggle')
@@ -2502,12 +2666,14 @@ function parseProxyFillAckResult(value) {
2502
2666
  typeof candidate.busyCount !== 'number') {
2503
2667
  return undefined;
2504
2668
  }
2669
+ const invalidFields = Array.isArray(candidate.invalidFields) ? candidate.invalidFields : undefined;
2505
2670
  return {
2506
2671
  ...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
2507
2672
  invalidCount: candidate.invalidCount,
2508
2673
  alertCount: candidate.alertCount,
2509
2674
  dialogCount: candidate.dialogCount,
2510
2675
  busyCount: candidate.busyCount,
2676
+ ...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
2511
2677
  };
2512
2678
  }
2513
2679
  function directLabelBatchFields(valuesByLabel) {
@@ -2968,8 +3134,49 @@ async function executeFillField(session, field, detail) {
2968
3134
  }
2969
3135
  }
2970
3136
  }
2971
- function ok(text) {
2972
- return { content: [{ type: 'text', text }] };
3137
+ function ok(text, screenshot) {
3138
+ const content = [
3139
+ { type: 'text', text },
3140
+ ];
3141
+ if (screenshot) {
3142
+ content.push({ type: 'image', data: screenshot, mimeType: 'image/png' });
3143
+ }
3144
+ return { content };
3145
+ }
3146
+ function recordWorkflowFill(session, formId, formName, valuesById, valuesByLabel, invalidCount, fieldCount) {
3147
+ if (!session.workflowState) {
3148
+ session.workflowState = { pages: [], startedAt: Date.now() };
3149
+ }
3150
+ const filledValues = {};
3151
+ for (const [k, v] of Object.entries(valuesById ?? {})) {
3152
+ if (typeof v === 'string' || typeof v === 'boolean')
3153
+ filledValues[k] = v;
3154
+ }
3155
+ for (const [k, v] of Object.entries(valuesByLabel ?? {})) {
3156
+ if (typeof v === 'string' || typeof v === 'boolean')
3157
+ filledValues[k] = v;
3158
+ }
3159
+ const a11y = sessionA11y(session);
3160
+ const pageUrl = a11y?.meta?.pageUrl ?? session.url;
3161
+ session.workflowState.pages.push({
3162
+ pageUrl,
3163
+ formId,
3164
+ formName,
3165
+ filledValues,
3166
+ filledAt: Date.now(),
3167
+ fieldCount,
3168
+ invalidCount,
3169
+ });
3170
+ }
3171
+ async function captureScreenshotBase64(session) {
3172
+ try {
3173
+ const wait = await sendScreenshot(session);
3174
+ const result = wait.result;
3175
+ return typeof result?.screenshot === 'string' ? result.screenshot : undefined;
3176
+ }
3177
+ catch {
3178
+ return undefined;
3179
+ }
2973
3180
  }
2974
3181
  function err(text) {
2975
3182
  return { content: [{ type: 'text', text }], isError: true };
package/dist/session.d.ts CHANGED
@@ -121,6 +121,11 @@ export interface PageDialogModel extends PageSectionSummaryBase {
121
121
  export interface PageListModel extends PageSectionSummaryBase {
122
122
  itemCount: number;
123
123
  }
124
+ export interface CaptchaDetection {
125
+ detected: boolean;
126
+ type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
127
+ hint?: string;
128
+ }
124
129
  export interface PageModel {
125
130
  viewport: {
126
131
  width: number;
@@ -134,6 +139,7 @@ export interface PageModel {
134
139
  listCount: number;
135
140
  focusableCount: number;
136
141
  };
142
+ captcha?: CaptchaDetection;
137
143
  primaryActions: PagePrimaryAction[];
138
144
  landmarks: PageLandmark[];
139
145
  forms: PageFormModel[];
@@ -263,8 +269,13 @@ export interface FormSchemaField {
263
269
  values?: string[];
264
270
  optionCount?: number;
265
271
  options?: string[];
272
+ aliases?: Record<string, string[]>;
266
273
  context?: NodeContextModel;
267
274
  }
275
+ export interface FormSchemaSection {
276
+ name: string;
277
+ fieldIds: string[];
278
+ }
268
279
  export interface FormSchemaModel {
269
280
  formId: string;
270
281
  name?: string;
@@ -272,6 +283,7 @@ export interface FormSchemaModel {
272
283
  requiredCount: number;
273
284
  invalidCount: number;
274
285
  fields: FormSchemaField[];
286
+ sections?: FormSchemaSection[];
275
287
  }
276
288
  export interface FormRequiredFieldSnapshot extends FormSchemaField {
277
289
  bounds: {
@@ -337,6 +349,19 @@ export interface UiDelta {
337
349
  viewport?: UiViewportChange;
338
350
  focus?: UiFocusChange;
339
351
  }
352
+ export interface WorkflowPageEntry {
353
+ pageUrl: string;
354
+ formId?: string;
355
+ formName?: string;
356
+ filledValues: Record<string, string | boolean>;
357
+ filledAt: number;
358
+ fieldCount: number;
359
+ invalidCount: number;
360
+ }
361
+ export interface WorkflowState {
362
+ pages: WorkflowPageEntry[];
363
+ startedAt: number;
364
+ }
340
365
  export interface Session {
341
366
  ws: WebSocket;
342
367
  layout: Record<string, unknown> | null;
@@ -354,6 +379,7 @@ export interface Session {
354
379
  revision: number;
355
380
  forms: FormSchemaModel[];
356
381
  }>;
382
+ workflowState?: WorkflowState;
357
383
  }
358
384
  export interface SessionConnectTrace {
359
385
  mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
@@ -531,6 +557,8 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
531
557
  x?: number;
532
558
  y?: number;
533
559
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
560
+ /** Capture a viewport screenshot from the proxy (base64 PNG). */
561
+ export declare function sendScreenshot(session: Session, timeoutMs?: number): Promise<UpdateWaitResult>;
534
562
  /** Navigate the proxy page to a new URL while keeping the browser process alive. */
535
563
  export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
536
564
  /**
@@ -555,10 +583,6 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
555
583
  };
556
584
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
557
585
  export declare function nodeContextForNode(root: A11yNode, node: A11yNode): NodeContextModel | undefined;
558
- /**
559
- * Build a summary-first, stable-ID webpage model from the accessibility tree.
560
- * Use {@link expandPageSection} to fetch details for a specific section on demand.
561
- */
562
586
  export declare function buildPageModel(root: A11yNode, options?: {
563
587
  maxPrimaryActions?: number;
564
588
  maxSectionsPerKind?: number;
package/dist/session.js CHANGED
@@ -875,6 +875,10 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
875
875
  ...(opts?.y !== undefined ? { y: opts.y } : {}),
876
876
  }, timeoutMs);
877
877
  }
878
+ /** Capture a viewport screenshot from the proxy (base64 PNG). */
879
+ export function sendScreenshot(session, timeoutMs = 10_000) {
880
+ return sendAndWaitForUpdate(session, { type: 'screenshot' }, timeoutMs);
881
+ }
878
882
  /** Navigate the proxy page to a new URL while keeping the browser process alive. */
879
883
  export function sendNavigate(session, url, timeoutMs = 15_000) {
880
884
  return sendAndWaitForUpdate(session, {
@@ -1566,6 +1570,33 @@ function groupedChoiceForNode(root, formNode, seed) {
1566
1570
  const controls = sortByBounds(collectDescendants(formNode, matchesPrompt));
1567
1571
  return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
1568
1572
  }
1573
+ const SEMANTIC_ALIAS_GROUPS = [
1574
+ { triggers: ['yes', 'true'], aliases: ['yes', 'true', 'agree', 'agreed', 'accept', 'accepted', 'consent', 'acknowledge', 'opt in'] },
1575
+ { triggers: ['no', 'false'], aliases: ['no', 'false', 'decline', 'declined', 'disagree', 'deny', 'opt out', 'prefer not'] },
1576
+ { triggers: ['decline'], aliases: ['decline', 'prefer not', 'opt out', 'do not'] },
1577
+ { triggers: ['atx', 'austin'], aliases: ['atx', 'austin', 'austin tx', 'austin texas'] },
1578
+ { triggers: ['nyc', 'new york'], aliases: ['nyc', 'new york', 'new york ny'] },
1579
+ { triggers: ['sf', 'san francisco'], aliases: ['sf', 'san francisco', 'san francisco ca'] },
1580
+ { triggers: ['la', 'los angeles'], aliases: ['la', 'los angeles', 'los angeles ca'] },
1581
+ { triggers: ['dc', 'washington dc'], aliases: ['dc', 'washington dc', 'washington d c'] },
1582
+ { triggers: ['us', 'usa', 'united states'], aliases: ['us', 'usa', 'united states'] },
1583
+ ];
1584
+ function computeOptionAliases(options) {
1585
+ const result = {};
1586
+ for (const option of options) {
1587
+ const normalized = option.toLowerCase().trim();
1588
+ for (const group of SEMANTIC_ALIAS_GROUPS) {
1589
+ if (group.triggers.some(t => normalized === t || normalized.includes(t))) {
1590
+ const relevant = group.aliases.filter(a => a !== normalized);
1591
+ if (relevant.length > 0) {
1592
+ result[option] = relevant;
1593
+ break;
1594
+ }
1595
+ }
1596
+ }
1597
+ }
1598
+ return Object.keys(result).length > 0 ? result : undefined;
1599
+ }
1569
1600
  function simpleSchemaField(root, node) {
1570
1601
  const context = nodeContextForNode(root, node);
1571
1602
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
@@ -1617,6 +1648,7 @@ function groupedSchemaField(root, grouped) {
1617
1648
  }),
1618
1649
  optionCount: options.length,
1619
1650
  options,
1651
+ ...(computeOptionAliases(options) ? { aliases: computeOptionAliases(options) } : {}),
1620
1652
  ...(compactSchemaContext(context, grouped.prompt) ? { context: compactSchemaContext(context, grouped.prompt) } : {}),
1621
1653
  };
1622
1654
  }
@@ -1637,6 +1669,41 @@ function toggleSchemaField(root, node) {
1637
1669
  ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
1638
1670
  };
1639
1671
  }
1672
+ function detectFormSections(formNode, fields) {
1673
+ const sectionRoles = new Set(['group', 'region']);
1674
+ const sectionNodes = [];
1675
+ function walk(node) {
1676
+ if (sectionRoles.has(node.role) && node.name && node.path.length > formNode.path.length) {
1677
+ sectionNodes.push({ name: node.name, path: node.path });
1678
+ }
1679
+ for (const child of node.children)
1680
+ walk(child);
1681
+ }
1682
+ walk(formNode);
1683
+ if (sectionNodes.length === 0)
1684
+ return [];
1685
+ const fieldIdToPath = new Map();
1686
+ for (const field of fields) {
1687
+ const parsed = parseFormFieldId(field.id);
1688
+ if (parsed)
1689
+ fieldIdToPath.set(field.id, parsed);
1690
+ }
1691
+ const sections = [];
1692
+ for (const sec of sectionNodes) {
1693
+ const fieldIds = fields
1694
+ .filter(field => {
1695
+ const fieldPath = fieldIdToPath.get(field.id);
1696
+ if (!fieldPath || fieldPath.length <= sec.path.length)
1697
+ return false;
1698
+ return sec.path.every((v, i) => fieldPath[i] === v);
1699
+ })
1700
+ .map(field => field.id);
1701
+ if (fieldIds.length > 0) {
1702
+ sections.push({ name: sec.name, fieldIds });
1703
+ }
1704
+ }
1705
+ return sections;
1706
+ }
1640
1707
  function buildFormSchemaForNode(root, formNode, options) {
1641
1708
  const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
1642
1709
  candidate.role === 'combobox' ||
@@ -1690,6 +1757,10 @@ function buildFormSchemaForNode(root, formNode, options) {
1690
1757
  requiredCount: compactFields.filter(field => field.required).length,
1691
1758
  invalidCount: compactFields.filter(field => field.invalid).length,
1692
1759
  fields: pageFields,
1760
+ ...(() => {
1761
+ const sections = detectFormSections(formNode, pageFields);
1762
+ return sections.length > 0 ? { sections } : {};
1763
+ })(),
1693
1764
  };
1694
1765
  }
1695
1766
  function trimSchemaFieldContexts(fields) {
@@ -1712,8 +1783,10 @@ function presentFormSchemaFields(fields, options) {
1712
1783
  const next = { ...field };
1713
1784
  if (booleanChoice)
1714
1785
  next.booleanChoice = true;
1715
- if (!includeOptions)
1786
+ if (!includeOptions) {
1716
1787
  delete next.options;
1788
+ delete next.aliases;
1789
+ }
1717
1790
  if (includeContext === 'none') {
1718
1791
  delete next.context;
1719
1792
  return next;
@@ -1772,6 +1845,47 @@ function inferPageArchetypes(model) {
1772
1845
  * Build a summary-first, stable-ID webpage model from the accessibility tree.
1773
1846
  * Use {@link expandPageSection} to fetch details for a specific section on demand.
1774
1847
  */
1848
+ const CAPTCHA_PATTERNS = [
1849
+ { pattern: /recaptcha|g-recaptcha/i, type: 'recaptcha', hint: 'Google reCAPTCHA detected' },
1850
+ { pattern: /hcaptcha|h-captcha/i, type: 'hcaptcha', hint: 'hCaptcha detected' },
1851
+ { pattern: /turnstile|cf-turnstile/i, type: 'turnstile', hint: 'Cloudflare Turnstile detected' },
1852
+ { pattern: /cloudflare.*challenge|challenge-platform|just a moment/i, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page detected' },
1853
+ { pattern: /captcha/i, type: 'unknown', hint: 'CAPTCHA element detected' },
1854
+ ];
1855
+ function detectCaptcha(root) {
1856
+ let found;
1857
+ function walk(node) {
1858
+ if (found)
1859
+ return;
1860
+ const text = [node.name, node.value, node.role].filter(Boolean).join(' ');
1861
+ for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
1862
+ if (pattern.test(text)) {
1863
+ found = { detected: true, type, hint };
1864
+ return;
1865
+ }
1866
+ }
1867
+ // Check iframe placeholders (common for reCAPTCHA/hCaptcha/Turnstile)
1868
+ if (node.meta && typeof node.meta.frameUrl === 'string') {
1869
+ const frameUrl = node.meta.frameUrl;
1870
+ for (const { pattern, type, hint } of CAPTCHA_PATTERNS) {
1871
+ if (pattern.test(frameUrl)) {
1872
+ found = { detected: true, type, hint };
1873
+ return;
1874
+ }
1875
+ }
1876
+ }
1877
+ for (const child of node.children)
1878
+ walk(child);
1879
+ }
1880
+ walk(root);
1881
+ // Also check the page URL for Cloudflare challenge pages
1882
+ if (!found && root.meta?.pageUrl) {
1883
+ if (/challenge|cdn-cgi.*challenge/i.test(root.meta.pageUrl)) {
1884
+ found = { detected: true, type: 'cloudflare-challenge', hint: 'Cloudflare challenge page URL detected' };
1885
+ }
1886
+ }
1887
+ return found ?? { detected: false };
1888
+ }
1775
1889
  export function buildPageModel(root, options) {
1776
1890
  const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
1777
1891
  const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
@@ -1855,8 +1969,10 @@ export function buildPageModel(root, options) {
1855
1969
  dialogs: sortByBounds(dialogs).slice(0, maxSectionsPerKind),
1856
1970
  lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
1857
1971
  };
1972
+ const captcha = detectCaptcha(root);
1858
1973
  return {
1859
1974
  ...baseModel,
1975
+ ...(captcha.detected ? { captcha } : {}),
1860
1976
  archetypes: inferPageArchetypes(baseModel),
1861
1977
  };
1862
1978
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.23",
3
+ "version": "1.21.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",