@geometra/mcp 1.19.11 → 1.19.13
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 +47 -22
- package/dist/__tests__/connect-utils.test.js +44 -2
- package/dist/__tests__/server-batch-results.test.js +219 -3
- package/dist/__tests__/session-model.test.js +230 -1
- package/dist/proxy-spawn.js +80 -4
- package/dist/server.d.ts +1 -0
- package/dist/server.js +455 -18
- package/dist/session.d.ts +87 -0
- package/dist/session.js +350 -6
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
4
|
-
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
4
|
+
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
5
5
|
function checkedStateInput() {
|
|
6
6
|
return z
|
|
7
7
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -21,6 +21,7 @@ function nodeFilterShape() {
|
|
|
21
21
|
role: z.string().optional().describe('ARIA role to match'),
|
|
22
22
|
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
23
23
|
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
24
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated controls with the same visible name'),
|
|
24
25
|
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
25
26
|
checked: checkedStateInput(),
|
|
26
27
|
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
@@ -65,6 +66,12 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
|
|
|
65
66
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
66
67
|
}),
|
|
67
68
|
]);
|
|
69
|
+
const formValueSchema = z.union([
|
|
70
|
+
z.string(),
|
|
71
|
+
z.boolean(),
|
|
72
|
+
z.array(z.string()).min(1),
|
|
73
|
+
]);
|
|
74
|
+
const formValuesRecordSchema = z.record(z.string(), formValueSchema);
|
|
68
75
|
const batchActionSchema = z.discriminatedUnion('type', [
|
|
69
76
|
z.object({
|
|
70
77
|
type: z.literal('click'),
|
|
@@ -145,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
145
152
|
}),
|
|
146
153
|
]);
|
|
147
154
|
export function createServer() {
|
|
148
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
155
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.13' }, { capabilities: { tools: {} } });
|
|
149
156
|
// ── connect ──────────────────────────────────────────────────
|
|
150
157
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
151
158
|
|
|
@@ -183,6 +190,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
183
190
|
.nonnegative()
|
|
184
191
|
.optional()
|
|
185
192
|
.describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
|
|
193
|
+
detail: detailInput(),
|
|
186
194
|
}, async (input) => {
|
|
187
195
|
const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
|
|
188
196
|
if (!normalized.ok)
|
|
@@ -198,13 +206,20 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
198
206
|
height: input.height,
|
|
199
207
|
slowMo: input.slowMo,
|
|
200
208
|
});
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
209
|
+
return ok(JSON.stringify(connectPayload(session, {
|
|
210
|
+
transport: 'proxy',
|
|
211
|
+
requestedPageUrl: target.pageUrl,
|
|
212
|
+
autoCoercedFromUrl: target.autoCoercedFromUrl,
|
|
213
|
+
detail: input.detail,
|
|
214
|
+
}), null, input.detail === 'verbose' ? 2 : undefined));
|
|
204
215
|
}
|
|
205
216
|
const session = await connect(target.wsUrl);
|
|
206
|
-
|
|
207
|
-
|
|
217
|
+
return ok(JSON.stringify(connectPayload(session, {
|
|
218
|
+
transport: 'ws',
|
|
219
|
+
requestedWsUrl: target.wsUrl,
|
|
220
|
+
autoCoercedFromUrl: false,
|
|
221
|
+
detail: input.detail,
|
|
222
|
+
}), null, input.detail === 'verbose' ? 2 : undefined));
|
|
208
223
|
}
|
|
209
224
|
catch (e) {
|
|
210
225
|
return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
|
|
@@ -213,7 +228,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
213
228
|
// ── query ────────────────────────────────────────────────────
|
|
214
229
|
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, text content, current value, or semantic state. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, value, state, and tree path.
|
|
215
230
|
|
|
216
|
-
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
231
|
+
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, nodeFilterShape(), async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
217
232
|
const session = getSession();
|
|
218
233
|
if (!session?.tree || !session?.layout)
|
|
219
234
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -223,6 +238,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
223
238
|
role,
|
|
224
239
|
name,
|
|
225
240
|
text,
|
|
241
|
+
contextText,
|
|
226
242
|
value,
|
|
227
243
|
checked,
|
|
228
244
|
disabled,
|
|
@@ -234,12 +250,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
234
250
|
busy,
|
|
235
251
|
};
|
|
236
252
|
if (!hasNodeFilter(filter))
|
|
237
|
-
return err('Provide at least one query filter (id, role, name, text, value, or state)');
|
|
253
|
+
return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
|
|
238
254
|
const matches = findNodes(a11y, filter);
|
|
239
255
|
if (matches.length === 0) {
|
|
240
256
|
return ok(`No elements found matching ${JSON.stringify(filter)}`);
|
|
241
257
|
}
|
|
242
|
-
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
258
|
+
const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
|
|
243
259
|
return ok(JSON.stringify(result, null, 2));
|
|
244
260
|
});
|
|
245
261
|
server.tool('geometra_wait_for', `Wait for a semantic UI condition without guessing sleep durations. Use this for slow SPA transitions, resume parsing, custom validation alerts, disabled submit buttons, and value/state confirmation before submit.
|
|
@@ -255,7 +271,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
255
271
|
.optional()
|
|
256
272
|
.default(10_000)
|
|
257
273
|
.describe('Maximum time to wait before returning an error (default 10000ms)'),
|
|
258
|
-
}, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
274
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
259
275
|
const session = getSession();
|
|
260
276
|
if (!session?.tree || !session?.layout)
|
|
261
277
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -264,6 +280,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
264
280
|
role,
|
|
265
281
|
name,
|
|
266
282
|
text,
|
|
283
|
+
contextText,
|
|
267
284
|
value,
|
|
268
285
|
checked,
|
|
269
286
|
disabled,
|
|
@@ -275,7 +292,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
275
292
|
busy,
|
|
276
293
|
};
|
|
277
294
|
if (!hasNodeFilter(filter))
|
|
278
|
-
return err('Provide at least one wait filter (id, role, name, text, value, or state)');
|
|
295
|
+
return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
|
|
279
296
|
const matchesCondition = () => {
|
|
280
297
|
if (!session.tree || !session.layout)
|
|
281
298
|
return false;
|
|
@@ -296,7 +313,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
296
313
|
if (!after)
|
|
297
314
|
return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
|
|
298
315
|
const matches = findNodes(after, filter);
|
|
299
|
-
const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
|
|
316
|
+
const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
|
|
300
317
|
return ok(JSON.stringify(result, null, 2));
|
|
301
318
|
});
|
|
302
319
|
server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
|
|
@@ -357,6 +374,85 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
357
374
|
}
|
|
358
375
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
359
376
|
});
|
|
377
|
+
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.
|
|
378
|
+
|
|
379
|
+
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.`, {
|
|
380
|
+
formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
|
|
381
|
+
valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
|
|
382
|
+
valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
|
|
383
|
+
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
|
|
384
|
+
failOnInvalid: z
|
|
385
|
+
.boolean()
|
|
386
|
+
.optional()
|
|
387
|
+
.default(false)
|
|
388
|
+
.describe('Return an error if invalid fields remain after filling'),
|
|
389
|
+
includeSteps: z
|
|
390
|
+
.boolean()
|
|
391
|
+
.optional()
|
|
392
|
+
.default(false)
|
|
393
|
+
.describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
|
|
394
|
+
detail: detailInput(),
|
|
395
|
+
}, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
|
|
396
|
+
const session = getSession();
|
|
397
|
+
if (!session)
|
|
398
|
+
return err('Not connected. Call geometra_connect first.');
|
|
399
|
+
const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
|
|
400
|
+
if (entryCount === 0) {
|
|
401
|
+
return err('Provide at least one value in valuesById or valuesByLabel');
|
|
402
|
+
}
|
|
403
|
+
const afterConnect = sessionA11y(session);
|
|
404
|
+
if (!afterConnect)
|
|
405
|
+
return err('No UI tree available for form filling');
|
|
406
|
+
const schemas = buildFormSchemas(afterConnect);
|
|
407
|
+
if (schemas.length === 0)
|
|
408
|
+
return err('No forms found in the current UI');
|
|
409
|
+
const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
|
|
410
|
+
if (!resolution.ok)
|
|
411
|
+
return err(resolution.error);
|
|
412
|
+
const schema = resolution.schema;
|
|
413
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel });
|
|
414
|
+
if (!planned.ok)
|
|
415
|
+
return err(planned.error);
|
|
416
|
+
const steps = [];
|
|
417
|
+
let stoppedAt;
|
|
418
|
+
for (let index = 0; index < planned.fields.length; index++) {
|
|
419
|
+
const field = planned.fields[index];
|
|
420
|
+
try {
|
|
421
|
+
const result = await executeFillField(session, field, detail);
|
|
422
|
+
steps.push(detail === 'verbose'
|
|
423
|
+
? { index, kind: field.kind, ok: true, summary: result.summary }
|
|
424
|
+
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
428
|
+
steps.push({ index, kind: field.kind, ok: false, error: message });
|
|
429
|
+
if (stopOnError) {
|
|
430
|
+
stoppedAt = index;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const after = sessionA11y(session);
|
|
436
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
437
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
438
|
+
const successCount = steps.filter(step => step.ok === true).length;
|
|
439
|
+
const errorCount = steps.length - successCount;
|
|
440
|
+
const payload = {
|
|
441
|
+
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
442
|
+
formId: schema.formId,
|
|
443
|
+
requestedValueCount: entryCount,
|
|
444
|
+
fieldCount: planned.fields.length,
|
|
445
|
+
successCount,
|
|
446
|
+
errorCount,
|
|
447
|
+
...(includeSteps ? { steps } : {}),
|
|
448
|
+
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
449
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
450
|
+
};
|
|
451
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
452
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
453
|
+
}
|
|
454
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
455
|
+
});
|
|
360
456
|
server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
|
|
361
457
|
|
|
362
458
|
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
|
|
@@ -433,18 +529,47 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
433
529
|
const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
|
|
434
530
|
return ok(JSON.stringify(model));
|
|
435
531
|
});
|
|
532
|
+
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.
|
|
533
|
+
|
|
534
|
+
Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
|
|
535
|
+
formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
|
|
536
|
+
maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
|
|
537
|
+
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
538
|
+
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
539
|
+
}, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
|
|
540
|
+
const session = getSession();
|
|
541
|
+
if (!session?.tree || !session?.layout)
|
|
542
|
+
return err('Not connected. Call geometra_connect first.');
|
|
543
|
+
const a11y = buildA11yTree(session.tree, session.layout);
|
|
544
|
+
const forms = buildFormSchemas(a11y, {
|
|
545
|
+
formId,
|
|
546
|
+
maxFields,
|
|
547
|
+
onlyRequiredFields,
|
|
548
|
+
onlyInvalidFields,
|
|
549
|
+
});
|
|
550
|
+
if (forms.length === 0) {
|
|
551
|
+
return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
|
|
552
|
+
}
|
|
553
|
+
return ok(JSON.stringify({ forms }));
|
|
554
|
+
});
|
|
436
555
|
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.
|
|
437
556
|
|
|
438
557
|
Use this after geometra_page_model when you know which form/dialog/list/landmark you want to inspect more closely. Per-item bounds are omitted by default to save tokens; set includeBounds=true if you need them immediately.`, {
|
|
439
558
|
id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
|
|
440
559
|
maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
|
|
441
560
|
maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
|
|
561
|
+
fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
|
|
562
|
+
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
563
|
+
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
442
564
|
maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
|
|
565
|
+
actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
|
|
443
566
|
maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
|
|
567
|
+
listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
|
|
444
568
|
maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
|
|
569
|
+
itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
|
|
445
570
|
maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
|
|
446
571
|
includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
|
|
447
|
-
}, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
|
|
572
|
+
}, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
|
|
448
573
|
const session = getSession();
|
|
449
574
|
if (!session?.tree || !session?.layout)
|
|
450
575
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -452,9 +577,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
452
577
|
const detail = expandPageSection(a11y, id, {
|
|
453
578
|
maxHeadings,
|
|
454
579
|
maxFields,
|
|
580
|
+
fieldOffset,
|
|
581
|
+
onlyRequiredFields,
|
|
582
|
+
onlyInvalidFields,
|
|
455
583
|
maxActions,
|
|
584
|
+
actionOffset,
|
|
456
585
|
maxLists,
|
|
586
|
+
listOffset,
|
|
457
587
|
maxItems,
|
|
588
|
+
itemOffset,
|
|
458
589
|
maxTextPreview,
|
|
459
590
|
includeBounds,
|
|
460
591
|
});
|
|
@@ -462,6 +593,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
462
593
|
return err(`No expandable section found for id ${id}`);
|
|
463
594
|
return ok(JSON.stringify(detail));
|
|
464
595
|
});
|
|
596
|
+
server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
|
|
597
|
+
|
|
598
|
+
Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
|
|
599
|
+
...nodeFilterShape(),
|
|
600
|
+
index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
|
|
601
|
+
fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
|
|
602
|
+
maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
|
|
603
|
+
timeoutMs: z
|
|
604
|
+
.number()
|
|
605
|
+
.int()
|
|
606
|
+
.min(50)
|
|
607
|
+
.max(60_000)
|
|
608
|
+
.optional()
|
|
609
|
+
.default(2_500)
|
|
610
|
+
.describe('Per-scroll wait timeout (default 2500ms)'),
|
|
611
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
|
|
612
|
+
const session = getSession();
|
|
613
|
+
if (!session)
|
|
614
|
+
return err('Not connected. Call geometra_connect first.');
|
|
615
|
+
const matchIndex = index ?? 0;
|
|
616
|
+
const requireFullyVisible = fullyVisible ?? true;
|
|
617
|
+
const revealSteps = maxSteps ?? 6;
|
|
618
|
+
const waitTimeout = timeoutMs ?? 2_500;
|
|
619
|
+
const filter = {
|
|
620
|
+
id,
|
|
621
|
+
role,
|
|
622
|
+
name,
|
|
623
|
+
text,
|
|
624
|
+
contextText,
|
|
625
|
+
value,
|
|
626
|
+
checked,
|
|
627
|
+
disabled,
|
|
628
|
+
focused,
|
|
629
|
+
selected,
|
|
630
|
+
expanded,
|
|
631
|
+
invalid,
|
|
632
|
+
required,
|
|
633
|
+
busy,
|
|
634
|
+
};
|
|
635
|
+
if (!hasNodeFilter(filter))
|
|
636
|
+
return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
|
|
637
|
+
let attempts = 0;
|
|
638
|
+
while (attempts <= revealSteps) {
|
|
639
|
+
const a11y = sessionA11y(session);
|
|
640
|
+
if (!a11y)
|
|
641
|
+
return err('No UI tree available to reveal from');
|
|
642
|
+
const matches = sortA11yNodes(findNodes(a11y, filter));
|
|
643
|
+
if (matches.length === 0) {
|
|
644
|
+
return err(`No elements found matching ${JSON.stringify(filter)}`);
|
|
645
|
+
}
|
|
646
|
+
if (matchIndex >= matches.length) {
|
|
647
|
+
return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
|
|
648
|
+
}
|
|
649
|
+
const target = matches[matchIndex];
|
|
650
|
+
const formatted = formatNode(target, a11y, a11y.bounds);
|
|
651
|
+
const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
|
|
652
|
+
if (visible) {
|
|
653
|
+
return ok(JSON.stringify({
|
|
654
|
+
revealed: true,
|
|
655
|
+
attempts,
|
|
656
|
+
target: formatted,
|
|
657
|
+
}, null, 2));
|
|
658
|
+
}
|
|
659
|
+
if (attempts === revealSteps) {
|
|
660
|
+
return err(JSON.stringify({
|
|
661
|
+
revealed: false,
|
|
662
|
+
attempts,
|
|
663
|
+
target: formatted,
|
|
664
|
+
}, null, 2));
|
|
665
|
+
}
|
|
666
|
+
const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
|
|
667
|
+
let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
|
|
668
|
+
if (deltaY === 0 && !formatted.visibility.fullyVisible) {
|
|
669
|
+
deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
|
|
670
|
+
}
|
|
671
|
+
await sendWheel(session, deltaY, {
|
|
672
|
+
deltaX,
|
|
673
|
+
x: formatted.center.x,
|
|
674
|
+
y: formatted.center.y,
|
|
675
|
+
}, waitTimeout);
|
|
676
|
+
attempts++;
|
|
677
|
+
}
|
|
678
|
+
return err(`Failed to reveal ${JSON.stringify(filter)}`);
|
|
679
|
+
});
|
|
465
680
|
// ── click ────────────────────────────────────────────────────
|
|
466
681
|
server.tool('geometra_click', `Click an element in the Geometra UI. Provide either the element's bounds (from geometra_query) or raw x,y coordinates. The click is dispatched server-side via the geometry protocol — no browser, no simulated DOM events.
|
|
467
682
|
|
|
@@ -764,6 +979,18 @@ function compactSessionSummary(session) {
|
|
|
764
979
|
return 'No UI update received';
|
|
765
980
|
return sessionOverviewFromA11y(a11y);
|
|
766
981
|
}
|
|
982
|
+
function connectPayload(session, opts) {
|
|
983
|
+
const a11y = sessionA11y(session);
|
|
984
|
+
return {
|
|
985
|
+
connected: true,
|
|
986
|
+
transport: opts.transport,
|
|
987
|
+
wsUrl: session.url,
|
|
988
|
+
...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
|
|
989
|
+
...(opts.requestedWsUrl ? { requestedWsUrl: opts.requestedWsUrl } : {}),
|
|
990
|
+
...(opts.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
|
|
991
|
+
...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
|
|
992
|
+
};
|
|
993
|
+
}
|
|
767
994
|
function sessionA11y(session) {
|
|
768
995
|
if (!session.tree || !session.layout)
|
|
769
996
|
return null;
|
|
@@ -973,6 +1200,120 @@ function waitStatusPayload(wait) {
|
|
|
973
1200
|
function compactFilterPayload(filter) {
|
|
974
1201
|
return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
|
|
975
1202
|
}
|
|
1203
|
+
function normalizeLookupKey(value) {
|
|
1204
|
+
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1205
|
+
}
|
|
1206
|
+
function resolveTargetFormSchema(schemas, opts) {
|
|
1207
|
+
if (opts.formId) {
|
|
1208
|
+
const matched = schemas.find(schema => schema.formId === opts.formId);
|
|
1209
|
+
return matched
|
|
1210
|
+
? { ok: true, schema: matched }
|
|
1211
|
+
: { ok: false, error: `No form schema found for id ${opts.formId}` };
|
|
1212
|
+
}
|
|
1213
|
+
if (schemas.length === 1)
|
|
1214
|
+
return { ok: true, schema: schemas[0] };
|
|
1215
|
+
const idKeys = Object.keys(opts.valuesById ?? {});
|
|
1216
|
+
const labelKeys = Object.keys(opts.valuesByLabel ?? {}).map(normalizeLookupKey);
|
|
1217
|
+
const matches = schemas.filter(schema => {
|
|
1218
|
+
const ids = new Set(schema.fields.map(field => field.id));
|
|
1219
|
+
const labels = new Set(schema.fields.map(field => normalizeLookupKey(field.label)));
|
|
1220
|
+
return idKeys.every(id => ids.has(id)) && labelKeys.every(label => labels.has(label));
|
|
1221
|
+
});
|
|
1222
|
+
if (matches.length === 1)
|
|
1223
|
+
return { ok: true, schema: matches[0] };
|
|
1224
|
+
if (matches.length === 0) {
|
|
1225
|
+
return {
|
|
1226
|
+
ok: false,
|
|
1227
|
+
error: 'Could not infer which form to fill from the provided field ids/labels. Pass formId from geometra_form_schema.',
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
ok: false,
|
|
1232
|
+
error: 'Multiple forms match the provided field ids/labels. Pass formId from geometra_form_schema.',
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function coerceChoiceValue(field, value) {
|
|
1236
|
+
if (typeof value === 'string')
|
|
1237
|
+
return value;
|
|
1238
|
+
if (typeof value !== 'boolean')
|
|
1239
|
+
return null;
|
|
1240
|
+
const desired = value ? 'yes' : 'no';
|
|
1241
|
+
const option = field.options?.find(option => normalizeLookupKey(option) === desired);
|
|
1242
|
+
return option ?? (value ? 'Yes' : 'No');
|
|
1243
|
+
}
|
|
1244
|
+
function plannedFillInputsForField(field, value) {
|
|
1245
|
+
if (field.kind === 'text') {
|
|
1246
|
+
if (typeof value !== 'string')
|
|
1247
|
+
return { error: `Field "${field.label}" expects a string value` };
|
|
1248
|
+
return [{ kind: 'text', fieldLabel: field.label, value }];
|
|
1249
|
+
}
|
|
1250
|
+
if (field.kind === 'choice') {
|
|
1251
|
+
const coerced = coerceChoiceValue(field, value);
|
|
1252
|
+
if (!coerced)
|
|
1253
|
+
return { error: `Field "${field.label}" expects a string value` };
|
|
1254
|
+
return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
|
|
1255
|
+
}
|
|
1256
|
+
if (field.kind === 'toggle') {
|
|
1257
|
+
if (typeof value !== 'boolean')
|
|
1258
|
+
return { error: `Field "${field.label}" expects a boolean value` };
|
|
1259
|
+
return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
|
|
1260
|
+
}
|
|
1261
|
+
const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
1262
|
+
if (!selected || selected.length === 0)
|
|
1263
|
+
return { error: `Field "${field.label}" expects a string array value` };
|
|
1264
|
+
if (!field.options || field.options.length === 0) {
|
|
1265
|
+
return { error: `Field "${field.label}" does not expose checkbox options; use geometra_fill_fields for this field` };
|
|
1266
|
+
}
|
|
1267
|
+
const selectedKeys = new Set(selected.map(normalizeLookupKey));
|
|
1268
|
+
return field.options.map(option => ({
|
|
1269
|
+
kind: 'toggle',
|
|
1270
|
+
label: option,
|
|
1271
|
+
checked: selectedKeys.has(normalizeLookupKey(option)),
|
|
1272
|
+
controlType: 'checkbox',
|
|
1273
|
+
}));
|
|
1274
|
+
}
|
|
1275
|
+
function planFormFill(schema, opts) {
|
|
1276
|
+
const fieldById = new Map(schema.fields.map(field => [field.id, field]));
|
|
1277
|
+
const fieldsByLabel = new Map();
|
|
1278
|
+
for (const field of schema.fields) {
|
|
1279
|
+
const key = normalizeLookupKey(field.label);
|
|
1280
|
+
const existing = fieldsByLabel.get(key);
|
|
1281
|
+
if (existing)
|
|
1282
|
+
existing.push(field);
|
|
1283
|
+
else
|
|
1284
|
+
fieldsByLabel.set(key, [field]);
|
|
1285
|
+
}
|
|
1286
|
+
const planned = [];
|
|
1287
|
+
const seenFieldIds = new Set();
|
|
1288
|
+
for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
|
|
1289
|
+
const field = fieldById.get(fieldId);
|
|
1290
|
+
if (!field)
|
|
1291
|
+
return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
|
|
1292
|
+
const next = plannedFillInputsForField(field, value);
|
|
1293
|
+
if ('error' in next)
|
|
1294
|
+
return { ok: false, error: next.error };
|
|
1295
|
+
planned.push(...next);
|
|
1296
|
+
seenFieldIds.add(field.id);
|
|
1297
|
+
}
|
|
1298
|
+
for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
|
|
1299
|
+
const matches = fieldsByLabel.get(normalizeLookupKey(label)) ?? [];
|
|
1300
|
+
if (matches.length === 0)
|
|
1301
|
+
return { ok: false, error: `Unknown form field label "${label}". Refresh geometra_form_schema and try again.` };
|
|
1302
|
+
if (matches.length > 1) {
|
|
1303
|
+
return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
|
|
1304
|
+
}
|
|
1305
|
+
const field = matches[0];
|
|
1306
|
+
if (seenFieldIds.has(field.id)) {
|
|
1307
|
+
return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
|
|
1308
|
+
}
|
|
1309
|
+
const next = plannedFillInputsForField(field, value);
|
|
1310
|
+
if ('error' in next)
|
|
1311
|
+
return { ok: false, error: next.error };
|
|
1312
|
+
planned.push(...next);
|
|
1313
|
+
seenFieldIds.add(field.id);
|
|
1314
|
+
}
|
|
1315
|
+
return { ok: true, fields: planned };
|
|
1316
|
+
}
|
|
976
1317
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
977
1318
|
switch (action.type) {
|
|
978
1319
|
case 'click': {
|
|
@@ -1111,6 +1452,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
1111
1452
|
role: action.role,
|
|
1112
1453
|
name: action.name,
|
|
1113
1454
|
text: action.text,
|
|
1455
|
+
contextText: action.contextText,
|
|
1114
1456
|
value: action.value,
|
|
1115
1457
|
checked: action.checked,
|
|
1116
1458
|
disabled: action.disabled,
|
|
@@ -1162,7 +1504,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
1162
1504
|
const matches = findNodes(after, filter);
|
|
1163
1505
|
if (detail === 'verbose') {
|
|
1164
1506
|
return {
|
|
1165
|
-
summary: JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2),
|
|
1507
|
+
summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
|
|
1166
1508
|
compact: {
|
|
1167
1509
|
present,
|
|
1168
1510
|
elapsedMs,
|
|
@@ -1287,7 +1629,96 @@ function textMatches(haystack, needle) {
|
|
|
1287
1629
|
return false;
|
|
1288
1630
|
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1289
1631
|
}
|
|
1290
|
-
function
|
|
1632
|
+
function sortA11yNodes(nodes) {
|
|
1633
|
+
return [...nodes].sort((a, b) => {
|
|
1634
|
+
if (a.bounds.y !== b.bounds.y)
|
|
1635
|
+
return a.bounds.y - b.bounds.y;
|
|
1636
|
+
if (a.bounds.x !== b.bounds.x)
|
|
1637
|
+
return a.bounds.x - b.bounds.x;
|
|
1638
|
+
return a.path.length - b.path.length;
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
function clamp(value, min, max) {
|
|
1642
|
+
return Math.min(Math.max(value, min), max);
|
|
1643
|
+
}
|
|
1644
|
+
function pathStartsWith(path, prefix) {
|
|
1645
|
+
if (prefix.length > path.length)
|
|
1646
|
+
return false;
|
|
1647
|
+
for (let index = 0; index < prefix.length; index++) {
|
|
1648
|
+
if (path[index] !== prefix[index])
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
return true;
|
|
1652
|
+
}
|
|
1653
|
+
function namedAncestors(root, path) {
|
|
1654
|
+
const out = [];
|
|
1655
|
+
let current = root;
|
|
1656
|
+
for (const index of path) {
|
|
1657
|
+
out.push(current);
|
|
1658
|
+
if (!current.children[index])
|
|
1659
|
+
break;
|
|
1660
|
+
current = current.children[index];
|
|
1661
|
+
}
|
|
1662
|
+
return out;
|
|
1663
|
+
}
|
|
1664
|
+
function collectDescendants(node, predicate) {
|
|
1665
|
+
const out = [];
|
|
1666
|
+
function walk(current) {
|
|
1667
|
+
for (const child of current.children) {
|
|
1668
|
+
if (predicate(child))
|
|
1669
|
+
out.push(child);
|
|
1670
|
+
walk(child);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
walk(node);
|
|
1674
|
+
return out;
|
|
1675
|
+
}
|
|
1676
|
+
function promptContext(root, node) {
|
|
1677
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1678
|
+
const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1679
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1680
|
+
const ancestor = ancestors[index];
|
|
1681
|
+
const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
|
|
1682
|
+
if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
|
|
1683
|
+
continue;
|
|
1684
|
+
const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
|
|
1685
|
+
!!truncateInlineText(candidate.name, 120) &&
|
|
1686
|
+
!pathStartsWith(candidate.path, node.path))
|
|
1687
|
+
.filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
|
|
1688
|
+
.map(candidate => {
|
|
1689
|
+
const text = truncateInlineText(candidate.name, 120);
|
|
1690
|
+
if (!text)
|
|
1691
|
+
return null;
|
|
1692
|
+
if (text.toLowerCase() === normalizedName)
|
|
1693
|
+
return null;
|
|
1694
|
+
const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
|
|
1695
|
+
const dx = Math.abs(node.bounds.x - candidate.bounds.x);
|
|
1696
|
+
const headingBonus = candidate.role === 'heading' ? -32 : 0;
|
|
1697
|
+
return { text, score: dy * 4 + dx + headingBonus };
|
|
1698
|
+
})
|
|
1699
|
+
.filter((candidate) => !!candidate)
|
|
1700
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
1701
|
+
if (best?.text)
|
|
1702
|
+
return best.text;
|
|
1703
|
+
}
|
|
1704
|
+
return undefined;
|
|
1705
|
+
}
|
|
1706
|
+
function sectionContext(root, node) {
|
|
1707
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1708
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1709
|
+
const ancestor = ancestors[index];
|
|
1710
|
+
if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
|
|
1711
|
+
const name = truncateInlineText(ancestor.name, 80);
|
|
1712
|
+
if (name)
|
|
1713
|
+
return name;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
return undefined;
|
|
1717
|
+
}
|
|
1718
|
+
function nodeContextText(root, node) {
|
|
1719
|
+
return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
|
|
1720
|
+
}
|
|
1721
|
+
function nodeMatchesFilter(node, filter, contextText) {
|
|
1291
1722
|
if (filter.id && nodeIdForPath(node.path) !== filter.id)
|
|
1292
1723
|
return false;
|
|
1293
1724
|
if (filter.role && node.role !== filter.role)
|
|
@@ -1299,6 +1730,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1299
1730
|
if (filter.text &&
|
|
1300
1731
|
!textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
|
|
1301
1732
|
return false;
|
|
1733
|
+
if (!textMatches(contextText, filter.contextText))
|
|
1734
|
+
return false;
|
|
1302
1735
|
if (filter.checked !== undefined && node.state?.checked !== filter.checked)
|
|
1303
1736
|
return false;
|
|
1304
1737
|
if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
|
|
@@ -1320,7 +1753,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1320
1753
|
export function findNodes(node, filter) {
|
|
1321
1754
|
const matches = [];
|
|
1322
1755
|
function walk(n) {
|
|
1323
|
-
|
|
1756
|
+
const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
|
|
1757
|
+
if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
|
|
1324
1758
|
matches.push(n);
|
|
1325
1759
|
for (const child of n.children)
|
|
1326
1760
|
walk(child);
|
|
@@ -1345,7 +1779,7 @@ function summarizeFieldLabelState(session, fieldLabel) {
|
|
|
1345
1779
|
parts.push(`error=${JSON.stringify(payload.error)}`);
|
|
1346
1780
|
return parts.join(' ');
|
|
1347
1781
|
}
|
|
1348
|
-
function formatNode(node, viewport) {
|
|
1782
|
+
function formatNode(node, root, viewport) {
|
|
1349
1783
|
const visibleLeft = Math.max(0, node.bounds.x);
|
|
1350
1784
|
const visibleTop = Math.max(0, node.bounds.y);
|
|
1351
1785
|
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
@@ -1363,11 +1797,14 @@ function formatNode(node, viewport) {
|
|
|
1363
1797
|
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
1364
1798
|
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
1365
1799
|
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
1800
|
+
const prompt = promptContext(root, node);
|
|
1801
|
+
const section = sectionContext(root, node);
|
|
1366
1802
|
return {
|
|
1367
1803
|
id: nodeIdForPath(node.path),
|
|
1368
1804
|
role: node.role,
|
|
1369
1805
|
name: node.name,
|
|
1370
1806
|
...(node.value ? { value: node.value } : {}),
|
|
1807
|
+
...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
|
|
1371
1808
|
bounds: node.bounds,
|
|
1372
1809
|
visibleBounds: {
|
|
1373
1810
|
x: visibleLeft,
|