@geometra/mcp 1.26.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/index.js CHANGED
@@ -8,7 +8,7 @@ function cleanupActiveSession() {
8
8
  return;
9
9
  cleanedUp = true;
10
10
  try {
11
- disconnect();
11
+ disconnect({ closeProxy: true });
12
12
  }
13
13
  catch {
14
14
  /* ignore */
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
@@ -8,6 +8,9 @@ let nextSessionId = 0;
8
8
  function generateSessionId() { return `s${++nextSessionId}`; }
9
9
  let reusableProxies = [];
10
10
  const REUSABLE_PROXY_POOL_LIMIT = 6;
11
+ /** Close idle reusable proxies after 5 minutes of inactivity. */
12
+ const REUSABLE_PROXY_IDLE_TTL_MS = 5 * 60 * 1000;
13
+ let idleProxyTimer = null;
11
14
  const trackedReusableProxyChildren = new WeakSet();
12
15
  const ACTION_UPDATE_TIMEOUT_MS = 2000;
13
16
  const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
@@ -66,9 +69,11 @@ function closeReusableProxy(entry) {
66
69
  catch {
67
70
  /* ignore */
68
71
  }
72
+ ensureIdleProxyTimer();
69
73
  return;
70
74
  }
71
75
  void entry.runtime?.close().catch(() => { });
76
+ ensureIdleProxyTimer();
72
77
  }
73
78
  function closeReusableProxies() {
74
79
  clearReusableProxiesIfExited();
@@ -87,6 +92,25 @@ function closeReusableProxies() {
87
92
  void entry.runtime?.close().catch(() => { });
88
93
  }
89
94
  }
95
+ function evictIdleReusableProxies() {
96
+ clearReusableProxiesIfExited();
97
+ const now = Date.now();
98
+ const stale = reusableProxies.filter(entry => !reusableProxyEntryIsActive(entry) && (now - entry.lastUsedAt) > REUSABLE_PROXY_IDLE_TTL_MS);
99
+ for (const entry of stale) {
100
+ closeReusableProxy(entry);
101
+ }
102
+ ensureIdleProxyTimer();
103
+ }
104
+ function ensureIdleProxyTimer() {
105
+ if (reusableProxies.length > 0 && !idleProxyTimer) {
106
+ idleProxyTimer = setInterval(evictIdleReusableProxies, 60_000);
107
+ idleProxyTimer.unref();
108
+ }
109
+ else if (reusableProxies.length === 0 && idleProxyTimer) {
110
+ clearInterval(idleProxyTimer);
111
+ idleProxyTimer = null;
112
+ }
113
+ }
90
114
  function enforceReusableProxyPoolLimit() {
91
115
  clearReusableProxiesIfExited();
92
116
  if (reusableProxies.length <= REUSABLE_PROXY_POOL_LIMIT)
@@ -139,6 +163,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
139
163
  child.once('error', clear);
140
164
  }
141
165
  enforceReusableProxyPoolLimit();
166
+ ensureIdleProxyTimer();
142
167
  return;
143
168
  }
144
169
  reusableProxies.push({
@@ -153,6 +178,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
153
178
  lastUsedAt: now,
154
179
  });
155
180
  enforceReusableProxyPoolLimit();
181
+ ensureIdleProxyTimer();
156
182
  }
157
183
  function rememberReusableProxyPageUrl(session) {
158
184
  const entry = reusableProxyEntryForSession(session);
@@ -552,6 +578,21 @@ export function connect(url, opts) {
552
578
  cachedFormSchemas: new Map(),
553
579
  };
554
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
+ }
555
596
  const timeout = setTimeout(() => {
556
597
  if (!resolved) {
557
598
  resolved = true;
@@ -577,10 +618,12 @@ export function connect(url, opts) {
577
618
  }
578
619
  activeSessions.set(session.id, session);
579
620
  defaultSessionId = session.id;
621
+ startHeartbeat();
580
622
  resolve(session);
581
623
  }
582
624
  });
583
625
  ws.on('message', (data) => {
626
+ lastMessageAt = Date.now();
584
627
  try {
585
628
  const msg = JSON.parse(String(data));
586
629
  if (msg.type === 'frame') {
@@ -600,6 +643,7 @@ export function connect(url, opts) {
600
643
  }
601
644
  activeSessions.set(session.id, session);
602
645
  defaultSessionId = session.id;
646
+ startHeartbeat();
603
647
  resolve(session);
604
648
  }
605
649
  }
@@ -619,6 +663,10 @@ export function connect(url, opts) {
619
663
  }
620
664
  });
621
665
  ws.on('close', () => {
666
+ if (heartbeatInterval) {
667
+ clearInterval(heartbeatInterval);
668
+ heartbeatInterval = null;
669
+ }
622
670
  if (activeSessions.get(session.id) === session) {
623
671
  activeSessions.delete(session.id);
624
672
  if (defaultSessionId === session.id)
@@ -1963,6 +2011,36 @@ function detectCaptcha(root) {
1963
2011
  }
1964
2012
  return found ?? { detected: false };
1965
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
+ }
1966
2044
  export function buildPageModel(root, options) {
1967
2045
  const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
1968
2046
  const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
@@ -2047,9 +2125,11 @@ export function buildPageModel(root, options) {
2047
2125
  lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
2048
2126
  };
2049
2127
  const captcha = detectCaptcha(root);
2128
+ const verification = detectVerification(root);
2050
2129
  return {
2051
2130
  ...baseModel,
2052
2131
  ...(captcha.detected ? { captcha } : {}),
2132
+ ...(verification.detected ? { verification } : {}),
2053
2133
  archetypes: inferPageArchetypes(baseModel),
2054
2134
  };
2055
2135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.26.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",