@geometra/mcp 1.27.0 → 1.28.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.
@@ -967,7 +967,7 @@ describe('query and reveal tools', () => {
967
967
  });
968
968
  });
969
969
  it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
970
- const handler = getToolHandler('geometra_reveal');
970
+ const handler = getToolHandler('geometra_scroll_to');
971
971
  mockState.currentA11yRoot = node('group', undefined, {
972
972
  bounds: { x: 0, y: 0, width: 1280, height: 800 },
973
973
  meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
@@ -1024,7 +1024,7 @@ describe('query and reveal tools', () => {
1024
1024
  });
1025
1025
  });
1026
1026
  it('auto-scales reveal steps for tall forms when maxSteps is omitted', async () => {
1027
- const handler = getToolHandler('geometra_reveal');
1027
+ const handler = getToolHandler('geometra_scroll_to');
1028
1028
  let scrollY = 0;
1029
1029
  const setTree = () => {
1030
1030
  mockState.currentA11yRoot = node('group', undefined, {
package/dist/server.js CHANGED
@@ -329,8 +329,8 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
329
329
  returnForms: z
330
330
  .boolean()
331
331
  .optional()
332
- .default(false)
333
- .describe('Include compact form schema discovery in the connect response so form flows can start in one turn.'),
332
+ .default(true)
333
+ .describe('Include compact form schema discovery in the connect response (default true). Set false to skip form discovery for non-form workflows.'),
334
334
  returnPageModel: z
335
335
  .boolean()
336
336
  .optional()
@@ -753,6 +753,17 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
753
753
  }
754
754
  catch (e) {
755
755
  const message = e instanceof Error ? e.message : String(e);
756
+ // Retry once for transient selection failures
757
+ if (message.includes('selection_not_confirmed')) {
758
+ try {
759
+ const retryResult = await executeFillField(session, field, detail);
760
+ steps.push(detail === 'verbose'
761
+ ? { index, kind: field.kind, ok: true, summary: retryResult.summary, retried: true }
762
+ : { index, kind: field.kind, ok: true, ...retryResult.compact, retried: true });
763
+ continue;
764
+ }
765
+ catch { /* fall through to error handling */ }
766
+ }
756
767
  const suggestion = isResolvedFillFieldInput(field) ? suggestRecovery(field, message) : undefined;
757
768
  steps.push({ index, kind: field.kind, ok: false, error: message, ...(suggestion ? { suggestion } : {}) });
758
769
  if (stopOnError) {
@@ -901,6 +912,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
901
912
  });
902
913
  if (schemas.length === 0)
903
914
  return err('No forms found in the current UI');
915
+ const entryUrl = afterConnect?.meta?.pageUrl;
904
916
  const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
905
917
  if (!resolution.ok)
906
918
  return err(resolution.error);
@@ -1053,6 +1065,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1053
1065
  ...(verification ? { verification } : {}),
1054
1066
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1055
1067
  };
1068
+ // Detect page navigation after fill (e.g. multi-page form submission)
1069
+ const afterUrl = after?.meta?.pageUrl;
1070
+ if (afterUrl && entryUrl && afterUrl !== entryUrl) {
1071
+ ;
1072
+ payload.navigated = true;
1073
+ payload.afterUrl = afterUrl;
1074
+ const model = after ? buildPageModel(after) : undefined;
1075
+ if (model) {
1076
+ ;
1077
+ payload.pageModel = summarizePageModel(model);
1078
+ if (model.captcha)
1079
+ payload.captcha = model.captcha;
1080
+ if (model.verification)
1081
+ payload.verification = model.verification;
1082
+ }
1083
+ }
1056
1084
  recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
1057
1085
  if (failOnInvalid && invalidRemaining > 0) {
1058
1086
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -1307,9 +1335,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
1307
1335
  return err(`No expandable section found for id ${id}`);
1308
1336
  return ok(JSON.stringify(detail));
1309
1337
  });
1310
- server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
1338
+ server.tool('geometra_scroll_to', `Scroll the page until a matching element is visible. Use this to reach off-screen elements like Submit buttons at the bottom of long forms, or fields below the fold.
1311
1339
 
1312
- Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
1340
+ This is the preferred approach for scrolling — no need to guess pixel offsets or wheel deltas. Accepts the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label. Auto-scales scroll steps based on distance.`, {
1313
1341
  ...nodeFilterShape(),
1314
1342
  index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
1315
1343
  fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
package/dist/session.d.ts CHANGED
@@ -130,6 +130,11 @@ export interface CaptchaDetection {
130
130
  type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
131
131
  hint?: string;
132
132
  }
133
+ export interface VerificationDetection {
134
+ detected: boolean;
135
+ type?: 'email_code' | 'sms_code' | 'security_question' | 'unknown';
136
+ hint?: string;
137
+ }
133
138
  export interface PageModel {
134
139
  viewport: {
135
140
  width: number;
@@ -144,6 +149,7 @@ export interface PageModel {
144
149
  focusableCount: number;
145
150
  };
146
151
  captcha?: CaptchaDetection;
152
+ verification?: VerificationDetection;
147
153
  primaryActions: PagePrimaryAction[];
148
154
  landmarks: PageLandmark[];
149
155
  forms: PageFormModel[];
package/dist/session.js CHANGED
@@ -578,6 +578,21 @@ export function connect(url, opts) {
578
578
  cachedFormSchemas: new Map(),
579
579
  };
580
580
  let resolved = false;
581
+ let lastMessageAt = Date.now();
582
+ let heartbeatInterval = null;
583
+ function startHeartbeat() {
584
+ if (heartbeatInterval)
585
+ return;
586
+ heartbeatInterval = setInterval(() => {
587
+ if (Date.now() - lastMessageAt > 30_000) {
588
+ try {
589
+ ws.close();
590
+ }
591
+ catch { /* ignore */ }
592
+ }
593
+ }, 30_000);
594
+ heartbeatInterval.unref();
595
+ }
581
596
  const timeout = setTimeout(() => {
582
597
  if (!resolved) {
583
598
  resolved = true;
@@ -603,10 +618,12 @@ export function connect(url, opts) {
603
618
  }
604
619
  activeSessions.set(session.id, session);
605
620
  defaultSessionId = session.id;
621
+ startHeartbeat();
606
622
  resolve(session);
607
623
  }
608
624
  });
609
625
  ws.on('message', (data) => {
626
+ lastMessageAt = Date.now();
610
627
  try {
611
628
  const msg = JSON.parse(String(data));
612
629
  if (msg.type === 'frame') {
@@ -626,6 +643,7 @@ export function connect(url, opts) {
626
643
  }
627
644
  activeSessions.set(session.id, session);
628
645
  defaultSessionId = session.id;
646
+ startHeartbeat();
629
647
  resolve(session);
630
648
  }
631
649
  }
@@ -645,6 +663,10 @@ export function connect(url, opts) {
645
663
  }
646
664
  });
647
665
  ws.on('close', () => {
666
+ if (heartbeatInterval) {
667
+ clearInterval(heartbeatInterval);
668
+ heartbeatInterval = null;
669
+ }
648
670
  if (activeSessions.get(session.id) === session) {
649
671
  activeSessions.delete(session.id);
650
672
  if (defaultSessionId === session.id)
@@ -1989,6 +2011,36 @@ function detectCaptcha(root) {
1989
2011
  }
1990
2012
  return found ?? { detected: false };
1991
2013
  }
2014
+ const VERIFICATION_FIELD_PATTERN = /verif|security.?code|confirm.*(code|email)|one.?time|otp|2fa|mfa|passcode/i;
2015
+ const VERIFICATION_CONTEXT_PATTERN = /sent.*(code|email|sms|text)|enter.*code|check.your.(email|phone|inbox)|we.sent|verification/i;
2016
+ function detectVerification(root) {
2017
+ let found;
2018
+ function walk(node) {
2019
+ if (found)
2020
+ return;
2021
+ const name = node.name ?? '';
2022
+ if (node.role === 'textbox' && VERIFICATION_FIELD_PATTERN.test(name)) {
2023
+ const type = /email|inbox/i.test(name) ? 'email_code'
2024
+ : /sms|phone|text/i.test(name) ? 'sms_code'
2025
+ : /security.?question/i.test(name) ? 'security_question'
2026
+ : 'unknown';
2027
+ found = { detected: true, type, hint: `Verification field: "${name}"` };
2028
+ return;
2029
+ }
2030
+ const text = [name, node.value].filter(Boolean).join(' ');
2031
+ if (VERIFICATION_CONTEXT_PATTERN.test(text)) {
2032
+ const type = /email|inbox/i.test(text) ? 'email_code'
2033
+ : /sms|phone|text.message/i.test(text) ? 'sms_code'
2034
+ : 'unknown';
2035
+ found = { detected: true, type, hint: text.slice(0, 120) };
2036
+ return;
2037
+ }
2038
+ for (const child of node.children)
2039
+ walk(child);
2040
+ }
2041
+ walk(root);
2042
+ return found ?? { detected: false };
2043
+ }
1992
2044
  export function buildPageModel(root, options) {
1993
2045
  const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
1994
2046
  const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
@@ -2073,9 +2125,11 @@ export function buildPageModel(root, options) {
2073
2125
  lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
2074
2126
  };
2075
2127
  const captcha = detectCaptcha(root);
2128
+ const verification = detectVerification(root);
2076
2129
  return {
2077
2130
  ...baseModel,
2078
2131
  ...(captcha.detected ? { captcha } : {}),
2132
+ ...(verification.detected ? { verification } : {}),
2079
2133
  archetypes: inferPageArchetypes(baseModel),
2080
2134
  };
2081
2135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.27.0",
3
+ "version": "1.28.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",