@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/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { z } from 'zod';
3
4
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
@@ -15,6 +16,20 @@ function detailInput() {
15
16
  .default('minimal')
16
17
  .describe('`minimal` (default) returns terse action summaries. Use `verbose` for a fuller current-UI fallback.');
17
18
  }
19
+ function formSchemaFormatInput() {
20
+ return z
21
+ .enum(['compact', 'packed'])
22
+ .optional()
23
+ .default('compact')
24
+ .describe('`compact` (default) returns readable JSON fields. Use `packed` for the smallest schema payload with short keys.');
25
+ }
26
+ function formSchemaContextInput() {
27
+ return z
28
+ .enum(['auto', 'always', 'none'])
29
+ .optional()
30
+ .default('auto')
31
+ .describe('How much disambiguation context to include in form schema rows. `auto` keeps context only when it helps.');
32
+ }
18
33
  function nodeFilterShape() {
19
34
  return {
20
35
  id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
@@ -37,6 +52,7 @@ const timeoutMsInput = z.number().int().min(50).max(60_000).optional();
37
52
  const fillFieldSchema = z.discriminatedUnion('kind', [
38
53
  z.object({
39
54
  kind: z.literal('text'),
55
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
40
56
  fieldLabel: z.string().describe('Visible field label / accessible name'),
41
57
  value: z.string().describe('Text value to set'),
42
58
  exact: z.boolean().optional().describe('Exact label match'),
@@ -44,14 +60,20 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
44
60
  }),
45
61
  z.object({
46
62
  kind: z.literal('choice'),
63
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
47
64
  fieldLabel: z.string().describe('Visible field label / accessible name'),
48
65
  value: z.string().describe('Desired option value / answer label'),
49
66
  query: z.string().optional().describe('Optional search text for searchable comboboxes'),
67
+ choiceType: z
68
+ .enum(['select', 'group', 'listbox'])
69
+ .optional()
70
+ .describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
50
71
  exact: z.boolean().optional().describe('Exact label match'),
51
72
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
52
73
  }),
53
74
  z.object({
54
75
  kind: z.literal('toggle'),
76
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
55
77
  label: z.string().describe('Visible checkbox/radio label to set'),
56
78
  checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
57
79
  exact: z.boolean().optional().describe('Exact label match'),
@@ -60,6 +82,7 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
60
82
  }),
61
83
  z.object({
62
84
  kind: z.literal('file'),
85
+ fieldId: z.string().optional().describe('Optional stable field id from geometra_form_schema'),
63
86
  fieldLabel: z.string().describe('Visible file-field label / accessible name'),
64
87
  paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
65
88
  exact: z.boolean().optional().describe('Exact label match'),
@@ -190,12 +213,35 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
190
213
  .nonnegative()
191
214
  .optional()
192
215
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
216
+ returnForms: z
217
+ .boolean()
218
+ .optional()
219
+ .default(false)
220
+ .describe('Include compact form schema discovery in the connect response so form flows can start in one turn.'),
221
+ formId: z.string().optional().describe('Optional form id filter when returnForms=true'),
222
+ maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form when returnForms=true'),
223
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
224
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
225
+ includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
226
+ includeContext: formSchemaContextInput(),
227
+ sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
228
+ schemaFormat: formSchemaFormatInput(),
193
229
  detail: detailInput(),
194
230
  }, async (input) => {
195
231
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
196
232
  if (!normalized.ok)
197
233
  return err(normalized.error);
198
234
  const target = normalized.value;
235
+ const formSchema = {
236
+ formId: input.formId,
237
+ maxFields: input.maxFields,
238
+ onlyRequiredFields: input.onlyRequiredFields,
239
+ onlyInvalidFields: input.onlyInvalidFields,
240
+ includeOptions: input.includeOptions,
241
+ includeContext: input.includeContext,
242
+ sinceSchemaId: input.sinceSchemaId,
243
+ format: input.schemaFormat,
244
+ };
199
245
  try {
200
246
  if (target.kind === 'proxy') {
201
247
  const session = await connectThroughProxy({
@@ -206,22 +252,32 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
206
252
  height: input.height,
207
253
  slowMo: input.slowMo,
208
254
  });
209
- return ok(JSON.stringify(connectPayload(session, {
255
+ if (input.returnForms) {
256
+ await stabilizeInlineFormSchemas(session, formSchema);
257
+ }
258
+ return ok(JSON.stringify(connectResponsePayload(session, {
210
259
  transport: 'proxy',
211
260
  requestedPageUrl: target.pageUrl,
212
261
  autoCoercedFromUrl: target.autoCoercedFromUrl,
213
262
  detail: input.detail,
263
+ returnForms: input.returnForms,
264
+ formSchema,
214
265
  }), null, input.detail === 'verbose' ? 2 : undefined));
215
266
  }
216
267
  const session = await connect(target.wsUrl, {
217
268
  width: input.width,
218
269
  height: input.height,
219
270
  });
220
- return ok(JSON.stringify(connectPayload(session, {
271
+ if (input.returnForms) {
272
+ await stabilizeInlineFormSchemas(session, formSchema);
273
+ }
274
+ return ok(JSON.stringify(connectResponsePayload(session, {
221
275
  transport: 'ws',
222
276
  requestedWsUrl: target.wsUrl,
223
277
  autoCoercedFromUrl: false,
224
278
  detail: input.detail,
279
+ returnForms: input.returnForms,
280
+ formSchema,
225
281
  }), null, input.detail === 'verbose' ? 2 : undefined));
226
282
  }
227
283
  catch (e) {
@@ -235,7 +291,9 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
235
291
  const session = getSession();
236
292
  if (!session?.tree || !session?.layout)
237
293
  return err('Not connected. Call geometra_connect first.');
238
- const a11y = buildA11yTree(session.tree, session.layout);
294
+ const a11y = sessionA11y(session);
295
+ if (!a11y)
296
+ return err('No UI tree available');
239
297
  const filter = {
240
298
  id,
241
299
  role,
@@ -299,7 +357,9 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
299
357
  const matchesCondition = () => {
300
358
  if (!session.tree || !session.layout)
301
359
  return false;
302
- const a11y = buildA11yTree(session.tree, session.layout);
360
+ const a11y = sessionA11y(session);
361
+ if (!a11y)
362
+ return false;
303
363
  const matches = findNodes(a11y, filter);
304
364
  return present ? matches.length > 0 : matches.length === 0;
305
365
  };
@@ -379,7 +439,14 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
379
439
  });
380
440
  server.tool('geometra_fill_form', `Fill a form from a compact values object instead of expanding sections first. This is the lowest-token happy path for standard application flows.
381
441
 
382
- Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result.`, {
442
+ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result. If you pass \`pageUrl\` or \`url\`, MCP will connect first so known-form fills can run in a single tool call.`, {
443
+ url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before filling.'),
444
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before filling. Prefer this over url for browser pages.'),
445
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
446
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
447
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
448
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
449
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
383
450
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
384
451
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
385
452
  valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
@@ -395,18 +462,80 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
395
462
  .default(false)
396
463
  .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
397
464
  detail: detailInput(),
398
- }, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
399
- const session = getSession();
400
- if (!session)
401
- return err('Not connected. Call geometra_connect first.');
465
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
466
+ const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
467
+ ? directLabelBatchFields(valuesByLabel)
468
+ : null;
469
+ const resolved = await ensureToolSession({
470
+ url,
471
+ pageUrl,
472
+ port,
473
+ headless,
474
+ width,
475
+ height,
476
+ slowMo,
477
+ awaitInitialFrame: directFields ? false : undefined,
478
+ }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
479
+ if (!resolved.ok)
480
+ return err(resolved.error);
481
+ const session = resolved.session;
482
+ const connection = autoConnectionPayload(resolved);
402
483
  const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
403
484
  if (entryCount === 0) {
404
485
  return err('Provide at least one value in valuesById or valuesByLabel');
405
486
  }
487
+ if (directFields) {
488
+ try {
489
+ const startRevision = session.updateRevision;
490
+ const wait = await sendFillFields(session, directFields);
491
+ const ackResult = parseProxyFillAckResult(wait.result);
492
+ if (ackResult && ackResult.invalidCount === 0) {
493
+ return ok(JSON.stringify({
494
+ ...connection,
495
+ completed: true,
496
+ execution: 'batched-direct',
497
+ finalSource: 'proxy',
498
+ requestedValueCount: entryCount,
499
+ fieldCount: directFields.length,
500
+ successCount: directFields.length,
501
+ errorCount: 0,
502
+ final: ackResult,
503
+ }, null, detail === 'verbose' ? 2 : undefined));
504
+ }
505
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
506
+ const afterDirect = sessionA11y(session);
507
+ const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
508
+ if (directSignals && directSignals.invalidFields.length === 0) {
509
+ return ok(JSON.stringify({
510
+ ...connection,
511
+ completed: true,
512
+ execution: 'batched-direct',
513
+ finalSource: 'session',
514
+ requestedValueCount: entryCount,
515
+ fieldCount: directFields.length,
516
+ successCount: directFields.length,
517
+ errorCount: 0,
518
+ final: sessionSignalsPayload(directSignals, detail),
519
+ }, null, detail === 'verbose' ? 2 : undefined));
520
+ }
521
+ }
522
+ catch (e) {
523
+ if (!canFallbackToSequentialFill(e)) {
524
+ const message = e instanceof Error ? e.message : String(e);
525
+ return err(message);
526
+ }
527
+ }
528
+ }
529
+ if (!session.tree || !session.layout) {
530
+ await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
531
+ }
406
532
  const afterConnect = sessionA11y(session);
407
533
  if (!afterConnect)
408
534
  return err('No UI tree available for form filling');
409
- const schemas = buildFormSchemas(afterConnect);
535
+ const schemas = getSessionFormSchemas(session, {
536
+ includeOptions: true,
537
+ includeContext: 'auto',
538
+ });
410
539
  if (schemas.length === 0)
411
540
  return err('No forms found in the current UI');
412
541
  const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
@@ -427,6 +556,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
427
556
  if (ackResult && ackResult.invalidCount === 0) {
428
557
  usedBatch = true;
429
558
  const payload = {
559
+ ...connection,
430
560
  completed: true,
431
561
  execution: 'batched',
432
562
  finalSource: 'proxy',
@@ -462,6 +592,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
462
592
  const signals = after ? collectSessionSignals(after) : undefined;
463
593
  const invalidRemaining = signals?.invalidFields.length ?? 0;
464
594
  const payload = {
595
+ ...connection,
465
596
  completed: true,
466
597
  execution: 'batched',
467
598
  finalSource: 'session',
@@ -503,6 +634,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
503
634
  const successCount = steps.filter(step => step.ok === true).length;
504
635
  const errorCount = steps.length - successCount;
505
636
  const payload = {
637
+ ...connection,
506
638
  completed: stoppedAt === undefined && steps.length === planned.fields.length,
507
639
  execution: 'sequential',
508
640
  formId: schema.formId,
@@ -591,32 +723,52 @@ Use this first on normal HTML pages when you want to understand the page shape w
591
723
  const session = getSession();
592
724
  if (!session?.tree || !session?.layout)
593
725
  return err('Not connected. Call geometra_connect first.');
594
- const a11y = buildA11yTree(session.tree, session.layout);
726
+ const a11y = sessionA11y(session);
727
+ if (!a11y)
728
+ return err('No UI tree available');
595
729
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
596
730
  return ok(JSON.stringify(model));
597
731
  });
598
732
  server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
599
733
 
600
- Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
734
+ Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default. If you pass \`pageUrl\` or \`url\`, MCP will connect first so discovery can happen in one tool call.`, {
735
+ url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before discovery.'),
736
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before discovery. Prefer this over url for browser pages.'),
737
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
738
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
739
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
740
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
741
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
601
742
  formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
602
743
  maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
603
744
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
604
745
  onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
605
- }, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
606
- const session = getSession();
607
- if (!session?.tree || !session?.layout)
608
- return err('Not connected. Call geometra_connect first.');
609
- const a11y = buildA11yTree(session.tree, session.layout);
610
- const forms = buildFormSchemas(a11y, {
746
+ includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
747
+ includeContext: formSchemaContextInput(),
748
+ sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
749
+ format: formSchemaFormatInput(),
750
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format }) => {
751
+ const resolved = await ensureToolSession({ url, pageUrl, port, headless, width, height, slowMo }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
752
+ if (!resolved.ok)
753
+ return err(resolved.error);
754
+ const session = resolved.session;
755
+ const payload = formSchemaResponsePayload(session, {
611
756
  formId,
612
757
  maxFields,
613
758
  onlyRequiredFields,
614
759
  onlyInvalidFields,
760
+ includeOptions,
761
+ includeContext,
762
+ sinceSchemaId,
763
+ format,
615
764
  });
616
- if (forms.length === 0) {
765
+ if (payload.formCount === 0) {
617
766
  return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
618
767
  }
619
- return ok(JSON.stringify({ forms }));
768
+ return ok(JSON.stringify({
769
+ ...autoConnectionPayload(resolved),
770
+ ...payload,
771
+ }));
620
772
  });
621
773
  server.tool('geometra_expand_section', `Expand one section from geometra_page_model by stable id. Returns richer on-demand details such as headings, fields, actions, nested lists, list items, and text preview.
622
774
 
@@ -639,7 +791,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
639
791
  const session = getSession();
640
792
  if (!session?.tree || !session?.layout)
641
793
  return err('Not connected. Call geometra_connect first.');
642
- const a11y = buildA11yTree(session.tree, session.layout);
794
+ const a11y = sessionA11y(session);
795
+ if (!a11y)
796
+ return err('No UI tree available');
643
797
  const detail = expandPageSection(a11y, id, {
644
798
  maxHeadings,
645
799
  maxFields,
@@ -1008,7 +1162,9 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1008
1162
  const session = getSession();
1009
1163
  if (!session?.tree || !session?.layout)
1010
1164
  return err('Not connected. Call geometra_connect first.');
1011
- const a11y = buildA11yTree(session.tree, session.layout);
1165
+ const a11y = sessionA11y(session);
1166
+ if (!a11y)
1167
+ return err('No UI tree available');
1012
1168
  if (view === 'full') {
1013
1169
  return ok(JSON.stringify(a11y, null, 2));
1014
1170
  }
@@ -1032,9 +1188,11 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
1032
1188
  return ok(JSON.stringify(session.layout, null, 2));
1033
1189
  });
1034
1190
  // ── disconnect ───────────────────────────────────────────────
1035
- server.tool('geometra_disconnect', `Disconnect from the Geometra server and clean up the WebSocket connection.`, {}, async () => {
1036
- disconnect();
1037
- return ok('Disconnected.');
1191
+ server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep the browser alive by default so the next geometra_connect can reuse it quickly; pass closeBrowser=true to fully tear down the proxy/browser.`, {
1192
+ closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
1193
+ }, async ({ closeBrowser }) => {
1194
+ disconnect({ closeProxy: closeBrowser });
1195
+ return ok(closeBrowser ? 'Disconnected and closed browser.' : 'Disconnected.');
1038
1196
  });
1039
1197
  return server;
1040
1198
  }
@@ -1060,7 +1218,191 @@ function connectPayload(session, opts) {
1060
1218
  function sessionA11y(session) {
1061
1219
  if (!session.tree || !session.layout)
1062
1220
  return null;
1063
- return buildA11yTree(session.tree, session.layout);
1221
+ if (session.cachedA11yRevision === session.updateRevision) {
1222
+ return session.cachedA11y ?? null;
1223
+ }
1224
+ const a11y = buildA11yTree(session.tree, session.layout);
1225
+ session.cachedA11y = a11y;
1226
+ session.cachedA11yRevision = session.updateRevision;
1227
+ return a11y;
1228
+ }
1229
+ function shortHash(value) {
1230
+ return createHash('sha1').update(value).digest('hex').slice(0, 12);
1231
+ }
1232
+ function formSchemaCacheKey(options) {
1233
+ return JSON.stringify({
1234
+ formId: options.formId ?? null,
1235
+ maxFields: options.maxFields ?? null,
1236
+ onlyRequiredFields: options.onlyRequiredFields ?? false,
1237
+ onlyInvalidFields: options.onlyInvalidFields ?? false,
1238
+ includeOptions: options.includeOptions ?? false,
1239
+ includeContext: options.includeContext ?? 'auto',
1240
+ });
1241
+ }
1242
+ function getSessionFormSchemas(session, options) {
1243
+ const key = formSchemaCacheKey(options);
1244
+ const cached = session.cachedFormSchemas?.get(key);
1245
+ if (cached && cached.revision === session.updateRevision)
1246
+ return cached.forms;
1247
+ const a11y = sessionA11y(session);
1248
+ if (!a11y)
1249
+ return [];
1250
+ const forms = buildFormSchemas(a11y, options);
1251
+ if (!session.cachedFormSchemas)
1252
+ session.cachedFormSchemas = new Map();
1253
+ session.cachedFormSchemas.set(key, {
1254
+ revision: session.updateRevision,
1255
+ forms,
1256
+ });
1257
+ return forms;
1258
+ }
1259
+ function packedFormSchemas(forms) {
1260
+ return forms.map(form => ({
1261
+ i: form.formId,
1262
+ ...(form.name ? { n: form.name } : {}),
1263
+ fc: form.fieldCount,
1264
+ rc: form.requiredCount,
1265
+ ic: form.invalidCount,
1266
+ f: form.fields.map(field => ({
1267
+ i: field.id,
1268
+ k: field.kind,
1269
+ l: field.label,
1270
+ ...(field.required ? { r: 1 } : {}),
1271
+ ...(field.invalid ? { iv: 1 } : {}),
1272
+ ...(field.choiceType ? { ch: field.choiceType } : {}),
1273
+ ...(field.booleanChoice ? { b: 1 } : {}),
1274
+ ...(field.controlType ? { t: field.controlType } : {}),
1275
+ ...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
1276
+ ...(field.value ? { v: field.value } : {}),
1277
+ ...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
1278
+ ...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
1279
+ ...(field.values && field.values.length > 0 ? { vs: field.values } : {}),
1280
+ ...(field.context ? { x: field.context } : {}),
1281
+ })),
1282
+ }));
1283
+ }
1284
+ function formSchemaResponsePayload(session, opts) {
1285
+ const forms = getSessionFormSchemas(session, opts);
1286
+ const schemaJson = JSON.stringify(forms);
1287
+ const schemaId = `fs:${shortHash(schemaJson)}`;
1288
+ if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
1289
+ return {
1290
+ schemaId,
1291
+ changed: false,
1292
+ formCount: forms.length,
1293
+ format: opts.format ?? 'compact',
1294
+ };
1295
+ }
1296
+ return {
1297
+ schemaId,
1298
+ changed: true,
1299
+ formCount: forms.length,
1300
+ format: opts.format ?? 'compact',
1301
+ forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
1302
+ };
1303
+ }
1304
+ function totalReturnedSchemaFields(forms) {
1305
+ return forms.reduce((sum, form) => sum + form.fields.length, 0);
1306
+ }
1307
+ function expectedReturnedSchemaFields(forms, maxFields) {
1308
+ return forms.reduce((sum, form) => sum + Math.min(form.fieldCount, maxFields ?? form.fieldCount), 0);
1309
+ }
1310
+ function schemaShapeSignature(forms) {
1311
+ return JSON.stringify(forms.map(form => ({
1312
+ formId: form.formId,
1313
+ fieldCount: form.fieldCount,
1314
+ fields: form.fields.map(field => field.id),
1315
+ })));
1316
+ }
1317
+ async function stabilizeInlineFormSchemas(session, options, opts) {
1318
+ const timeoutMs = opts?.timeoutMs ?? 2_000;
1319
+ const pollMs = opts?.pollMs ?? 60;
1320
+ const stableMs = opts?.stableMs ?? 120;
1321
+ const deadline = Date.now() + timeoutMs;
1322
+ let forms = getSessionFormSchemas(session, options);
1323
+ let lastSignature = schemaShapeSignature(forms);
1324
+ let stableSince = Date.now();
1325
+ while (Date.now() < deadline) {
1326
+ const expectedFields = expectedReturnedSchemaFields(forms, options.maxFields);
1327
+ if (forms.length > 0 && totalReturnedSchemaFields(forms) >= expectedFields && Date.now() - stableSince >= stableMs) {
1328
+ return;
1329
+ }
1330
+ await new Promise(resolve => setTimeout(resolve, pollMs));
1331
+ forms = getSessionFormSchemas(session, options);
1332
+ const signature = schemaShapeSignature(forms);
1333
+ if (signature !== lastSignature) {
1334
+ lastSignature = signature;
1335
+ stableSince = Date.now();
1336
+ }
1337
+ }
1338
+ }
1339
+ function connectResponsePayload(session, opts) {
1340
+ const payload = connectPayload(session, opts);
1341
+ if (!opts.returnForms)
1342
+ return payload;
1343
+ return {
1344
+ ...payload,
1345
+ formSchema: formSchemaResponsePayload(session, opts.formSchema ?? {}),
1346
+ };
1347
+ }
1348
+ async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
1349
+ if (!target.url && !target.pageUrl) {
1350
+ const session = getSession();
1351
+ if (!session)
1352
+ return { ok: false, error: missingConnectionMessage };
1353
+ return { ok: true, session, autoConnected: false };
1354
+ }
1355
+ const normalized = normalizeConnectTarget({ url: target.url, pageUrl: target.pageUrl });
1356
+ if (!normalized.ok)
1357
+ return { ok: false, error: normalized.error };
1358
+ const resolvedTarget = normalized.value;
1359
+ try {
1360
+ if (resolvedTarget.kind === 'proxy') {
1361
+ const session = await connectThroughProxy({
1362
+ pageUrl: resolvedTarget.pageUrl,
1363
+ port: target.port,
1364
+ headless: target.headless,
1365
+ width: target.width,
1366
+ height: target.height,
1367
+ slowMo: target.slowMo,
1368
+ awaitInitialFrame: target.awaitInitialFrame,
1369
+ });
1370
+ return {
1371
+ ok: true,
1372
+ session,
1373
+ autoConnected: true,
1374
+ transport: 'proxy',
1375
+ requestedPageUrl: resolvedTarget.pageUrl,
1376
+ autoCoercedFromUrl: resolvedTarget.autoCoercedFromUrl,
1377
+ };
1378
+ }
1379
+ const session = await connect(resolvedTarget.wsUrl, {
1380
+ width: target.width,
1381
+ height: target.height,
1382
+ awaitInitialFrame: target.awaitInitialFrame,
1383
+ });
1384
+ return {
1385
+ ok: true,
1386
+ session,
1387
+ autoConnected: true,
1388
+ transport: 'ws',
1389
+ requestedWsUrl: resolvedTarget.wsUrl,
1390
+ };
1391
+ }
1392
+ catch (e) {
1393
+ return { ok: false, error: `Failed to connect: ${formatConnectFailureMessage(e, resolvedTarget)}` };
1394
+ }
1395
+ }
1396
+ function autoConnectionPayload(target) {
1397
+ if (!target?.autoConnected)
1398
+ return {};
1399
+ return {
1400
+ autoConnected: true,
1401
+ ...(target.transport ? { transport: target.transport } : {}),
1402
+ ...(target.requestedPageUrl ? { pageUrl: target.requestedPageUrl } : {}),
1403
+ ...(target.requestedWsUrl ? { requestedWsUrl: target.requestedWsUrl } : {}),
1404
+ ...(target.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
1405
+ };
1064
1406
  }
1065
1407
  function sessionOverviewFromA11y(a11y) {
1066
1408
  const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
@@ -1303,6 +1645,8 @@ function coerceChoiceValue(field, value) {
1303
1645
  return value;
1304
1646
  if (typeof value !== 'boolean')
1305
1647
  return null;
1648
+ if (field.booleanChoice)
1649
+ return value ? 'Yes' : 'No';
1306
1650
  const desired = value ? 'yes' : 'no';
1307
1651
  const option = field.options?.find(option => normalizeLookupKey(option) === desired);
1308
1652
  return option ?? (value ? 'Yes' : 'No');
@@ -1311,18 +1655,24 @@ function plannedFillInputsForField(field, value) {
1311
1655
  if (field.kind === 'text') {
1312
1656
  if (typeof value !== 'string')
1313
1657
  return { error: `Field "${field.label}" expects a string value` };
1314
- return [{ kind: 'text', fieldLabel: field.label, value }];
1658
+ return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value }];
1315
1659
  }
1316
1660
  if (field.kind === 'choice') {
1317
1661
  const coerced = coerceChoiceValue(field, value);
1318
1662
  if (!coerced)
1319
1663
  return { error: `Field "${field.label}" expects a string value` };
1320
- return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
1664
+ return [{
1665
+ kind: 'choice',
1666
+ fieldId: field.id,
1667
+ fieldLabel: field.label,
1668
+ value: coerced,
1669
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
1670
+ }];
1321
1671
  }
1322
1672
  if (field.kind === 'toggle') {
1323
1673
  if (typeof value !== 'boolean')
1324
1674
  return { error: `Field "${field.label}" expects a boolean value` };
1325
- return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
1675
+ return [{ kind: 'toggle', fieldId: field.id, label: field.label, checked: value, controlType: field.controlType }];
1326
1676
  }
1327
1677
  const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
1328
1678
  if (!selected || selected.length === 0)
@@ -1333,6 +1683,7 @@ function plannedFillInputsForField(field, value) {
1333
1683
  const selectedKeys = new Set(selected.map(normalizeLookupKey));
1334
1684
  return field.options.map(option => ({
1335
1685
  kind: 'toggle',
1686
+ fieldId: field.id,
1336
1687
  label: option,
1337
1688
  checked: selectedKeys.has(normalizeLookupKey(option)),
1338
1689
  controlType: 'checkbox',
@@ -1383,7 +1734,14 @@ function planFormFill(schema, opts) {
1383
1734
  function canFallbackToSequentialFill(error) {
1384
1735
  const message = error instanceof Error ? error.message : String(error);
1385
1736
  return (message.includes('Unsupported client message type "fillFields"') ||
1386
- message.includes('Client message type "fillFields" is not supported'));
1737
+ message.includes('Client message type "fillFields" is not supported') ||
1738
+ message.startsWith('setFieldText:') ||
1739
+ message.startsWith('setFieldChoice:') ||
1740
+ message.startsWith('setChecked:') ||
1741
+ message.startsWith('attachFiles:') ||
1742
+ message.startsWith('pickListboxOption:') ||
1743
+ message.startsWith('Could not find a') ||
1744
+ message.startsWith('No visible'));
1387
1745
  }
1388
1746
  function parseProxyFillAckResult(value) {
1389
1747
  if (!value || typeof value !== 'object')
@@ -1403,6 +1761,18 @@ function parseProxyFillAckResult(value) {
1403
1761
  busyCount: candidate.busyCount,
1404
1762
  };
1405
1763
  }
1764
+ function directLabelBatchFields(valuesByLabel) {
1765
+ const entries = Object.entries(valuesByLabel ?? {});
1766
+ if (entries.length === 0)
1767
+ return null;
1768
+ const fields = [];
1769
+ for (const [fieldLabel, value] of entries) {
1770
+ if (typeof value !== 'string' && typeof value !== 'boolean')
1771
+ return null;
1772
+ fields.push({ kind: 'auto', fieldLabel, value });
1773
+ }
1774
+ return fields;
1775
+ }
1406
1776
  async function waitForDeferredBatchUpdate(session, startRevision, wait) {
1407
1777
  if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
1408
1778
  return;
@@ -1599,9 +1969,9 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1599
1969
  const timeoutMs = action.timeoutMs ?? 10_000;
1600
1970
  const startedAt = Date.now();
1601
1971
  const matched = await waitForUiCondition(session, () => {
1602
- if (!session.tree || !session.layout)
1972
+ const a11y = sessionA11y(session);
1973
+ if (!a11y)
1603
1974
  return false;
1604
- const a11y = buildA11yTree(session.tree, session.layout);
1605
1975
  const matches = findNodes(a11y, filter);
1606
1976
  return present ? matches.length > 0 : matches.length === 0;
1607
1977
  }, timeoutMs);
@@ -1675,7 +2045,7 @@ async function executeFillField(session, field, detail) {
1675
2045
  switch (field.kind) {
1676
2046
  case 'text': {
1677
2047
  const before = sessionA11y(session);
1678
- const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
2048
+ const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact, fieldId: field.fieldId }, field.timeoutMs);
1679
2049
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1680
2050
  return {
1681
2051
  summary: [
@@ -1684,6 +2054,7 @@ async function executeFillField(session, field, detail) {
1684
2054
  postActionSummary(session, before, wait, detail),
1685
2055
  ].filter(Boolean).join('\n'),
1686
2056
  compact: {
2057
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
1687
2058
  fieldLabel: field.fieldLabel,
1688
2059
  ...compactTextValue(field.value),
1689
2060
  ...waitStatusPayload(wait),
@@ -1693,7 +2064,7 @@ async function executeFillField(session, field, detail) {
1693
2064
  }
1694
2065
  case 'choice': {
1695
2066
  const before = sessionA11y(session);
1696
- const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
2067
+ const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query, choiceType: field.choiceType, fieldId: field.fieldId }, field.timeoutMs);
1697
2068
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1698
2069
  return {
1699
2070
  summary: [
@@ -1702,8 +2073,10 @@ async function executeFillField(session, field, detail) {
1702
2073
  postActionSummary(session, before, wait, detail),
1703
2074
  ].filter(Boolean).join('\n'),
1704
2075
  compact: {
2076
+ ...(field.fieldId ? { fieldId: field.fieldId } : {}),
1705
2077
  fieldLabel: field.fieldLabel,
1706
2078
  value: field.value,
2079
+ ...(field.choiceType ? { choiceType: field.choiceType } : {}),
1707
2080
  ...waitStatusPayload(wait),
1708
2081
  readback: fieldStatePayload(session, field.fieldLabel),
1709
2082
  },