@geometra/mcp 1.19.20 → 1.19.23

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/session.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { performance } from 'node:perf_hooks';
1
2
  import WebSocket from 'ws';
2
3
  import { spawnGeometraProxy, startEmbeddedGeometraProxy } from './proxy-spawn.js';
3
4
  let activeSession = null;
@@ -42,6 +43,11 @@ function clearReusableProxiesIfExited() {
42
43
  function touchReusableProxy(entry) {
43
44
  entry.lastUsedAt = Date.now();
44
45
  }
46
+ function updateReusableProxySnapshotState(entry, session) {
47
+ if (session.tree && session.layout) {
48
+ entry.snapshotReady = true;
49
+ }
50
+ }
45
51
  function closeReusableProxy(entry) {
46
52
  reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
47
53
  if (entry.child) {
@@ -96,6 +102,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
96
102
  existing.width = opts.width ?? 1280;
97
103
  existing.height = opts.height ?? 720;
98
104
  existing.pageUrl = opts.pageUrl;
105
+ existing.snapshotReady = opts.snapshotReady ?? existing.snapshotReady;
99
106
  existing.lastUsedAt = now;
100
107
  return;
101
108
  }
@@ -109,6 +116,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
109
116
  width: opts.width ?? 1280,
110
117
  height: opts.height ?? 720,
111
118
  pageUrl: opts.pageUrl,
119
+ snapshotReady: opts.snapshotReady === true,
112
120
  lastUsedAt: now,
113
121
  };
114
122
  reusableProxies.push(entry);
@@ -132,19 +140,21 @@ function setReusableProxy(proxy, wsUrl, opts) {
132
140
  width: opts.width ?? 1280,
133
141
  height: opts.height ?? 720,
134
142
  pageUrl: opts.pageUrl,
143
+ snapshotReady: opts.snapshotReady === true,
135
144
  lastUsedAt: now,
136
145
  });
137
146
  enforceReusableProxyPoolLimit();
138
147
  }
139
148
  function rememberReusableProxyPageUrl(session) {
140
- const pageUrl = session.cachedA11y?.meta?.pageUrl;
141
- if (!pageUrl)
142
- return;
143
149
  const entry = reusableProxyEntryForSession(session);
144
- if (entry) {
150
+ if (!entry)
151
+ return;
152
+ updateReusableProxySnapshotState(entry, session);
153
+ const pageUrl = session.cachedA11y?.meta?.pageUrl;
154
+ if (pageUrl) {
145
155
  entry.pageUrl = pageUrl;
146
- touchReusableProxy(entry);
147
156
  }
157
+ touchReusableProxy(entry);
148
158
  }
149
159
  function shutdownPreviousSession(opts) {
150
160
  const prev = activeSession;
@@ -199,6 +209,22 @@ function shutdownPreviousSession(opts) {
199
209
  function formatUnknownError(err) {
200
210
  return err instanceof Error ? err.message : String(err);
201
211
  }
212
+ function reusableProxyMatchesOptions(entry, options) {
213
+ return (entry.pageUrl === options.pageUrl &&
214
+ entry.headless === (options.headless === true) &&
215
+ entry.slowMo === (options.slowMo ?? 0) &&
216
+ entry.width === (options.width ?? 1280) &&
217
+ entry.height === (options.height ?? 720));
218
+ }
219
+ function findExactReusableProxy(options) {
220
+ clearReusableProxiesIfExited();
221
+ return reusableProxies
222
+ .filter(entry => reusableProxyMatchesOptions(entry, options))
223
+ .sort((a, b) => {
224
+ const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
225
+ return activeBonus || b.lastUsedAt - a.lastUsedAt;
226
+ })[0];
227
+ }
202
228
  function findReusableProxy(options) {
203
229
  clearReusableProxiesIfExited();
204
230
  const desiredHeadless = options.headless === true;
@@ -221,15 +247,106 @@ function findReusableProxy(options) {
221
247
  return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
222
248
  })[0];
223
249
  }
250
+ export async function prewarmProxy(options) {
251
+ clearReusableProxiesIfExited();
252
+ const existing = findExactReusableProxy(options);
253
+ if (existing) {
254
+ touchReusableProxy(existing);
255
+ return {
256
+ prepared: true,
257
+ reused: true,
258
+ transport: existing.runtime ? 'embedded' : 'child',
259
+ pageUrl: options.pageUrl,
260
+ wsUrl: existing.wsUrl,
261
+ headless: options.headless === true,
262
+ width: options.width ?? 1280,
263
+ height: options.height ?? 720,
264
+ };
265
+ }
266
+ let embeddedFailure;
267
+ try {
268
+ const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
269
+ pageUrl: options.pageUrl,
270
+ port: options.port ?? 0,
271
+ headless: options.headless,
272
+ width: options.width,
273
+ height: options.height,
274
+ slowMo: options.slowMo,
275
+ });
276
+ try {
277
+ await runtime.ready;
278
+ }
279
+ catch (err) {
280
+ await runtime.close().catch(() => { });
281
+ throw err;
282
+ }
283
+ setReusableProxy({ runtime }, wsUrl, {
284
+ headless: options.headless,
285
+ slowMo: options.slowMo,
286
+ width: options.width,
287
+ height: options.height,
288
+ pageUrl: options.pageUrl,
289
+ snapshotReady: true,
290
+ });
291
+ return {
292
+ prepared: true,
293
+ reused: false,
294
+ transport: 'embedded',
295
+ pageUrl: options.pageUrl,
296
+ wsUrl,
297
+ headless: options.headless === true,
298
+ width: options.width ?? 1280,
299
+ height: options.height ?? 720,
300
+ };
301
+ }
302
+ catch (err) {
303
+ embeddedFailure = err;
304
+ }
305
+ try {
306
+ const { child, wsUrl } = await spawnGeometraProxy({
307
+ pageUrl: options.pageUrl,
308
+ port: options.port ?? 0,
309
+ headless: options.headless,
310
+ width: options.width,
311
+ height: options.height,
312
+ slowMo: options.slowMo,
313
+ });
314
+ setReusableProxy({ child }, wsUrl, {
315
+ headless: options.headless,
316
+ slowMo: options.slowMo,
317
+ width: options.width,
318
+ height: options.height,
319
+ pageUrl: options.pageUrl,
320
+ });
321
+ return {
322
+ prepared: true,
323
+ reused: false,
324
+ transport: 'child',
325
+ pageUrl: options.pageUrl,
326
+ wsUrl,
327
+ headless: options.headless === true,
328
+ width: options.width ?? 1280,
329
+ height: options.height ?? 720,
330
+ };
331
+ }
332
+ catch (spawnFailure) {
333
+ throw new Error(`Failed to prewarm embedded browser session: ${formatUnknownError(embeddedFailure)}\nChild-process proxy prewarm also failed: ${formatUnknownError(spawnFailure)}`);
334
+ }
335
+ }
224
336
  async function attachToReusableProxy(proxy, options) {
225
- const session = ((proxy.child && activeSession?.proxyChild === proxy.child) ||
337
+ const startedAt = performance.now();
338
+ const desiredWidth = options.width ?? proxy.width;
339
+ const desiredHeight = options.height ?? proxy.height;
340
+ const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
341
+ const reusedActiveSession = ((proxy.child && activeSession?.proxyChild === proxy.child) ||
226
342
  (proxy.runtime && activeSession?.proxyRuntime === proxy.runtime))
227
343
  ? activeSession
228
- : await connect(proxy.wsUrl, {
229
- skipInitialResize: true,
230
- closePreviousProxy: false,
231
- awaitInitialFrame: options.awaitInitialFrame,
232
- });
344
+ : null;
345
+ const session = reusedActiveSession ?? await connect(proxy.wsUrl, {
346
+ skipInitialResize: true,
347
+ closePreviousProxy: false,
348
+ awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
349
+ });
233
350
  if (!session) {
234
351
  throw new Error('Failed to attach to reusable proxy session');
235
352
  }
@@ -237,26 +354,53 @@ async function attachToReusableProxy(proxy, options) {
237
354
  session.proxyRuntime = proxy.runtime;
238
355
  session.proxyReusable = true;
239
356
  touchReusableProxy(proxy);
240
- const desiredWidth = options.width ?? proxy.width;
241
- const desiredHeight = options.height ?? proxy.height;
242
- if (desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
243
- await sendAndWaitForUpdate(session, {
244
- type: 'resize',
245
- width: desiredWidth,
246
- height: desiredHeight,
247
- }, 5_000);
357
+ let resizeKickoffMs;
358
+ if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
359
+ const resizeStartedAt = performance.now();
360
+ const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
361
+ resizeKickoffMs = performance.now() - resizeStartedAt;
362
+ if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
363
+ throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
364
+ }
248
365
  proxy.width = desiredWidth;
249
366
  proxy.height = desiredHeight;
367
+ updateReusableProxySnapshotState(proxy, session);
250
368
  }
251
369
  const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
370
+ let navigateMs;
252
371
  if (currentUrl !== options.pageUrl) {
372
+ const navigateStartedAt = performance.now();
253
373
  await sendNavigate(session, options.pageUrl, 15_000);
374
+ navigateMs = performance.now() - navigateStartedAt;
254
375
  proxy.pageUrl = options.pageUrl;
255
- }
376
+ updateReusableProxySnapshotState(proxy, session);
377
+ }
378
+ const baseConnectTrace = !reusedActiveSession ? session.connectTrace : undefined;
379
+ session.connectTrace = {
380
+ mode: 'reused-proxy',
381
+ reused: true,
382
+ awaitInitialFrame: options.awaitInitialFrame !== false,
383
+ connectMs: baseConnectTrace?.totalMs ?? 0,
384
+ wsOpenMs: baseConnectTrace?.wsOpenMs,
385
+ firstFrameMs: baseConnectTrace?.firstFrameMs,
386
+ resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
387
+ snapshotKickoff: needsSnapshotKickoff,
388
+ resizeKickoffMs,
389
+ navigateMs,
390
+ totalMs: performance.now() - startedAt,
391
+ };
392
+ updateReusableProxySnapshotState(proxy, session);
256
393
  return session;
257
394
  }
258
395
  async function startFreshProxySession(options) {
396
+ const startedAt = performance.now();
397
+ const eagerInitialExtract = options.eagerInitialExtract !== undefined
398
+ ? options.eagerInitialExtract
399
+ : options.awaitInitialFrame !== false
400
+ ? undefined
401
+ : false;
259
402
  try {
403
+ const proxyStartStartedAt = performance.now();
260
404
  const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
261
405
  pageUrl: options.pageUrl,
262
406
  port: options.port ?? 0,
@@ -264,7 +408,9 @@ async function startFreshProxySession(options) {
264
408
  width: options.width,
265
409
  height: options.height,
266
410
  slowMo: options.slowMo,
411
+ eagerInitialExtract,
267
412
  });
413
+ const proxyStartMs = performance.now() - proxyStartStartedAt;
268
414
  const session = await connect(wsUrl, {
269
415
  skipInitialResize: true,
270
416
  closePreviousProxy: false,
@@ -278,10 +424,25 @@ async function startFreshProxySession(options) {
278
424
  width: options.width,
279
425
  height: options.height,
280
426
  pageUrl: options.pageUrl,
427
+ snapshotReady: Boolean(session.tree && session.layout),
281
428
  });
429
+ const baseConnectTrace = session.connectTrace;
430
+ session.connectTrace = {
431
+ mode: 'fresh-proxy',
432
+ reused: false,
433
+ awaitInitialFrame: options.awaitInitialFrame !== false,
434
+ proxyStartMode: 'embedded',
435
+ proxyStartMs,
436
+ connectMs: baseConnectTrace?.totalMs,
437
+ wsOpenMs: baseConnectTrace?.wsOpenMs,
438
+ firstFrameMs: baseConnectTrace?.firstFrameMs,
439
+ resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
440
+ totalMs: performance.now() - startedAt,
441
+ };
282
442
  return session;
283
443
  }
284
444
  catch (e) {
445
+ const proxyStartStartedAt = performance.now();
285
446
  const { child, wsUrl } = await spawnGeometraProxy({
286
447
  pageUrl: options.pageUrl,
287
448
  port: options.port ?? 0,
@@ -289,7 +450,9 @@ async function startFreshProxySession(options) {
289
450
  width: options.width,
290
451
  height: options.height,
291
452
  slowMo: options.slowMo,
453
+ eagerInitialExtract,
292
454
  });
455
+ const proxyStartMs = performance.now() - proxyStartStartedAt;
293
456
  try {
294
457
  const session = await connect(wsUrl, {
295
458
  skipInitialResize: true,
@@ -304,7 +467,21 @@ async function startFreshProxySession(options) {
304
467
  width: options.width,
305
468
  height: options.height,
306
469
  pageUrl: options.pageUrl,
470
+ snapshotReady: Boolean(session.tree && session.layout),
307
471
  });
472
+ const baseConnectTrace = session.connectTrace;
473
+ session.connectTrace = {
474
+ mode: 'fresh-proxy',
475
+ reused: false,
476
+ awaitInitialFrame: options.awaitInitialFrame !== false,
477
+ proxyStartMode: 'child',
478
+ proxyStartMs,
479
+ connectMs: baseConnectTrace?.totalMs,
480
+ wsOpenMs: baseConnectTrace?.wsOpenMs,
481
+ firstFrameMs: baseConnectTrace?.firstFrameMs,
482
+ resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
483
+ totalMs: performance.now() - startedAt,
484
+ };
308
485
  return session;
309
486
  }
310
487
  catch (fallbackError) {
@@ -324,6 +501,7 @@ async function startFreshProxySession(options) {
324
501
  */
325
502
  export function connect(url, opts) {
326
503
  return new Promise((resolve, reject) => {
504
+ const startedAt = performance.now();
327
505
  clearReusableProxiesIfExited();
328
506
  shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
329
507
  const ws = new WebSocket(url);
@@ -333,6 +511,12 @@ export function connect(url, opts) {
333
511
  tree: null,
334
512
  url,
335
513
  updateRevision: 0,
514
+ connectTrace: {
515
+ mode: 'direct-ws',
516
+ reused: false,
517
+ awaitInitialFrame: opts?.awaitInitialFrame !== false,
518
+ totalMs: 0,
519
+ },
336
520
  cachedA11y: null,
337
521
  cachedA11yRevision: -1,
338
522
  cachedFormSchemas: new Map(),
@@ -346,6 +530,9 @@ export function connect(url, opts) {
346
530
  }
347
531
  }, 10_000);
348
532
  ws.on('open', () => {
533
+ if (session.connectTrace) {
534
+ session.connectTrace.wsOpenMs = performance.now() - startedAt;
535
+ }
349
536
  if (!opts?.skipInitialResize) {
350
537
  const width = opts?.width ?? 1024;
351
538
  const height = opts?.height ?? 768;
@@ -354,6 +541,10 @@ export function connect(url, opts) {
354
541
  if (opts?.awaitInitialFrame === false && !resolved) {
355
542
  resolved = true;
356
543
  clearTimeout(timeout);
544
+ if (session.connectTrace) {
545
+ session.connectTrace.resolvedWithoutInitialFrame = true;
546
+ session.connectTrace.totalMs = performance.now() - startedAt;
547
+ }
357
548
  activeSession = session;
358
549
  resolve(session);
359
550
  }
@@ -366,9 +557,16 @@ export function connect(url, opts) {
366
557
  session.tree = msg.tree;
367
558
  session.updateRevision++;
368
559
  invalidateSessionCaches(session);
560
+ const connectTrace = session.connectTrace;
561
+ if (connectTrace && connectTrace.firstFrameMs === undefined) {
562
+ connectTrace.firstFrameMs = performance.now() - startedAt;
563
+ }
369
564
  if (!resolved) {
370
565
  resolved = true;
371
566
  clearTimeout(timeout);
567
+ if (session.connectTrace) {
568
+ session.connectTrace.totalMs = performance.now() - startedAt;
569
+ }
372
570
  activeSession = session;
373
571
  resolve(session);
374
572
  }
@@ -511,6 +709,17 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
511
709
  check();
512
710
  });
513
711
  }
712
+ function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
713
+ return new Promise((resolve, reject) => {
714
+ if (session.ws.readyState !== WebSocket.OPEN) {
715
+ reject(new Error('Not connected'));
716
+ return;
717
+ }
718
+ const startRevision = session.updateRevision;
719
+ session.ws.send(JSON.stringify({ type: 'resize', width, height }));
720
+ waitForNextUpdate(session, timeoutMs, undefined, startRevision).then(resolve).catch(reject);
721
+ });
722
+ }
514
723
  /**
515
724
  * Send a click event at (x, y) and wait for the next frame/patch response.
516
725
  */
@@ -755,6 +964,12 @@ export function nodeIdForPath(path) {
755
964
  function formFieldIdForPath(path) {
756
965
  return `ff:${encodePath(path)}`;
757
966
  }
967
+ function parseFormFieldId(id) {
968
+ const [prefix, encoded] = id.split(':', 2);
969
+ if (prefix !== 'ff' || !encoded)
970
+ return null;
971
+ return decodePath(encoded);
972
+ }
758
973
  function sectionPrefix(kind) {
759
974
  if (kind === 'landmark')
760
975
  return 'lm';
@@ -1102,12 +1317,14 @@ function textPreview(node, maxItems) {
1102
1317
  !!sanitizeInlineName(candidate.name, 90));
1103
1318
  return dedupeStrings(texts.map(candidate => contentPreviewName(candidate)), maxItems);
1104
1319
  }
1105
- function primaryAction(node) {
1320
+ function primaryAction(root, node) {
1321
+ const context = nodeContextForNode(root, node);
1106
1322
  return {
1107
1323
  id: nodeIdForPath(node.path),
1108
1324
  role: node.role,
1109
1325
  ...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
1110
1326
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
1327
+ ...(context ? { context } : {}),
1111
1328
  bounds: cloneBounds(node.bounds),
1112
1329
  };
1113
1330
  }
@@ -1176,7 +1393,57 @@ function nearestPromptText(container, target) {
1176
1393
  .sort((a, b) => a.score - b.score)[0];
1177
1394
  return best?.text;
1178
1395
  }
1179
- function nodeContext(root, node) {
1396
+ function nearestItemText(container, target) {
1397
+ const normalizedTarget = normalizeUiText(target.name ?? '');
1398
+ const best = collectDescendants(container, candidate => (candidate.role === 'heading' || candidate.role === 'link' || candidate.role === 'text') &&
1399
+ !!sanitizeInlineName(candidate.name, 120) &&
1400
+ pathKey(candidate.path) !== pathKey(target.path))
1401
+ .filter(candidate => candidate.bounds.y <= target.bounds.y + Math.max(8, target.bounds.height))
1402
+ .map(candidate => {
1403
+ const text = sanitizeInlineName(candidate.name, 120);
1404
+ if (!text)
1405
+ return null;
1406
+ if (normalizeUiText(text) === normalizedTarget)
1407
+ return null;
1408
+ const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
1409
+ const dx = Math.abs(target.bounds.x - candidate.bounds.x);
1410
+ const headingBonus = candidate.role === 'heading' ? -36 : 0;
1411
+ const linkBonus = candidate.role === 'link' ? -24 : 0;
1412
+ const questionBonus = /\?\s*$/.test(text) ? 80 : 0;
1413
+ const longTextPenalty = text.length > 90 ? 80 : text.length > 60 ? 40 : 0;
1414
+ const pricePenalty = /^[^\p{L}\p{N}]*[$€£]/u.test(text) ? 120 : 0;
1415
+ return { text, score: dy * 4 + dx + headingBonus + linkBonus + questionBonus + longTextPenalty + pricePenalty };
1416
+ })
1417
+ .filter((candidate) => candidate !== null)
1418
+ .sort((a, b) => a.score - b.score)[0];
1419
+ return best?.text;
1420
+ }
1421
+ function itemContext(root, node) {
1422
+ if (node.role !== 'button' && node.role !== 'link')
1423
+ return undefined;
1424
+ const ancestors = ancestorNodes(root, node.path);
1425
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1426
+ const ancestor = ancestors[index];
1427
+ if (ancestor.role === 'article') {
1428
+ const articleName = sectionDisplayName(ancestor, 'landmark');
1429
+ if (articleName && normalizeUiText(articleName) !== normalizeUiText(node.name ?? ''))
1430
+ return articleName;
1431
+ }
1432
+ if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
1433
+ continue;
1434
+ }
1435
+ if (ancestor.role === 'listitem') {
1436
+ const itemName = listItemName(ancestor);
1437
+ if (itemName && normalizeUiText(itemName) !== normalizeUiText(node.name ?? ''))
1438
+ return itemName;
1439
+ }
1440
+ const nearby = nearestItemText(ancestor, node);
1441
+ if (nearby)
1442
+ return nearby;
1443
+ }
1444
+ return undefined;
1445
+ }
1446
+ export function nodeContextForNode(root, node) {
1180
1447
  const ancestors = ancestorNodes(root, node.path);
1181
1448
  let prompt;
1182
1449
  const promptEligibleNode = node.role === 'radio' || node.role === 'button';
@@ -1200,20 +1467,26 @@ function nodeContext(root, node) {
1200
1467
  const kind = sectionKindForNode(ancestor);
1201
1468
  if (!kind)
1202
1469
  continue;
1470
+ if (kind === 'list')
1471
+ continue;
1472
+ if (ancestor.role === 'article')
1473
+ continue;
1203
1474
  section = sectionDisplayName(ancestor, kind);
1204
1475
  if (section)
1205
1476
  break;
1206
1477
  }
1207
- if (!prompt && !section)
1478
+ const item = itemContext(root, node);
1479
+ if (!prompt && !section && !item)
1208
1480
  return undefined;
1209
1481
  return {
1210
1482
  ...(prompt ? { prompt } : {}),
1211
1483
  ...(section ? { section } : {}),
1484
+ ...(item ? { item } : {}),
1212
1485
  };
1213
1486
  }
1214
1487
  function toFieldModel(root, node, includeBounds = true) {
1215
1488
  const value = sanitizeInlineName(node.value, 120);
1216
- const context = nodeContext(root, node);
1489
+ const context = nodeContextForNode(root, node);
1217
1490
  const visibility = buildVisibility(node.bounds, root.bounds);
1218
1491
  const scrollHint = buildScrollHint(node.bounds, root.bounds);
1219
1492
  return {
@@ -1230,7 +1503,7 @@ function toFieldModel(root, node, includeBounds = true) {
1230
1503
  };
1231
1504
  }
1232
1505
  function toActionModel(root, node, includeBounds = true) {
1233
- const context = nodeContext(root, node);
1506
+ const context = nodeContextForNode(root, node);
1234
1507
  const visibility = buildVisibility(node.bounds, root.bounds);
1235
1508
  const scrollHint = buildScrollHint(node.bounds, root.bounds);
1236
1509
  return {
@@ -1269,14 +1542,14 @@ function isGroupedChoiceControl(node) {
1269
1542
  return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
1270
1543
  }
1271
1544
  function groupedChoiceForNode(root, formNode, seed) {
1272
- const context = nodeContext(root, seed);
1545
+ const context = nodeContextForNode(root, seed);
1273
1546
  const prompt = context?.prompt;
1274
1547
  if (!prompt)
1275
1548
  return null;
1276
1549
  const matchesPrompt = (candidate) => {
1277
1550
  if (!isGroupedChoiceControl(candidate))
1278
1551
  return false;
1279
- return nodeContext(root, candidate)?.prompt === prompt;
1552
+ return nodeContextForNode(root, candidate)?.prompt === prompt;
1280
1553
  };
1281
1554
  const ancestors = ancestorNodes(root, seed.path);
1282
1555
  for (let index = ancestors.length - 1; index >= 0; index--) {
@@ -1294,7 +1567,7 @@ function groupedChoiceForNode(root, formNode, seed) {
1294
1567
  return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
1295
1568
  }
1296
1569
  function simpleSchemaField(root, node) {
1297
- const context = nodeContext(root, node);
1570
+ const context = nodeContextForNode(root, node);
1298
1571
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
1299
1572
  if (!label)
1300
1573
  return null;
@@ -1327,7 +1600,7 @@ function groupedSchemaField(root, grouped) {
1327
1600
  const options = dedupeStrings(optionEntries.map(entry => entry.label), 16);
1328
1601
  const selectedOptions = dedupeStrings(optionEntries.filter(entry => entry.selected).map(entry => entry.label), 16);
1329
1602
  const radioLike = optionEntries.every(entry => entry.role === 'radio' || entry.role === 'button');
1330
- const context = nodeContext(root, grouped.controls[0]);
1603
+ const context = nodeContextForNode(root, grouped.controls[0]);
1331
1604
  return {
1332
1605
  id: formFieldIdForPath(grouped.container.path),
1333
1606
  kind: radioLike ? 'choice' : 'multi_choice',
@@ -1351,7 +1624,7 @@ function toggleSchemaField(root, node) {
1351
1624
  const label = schemaOptionLabel(node);
1352
1625
  if (!label)
1353
1626
  return null;
1354
- const context = nodeContext(root, node);
1627
+ const context = nodeContextForNode(root, node);
1355
1628
  const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
1356
1629
  return {
1357
1630
  id: formFieldIdForPath(node.path),
@@ -1555,7 +1828,7 @@ export function buildPageModel(root, options) {
1555
1828
  const primaryActions = compact.nodes
1556
1829
  .filter(node => node.focusable && ACTION_ROLES.has(node.role))
1557
1830
  .slice(0, maxPrimaryActions)
1558
- .map(node => primaryAction({
1831
+ .map(node => primaryAction(root, findNodeByPath(root, node.path) ?? {
1559
1832
  role: node.role,
1560
1833
  name: node.name,
1561
1834
  state: node.state,
@@ -1596,6 +1869,45 @@ export function buildFormSchemas(root, options) {
1596
1869
  .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
1597
1870
  .map(form => buildFormSchemaForNode(root, form, options));
1598
1871
  }
1872
+ /**
1873
+ * Required-field snapshot for automation: every required field in a form, including
1874
+ * offscreen entries, annotated with visibility and scroll hints so agents do not
1875
+ * mistake long-form fields for missing controls.
1876
+ */
1877
+ export function buildFormRequiredSnapshot(root, options) {
1878
+ const schemas = buildFormSchemas(root, {
1879
+ formId: options?.formId,
1880
+ maxFields: options?.maxFields,
1881
+ onlyRequiredFields: true,
1882
+ includeOptions: options?.includeOptions,
1883
+ includeContext: options?.includeContext,
1884
+ });
1885
+ return schemas.map(schema => {
1886
+ const parsedForm = parseSectionId(schema.formId);
1887
+ const formNode = parsedForm ? findNodeByPath(root, parsedForm.path) : null;
1888
+ const fields = schema.fields
1889
+ .map(field => {
1890
+ const fieldPath = parseFormFieldId(field.id);
1891
+ const target = fieldPath ? findNodeByPath(root, fieldPath) ?? formNode : formNode;
1892
+ if (!target)
1893
+ return null;
1894
+ return {
1895
+ ...field,
1896
+ bounds: cloneBounds(target.bounds),
1897
+ visibility: buildVisibility(target.bounds, root.bounds),
1898
+ scrollHint: buildScrollHint(target.bounds, root.bounds),
1899
+ };
1900
+ })
1901
+ .filter((field) => field !== null);
1902
+ return {
1903
+ formId: schema.formId,
1904
+ ...(schema.name ? { name: schema.name } : {}),
1905
+ requiredCount: schema.requiredCount,
1906
+ invalidCount: schema.invalidCount,
1907
+ fields,
1908
+ };
1909
+ });
1910
+ }
1599
1911
  function headingModels(node, maxHeadings, includeBounds) {
1600
1912
  const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
1601
1913
  return headings.slice(0, maxHeadings).map(heading => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.20",
3
+ "version": "1.19.23",
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",
@@ -30,7 +30,7 @@
30
30
  "ui-testing"
31
31
  ],
32
32
  "dependencies": {
33
- "@geometra/proxy": "^1.19.20",
33
+ "@geometra/proxy": "^1.19.23",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"