@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/README.md +7 -6
- package/dist/__tests__/proxy-session-actions.test.js +75 -17
- package/dist/__tests__/server-batch-results.test.js +253 -13
- package/dist/__tests__/session-model.test.js +12 -3
- package/dist/server.js +417 -34
- package/dist/session.d.ts +42 -7
- package/dist/session.js +237 -29
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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 =
|
|
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 });
|
|
@@ -418,13 +547,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
418
547
|
return err(planned.error);
|
|
419
548
|
if (!includeSteps) {
|
|
420
549
|
let usedBatch = false;
|
|
550
|
+
let batchAckResult;
|
|
421
551
|
try {
|
|
422
552
|
const startRevision = session.updateRevision;
|
|
423
553
|
const wait = await sendFillFields(session, planned.fields);
|
|
424
554
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
555
|
+
batchAckResult = ackResult;
|
|
425
556
|
if (ackResult && ackResult.invalidCount === 0) {
|
|
426
557
|
usedBatch = true;
|
|
427
558
|
const payload = {
|
|
559
|
+
...connection,
|
|
428
560
|
completed: true,
|
|
429
561
|
execution: 'batched',
|
|
430
562
|
finalSource: 'proxy',
|
|
@@ -447,11 +579,20 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
447
579
|
return err(message);
|
|
448
580
|
}
|
|
449
581
|
}
|
|
582
|
+
if (usedBatch) {
|
|
583
|
+
const after = sessionA11y(session);
|
|
584
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
585
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
586
|
+
if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
|
|
587
|
+
usedBatch = false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
450
590
|
if (usedBatch) {
|
|
451
591
|
const after = sessionA11y(session);
|
|
452
592
|
const signals = after ? collectSessionSignals(after) : undefined;
|
|
453
593
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
454
594
|
const payload = {
|
|
595
|
+
...connection,
|
|
455
596
|
completed: true,
|
|
456
597
|
execution: 'batched',
|
|
457
598
|
finalSource: 'session',
|
|
@@ -493,6 +634,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
493
634
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
494
635
|
const errorCount = steps.length - successCount;
|
|
495
636
|
const payload = {
|
|
637
|
+
...connection,
|
|
496
638
|
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
497
639
|
execution: 'sequential',
|
|
498
640
|
formId: schema.formId,
|
|
@@ -581,32 +723,52 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
581
723
|
const session = getSession();
|
|
582
724
|
if (!session?.tree || !session?.layout)
|
|
583
725
|
return err('Not connected. Call geometra_connect first.');
|
|
584
|
-
const a11y =
|
|
726
|
+
const a11y = sessionA11y(session);
|
|
727
|
+
if (!a11y)
|
|
728
|
+
return err('No UI tree available');
|
|
585
729
|
const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
|
|
586
730
|
return ok(JSON.stringify(model));
|
|
587
731
|
});
|
|
588
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.
|
|
589
733
|
|
|
590
|
-
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.'),
|
|
591
742
|
formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
|
|
592
743
|
maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
|
|
593
744
|
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
594
745
|
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
const
|
|
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, {
|
|
601
756
|
formId,
|
|
602
757
|
maxFields,
|
|
603
758
|
onlyRequiredFields,
|
|
604
759
|
onlyInvalidFields,
|
|
760
|
+
includeOptions,
|
|
761
|
+
includeContext,
|
|
762
|
+
sinceSchemaId,
|
|
763
|
+
format,
|
|
605
764
|
});
|
|
606
|
-
if (
|
|
765
|
+
if (payload.formCount === 0) {
|
|
607
766
|
return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
|
|
608
767
|
}
|
|
609
|
-
return ok(JSON.stringify({
|
|
768
|
+
return ok(JSON.stringify({
|
|
769
|
+
...autoConnectionPayload(resolved),
|
|
770
|
+
...payload,
|
|
771
|
+
}));
|
|
610
772
|
});
|
|
611
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.
|
|
612
774
|
|
|
@@ -629,7 +791,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
629
791
|
const session = getSession();
|
|
630
792
|
if (!session?.tree || !session?.layout)
|
|
631
793
|
return err('Not connected. Call geometra_connect first.');
|
|
632
|
-
const a11y =
|
|
794
|
+
const a11y = sessionA11y(session);
|
|
795
|
+
if (!a11y)
|
|
796
|
+
return err('No UI tree available');
|
|
633
797
|
const detail = expandPageSection(a11y, id, {
|
|
634
798
|
maxHeadings,
|
|
635
799
|
maxFields,
|
|
@@ -998,7 +1162,9 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
998
1162
|
const session = getSession();
|
|
999
1163
|
if (!session?.tree || !session?.layout)
|
|
1000
1164
|
return err('Not connected. Call geometra_connect first.');
|
|
1001
|
-
const a11y =
|
|
1165
|
+
const a11y = sessionA11y(session);
|
|
1166
|
+
if (!a11y)
|
|
1167
|
+
return err('No UI tree available');
|
|
1002
1168
|
if (view === 'full') {
|
|
1003
1169
|
return ok(JSON.stringify(a11y, null, 2));
|
|
1004
1170
|
}
|
|
@@ -1022,9 +1188,11 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
|
|
|
1022
1188
|
return ok(JSON.stringify(session.layout, null, 2));
|
|
1023
1189
|
});
|
|
1024
1190
|
// ── disconnect ───────────────────────────────────────────────
|
|
1025
|
-
server.tool('geometra_disconnect', `Disconnect from the Geometra server
|
|
1026
|
-
|
|
1027
|
-
|
|
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.');
|
|
1028
1196
|
});
|
|
1029
1197
|
return server;
|
|
1030
1198
|
}
|
|
@@ -1050,7 +1218,191 @@ function connectPayload(session, opts) {
|
|
|
1050
1218
|
function sessionA11y(session) {
|
|
1051
1219
|
if (!session.tree || !session.layout)
|
|
1052
1220
|
return null;
|
|
1053
|
-
|
|
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
|
+
};
|
|
1054
1406
|
}
|
|
1055
1407
|
function sessionOverviewFromA11y(a11y) {
|
|
1056
1408
|
const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
|
|
@@ -1293,6 +1645,8 @@ function coerceChoiceValue(field, value) {
|
|
|
1293
1645
|
return value;
|
|
1294
1646
|
if (typeof value !== 'boolean')
|
|
1295
1647
|
return null;
|
|
1648
|
+
if (field.booleanChoice)
|
|
1649
|
+
return value ? 'Yes' : 'No';
|
|
1296
1650
|
const desired = value ? 'yes' : 'no';
|
|
1297
1651
|
const option = field.options?.find(option => normalizeLookupKey(option) === desired);
|
|
1298
1652
|
return option ?? (value ? 'Yes' : 'No');
|
|
@@ -1301,18 +1655,24 @@ function plannedFillInputsForField(field, value) {
|
|
|
1301
1655
|
if (field.kind === 'text') {
|
|
1302
1656
|
if (typeof value !== 'string')
|
|
1303
1657
|
return { error: `Field "${field.label}" expects a string value` };
|
|
1304
|
-
return [{ kind: 'text', fieldLabel: field.label, value }];
|
|
1658
|
+
return [{ kind: 'text', fieldId: field.id, fieldLabel: field.label, value }];
|
|
1305
1659
|
}
|
|
1306
1660
|
if (field.kind === 'choice') {
|
|
1307
1661
|
const coerced = coerceChoiceValue(field, value);
|
|
1308
1662
|
if (!coerced)
|
|
1309
1663
|
return { error: `Field "${field.label}" expects a string value` };
|
|
1310
|
-
return [{
|
|
1664
|
+
return [{
|
|
1665
|
+
kind: 'choice',
|
|
1666
|
+
fieldId: field.id,
|
|
1667
|
+
fieldLabel: field.label,
|
|
1668
|
+
value: coerced,
|
|
1669
|
+
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
1670
|
+
}];
|
|
1311
1671
|
}
|
|
1312
1672
|
if (field.kind === 'toggle') {
|
|
1313
1673
|
if (typeof value !== 'boolean')
|
|
1314
1674
|
return { error: `Field "${field.label}" expects a boolean value` };
|
|
1315
|
-
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 }];
|
|
1316
1676
|
}
|
|
1317
1677
|
const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
1318
1678
|
if (!selected || selected.length === 0)
|
|
@@ -1323,6 +1683,7 @@ function plannedFillInputsForField(field, value) {
|
|
|
1323
1683
|
const selectedKeys = new Set(selected.map(normalizeLookupKey));
|
|
1324
1684
|
return field.options.map(option => ({
|
|
1325
1685
|
kind: 'toggle',
|
|
1686
|
+
fieldId: field.id,
|
|
1326
1687
|
label: option,
|
|
1327
1688
|
checked: selectedKeys.has(normalizeLookupKey(option)),
|
|
1328
1689
|
controlType: 'checkbox',
|
|
@@ -1373,7 +1734,14 @@ function planFormFill(schema, opts) {
|
|
|
1373
1734
|
function canFallbackToSequentialFill(error) {
|
|
1374
1735
|
const message = error instanceof Error ? error.message : String(error);
|
|
1375
1736
|
return (message.includes('Unsupported client message type "fillFields"') ||
|
|
1376
|
-
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'));
|
|
1377
1745
|
}
|
|
1378
1746
|
function parseProxyFillAckResult(value) {
|
|
1379
1747
|
if (!value || typeof value !== 'object')
|
|
@@ -1393,6 +1761,18 @@ function parseProxyFillAckResult(value) {
|
|
|
1393
1761
|
busyCount: candidate.busyCount,
|
|
1394
1762
|
};
|
|
1395
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
|
+
}
|
|
1396
1776
|
async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
1397
1777
|
if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
|
|
1398
1778
|
return;
|
|
@@ -1589,9 +1969,9 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
1589
1969
|
const timeoutMs = action.timeoutMs ?? 10_000;
|
|
1590
1970
|
const startedAt = Date.now();
|
|
1591
1971
|
const matched = await waitForUiCondition(session, () => {
|
|
1592
|
-
|
|
1972
|
+
const a11y = sessionA11y(session);
|
|
1973
|
+
if (!a11y)
|
|
1593
1974
|
return false;
|
|
1594
|
-
const a11y = buildA11yTree(session.tree, session.layout);
|
|
1595
1975
|
const matches = findNodes(a11y, filter);
|
|
1596
1976
|
return present ? matches.length > 0 : matches.length === 0;
|
|
1597
1977
|
}, timeoutMs);
|
|
@@ -1665,7 +2045,7 @@ async function executeFillField(session, field, detail) {
|
|
|
1665
2045
|
switch (field.kind) {
|
|
1666
2046
|
case 'text': {
|
|
1667
2047
|
const before = sessionA11y(session);
|
|
1668
|
-
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);
|
|
1669
2049
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1670
2050
|
return {
|
|
1671
2051
|
summary: [
|
|
@@ -1674,6 +2054,7 @@ async function executeFillField(session, field, detail) {
|
|
|
1674
2054
|
postActionSummary(session, before, wait, detail),
|
|
1675
2055
|
].filter(Boolean).join('\n'),
|
|
1676
2056
|
compact: {
|
|
2057
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
1677
2058
|
fieldLabel: field.fieldLabel,
|
|
1678
2059
|
...compactTextValue(field.value),
|
|
1679
2060
|
...waitStatusPayload(wait),
|
|
@@ -1683,7 +2064,7 @@ async function executeFillField(session, field, detail) {
|
|
|
1683
2064
|
}
|
|
1684
2065
|
case 'choice': {
|
|
1685
2066
|
const before = sessionA11y(session);
|
|
1686
|
-
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);
|
|
1687
2068
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1688
2069
|
return {
|
|
1689
2070
|
summary: [
|
|
@@ -1692,8 +2073,10 @@ async function executeFillField(session, field, detail) {
|
|
|
1692
2073
|
postActionSummary(session, before, wait, detail),
|
|
1693
2074
|
].filter(Boolean).join('\n'),
|
|
1694
2075
|
compact: {
|
|
2076
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
1695
2077
|
fieldLabel: field.fieldLabel,
|
|
1696
2078
|
value: field.value,
|
|
2079
|
+
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
1697
2080
|
...waitStatusPayload(wait),
|
|
1698
2081
|
readback: fieldStatePayload(session, field.fieldLabel),
|
|
1699
2082
|
},
|