@geometra/mcp 1.19.14 → 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,16 +1,69 @@
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;
7
- const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS = 250;
8
- const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 450;
9
- const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 200;
8
+ const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS = 275;
9
+ const FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS = 120;
10
+ const FILL_BATCH_TEXT_LENGTH_SLICE = 80;
11
+ const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 500;
12
+ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
10
13
  const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
11
- const FILL_BATCH_MAX_TIMEOUT_MS = 30_000;
14
+ const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
12
15
  let nextRequestSequence = 0;
13
- 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) {
14
67
  const prev = activeSession;
15
68
  if (!prev)
16
69
  return;
@@ -22,6 +75,12 @@ function shutdownPreviousSession() {
22
75
  /* ignore */
23
76
  }
24
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;
25
84
  try {
26
85
  prev.proxyChild.kill('SIGTERM');
27
86
  }
@@ -36,9 +95,22 @@ function shutdownPreviousSession() {
36
95
  */
37
96
  export function connect(url, opts) {
38
97
  return new Promise((resolve, reject) => {
39
- shutdownPreviousSession();
98
+ clearReusableProxyIfExited();
99
+ if (reusableProxy && reusableProxy.wsUrl !== url) {
100
+ closeReusableProxy();
101
+ }
102
+ shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
40
103
  const ws = new WebSocket(url);
41
- 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
+ };
42
114
  let resolved = false;
43
115
  const timeout = setTimeout(() => {
44
116
  if (!resolved) {
@@ -48,11 +120,17 @@ export function connect(url, opts) {
48
120
  }
49
121
  }, 10_000);
50
122
  ws.on('open', () => {
51
- if (opts?.skipInitialResize)
52
- return;
53
- const width = opts?.width ?? 1024;
54
- const height = opts?.height ?? 768;
55
- 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
+ }
56
134
  });
57
135
  ws.on('message', (data) => {
58
136
  try {
@@ -61,6 +139,7 @@ export function connect(url, opts) {
61
139
  session.layout = msg.layout;
62
140
  session.tree = msg.tree;
63
141
  session.updateRevision++;
142
+ invalidateSessionCaches(session);
64
143
  if (!resolved) {
65
144
  resolved = true;
66
145
  clearTimeout(timeout);
@@ -71,6 +150,7 @@ export function connect(url, opts) {
71
150
  else if (msg.type === 'patch' && session.layout) {
72
151
  applyPatches(session.layout, msg.patches);
73
152
  session.updateRevision++;
153
+ invalidateSessionCaches(session);
74
154
  }
75
155
  }
76
156
  catch { /* ignore malformed messages */ }
@@ -85,7 +165,7 @@ export function connect(url, opts) {
85
165
  ws.on('close', () => {
86
166
  if (activeSession === session) {
87
167
  activeSession = null;
88
- if (session.proxyChild) {
168
+ if (session.proxyChild && !session.proxyReusable) {
89
169
  try {
90
170
  session.proxyChild.kill('SIGTERM');
91
171
  }
@@ -107,6 +187,47 @@ export function connect(url, opts) {
107
187
  * process to the session so disconnect / reconnect can clean it up.
108
188
  */
109
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();
110
231
  const { child, wsUrl } = await spawnGeometraProxy({
111
232
  pageUrl: options.pageUrl,
112
233
  port: options.port ?? 0,
@@ -116,8 +237,20 @@ export async function connectThroughProxy(options) {
116
237
  slowMo: options.slowMo,
117
238
  });
118
239
  try {
119
- const session = await connect(wsUrl, { skipInitialResize: true });
240
+ const session = await connect(wsUrl, {
241
+ skipInitialResize: true,
242
+ closePreviousProxy: false,
243
+ awaitInitialFrame: options.awaitInitialFrame,
244
+ });
120
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
+ });
121
254
  return session;
122
255
  }
123
256
  catch (e) {
@@ -133,18 +266,26 @@ export async function connectThroughProxy(options) {
133
266
  export function getSession() {
134
267
  return activeSession;
135
268
  }
136
- export function disconnect() {
137
- shutdownPreviousSession();
269
+ export function disconnect(opts) {
270
+ shutdownPreviousSession({ closeProxy: opts?.closeProxy ?? false });
271
+ if (opts?.closeProxy)
272
+ closeReusableProxy();
138
273
  }
139
274
  function estimateFillBatchTimeout(fields) {
140
275
  let total = FILL_BATCH_BASE_TIMEOUT_MS;
276
+ let totalTextLength = 0;
141
277
  for (const field of fields) {
142
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;
143
282
  case 'text':
283
+ totalTextLength += field.value.length;
144
284
  total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
285
+ total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
145
286
  break;
146
287
  case 'choice':
147
- total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
288
+ total += field.choiceType === 'group' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
148
289
  break;
149
290
  case 'toggle':
150
291
  total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
@@ -154,6 +295,9 @@ function estimateFillBatchTimeout(fields) {
154
295
  break;
155
296
  }
156
297
  }
298
+ if (fields.length >= 20 || totalTextLength >= 1500) {
299
+ total = Math.max(total, 30_000);
300
+ }
157
301
  return Math.min(total, FILL_BATCH_MAX_TIMEOUT_MS);
158
302
  }
159
303
  export function waitForUiCondition(session, predicate, timeoutMs) {
@@ -277,6 +421,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
277
421
  };
278
422
  if (opts?.exact !== undefined)
279
423
  payload.exact = opts.exact;
424
+ if (opts?.fieldId)
425
+ payload.fieldId = opts.fieldId;
280
426
  return sendAndWaitForUpdate(session, payload, timeoutMs);
281
427
  }
282
428
  /** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
@@ -290,6 +436,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
290
436
  payload.exact = opts.exact;
291
437
  if (opts?.query)
292
438
  payload.query = opts.query;
439
+ if (opts?.choiceType)
440
+ payload.choiceType = opts.choiceType;
441
+ if (opts?.fieldId)
442
+ payload.fieldId = opts.fieldId;
293
443
  return sendAndWaitForUpdate(session, payload, timeoutMs);
294
444
  }
295
445
  /** Fill several semantic form fields in one proxy-side batch. */
@@ -341,6 +491,13 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
341
491
  ...(opts?.y !== undefined ? { y: opts.y } : {}),
342
492
  }, timeoutMs);
343
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
+ }
344
501
  /**
345
502
  * Build a flat accessibility tree from the raw UI tree + layout.
346
503
  * This is a standalone reimplementation that works with raw JSON —
@@ -966,10 +1123,16 @@ function simpleSchemaField(root, node) {
966
1123
  const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
967
1124
  if (!label)
968
1125
  return null;
1126
+ const choiceType = node.role === 'combobox'
1127
+ ? node.meta?.controlTag === 'select'
1128
+ ? 'select'
1129
+ : 'listbox'
1130
+ : undefined;
969
1131
  return {
970
1132
  id: formFieldIdForPath(node.path),
971
1133
  kind: node.role === 'combobox' ? 'choice' : 'text',
972
1134
  label,
1135
+ ...(choiceType ? { choiceType } : {}),
973
1136
  ...(node.state?.required ? { required: true } : {}),
974
1137
  ...(node.state?.invalid ? { invalid: true } : {}),
975
1138
  ...compactSchemaValue(node.value, 72),
@@ -994,6 +1157,7 @@ function groupedSchemaField(root, grouped) {
994
1157
  id: formFieldIdForPath(grouped.container.path),
995
1158
  kind: radioLike ? 'choice' : 'multi_choice',
996
1159
  label: grouped.prompt,
1160
+ ...(radioLike ? { choiceType: 'group' } : {}),
997
1161
  ...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
998
1162
  ...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
999
1163
  ...(radioLike
@@ -1060,7 +1224,7 @@ function buildFormSchemaForNode(root, formNode, options) {
1060
1224
  consumed.add(candidateKey);
1061
1225
  }
1062
1226
  }
1063
- const compactFields = trimSchemaFieldContexts(fields);
1227
+ const compactFields = presentFormSchemaFields(fields, options);
1064
1228
  const filteredFields = compactFields.filter(field => {
1065
1229
  if (options?.onlyRequiredFields && !field.required)
1066
1230
  return false;
@@ -1081,14 +1245,35 @@ function buildFormSchemaForNode(root, formNode, options) {
1081
1245
  };
1082
1246
  }
1083
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';
1084
1253
  const labelCounts = new Map();
1085
1254
  for (const field of fields) {
1086
1255
  const key = normalizeUiText(field.label);
1087
1256
  labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
1088
1257
  }
1089
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
+ }
1090
1273
  if (!field.context)
1091
- return field;
1274
+ return next;
1275
+ if (includeContext === 'always')
1276
+ return next;
1092
1277
  const trimmed = {};
1093
1278
  if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
1094
1279
  trimmed.prompt = field.context.prompt;
@@ -1097,10 +1282,11 @@ function trimSchemaFieldContexts(fields) {
1097
1282
  trimmed.section = field.context.section;
1098
1283
  }
1099
1284
  if (Object.keys(trimmed).length === 0) {
1100
- const { context: _context, ...rest } = field;
1101
- return rest;
1285
+ delete next.context;
1286
+ return next;
1102
1287
  }
1103
- return { ...field, context: trimmed };
1288
+ next.context = trimmed;
1289
+ return next;
1104
1290
  });
1105
1291
  }
1106
1292
  function toLandmarkModel(node) {
@@ -1682,6 +1868,8 @@ function walkNode(element, layout, path) {
1682
1868
  meta.scrollX = semantic.scrollX;
1683
1869
  if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
1684
1870
  meta.scrollY = semantic.scrollY;
1871
+ if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
1872
+ meta.controlTag = semantic.tag;
1685
1873
  const children = [];
1686
1874
  const elementChildren = element.children;
1687
1875
  const layoutChildren = layout.children;
@@ -1781,7 +1969,7 @@ function applyPatches(layout, patches) {
1781
1969
  node.height = patch.height;
1782
1970
  }
1783
1971
  }
1784
- function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
1972
+ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
1785
1973
  return new Promise((resolve, reject) => {
1786
1974
  if (session.ws.readyState !== WebSocket.OPEN) {
1787
1975
  reject(new Error('Not connected'));
@@ -1790,11 +1978,14 @@ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOU
1790
1978
  const requestId = `req-${++nextRequestSequence}`;
1791
1979
  const startRevision = session.updateRevision;
1792
1980
  session.ws.send(JSON.stringify({ ...message, requestId }));
1793
- waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
1981
+ waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts).then(resolve).catch(reject);
1794
1982
  });
1795
1983
  }
1796
- 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) {
1797
1985
  return new Promise((resolve, reject) => {
1986
+ let ackSeen = false;
1987
+ let ackResult;
1988
+ const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
1798
1989
  const onMessage = (data) => {
1799
1990
  try {
1800
1991
  const msg = JSON.parse(String(data));
@@ -1805,13 +1996,26 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1805
1996
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1806
1997
  return;
1807
1998
  }
1808
- if (msg.type === 'ack' && messageRequestId === requestId) {
1999
+ if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
1809
2000
  cleanup();
1810
2001
  resolve({
1811
- status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
2002
+ status: 'updated',
1812
2003
  timeoutMs,
1813
- ...(msg.result !== undefined ? { result: msg.result } : {}),
2004
+ ...ackPayload(),
1814
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
+ }
1815
2019
  }
1816
2020
  return;
1817
2021
  }
@@ -1843,7 +2047,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1843
2047
  const timeout = setTimeout(() => {
1844
2048
  cleanup();
1845
2049
  if (requestId && session.updateRevision > startRevision) {
1846
- resolve({ status: 'updated', timeoutMs });
2050
+ resolve({ status: 'updated', timeoutMs, ...ackPayload() });
2051
+ return;
2052
+ }
2053
+ if (requestId && ackSeen) {
2054
+ resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
1847
2055
  return;
1848
2056
  }
1849
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.14",
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.14",
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"