@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/session.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface A11yNode {
|
|
|
27
27
|
pageUrl?: string;
|
|
28
28
|
scrollX?: number;
|
|
29
29
|
scrollY?: number;
|
|
30
|
+
controlTag?: string;
|
|
30
31
|
};
|
|
31
32
|
bounds: {
|
|
32
33
|
x: number;
|
|
@@ -242,12 +243,16 @@ export interface PageSectionDetail {
|
|
|
242
243
|
textPreview: string[];
|
|
243
244
|
}
|
|
244
245
|
export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
|
|
246
|
+
export type FormSchemaChoiceType = 'select' | 'group' | 'listbox';
|
|
247
|
+
export type FormSchemaContextMode = 'auto' | 'always' | 'none';
|
|
245
248
|
export interface FormSchemaField {
|
|
246
249
|
id: string;
|
|
247
250
|
kind: FormSchemaFieldKind;
|
|
248
251
|
label: string;
|
|
249
252
|
required?: boolean;
|
|
250
253
|
invalid?: boolean;
|
|
254
|
+
choiceType?: FormSchemaChoiceType;
|
|
255
|
+
booleanChoice?: boolean;
|
|
251
256
|
controlType?: 'checkbox' | 'radio';
|
|
252
257
|
value?: string;
|
|
253
258
|
valueLength?: number;
|
|
@@ -265,6 +270,14 @@ export interface FormSchemaModel {
|
|
|
265
270
|
invalidCount: number;
|
|
266
271
|
fields: FormSchemaField[];
|
|
267
272
|
}
|
|
273
|
+
export interface FormSchemaBuildOptions {
|
|
274
|
+
formId?: string;
|
|
275
|
+
maxFields?: number;
|
|
276
|
+
onlyRequiredFields?: boolean;
|
|
277
|
+
onlyInvalidFields?: boolean;
|
|
278
|
+
includeOptions?: boolean;
|
|
279
|
+
includeContext?: FormSchemaContextMode;
|
|
280
|
+
}
|
|
268
281
|
export interface UiNodeUpdate {
|
|
269
282
|
before: CompactUiNode;
|
|
270
283
|
after: CompactUiNode;
|
|
@@ -312,6 +325,13 @@ export interface Session {
|
|
|
312
325
|
updateRevision: number;
|
|
313
326
|
/** Present when this session owns a child geometra-proxy process (pageUrl connect). */
|
|
314
327
|
proxyChild?: ChildProcess;
|
|
328
|
+
proxyReusable?: boolean;
|
|
329
|
+
cachedA11y?: A11yNode | null;
|
|
330
|
+
cachedA11yRevision?: number;
|
|
331
|
+
cachedFormSchemas?: Map<string, {
|
|
332
|
+
revision: number;
|
|
333
|
+
forms: FormSchemaModel[];
|
|
334
|
+
}>;
|
|
315
335
|
}
|
|
316
336
|
export interface UpdateWaitResult {
|
|
317
337
|
status: 'updated' | 'acknowledged' | 'timed_out';
|
|
@@ -319,16 +339,25 @@ export interface UpdateWaitResult {
|
|
|
319
339
|
result?: unknown;
|
|
320
340
|
}
|
|
321
341
|
export type ProxyFillField = {
|
|
342
|
+
kind: 'auto';
|
|
343
|
+
fieldId?: string;
|
|
344
|
+
fieldLabel: string;
|
|
345
|
+
value: string | boolean;
|
|
346
|
+
exact?: boolean;
|
|
347
|
+
} | {
|
|
322
348
|
kind: 'text';
|
|
349
|
+
fieldId?: string;
|
|
323
350
|
fieldLabel: string;
|
|
324
351
|
value: string;
|
|
325
352
|
exact?: boolean;
|
|
326
353
|
} | {
|
|
327
354
|
kind: 'choice';
|
|
355
|
+
fieldId?: string;
|
|
328
356
|
fieldLabel: string;
|
|
329
357
|
value: string;
|
|
330
358
|
query?: string;
|
|
331
359
|
exact?: boolean;
|
|
360
|
+
choiceType?: FormSchemaChoiceType;
|
|
332
361
|
} | {
|
|
333
362
|
kind: 'toggle';
|
|
334
363
|
label: string;
|
|
@@ -337,6 +366,7 @@ export type ProxyFillField = {
|
|
|
337
366
|
controlType?: 'checkbox' | 'radio';
|
|
338
367
|
} | {
|
|
339
368
|
kind: 'file';
|
|
369
|
+
fieldId?: string;
|
|
340
370
|
fieldLabel: string;
|
|
341
371
|
paths: string[];
|
|
342
372
|
exact?: boolean;
|
|
@@ -349,6 +379,8 @@ export declare function connect(url: string, opts?: {
|
|
|
349
379
|
width?: number;
|
|
350
380
|
height?: number;
|
|
351
381
|
skipInitialResize?: boolean;
|
|
382
|
+
closePreviousProxy?: boolean;
|
|
383
|
+
awaitInitialFrame?: boolean;
|
|
352
384
|
}): Promise<Session>;
|
|
353
385
|
/**
|
|
354
386
|
* Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
|
|
@@ -361,9 +393,12 @@ export declare function connectThroughProxy(options: {
|
|
|
361
393
|
width?: number;
|
|
362
394
|
height?: number;
|
|
363
395
|
slowMo?: number;
|
|
396
|
+
awaitInitialFrame?: boolean;
|
|
364
397
|
}): Promise<Session>;
|
|
365
398
|
export declare function getSession(): Session | null;
|
|
366
|
-
export declare function disconnect(
|
|
399
|
+
export declare function disconnect(opts?: {
|
|
400
|
+
closeProxy?: boolean;
|
|
401
|
+
}): void;
|
|
367
402
|
export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
|
|
368
403
|
/**
|
|
369
404
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -402,11 +437,14 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
402
437
|
/** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
|
|
403
438
|
export declare function sendFieldText(session: Session, fieldLabel: string, value: string, opts?: {
|
|
404
439
|
exact?: boolean;
|
|
440
|
+
fieldId?: string;
|
|
405
441
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
406
442
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
407
443
|
export declare function sendFieldChoice(session: Session, fieldLabel: string, value: string, opts?: {
|
|
408
444
|
exact?: boolean;
|
|
409
445
|
query?: string;
|
|
446
|
+
choiceType?: FormSchemaChoiceType;
|
|
447
|
+
fieldId?: string;
|
|
410
448
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
411
449
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
412
450
|
export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
@@ -438,6 +476,8 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
|
438
476
|
x?: number;
|
|
439
477
|
y?: number;
|
|
440
478
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
479
|
+
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
480
|
+
export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
441
481
|
/**
|
|
442
482
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
443
483
|
* This is a standalone reimplementation that works with raw JSON —
|
|
@@ -467,12 +507,7 @@ export declare function buildPageModel(root: A11yNode, options?: {
|
|
|
467
507
|
maxPrimaryActions?: number;
|
|
468
508
|
maxSectionsPerKind?: number;
|
|
469
509
|
}): PageModel;
|
|
470
|
-
export declare function buildFormSchemas(root: A11yNode, options?:
|
|
471
|
-
formId?: string;
|
|
472
|
-
maxFields?: number;
|
|
473
|
-
onlyRequiredFields?: boolean;
|
|
474
|
-
onlyInvalidFields?: boolean;
|
|
475
|
-
}): FormSchemaModel[];
|
|
510
|
+
export declare function buildFormSchemas(root: A11yNode, options?: FormSchemaBuildOptions): FormSchemaModel[];
|
|
476
511
|
/**
|
|
477
512
|
* Expand a page-model section by stable ID into richer, on-demand details.
|
|
478
513
|
*/
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
3
3
|
let activeSession = null;
|
|
4
|
+
let reusableProxy = null;
|
|
4
5
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
6
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
7
|
const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
|
|
@@ -12,7 +13,57 @@ const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
|
|
|
12
13
|
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
13
14
|
const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
|
|
14
15
|
let nextRequestSequence = 0;
|
|
15
|
-
function
|
|
16
|
+
function invalidateSessionCaches(session) {
|
|
17
|
+
session.cachedA11y = null;
|
|
18
|
+
session.cachedA11yRevision = -1;
|
|
19
|
+
session.cachedFormSchemas?.clear();
|
|
20
|
+
}
|
|
21
|
+
function clearReusableProxyIfExited() {
|
|
22
|
+
if (!reusableProxy?.child.killed && reusableProxy?.child.exitCode === null && reusableProxy?.child.signalCode === null) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
reusableProxy = null;
|
|
26
|
+
}
|
|
27
|
+
function setReusableProxy(child, wsUrl, opts) {
|
|
28
|
+
reusableProxy = {
|
|
29
|
+
child,
|
|
30
|
+
wsUrl,
|
|
31
|
+
headless: opts.headless === true,
|
|
32
|
+
slowMo: opts.slowMo ?? 0,
|
|
33
|
+
width: opts.width ?? 1280,
|
|
34
|
+
height: opts.height ?? 720,
|
|
35
|
+
pageUrl: opts.pageUrl,
|
|
36
|
+
};
|
|
37
|
+
const clear = () => {
|
|
38
|
+
if (reusableProxy?.child === child)
|
|
39
|
+
reusableProxy = null;
|
|
40
|
+
};
|
|
41
|
+
child.once('exit', clear);
|
|
42
|
+
child.once('close', clear);
|
|
43
|
+
child.once('error', clear);
|
|
44
|
+
}
|
|
45
|
+
function closeReusableProxy() {
|
|
46
|
+
clearReusableProxyIfExited();
|
|
47
|
+
const proxy = reusableProxy;
|
|
48
|
+
reusableProxy = null;
|
|
49
|
+
if (!proxy)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
proxy.child.kill('SIGTERM');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function rememberReusableProxyPageUrl(session) {
|
|
59
|
+
const pageUrl = session.cachedA11y?.meta?.pageUrl;
|
|
60
|
+
if (!pageUrl)
|
|
61
|
+
return;
|
|
62
|
+
if (session.proxyChild && reusableProxy?.child === session.proxyChild) {
|
|
63
|
+
reusableProxy.pageUrl = pageUrl;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function shutdownPreviousSession(opts) {
|
|
16
67
|
const prev = activeSession;
|
|
17
68
|
if (!prev)
|
|
18
69
|
return;
|
|
@@ -24,6 +75,12 @@ function shutdownPreviousSession() {
|
|
|
24
75
|
/* ignore */
|
|
25
76
|
}
|
|
26
77
|
if (prev.proxyChild) {
|
|
78
|
+
const shouldKeepProxy = prev.proxyReusable && opts?.closeProxy === false;
|
|
79
|
+
rememberReusableProxyPageUrl(prev);
|
|
80
|
+
if (shouldKeepProxy)
|
|
81
|
+
return;
|
|
82
|
+
if (reusableProxy?.child === prev.proxyChild)
|
|
83
|
+
reusableProxy = null;
|
|
27
84
|
try {
|
|
28
85
|
prev.proxyChild.kill('SIGTERM');
|
|
29
86
|
}
|
|
@@ -38,9 +95,22 @@ function shutdownPreviousSession() {
|
|
|
38
95
|
*/
|
|
39
96
|
export function connect(url, opts) {
|
|
40
97
|
return new Promise((resolve, reject) => {
|
|
41
|
-
|
|
98
|
+
clearReusableProxyIfExited();
|
|
99
|
+
if (reusableProxy && reusableProxy.wsUrl !== url) {
|
|
100
|
+
closeReusableProxy();
|
|
101
|
+
}
|
|
102
|
+
shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
|
|
42
103
|
const ws = new WebSocket(url);
|
|
43
|
-
const session = {
|
|
104
|
+
const session = {
|
|
105
|
+
ws,
|
|
106
|
+
layout: null,
|
|
107
|
+
tree: null,
|
|
108
|
+
url,
|
|
109
|
+
updateRevision: 0,
|
|
110
|
+
cachedA11y: null,
|
|
111
|
+
cachedA11yRevision: -1,
|
|
112
|
+
cachedFormSchemas: new Map(),
|
|
113
|
+
};
|
|
44
114
|
let resolved = false;
|
|
45
115
|
const timeout = setTimeout(() => {
|
|
46
116
|
if (!resolved) {
|
|
@@ -50,11 +120,17 @@ export function connect(url, opts) {
|
|
|
50
120
|
}
|
|
51
121
|
}, 10_000);
|
|
52
122
|
ws.on('open', () => {
|
|
53
|
-
if (opts?.skipInitialResize)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
123
|
+
if (!opts?.skipInitialResize) {
|
|
124
|
+
const width = opts?.width ?? 1024;
|
|
125
|
+
const height = opts?.height ?? 768;
|
|
126
|
+
ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
127
|
+
}
|
|
128
|
+
if (opts?.awaitInitialFrame === false && !resolved) {
|
|
129
|
+
resolved = true;
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
activeSession = session;
|
|
132
|
+
resolve(session);
|
|
133
|
+
}
|
|
58
134
|
});
|
|
59
135
|
ws.on('message', (data) => {
|
|
60
136
|
try {
|
|
@@ -63,6 +139,7 @@ export function connect(url, opts) {
|
|
|
63
139
|
session.layout = msg.layout;
|
|
64
140
|
session.tree = msg.tree;
|
|
65
141
|
session.updateRevision++;
|
|
142
|
+
invalidateSessionCaches(session);
|
|
66
143
|
if (!resolved) {
|
|
67
144
|
resolved = true;
|
|
68
145
|
clearTimeout(timeout);
|
|
@@ -73,6 +150,7 @@ export function connect(url, opts) {
|
|
|
73
150
|
else if (msg.type === 'patch' && session.layout) {
|
|
74
151
|
applyPatches(session.layout, msg.patches);
|
|
75
152
|
session.updateRevision++;
|
|
153
|
+
invalidateSessionCaches(session);
|
|
76
154
|
}
|
|
77
155
|
}
|
|
78
156
|
catch { /* ignore malformed messages */ }
|
|
@@ -87,7 +165,7 @@ export function connect(url, opts) {
|
|
|
87
165
|
ws.on('close', () => {
|
|
88
166
|
if (activeSession === session) {
|
|
89
167
|
activeSession = null;
|
|
90
|
-
if (session.proxyChild) {
|
|
168
|
+
if (session.proxyChild && !session.proxyReusable) {
|
|
91
169
|
try {
|
|
92
170
|
session.proxyChild.kill('SIGTERM');
|
|
93
171
|
}
|
|
@@ -109,6 +187,47 @@ export function connect(url, opts) {
|
|
|
109
187
|
* process to the session so disconnect / reconnect can clean it up.
|
|
110
188
|
*/
|
|
111
189
|
export async function connectThroughProxy(options) {
|
|
190
|
+
clearReusableProxyIfExited();
|
|
191
|
+
const desiredHeadless = options.headless === true;
|
|
192
|
+
const desiredSlowMo = options.slowMo ?? 0;
|
|
193
|
+
if (reusableProxy &&
|
|
194
|
+
reusableProxy.headless === desiredHeadless &&
|
|
195
|
+
reusableProxy.slowMo === desiredSlowMo) {
|
|
196
|
+
const session = activeSession?.proxyChild === reusableProxy.child
|
|
197
|
+
? activeSession
|
|
198
|
+
: await connect(reusableProxy.wsUrl, {
|
|
199
|
+
skipInitialResize: true,
|
|
200
|
+
closePreviousProxy: false,
|
|
201
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
202
|
+
});
|
|
203
|
+
if (!session) {
|
|
204
|
+
throw new Error('Failed to attach to reusable proxy session');
|
|
205
|
+
}
|
|
206
|
+
session.proxyChild = reusableProxy.child;
|
|
207
|
+
session.proxyReusable = true;
|
|
208
|
+
const desiredWidth = options.width ?? reusableProxy.width;
|
|
209
|
+
const desiredHeight = options.height ?? reusableProxy.height;
|
|
210
|
+
if (desiredWidth !== reusableProxy.width || desiredHeight !== reusableProxy.height) {
|
|
211
|
+
await sendAndWaitForUpdate(session, {
|
|
212
|
+
type: 'resize',
|
|
213
|
+
width: desiredWidth,
|
|
214
|
+
height: desiredHeight,
|
|
215
|
+
}, 5_000);
|
|
216
|
+
reusableProxy.width = desiredWidth;
|
|
217
|
+
reusableProxy.height = desiredHeight;
|
|
218
|
+
}
|
|
219
|
+
if (options.pageUrl) {
|
|
220
|
+
const currentUrl = session.cachedA11y?.meta?.pageUrl ?? reusableProxy.pageUrl;
|
|
221
|
+
if (currentUrl !== options.pageUrl) {
|
|
222
|
+
await sendNavigate(session, options.pageUrl, 15_000);
|
|
223
|
+
if (reusableProxy?.child === session.proxyChild) {
|
|
224
|
+
reusableProxy.pageUrl = options.pageUrl;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return session;
|
|
229
|
+
}
|
|
230
|
+
closeReusableProxy();
|
|
112
231
|
const { child, wsUrl } = await spawnGeometraProxy({
|
|
113
232
|
pageUrl: options.pageUrl,
|
|
114
233
|
port: options.port ?? 0,
|
|
@@ -118,8 +237,20 @@ export async function connectThroughProxy(options) {
|
|
|
118
237
|
slowMo: options.slowMo,
|
|
119
238
|
});
|
|
120
239
|
try {
|
|
121
|
-
const session = await connect(wsUrl, {
|
|
240
|
+
const session = await connect(wsUrl, {
|
|
241
|
+
skipInitialResize: true,
|
|
242
|
+
closePreviousProxy: false,
|
|
243
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
244
|
+
});
|
|
122
245
|
session.proxyChild = child;
|
|
246
|
+
session.proxyReusable = true;
|
|
247
|
+
setReusableProxy(child, wsUrl, {
|
|
248
|
+
headless: options.headless,
|
|
249
|
+
slowMo: options.slowMo,
|
|
250
|
+
width: options.width,
|
|
251
|
+
height: options.height,
|
|
252
|
+
pageUrl: options.pageUrl,
|
|
253
|
+
});
|
|
123
254
|
return session;
|
|
124
255
|
}
|
|
125
256
|
catch (e) {
|
|
@@ -135,21 +266,26 @@ export async function connectThroughProxy(options) {
|
|
|
135
266
|
export function getSession() {
|
|
136
267
|
return activeSession;
|
|
137
268
|
}
|
|
138
|
-
export function disconnect() {
|
|
139
|
-
shutdownPreviousSession();
|
|
269
|
+
export function disconnect(opts) {
|
|
270
|
+
shutdownPreviousSession({ closeProxy: opts?.closeProxy ?? false });
|
|
271
|
+
if (opts?.closeProxy)
|
|
272
|
+
closeReusableProxy();
|
|
140
273
|
}
|
|
141
274
|
function estimateFillBatchTimeout(fields) {
|
|
142
275
|
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
143
276
|
let totalTextLength = 0;
|
|
144
277
|
for (const field of fields) {
|
|
145
278
|
switch (field.kind) {
|
|
279
|
+
case 'auto':
|
|
280
|
+
total += typeof field.value === 'boolean' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
281
|
+
break;
|
|
146
282
|
case 'text':
|
|
147
283
|
totalTextLength += field.value.length;
|
|
148
284
|
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
149
285
|
total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
|
|
150
286
|
break;
|
|
151
287
|
case 'choice':
|
|
152
|
-
total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
288
|
+
total += field.choiceType === 'group' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
153
289
|
break;
|
|
154
290
|
case 'toggle':
|
|
155
291
|
total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
|
|
@@ -285,6 +421,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
|
|
|
285
421
|
};
|
|
286
422
|
if (opts?.exact !== undefined)
|
|
287
423
|
payload.exact = opts.exact;
|
|
424
|
+
if (opts?.fieldId)
|
|
425
|
+
payload.fieldId = opts.fieldId;
|
|
288
426
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
289
427
|
}
|
|
290
428
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
@@ -298,6 +436,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
298
436
|
payload.exact = opts.exact;
|
|
299
437
|
if (opts?.query)
|
|
300
438
|
payload.query = opts.query;
|
|
439
|
+
if (opts?.choiceType)
|
|
440
|
+
payload.choiceType = opts.choiceType;
|
|
441
|
+
if (opts?.fieldId)
|
|
442
|
+
payload.fieldId = opts.fieldId;
|
|
301
443
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
302
444
|
}
|
|
303
445
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
@@ -349,6 +491,13 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
|
349
491
|
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
350
492
|
}, timeoutMs);
|
|
351
493
|
}
|
|
494
|
+
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
495
|
+
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
496
|
+
return sendAndWaitForUpdate(session, {
|
|
497
|
+
type: 'navigate',
|
|
498
|
+
url,
|
|
499
|
+
}, timeoutMs, { requireUpdateOnAck: true });
|
|
500
|
+
}
|
|
352
501
|
/**
|
|
353
502
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
354
503
|
* This is a standalone reimplementation that works with raw JSON —
|
|
@@ -974,10 +1123,16 @@ function simpleSchemaField(root, node) {
|
|
|
974
1123
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
975
1124
|
if (!label)
|
|
976
1125
|
return null;
|
|
1126
|
+
const choiceType = node.role === 'combobox'
|
|
1127
|
+
? node.meta?.controlTag === 'select'
|
|
1128
|
+
? 'select'
|
|
1129
|
+
: 'listbox'
|
|
1130
|
+
: undefined;
|
|
977
1131
|
return {
|
|
978
1132
|
id: formFieldIdForPath(node.path),
|
|
979
1133
|
kind: node.role === 'combobox' ? 'choice' : 'text',
|
|
980
1134
|
label,
|
|
1135
|
+
...(choiceType ? { choiceType } : {}),
|
|
981
1136
|
...(node.state?.required ? { required: true } : {}),
|
|
982
1137
|
...(node.state?.invalid ? { invalid: true } : {}),
|
|
983
1138
|
...compactSchemaValue(node.value, 72),
|
|
@@ -1002,6 +1157,7 @@ function groupedSchemaField(root, grouped) {
|
|
|
1002
1157
|
id: formFieldIdForPath(grouped.container.path),
|
|
1003
1158
|
kind: radioLike ? 'choice' : 'multi_choice',
|
|
1004
1159
|
label: grouped.prompt,
|
|
1160
|
+
...(radioLike ? { choiceType: 'group' } : {}),
|
|
1005
1161
|
...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
|
|
1006
1162
|
...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
|
|
1007
1163
|
...(radioLike
|
|
@@ -1068,7 +1224,7 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1068
1224
|
consumed.add(candidateKey);
|
|
1069
1225
|
}
|
|
1070
1226
|
}
|
|
1071
|
-
const compactFields =
|
|
1227
|
+
const compactFields = presentFormSchemaFields(fields, options);
|
|
1072
1228
|
const filteredFields = compactFields.filter(field => {
|
|
1073
1229
|
if (options?.onlyRequiredFields && !field.required)
|
|
1074
1230
|
return false;
|
|
@@ -1089,14 +1245,35 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1089
1245
|
};
|
|
1090
1246
|
}
|
|
1091
1247
|
function trimSchemaFieldContexts(fields) {
|
|
1248
|
+
return presentFormSchemaFields(fields, { includeOptions: true, includeContext: 'auto' });
|
|
1249
|
+
}
|
|
1250
|
+
function presentFormSchemaFields(fields, options) {
|
|
1251
|
+
const includeOptions = options?.includeOptions ?? false;
|
|
1252
|
+
const includeContext = options?.includeContext ?? 'auto';
|
|
1092
1253
|
const labelCounts = new Map();
|
|
1093
1254
|
for (const field of fields) {
|
|
1094
1255
|
const key = normalizeUiText(field.label);
|
|
1095
1256
|
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
|
1096
1257
|
}
|
|
1097
1258
|
return fields.map(field => {
|
|
1259
|
+
const booleanChoice = field.kind === 'choice' &&
|
|
1260
|
+
field.choiceType === 'group' &&
|
|
1261
|
+
field.optionCount === 2 &&
|
|
1262
|
+
field.options?.length === 2 &&
|
|
1263
|
+
field.options.every(option => ['yes', 'no'].includes(normalizeUiText(option).toLowerCase()));
|
|
1264
|
+
const next = { ...field };
|
|
1265
|
+
if (booleanChoice)
|
|
1266
|
+
next.booleanChoice = true;
|
|
1267
|
+
if (!includeOptions)
|
|
1268
|
+
delete next.options;
|
|
1269
|
+
if (includeContext === 'none') {
|
|
1270
|
+
delete next.context;
|
|
1271
|
+
return next;
|
|
1272
|
+
}
|
|
1098
1273
|
if (!field.context)
|
|
1099
|
-
return
|
|
1274
|
+
return next;
|
|
1275
|
+
if (includeContext === 'always')
|
|
1276
|
+
return next;
|
|
1100
1277
|
const trimmed = {};
|
|
1101
1278
|
if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
1102
1279
|
trimmed.prompt = field.context.prompt;
|
|
@@ -1105,10 +1282,11 @@ function trimSchemaFieldContexts(fields) {
|
|
|
1105
1282
|
trimmed.section = field.context.section;
|
|
1106
1283
|
}
|
|
1107
1284
|
if (Object.keys(trimmed).length === 0) {
|
|
1108
|
-
|
|
1109
|
-
return
|
|
1285
|
+
delete next.context;
|
|
1286
|
+
return next;
|
|
1110
1287
|
}
|
|
1111
|
-
|
|
1288
|
+
next.context = trimmed;
|
|
1289
|
+
return next;
|
|
1112
1290
|
});
|
|
1113
1291
|
}
|
|
1114
1292
|
function toLandmarkModel(node) {
|
|
@@ -1690,6 +1868,8 @@ function walkNode(element, layout, path) {
|
|
|
1690
1868
|
meta.scrollX = semantic.scrollX;
|
|
1691
1869
|
if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
|
|
1692
1870
|
meta.scrollY = semantic.scrollY;
|
|
1871
|
+
if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
|
|
1872
|
+
meta.controlTag = semantic.tag;
|
|
1693
1873
|
const children = [];
|
|
1694
1874
|
const elementChildren = element.children;
|
|
1695
1875
|
const layoutChildren = layout.children;
|
|
@@ -1789,7 +1969,7 @@ function applyPatches(layout, patches) {
|
|
|
1789
1969
|
node.height = patch.height;
|
|
1790
1970
|
}
|
|
1791
1971
|
}
|
|
1792
|
-
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
|
|
1972
|
+
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
1793
1973
|
return new Promise((resolve, reject) => {
|
|
1794
1974
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
1795
1975
|
reject(new Error('Not connected'));
|
|
@@ -1798,11 +1978,14 @@ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOU
|
|
|
1798
1978
|
const requestId = `req-${++nextRequestSequence}`;
|
|
1799
1979
|
const startRevision = session.updateRevision;
|
|
1800
1980
|
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
1801
|
-
waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
|
|
1981
|
+
waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts).then(resolve).catch(reject);
|
|
1802
1982
|
});
|
|
1803
1983
|
}
|
|
1804
|
-
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
|
|
1984
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
|
|
1805
1985
|
return new Promise((resolve, reject) => {
|
|
1986
|
+
let ackSeen = false;
|
|
1987
|
+
let ackResult;
|
|
1988
|
+
const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
|
|
1806
1989
|
const onMessage = (data) => {
|
|
1807
1990
|
try {
|
|
1808
1991
|
const msg = JSON.parse(String(data));
|
|
@@ -1813,13 +1996,26 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1813
1996
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1814
1997
|
return;
|
|
1815
1998
|
}
|
|
1816
|
-
if (msg.type === '
|
|
1999
|
+
if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
|
|
1817
2000
|
cleanup();
|
|
1818
2001
|
resolve({
|
|
1819
|
-
status:
|
|
2002
|
+
status: 'updated',
|
|
1820
2003
|
timeoutMs,
|
|
1821
|
-
...(
|
|
2004
|
+
...ackPayload(),
|
|
1822
2005
|
});
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
2009
|
+
ackSeen = true;
|
|
2010
|
+
ackResult = msg.result;
|
|
2011
|
+
if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
|
|
2012
|
+
cleanup();
|
|
2013
|
+
resolve({
|
|
2014
|
+
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
2015
|
+
timeoutMs,
|
|
2016
|
+
...ackPayload(),
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
1823
2019
|
}
|
|
1824
2020
|
return;
|
|
1825
2021
|
}
|
|
@@ -1851,7 +2047,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1851
2047
|
const timeout = setTimeout(() => {
|
|
1852
2048
|
cleanup();
|
|
1853
2049
|
if (requestId && session.updateRevision > startRevision) {
|
|
1854
|
-
resolve({ status: 'updated', timeoutMs });
|
|
2050
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (requestId && ackSeen) {
|
|
2054
|
+
resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
|
|
1855
2055
|
return;
|
|
1856
2056
|
}
|
|
1857
2057
|
resolve({ status: 'timed_out', timeoutMs });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.16",
|
|
4
4
|
"description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"ui-testing"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@geometra/proxy": "^1.19.
|
|
33
|
+
"@geometra/proxy": "^1.19.16",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|