@geometra/mcp 1.19.15 → 1.19.16

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.d.ts CHANGED
@@ -27,6 +27,7 @@ export interface A11yNode {
27
27
  pageUrl?: string;
28
28
  scrollX?: number;
29
29
  scrollY?: number;
30
+ controlTag?: string;
30
31
  };
31
32
  bounds: {
32
33
  x: number;
@@ -242,12 +243,16 @@ export interface PageSectionDetail {
242
243
  textPreview: string[];
243
244
  }
244
245
  export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
246
+ export type FormSchemaChoiceType = 'select' | 'group' | 'listbox';
247
+ export type FormSchemaContextMode = 'auto' | 'always' | 'none';
245
248
  export interface FormSchemaField {
246
249
  id: string;
247
250
  kind: FormSchemaFieldKind;
248
251
  label: string;
249
252
  required?: boolean;
250
253
  invalid?: boolean;
254
+ choiceType?: FormSchemaChoiceType;
255
+ booleanChoice?: boolean;
251
256
  controlType?: 'checkbox' | 'radio';
252
257
  value?: string;
253
258
  valueLength?: number;
@@ -265,6 +270,14 @@ export interface FormSchemaModel {
265
270
  invalidCount: number;
266
271
  fields: FormSchemaField[];
267
272
  }
273
+ export interface FormSchemaBuildOptions {
274
+ formId?: string;
275
+ maxFields?: number;
276
+ onlyRequiredFields?: boolean;
277
+ onlyInvalidFields?: boolean;
278
+ includeOptions?: boolean;
279
+ includeContext?: FormSchemaContextMode;
280
+ }
268
281
  export interface UiNodeUpdate {
269
282
  before: CompactUiNode;
270
283
  after: CompactUiNode;
@@ -312,6 +325,13 @@ export interface Session {
312
325
  updateRevision: number;
313
326
  /** Present when this session owns a child geometra-proxy process (pageUrl connect). */
314
327
  proxyChild?: ChildProcess;
328
+ proxyReusable?: boolean;
329
+ cachedA11y?: A11yNode | null;
330
+ cachedA11yRevision?: number;
331
+ cachedFormSchemas?: Map<string, {
332
+ revision: number;
333
+ forms: FormSchemaModel[];
334
+ }>;
315
335
  }
316
336
  export interface UpdateWaitResult {
317
337
  status: 'updated' | 'acknowledged' | 'timed_out';
@@ -319,16 +339,25 @@ export interface UpdateWaitResult {
319
339
  result?: unknown;
320
340
  }
321
341
  export type ProxyFillField = {
342
+ kind: 'auto';
343
+ fieldId?: string;
344
+ fieldLabel: string;
345
+ value: string | boolean;
346
+ exact?: boolean;
347
+ } | {
322
348
  kind: 'text';
349
+ fieldId?: string;
323
350
  fieldLabel: string;
324
351
  value: string;
325
352
  exact?: boolean;
326
353
  } | {
327
354
  kind: 'choice';
355
+ fieldId?: string;
328
356
  fieldLabel: string;
329
357
  value: string;
330
358
  query?: string;
331
359
  exact?: boolean;
360
+ choiceType?: FormSchemaChoiceType;
332
361
  } | {
333
362
  kind: 'toggle';
334
363
  label: string;
@@ -337,6 +366,7 @@ export type ProxyFillField = {
337
366
  controlType?: 'checkbox' | 'radio';
338
367
  } | {
339
368
  kind: 'file';
369
+ fieldId?: string;
340
370
  fieldLabel: string;
341
371
  paths: string[];
342
372
  exact?: boolean;
@@ -349,6 +379,8 @@ export declare function connect(url: string, opts?: {
349
379
  width?: number;
350
380
  height?: number;
351
381
  skipInitialResize?: boolean;
382
+ closePreviousProxy?: boolean;
383
+ awaitInitialFrame?: boolean;
352
384
  }): Promise<Session>;
353
385
  /**
354
386
  * Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
@@ -361,9 +393,12 @@ export declare function connectThroughProxy(options: {
361
393
  width?: number;
362
394
  height?: number;
363
395
  slowMo?: number;
396
+ awaitInitialFrame?: boolean;
364
397
  }): Promise<Session>;
365
398
  export declare function getSession(): Session | null;
366
- export declare function disconnect(): void;
399
+ export declare function disconnect(opts?: {
400
+ closeProxy?: boolean;
401
+ }): void;
367
402
  export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
368
403
  /**
369
404
  * Send a click event at (x, y) and wait for the next frame/patch response.
@@ -402,11 +437,14 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
402
437
  /** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
403
438
  export declare function sendFieldText(session: Session, fieldLabel: string, value: string, opts?: {
404
439
  exact?: boolean;
440
+ fieldId?: string;
405
441
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
406
442
  /** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
407
443
  export declare function sendFieldChoice(session: Session, fieldLabel: string, value: string, opts?: {
408
444
  exact?: boolean;
409
445
  query?: string;
446
+ choiceType?: FormSchemaChoiceType;
447
+ fieldId?: string;
410
448
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
411
449
  /** Fill several semantic form fields in one proxy-side batch. */
412
450
  export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
@@ -438,6 +476,8 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
438
476
  x?: number;
439
477
  y?: number;
440
478
  }, timeoutMs?: number): Promise<UpdateWaitResult>;
479
+ /** Navigate the proxy page to a new URL while keeping the browser process alive. */
480
+ export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
441
481
  /**
442
482
  * Build a flat accessibility tree from the raw UI tree + layout.
443
483
  * This is a standalone reimplementation that works with raw JSON —
@@ -467,12 +507,7 @@ export declare function buildPageModel(root: A11yNode, options?: {
467
507
  maxPrimaryActions?: number;
468
508
  maxSectionsPerKind?: number;
469
509
  }): PageModel;
470
- export declare function buildFormSchemas(root: A11yNode, options?: {
471
- formId?: string;
472
- maxFields?: number;
473
- onlyRequiredFields?: boolean;
474
- onlyInvalidFields?: boolean;
475
- }): FormSchemaModel[];
510
+ export declare function buildFormSchemas(root: A11yNode, options?: FormSchemaBuildOptions): FormSchemaModel[];
476
511
  /**
477
512
  * Expand a page-model section by stable ID into richer, on-demand details.
478
513
  */
package/dist/session.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
2
  import { spawnGeometraProxy } from './proxy-spawn.js';
3
3
  let activeSession = null;
4
+ let reusableProxy = null;
4
5
  const ACTION_UPDATE_TIMEOUT_MS = 2000;
5
6
  const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
6
7
  const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
@@ -12,7 +13,57 @@ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
12
13
  const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
13
14
  const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
14
15
  let nextRequestSequence = 0;
15
- function shutdownPreviousSession() {
16
+ function invalidateSessionCaches(session) {
17
+ session.cachedA11y = null;
18
+ session.cachedA11yRevision = -1;
19
+ session.cachedFormSchemas?.clear();
20
+ }
21
+ function clearReusableProxyIfExited() {
22
+ if (!reusableProxy?.child.killed && reusableProxy?.child.exitCode === null && reusableProxy?.child.signalCode === null) {
23
+ return;
24
+ }
25
+ reusableProxy = null;
26
+ }
27
+ function setReusableProxy(child, wsUrl, opts) {
28
+ reusableProxy = {
29
+ child,
30
+ wsUrl,
31
+ headless: opts.headless === true,
32
+ slowMo: opts.slowMo ?? 0,
33
+ width: opts.width ?? 1280,
34
+ height: opts.height ?? 720,
35
+ pageUrl: opts.pageUrl,
36
+ };
37
+ const clear = () => {
38
+ if (reusableProxy?.child === child)
39
+ reusableProxy = null;
40
+ };
41
+ child.once('exit', clear);
42
+ child.once('close', clear);
43
+ child.once('error', clear);
44
+ }
45
+ function closeReusableProxy() {
46
+ clearReusableProxyIfExited();
47
+ const proxy = reusableProxy;
48
+ reusableProxy = null;
49
+ if (!proxy)
50
+ return;
51
+ try {
52
+ proxy.child.kill('SIGTERM');
53
+ }
54
+ catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+ function rememberReusableProxyPageUrl(session) {
59
+ const pageUrl = session.cachedA11y?.meta?.pageUrl;
60
+ if (!pageUrl)
61
+ return;
62
+ if (session.proxyChild && reusableProxy?.child === session.proxyChild) {
63
+ reusableProxy.pageUrl = pageUrl;
64
+ }
65
+ }
66
+ function shutdownPreviousSession(opts) {
16
67
  const prev = activeSession;
17
68
  if (!prev)
18
69
  return;
@@ -24,6 +75,12 @@ function shutdownPreviousSession() {
24
75
  /* ignore */
25
76
  }
26
77
  if (prev.proxyChild) {
78
+ const shouldKeepProxy = prev.proxyReusable && opts?.closeProxy === false;
79
+ rememberReusableProxyPageUrl(prev);
80
+ if (shouldKeepProxy)
81
+ return;
82
+ if (reusableProxy?.child === prev.proxyChild)
83
+ reusableProxy = null;
27
84
  try {
28
85
  prev.proxyChild.kill('SIGTERM');
29
86
  }
@@ -38,9 +95,22 @@ function shutdownPreviousSession() {
38
95
  */
39
96
  export function connect(url, opts) {
40
97
  return new Promise((resolve, reject) => {
41
- shutdownPreviousSession();
98
+ clearReusableProxyIfExited();
99
+ if (reusableProxy && reusableProxy.wsUrl !== url) {
100
+ closeReusableProxy();
101
+ }
102
+ shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
42
103
  const ws = new WebSocket(url);
43
- const session = { ws, layout: null, tree: null, url, updateRevision: 0 };
104
+ const session = {
105
+ ws,
106
+ layout: null,
107
+ tree: null,
108
+ url,
109
+ updateRevision: 0,
110
+ cachedA11y: null,
111
+ cachedA11yRevision: -1,
112
+ cachedFormSchemas: new Map(),
113
+ };
44
114
  let resolved = false;
45
115
  const timeout = setTimeout(() => {
46
116
  if (!resolved) {
@@ -50,11 +120,17 @@ export function connect(url, opts) {
50
120
  }
51
121
  }, 10_000);
52
122
  ws.on('open', () => {
53
- if (opts?.skipInitialResize)
54
- return;
55
- const width = opts?.width ?? 1024;
56
- const height = opts?.height ?? 768;
57
- ws.send(JSON.stringify({ type: 'resize', width, height }));
123
+ if (!opts?.skipInitialResize) {
124
+ const width = opts?.width ?? 1024;
125
+ const height = opts?.height ?? 768;
126
+ ws.send(JSON.stringify({ type: 'resize', width, height }));
127
+ }
128
+ if (opts?.awaitInitialFrame === false && !resolved) {
129
+ resolved = true;
130
+ clearTimeout(timeout);
131
+ activeSession = session;
132
+ resolve(session);
133
+ }
58
134
  });
59
135
  ws.on('message', (data) => {
60
136
  try {
@@ -63,6 +139,7 @@ export function connect(url, opts) {
63
139
  session.layout = msg.layout;
64
140
  session.tree = msg.tree;
65
141
  session.updateRevision++;
142
+ invalidateSessionCaches(session);
66
143
  if (!resolved) {
67
144
  resolved = true;
68
145
  clearTimeout(timeout);
@@ -73,6 +150,7 @@ export function connect(url, opts) {
73
150
  else if (msg.type === 'patch' && session.layout) {
74
151
  applyPatches(session.layout, msg.patches);
75
152
  session.updateRevision++;
153
+ invalidateSessionCaches(session);
76
154
  }
77
155
  }
78
156
  catch { /* ignore malformed messages */ }
@@ -87,7 +165,7 @@ export function connect(url, opts) {
87
165
  ws.on('close', () => {
88
166
  if (activeSession === session) {
89
167
  activeSession = null;
90
- if (session.proxyChild) {
168
+ if (session.proxyChild && !session.proxyReusable) {
91
169
  try {
92
170
  session.proxyChild.kill('SIGTERM');
93
171
  }
@@ -109,6 +187,47 @@ export function connect(url, opts) {
109
187
  * process to the session so disconnect / reconnect can clean it up.
110
188
  */
111
189
  export async function connectThroughProxy(options) {
190
+ clearReusableProxyIfExited();
191
+ const desiredHeadless = options.headless === true;
192
+ const desiredSlowMo = options.slowMo ?? 0;
193
+ if (reusableProxy &&
194
+ reusableProxy.headless === desiredHeadless &&
195
+ reusableProxy.slowMo === desiredSlowMo) {
196
+ const session = activeSession?.proxyChild === reusableProxy.child
197
+ ? activeSession
198
+ : await connect(reusableProxy.wsUrl, {
199
+ skipInitialResize: true,
200
+ closePreviousProxy: false,
201
+ awaitInitialFrame: options.awaitInitialFrame,
202
+ });
203
+ if (!session) {
204
+ throw new Error('Failed to attach to reusable proxy session');
205
+ }
206
+ session.proxyChild = reusableProxy.child;
207
+ session.proxyReusable = true;
208
+ const desiredWidth = options.width ?? reusableProxy.width;
209
+ const desiredHeight = options.height ?? reusableProxy.height;
210
+ if (desiredWidth !== reusableProxy.width || desiredHeight !== reusableProxy.height) {
211
+ await sendAndWaitForUpdate(session, {
212
+ type: 'resize',
213
+ width: desiredWidth,
214
+ height: desiredHeight,
215
+ }, 5_000);
216
+ reusableProxy.width = desiredWidth;
217
+ reusableProxy.height = desiredHeight;
218
+ }
219
+ if (options.pageUrl) {
220
+ const currentUrl = session.cachedA11y?.meta?.pageUrl ?? reusableProxy.pageUrl;
221
+ if (currentUrl !== options.pageUrl) {
222
+ await sendNavigate(session, options.pageUrl, 15_000);
223
+ if (reusableProxy?.child === session.proxyChild) {
224
+ reusableProxy.pageUrl = options.pageUrl;
225
+ }
226
+ }
227
+ }
228
+ return session;
229
+ }
230
+ closeReusableProxy();
112
231
  const { child, wsUrl } = await spawnGeometraProxy({
113
232
  pageUrl: options.pageUrl,
114
233
  port: options.port ?? 0,
@@ -118,8 +237,20 @@ export async function connectThroughProxy(options) {
118
237
  slowMo: options.slowMo,
119
238
  });
120
239
  try {
121
- const session = await connect(wsUrl, { skipInitialResize: true });
240
+ const session = await connect(wsUrl, {
241
+ skipInitialResize: true,
242
+ closePreviousProxy: false,
243
+ awaitInitialFrame: options.awaitInitialFrame,
244
+ });
122
245
  session.proxyChild = child;
246
+ session.proxyReusable = true;
247
+ setReusableProxy(child, wsUrl, {
248
+ headless: options.headless,
249
+ slowMo: options.slowMo,
250
+ width: options.width,
251
+ height: options.height,
252
+ pageUrl: options.pageUrl,
253
+ });
123
254
  return session;
124
255
  }
125
256
  catch (e) {
@@ -135,21 +266,26 @@ export async function connectThroughProxy(options) {
135
266
  export function getSession() {
136
267
  return activeSession;
137
268
  }
138
- export function disconnect() {
139
- shutdownPreviousSession();
269
+ export function disconnect(opts) {
270
+ shutdownPreviousSession({ closeProxy: opts?.closeProxy ?? false });
271
+ if (opts?.closeProxy)
272
+ closeReusableProxy();
140
273
  }
141
274
  function estimateFillBatchTimeout(fields) {
142
275
  let total = FILL_BATCH_BASE_TIMEOUT_MS;
143
276
  let totalTextLength = 0;
144
277
  for (const field of fields) {
145
278
  switch (field.kind) {
279
+ case 'auto':
280
+ total += typeof field.value === 'boolean' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
281
+ break;
146
282
  case 'text':
147
283
  totalTextLength += field.value.length;
148
284
  total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
149
285
  total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
150
286
  break;
151
287
  case 'choice':
152
- total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
288
+ total += field.choiceType === 'group' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
153
289
  break;
154
290
  case 'toggle':
155
291
  total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
@@ -285,6 +421,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
285
421
  };
286
422
  if (opts?.exact !== undefined)
287
423
  payload.exact = opts.exact;
424
+ if (opts?.fieldId)
425
+ payload.fieldId = opts.fieldId;
288
426
  return sendAndWaitForUpdate(session, payload, timeoutMs);
289
427
  }
290
428
  /** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
@@ -298,6 +436,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
298
436
  payload.exact = opts.exact;
299
437
  if (opts?.query)
300
438
  payload.query = opts.query;
439
+ if (opts?.choiceType)
440
+ payload.choiceType = opts.choiceType;
441
+ if (opts?.fieldId)
442
+ payload.fieldId = opts.fieldId;
301
443
  return sendAndWaitForUpdate(session, payload, timeoutMs);
302
444
  }
303
445
  /** Fill several semantic form fields in one proxy-side batch. */
@@ -349,6 +491,13 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
349
491
  ...(opts?.y !== undefined ? { y: opts.y } : {}),
350
492
  }, timeoutMs);
351
493
  }
494
+ /** Navigate the proxy page to a new URL while keeping the browser process alive. */
495
+ export function sendNavigate(session, url, timeoutMs = 15_000) {
496
+ return sendAndWaitForUpdate(session, {
497
+ type: 'navigate',
498
+ url,
499
+ }, timeoutMs, { requireUpdateOnAck: true });
500
+ }
352
501
  /**
353
502
  * Build a flat accessibility tree from the raw UI tree + layout.
354
503
  * This is a standalone reimplementation that works with raw JSON —
@@ -974,10 +1123,16 @@ function simpleSchemaField(root, node) {
974
1123
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
975
1124
  if (!label)
976
1125
  return null;
1126
+ const choiceType = node.role === 'combobox'
1127
+ ? node.meta?.controlTag === 'select'
1128
+ ? 'select'
1129
+ : 'listbox'
1130
+ : undefined;
977
1131
  return {
978
1132
  id: formFieldIdForPath(node.path),
979
1133
  kind: node.role === 'combobox' ? 'choice' : 'text',
980
1134
  label,
1135
+ ...(choiceType ? { choiceType } : {}),
981
1136
  ...(node.state?.required ? { required: true } : {}),
982
1137
  ...(node.state?.invalid ? { invalid: true } : {}),
983
1138
  ...compactSchemaValue(node.value, 72),
@@ -1002,6 +1157,7 @@ function groupedSchemaField(root, grouped) {
1002
1157
  id: formFieldIdForPath(grouped.container.path),
1003
1158
  kind: radioLike ? 'choice' : 'multi_choice',
1004
1159
  label: grouped.prompt,
1160
+ ...(radioLike ? { choiceType: 'group' } : {}),
1005
1161
  ...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
1006
1162
  ...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
1007
1163
  ...(radioLike
@@ -1068,7 +1224,7 @@ function buildFormSchemaForNode(root, formNode, options) {
1068
1224
  consumed.add(candidateKey);
1069
1225
  }
1070
1226
  }
1071
- const compactFields = trimSchemaFieldContexts(fields);
1227
+ const compactFields = presentFormSchemaFields(fields, options);
1072
1228
  const filteredFields = compactFields.filter(field => {
1073
1229
  if (options?.onlyRequiredFields && !field.required)
1074
1230
  return false;
@@ -1089,14 +1245,35 @@ function buildFormSchemaForNode(root, formNode, options) {
1089
1245
  };
1090
1246
  }
1091
1247
  function trimSchemaFieldContexts(fields) {
1248
+ return presentFormSchemaFields(fields, { includeOptions: true, includeContext: 'auto' });
1249
+ }
1250
+ function presentFormSchemaFields(fields, options) {
1251
+ const includeOptions = options?.includeOptions ?? false;
1252
+ const includeContext = options?.includeContext ?? 'auto';
1092
1253
  const labelCounts = new Map();
1093
1254
  for (const field of fields) {
1094
1255
  const key = normalizeUiText(field.label);
1095
1256
  labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
1096
1257
  }
1097
1258
  return fields.map(field => {
1259
+ const booleanChoice = field.kind === 'choice' &&
1260
+ field.choiceType === 'group' &&
1261
+ field.optionCount === 2 &&
1262
+ field.options?.length === 2 &&
1263
+ field.options.every(option => ['yes', 'no'].includes(normalizeUiText(option).toLowerCase()));
1264
+ const next = { ...field };
1265
+ if (booleanChoice)
1266
+ next.booleanChoice = true;
1267
+ if (!includeOptions)
1268
+ delete next.options;
1269
+ if (includeContext === 'none') {
1270
+ delete next.context;
1271
+ return next;
1272
+ }
1098
1273
  if (!field.context)
1099
- return field;
1274
+ return next;
1275
+ if (includeContext === 'always')
1276
+ return next;
1100
1277
  const trimmed = {};
1101
1278
  if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
1102
1279
  trimmed.prompt = field.context.prompt;
@@ -1105,10 +1282,11 @@ function trimSchemaFieldContexts(fields) {
1105
1282
  trimmed.section = field.context.section;
1106
1283
  }
1107
1284
  if (Object.keys(trimmed).length === 0) {
1108
- const { context: _context, ...rest } = field;
1109
- return rest;
1285
+ delete next.context;
1286
+ return next;
1110
1287
  }
1111
- return { ...field, context: trimmed };
1288
+ next.context = trimmed;
1289
+ return next;
1112
1290
  });
1113
1291
  }
1114
1292
  function toLandmarkModel(node) {
@@ -1690,6 +1868,8 @@ function walkNode(element, layout, path) {
1690
1868
  meta.scrollX = semantic.scrollX;
1691
1869
  if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
1692
1870
  meta.scrollY = semantic.scrollY;
1871
+ if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
1872
+ meta.controlTag = semantic.tag;
1693
1873
  const children = [];
1694
1874
  const elementChildren = element.children;
1695
1875
  const layoutChildren = layout.children;
@@ -1789,7 +1969,7 @@ function applyPatches(layout, patches) {
1789
1969
  node.height = patch.height;
1790
1970
  }
1791
1971
  }
1792
- function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
1972
+ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
1793
1973
  return new Promise((resolve, reject) => {
1794
1974
  if (session.ws.readyState !== WebSocket.OPEN) {
1795
1975
  reject(new Error('Not connected'));
@@ -1798,11 +1978,14 @@ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOU
1798
1978
  const requestId = `req-${++nextRequestSequence}`;
1799
1979
  const startRevision = session.updateRevision;
1800
1980
  session.ws.send(JSON.stringify({ ...message, requestId }));
1801
- waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
1981
+ waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts).then(resolve).catch(reject);
1802
1982
  });
1803
1983
  }
1804
- function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
1984
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
1805
1985
  return new Promise((resolve, reject) => {
1986
+ let ackSeen = false;
1987
+ let ackResult;
1988
+ const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
1806
1989
  const onMessage = (data) => {
1807
1990
  try {
1808
1991
  const msg = JSON.parse(String(data));
@@ -1813,13 +1996,26 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1813
1996
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1814
1997
  return;
1815
1998
  }
1816
- if (msg.type === 'ack' && messageRequestId === requestId) {
1999
+ if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
1817
2000
  cleanup();
1818
2001
  resolve({
1819
- status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
2002
+ status: 'updated',
1820
2003
  timeoutMs,
1821
- ...(msg.result !== undefined ? { result: msg.result } : {}),
2004
+ ...ackPayload(),
1822
2005
  });
2006
+ return;
2007
+ }
2008
+ if (msg.type === 'ack' && messageRequestId === requestId) {
2009
+ ackSeen = true;
2010
+ ackResult = msg.result;
2011
+ if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
2012
+ cleanup();
2013
+ resolve({
2014
+ status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
2015
+ timeoutMs,
2016
+ ...ackPayload(),
2017
+ });
2018
+ }
1823
2019
  }
1824
2020
  return;
1825
2021
  }
@@ -1851,7 +2047,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1851
2047
  const timeout = setTimeout(() => {
1852
2048
  cleanup();
1853
2049
  if (requestId && session.updateRevision > startRevision) {
1854
- resolve({ status: 'updated', timeoutMs });
2050
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
2051
+ return;
2052
+ }
2053
+ if (requestId && ackSeen) {
2054
+ resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
1855
2055
  return;
1856
2056
  }
1857
2057
  resolve({ status: 'timed_out', timeoutMs });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.15",
3
+ "version": "1.19.16",
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.15",
33
+ "@geometra/proxy": "^1.19.16",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"