@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/README.md +7 -6
- package/dist/__tests__/proxy-session-actions.test.js +63 -5
- package/dist/__tests__/server-batch-results.test.js +199 -16
- package/dist/__tests__/session-model.test.js +12 -3
- package/dist/server.js +407 -34
- package/dist/session.d.ts +42 -7
- package/dist/session.js +225 -25
- 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 });
|
|
@@ -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 =
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
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, {
|
|
611
756
|
formId,
|
|
612
757
|
maxFields,
|
|
613
758
|
onlyRequiredFields,
|
|
614
759
|
onlyInvalidFields,
|
|
760
|
+
includeOptions,
|
|
761
|
+
includeContext,
|
|
762
|
+
sinceSchemaId,
|
|
763
|
+
format,
|
|
615
764
|
});
|
|
616
|
-
if (
|
|
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({
|
|
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 =
|
|
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 =
|
|
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
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
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 [{
|
|
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
|
-
|
|
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
|
},
|