@geometra/mcp 1.19.13 → 1.19.15
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.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { connect, disconnect, sendClick, sendListboxPick } from '../session.js';
|
|
3
|
+
import { connect, disconnect, sendClick, sendFillFields, sendListboxPick } from '../session.js';
|
|
4
4
|
describe('proxy-backed MCP actions', () => {
|
|
5
5
|
afterAll(() => {
|
|
6
6
|
disconnect();
|
|
@@ -97,6 +97,83 @@ describe('proxy-backed MCP actions', () => {
|
|
|
97
97
|
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
+
it('waits for the post-batch update before resolving fillFields acks', async () => {
|
|
101
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
102
|
+
let seenMessage;
|
|
103
|
+
wss.on('connection', ws => {
|
|
104
|
+
ws.on('message', raw => {
|
|
105
|
+
const msg = JSON.parse(String(raw));
|
|
106
|
+
if (msg.type === 'resize') {
|
|
107
|
+
ws.send(JSON.stringify({
|
|
108
|
+
type: 'frame',
|
|
109
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
110
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
111
|
+
}));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (msg.type === 'fillFields') {
|
|
115
|
+
seenMessage = msg;
|
|
116
|
+
ws.send(JSON.stringify({
|
|
117
|
+
type: 'frame',
|
|
118
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
119
|
+
tree: {
|
|
120
|
+
kind: 'box',
|
|
121
|
+
props: {},
|
|
122
|
+
semantic: { tag: 'body', role: 'group', ariaLabel: 'Filled' },
|
|
123
|
+
children: [],
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
ws.send(JSON.stringify({
|
|
127
|
+
type: 'ack',
|
|
128
|
+
requestId: msg.requestId,
|
|
129
|
+
result: {
|
|
130
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
131
|
+
invalidCount: 0,
|
|
132
|
+
alertCount: 0,
|
|
133
|
+
dialogCount: 0,
|
|
134
|
+
busyCount: 0,
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
const port = await new Promise((resolve, reject) => {
|
|
141
|
+
wss.once('listening', () => {
|
|
142
|
+
const address = wss.address();
|
|
143
|
+
if (typeof address === 'object' && address)
|
|
144
|
+
resolve(address.port);
|
|
145
|
+
else
|
|
146
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
147
|
+
});
|
|
148
|
+
wss.once('error', reject);
|
|
149
|
+
});
|
|
150
|
+
try {
|
|
151
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
152
|
+
await expect(sendFillFields(session, [
|
|
153
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
154
|
+
{ kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
|
|
155
|
+
], 80)).resolves.toMatchObject({
|
|
156
|
+
status: 'updated',
|
|
157
|
+
timeoutMs: 80,
|
|
158
|
+
result: {
|
|
159
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
160
|
+
invalidCount: 0,
|
|
161
|
+
alertCount: 0,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(seenMessage).toMatchObject({
|
|
165
|
+
type: 'fillFields',
|
|
166
|
+
fields: [
|
|
167
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
168
|
+
{ kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
disconnect();
|
|
174
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
175
|
+
}
|
|
176
|
+
});
|
|
100
177
|
it('ignores invalid patch paths instead of mutating ancestor layout nodes', async () => {
|
|
101
178
|
const wss = new WebSocketServer({ port: 0 });
|
|
102
179
|
wss.on('connection', ws => {
|
|
@@ -32,6 +32,11 @@ const mockState = vi.hoisted(() => ({
|
|
|
32
32
|
sendFileUpload: vi.fn(async () => ({ status: 'updated', timeoutMs: 8000 })),
|
|
33
33
|
sendFieldText: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
34
34
|
sendFieldChoice: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
35
|
+
sendFillFields: vi.fn(async () => ({
|
|
36
|
+
status: 'updated',
|
|
37
|
+
timeoutMs: 6000,
|
|
38
|
+
result: undefined,
|
|
39
|
+
})),
|
|
35
40
|
sendListboxPick: vi.fn(async () => ({ status: 'updated', timeoutMs: 4500 })),
|
|
36
41
|
sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
37
42
|
sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
|
|
@@ -49,6 +54,7 @@ vi.mock('../session.js', () => ({
|
|
|
49
54
|
sendFileUpload: mockState.sendFileUpload,
|
|
50
55
|
sendFieldText: mockState.sendFieldText,
|
|
51
56
|
sendFieldChoice: mockState.sendFieldChoice,
|
|
57
|
+
sendFillFields: mockState.sendFillFields,
|
|
52
58
|
sendListboxPick: mockState.sendListboxPick,
|
|
53
59
|
sendSelectOption: mockState.sendSelectOption,
|
|
54
60
|
sendSetChecked: mockState.sendSetChecked,
|
|
@@ -314,6 +320,82 @@ describe('batch MCP result shaping', () => {
|
|
|
314
320
|
readback: { role: 'textbox', valueLength: 220 },
|
|
315
321
|
});
|
|
316
322
|
});
|
|
323
|
+
it('uses batched proxy fill for compact fill_form responses', async () => {
|
|
324
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
325
|
+
mockState.sendFillFields.mockResolvedValueOnce({
|
|
326
|
+
status: 'acknowledged',
|
|
327
|
+
timeoutMs: 6000,
|
|
328
|
+
result: {
|
|
329
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
330
|
+
invalidCount: 0,
|
|
331
|
+
alertCount: 0,
|
|
332
|
+
dialogCount: 0,
|
|
333
|
+
busyCount: 0,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
mockState.formSchemas = [
|
|
337
|
+
{
|
|
338
|
+
formId: 'fm:0',
|
|
339
|
+
name: 'Application',
|
|
340
|
+
fieldCount: 3,
|
|
341
|
+
requiredCount: 2,
|
|
342
|
+
invalidCount: 0,
|
|
343
|
+
fields: [
|
|
344
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
|
|
345
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
|
|
346
|
+
{ id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
351
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
|
|
352
|
+
children: [
|
|
353
|
+
node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
|
|
354
|
+
node('combobox', 'Preferred location', { value: 'Berlin, Germany', path: [1] }),
|
|
355
|
+
node('checkbox', 'Share my profile for future roles', {
|
|
356
|
+
path: [2],
|
|
357
|
+
state: { checked: true },
|
|
358
|
+
}),
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
const result = await handler({
|
|
362
|
+
valuesById: {
|
|
363
|
+
'ff:0.0': 'Taylor Applicant',
|
|
364
|
+
'ff:0.1': 'Berlin, Germany',
|
|
365
|
+
'ff:0.2': true,
|
|
366
|
+
},
|
|
367
|
+
includeSteps: false,
|
|
368
|
+
detail: 'minimal',
|
|
369
|
+
});
|
|
370
|
+
const payload = JSON.parse(result.content[0].text);
|
|
371
|
+
expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
|
|
372
|
+
{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
|
|
373
|
+
{ kind: 'choice', fieldLabel: 'Preferred location', value: 'Berlin, Germany' },
|
|
374
|
+
{
|
|
375
|
+
kind: 'toggle',
|
|
376
|
+
label: 'Share my profile for future roles',
|
|
377
|
+
checked: true,
|
|
378
|
+
controlType: 'checkbox',
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
expect(mockState.sendFieldText).not.toHaveBeenCalled();
|
|
382
|
+
expect(mockState.sendFieldChoice).not.toHaveBeenCalled();
|
|
383
|
+
expect(mockState.sendSetChecked).not.toHaveBeenCalled();
|
|
384
|
+
expect(payload).toMatchObject({
|
|
385
|
+
completed: true,
|
|
386
|
+
execution: 'batched',
|
|
387
|
+
finalSource: 'proxy',
|
|
388
|
+
formId: 'fm:0',
|
|
389
|
+
fieldCount: 3,
|
|
390
|
+
successCount: 3,
|
|
391
|
+
errorCount: 0,
|
|
392
|
+
final: {
|
|
393
|
+
invalidCount: 0,
|
|
394
|
+
alertCount: 0,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
expect(payload).not.toHaveProperty('steps');
|
|
398
|
+
});
|
|
317
399
|
});
|
|
318
400
|
describe('query and reveal tools', () => {
|
|
319
401
|
beforeEach(() => {
|
|
@@ -363,6 +445,63 @@ describe('query and reveal tools', () => {
|
|
|
363
445
|
},
|
|
364
446
|
});
|
|
365
447
|
});
|
|
448
|
+
it('falls back to sequential fill when a batched fill ends without a clean ack and invalid fields remain', async () => {
|
|
449
|
+
const handler = getToolHandler('geometra_fill_form');
|
|
450
|
+
mockState.sendFillFields.mockResolvedValueOnce({
|
|
451
|
+
status: 'updated',
|
|
452
|
+
timeoutMs: 6000,
|
|
453
|
+
result: undefined,
|
|
454
|
+
});
|
|
455
|
+
mockState.formSchemas = [
|
|
456
|
+
{
|
|
457
|
+
formId: 'fm:0',
|
|
458
|
+
name: 'Application',
|
|
459
|
+
fieldCount: 2,
|
|
460
|
+
requiredCount: 2,
|
|
461
|
+
invalidCount: 2,
|
|
462
|
+
fields: [
|
|
463
|
+
{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, invalid: true },
|
|
464
|
+
{ id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true },
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
];
|
|
468
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
469
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
|
|
470
|
+
children: [
|
|
471
|
+
node('textbox', 'Full name', {
|
|
472
|
+
path: [0],
|
|
473
|
+
state: { required: true, invalid: true },
|
|
474
|
+
}),
|
|
475
|
+
node('combobox', 'Preferred location', {
|
|
476
|
+
path: [1],
|
|
477
|
+
value: 'Select',
|
|
478
|
+
state: { required: true, invalid: true },
|
|
479
|
+
}),
|
|
480
|
+
],
|
|
481
|
+
});
|
|
482
|
+
const result = await handler({
|
|
483
|
+
valuesById: {
|
|
484
|
+
'ff:0.0': 'Taylor Applicant',
|
|
485
|
+
'ff:0.1': 'Berlin, Germany',
|
|
486
|
+
},
|
|
487
|
+
includeSteps: false,
|
|
488
|
+
detail: 'minimal',
|
|
489
|
+
failOnInvalid: false,
|
|
490
|
+
});
|
|
491
|
+
const payload = JSON.parse(result.content[0].text);
|
|
492
|
+
expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
|
|
493
|
+
expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined }, undefined);
|
|
494
|
+
expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined }, undefined);
|
|
495
|
+
expect(payload).toMatchObject({
|
|
496
|
+
completed: true,
|
|
497
|
+
execution: 'sequential',
|
|
498
|
+
formId: 'fm:0',
|
|
499
|
+
requestedValueCount: 2,
|
|
500
|
+
fieldCount: 2,
|
|
501
|
+
successCount: 2,
|
|
502
|
+
errorCount: 0,
|
|
503
|
+
});
|
|
504
|
+
});
|
|
366
505
|
it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
|
|
367
506
|
const handler = getToolHandler('geometra_reveal');
|
|
368
507
|
mockState.currentA11yRoot = node('group', undefined, {
|
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, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
4
|
+
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendFillFields, 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')])
|
|
@@ -152,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
152
152
|
}),
|
|
153
153
|
]);
|
|
154
154
|
export function createServer() {
|
|
155
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
155
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.14' }, { capabilities: { tools: {} } });
|
|
156
156
|
// ── connect ──────────────────────────────────────────────────
|
|
157
157
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
158
158
|
|
|
@@ -213,7 +213,10 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
213
213
|
detail: input.detail,
|
|
214
214
|
}), null, input.detail === 'verbose' ? 2 : undefined));
|
|
215
215
|
}
|
|
216
|
-
const session = await connect(target.wsUrl
|
|
216
|
+
const session = await connect(target.wsUrl, {
|
|
217
|
+
width: input.width,
|
|
218
|
+
height: input.height,
|
|
219
|
+
});
|
|
217
220
|
return ok(JSON.stringify(connectPayload(session, {
|
|
218
221
|
transport: 'ws',
|
|
219
222
|
requestedWsUrl: target.wsUrl,
|
|
@@ -413,6 +416,68 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
413
416
|
const planned = planFormFill(schema, { valuesById, valuesByLabel });
|
|
414
417
|
if (!planned.ok)
|
|
415
418
|
return err(planned.error);
|
|
419
|
+
if (!includeSteps) {
|
|
420
|
+
let usedBatch = false;
|
|
421
|
+
let batchAckResult;
|
|
422
|
+
try {
|
|
423
|
+
const startRevision = session.updateRevision;
|
|
424
|
+
const wait = await sendFillFields(session, planned.fields);
|
|
425
|
+
const ackResult = parseProxyFillAckResult(wait.result);
|
|
426
|
+
batchAckResult = ackResult;
|
|
427
|
+
if (ackResult && ackResult.invalidCount === 0) {
|
|
428
|
+
usedBatch = true;
|
|
429
|
+
const payload = {
|
|
430
|
+
completed: true,
|
|
431
|
+
execution: 'batched',
|
|
432
|
+
finalSource: 'proxy',
|
|
433
|
+
formId: schema.formId,
|
|
434
|
+
requestedValueCount: entryCount,
|
|
435
|
+
fieldCount: planned.fields.length,
|
|
436
|
+
successCount: planned.fields.length,
|
|
437
|
+
errorCount: 0,
|
|
438
|
+
final: ackResult,
|
|
439
|
+
};
|
|
440
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
441
|
+
}
|
|
442
|
+
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
443
|
+
await waitForBatchFieldReadback(session, planned.fields);
|
|
444
|
+
usedBatch = true;
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
if (!canFallbackToSequentialFill(e)) {
|
|
448
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
449
|
+
return err(message);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (usedBatch) {
|
|
453
|
+
const after = sessionA11y(session);
|
|
454
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
455
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
456
|
+
if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
|
|
457
|
+
usedBatch = false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (usedBatch) {
|
|
461
|
+
const after = sessionA11y(session);
|
|
462
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
463
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
464
|
+
const payload = {
|
|
465
|
+
completed: true,
|
|
466
|
+
execution: 'batched',
|
|
467
|
+
finalSource: 'session',
|
|
468
|
+
formId: schema.formId,
|
|
469
|
+
requestedValueCount: entryCount,
|
|
470
|
+
fieldCount: planned.fields.length,
|
|
471
|
+
successCount: planned.fields.length,
|
|
472
|
+
errorCount: 0,
|
|
473
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
474
|
+
};
|
|
475
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
476
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
477
|
+
}
|
|
478
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
416
481
|
const steps = [];
|
|
417
482
|
let stoppedAt;
|
|
418
483
|
for (let index = 0; index < planned.fields.length; index++) {
|
|
@@ -439,6 +504,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
439
504
|
const errorCount = steps.length - successCount;
|
|
440
505
|
const payload = {
|
|
441
506
|
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
507
|
+
execution: 'sequential',
|
|
442
508
|
formId: schema.formId,
|
|
443
509
|
requestedValueCount: entryCount,
|
|
444
510
|
fieldCount: planned.fields.length,
|
|
@@ -1314,6 +1380,69 @@ function planFormFill(schema, opts) {
|
|
|
1314
1380
|
}
|
|
1315
1381
|
return { ok: true, fields: planned };
|
|
1316
1382
|
}
|
|
1383
|
+
function canFallbackToSequentialFill(error) {
|
|
1384
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1385
|
+
return (message.includes('Unsupported client message type "fillFields"') ||
|
|
1386
|
+
message.includes('Client message type "fillFields" is not supported'));
|
|
1387
|
+
}
|
|
1388
|
+
function parseProxyFillAckResult(value) {
|
|
1389
|
+
if (!value || typeof value !== 'object')
|
|
1390
|
+
return undefined;
|
|
1391
|
+
const candidate = value;
|
|
1392
|
+
if (typeof candidate.invalidCount !== 'number' ||
|
|
1393
|
+
typeof candidate.alertCount !== 'number' ||
|
|
1394
|
+
typeof candidate.dialogCount !== 'number' ||
|
|
1395
|
+
typeof candidate.busyCount !== 'number') {
|
|
1396
|
+
return undefined;
|
|
1397
|
+
}
|
|
1398
|
+
return {
|
|
1399
|
+
...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
|
|
1400
|
+
invalidCount: candidate.invalidCount,
|
|
1401
|
+
alertCount: candidate.alertCount,
|
|
1402
|
+
dialogCount: candidate.dialogCount,
|
|
1403
|
+
busyCount: candidate.busyCount,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
1407
|
+
if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
|
|
1408
|
+
return;
|
|
1409
|
+
await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
|
|
1410
|
+
}
|
|
1411
|
+
async function waitForBatchFieldReadback(session, fields) {
|
|
1412
|
+
await waitForUiCondition(session, () => {
|
|
1413
|
+
const a11y = sessionA11y(session);
|
|
1414
|
+
if (!a11y)
|
|
1415
|
+
return false;
|
|
1416
|
+
return fields.every(field => batchFieldReadbackMatches(a11y, field));
|
|
1417
|
+
}, 1500);
|
|
1418
|
+
}
|
|
1419
|
+
function batchFieldReadbackMatches(a11y, field) {
|
|
1420
|
+
switch (field.kind) {
|
|
1421
|
+
case 'text': {
|
|
1422
|
+
const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
|
|
1423
|
+
return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1424
|
+
}
|
|
1425
|
+
case 'choice': {
|
|
1426
|
+
const directMatches = [
|
|
1427
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
|
|
1428
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1429
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1430
|
+
];
|
|
1431
|
+
if (directMatches.length === 0)
|
|
1432
|
+
return true;
|
|
1433
|
+
return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1434
|
+
}
|
|
1435
|
+
case 'toggle':
|
|
1436
|
+
return true;
|
|
1437
|
+
case 'file': {
|
|
1438
|
+
const matches = [
|
|
1439
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1440
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1441
|
+
];
|
|
1442
|
+
return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1317
1446
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
1318
1447
|
switch (action.type) {
|
|
1319
1448
|
case 'click': {
|
package/dist/session.d.ts
CHANGED
|
@@ -316,12 +316,40 @@ export interface Session {
|
|
|
316
316
|
export interface UpdateWaitResult {
|
|
317
317
|
status: 'updated' | 'acknowledged' | 'timed_out';
|
|
318
318
|
timeoutMs: number;
|
|
319
|
+
result?: unknown;
|
|
319
320
|
}
|
|
321
|
+
export type ProxyFillField = {
|
|
322
|
+
kind: 'text';
|
|
323
|
+
fieldLabel: string;
|
|
324
|
+
value: string;
|
|
325
|
+
exact?: boolean;
|
|
326
|
+
} | {
|
|
327
|
+
kind: 'choice';
|
|
328
|
+
fieldLabel: string;
|
|
329
|
+
value: string;
|
|
330
|
+
query?: string;
|
|
331
|
+
exact?: boolean;
|
|
332
|
+
} | {
|
|
333
|
+
kind: 'toggle';
|
|
334
|
+
label: string;
|
|
335
|
+
checked?: boolean;
|
|
336
|
+
exact?: boolean;
|
|
337
|
+
controlType?: 'checkbox' | 'radio';
|
|
338
|
+
} | {
|
|
339
|
+
kind: 'file';
|
|
340
|
+
fieldLabel: string;
|
|
341
|
+
paths: string[];
|
|
342
|
+
exact?: boolean;
|
|
343
|
+
};
|
|
320
344
|
/**
|
|
321
345
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
322
346
|
* layout/tree state is available immediately after connection.
|
|
323
347
|
*/
|
|
324
|
-
export declare function connect(url: string
|
|
348
|
+
export declare function connect(url: string, opts?: {
|
|
349
|
+
width?: number;
|
|
350
|
+
height?: number;
|
|
351
|
+
skipInitialResize?: boolean;
|
|
352
|
+
}): Promise<Session>;
|
|
325
353
|
/**
|
|
326
354
|
* Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
|
|
327
355
|
* process to the session so disconnect / reconnect can clean it up.
|
|
@@ -380,6 +408,8 @@ export declare function sendFieldChoice(session: Session, fieldLabel: string, va
|
|
|
380
408
|
exact?: boolean;
|
|
381
409
|
query?: string;
|
|
382
410
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
411
|
+
/** Fill several semantic form fields in one proxy-side batch. */
|
|
412
|
+
export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
383
413
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
384
414
|
export declare function sendListboxPick(session: Session, label: string, opts?: {
|
|
385
415
|
exact?: boolean;
|
package/dist/session.js
CHANGED
|
@@ -3,6 +3,14 @@ import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
|
3
3
|
let activeSession = null;
|
|
4
4
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
5
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
|
+
const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
|
|
7
|
+
const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS = 275;
|
|
8
|
+
const FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS = 120;
|
|
9
|
+
const FILL_BATCH_TEXT_LENGTH_SLICE = 80;
|
|
10
|
+
const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 500;
|
|
11
|
+
const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
|
|
12
|
+
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
13
|
+
const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
|
|
6
14
|
let nextRequestSequence = 0;
|
|
7
15
|
function shutdownPreviousSession() {
|
|
8
16
|
const prev = activeSession;
|
|
@@ -28,7 +36,7 @@ function shutdownPreviousSession() {
|
|
|
28
36
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
29
37
|
* layout/tree state is available immediately after connection.
|
|
30
38
|
*/
|
|
31
|
-
export function connect(url) {
|
|
39
|
+
export function connect(url, opts) {
|
|
32
40
|
return new Promise((resolve, reject) => {
|
|
33
41
|
shutdownPreviousSession();
|
|
34
42
|
const ws = new WebSocket(url);
|
|
@@ -42,8 +50,11 @@ export function connect(url) {
|
|
|
42
50
|
}
|
|
43
51
|
}, 10_000);
|
|
44
52
|
ws.on('open', () => {
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
if (opts?.skipInitialResize)
|
|
54
|
+
return;
|
|
55
|
+
const width = opts?.width ?? 1024;
|
|
56
|
+
const height = opts?.height ?? 768;
|
|
57
|
+
ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
47
58
|
});
|
|
48
59
|
ws.on('message', (data) => {
|
|
49
60
|
try {
|
|
@@ -107,7 +118,7 @@ export async function connectThroughProxy(options) {
|
|
|
107
118
|
slowMo: options.slowMo,
|
|
108
119
|
});
|
|
109
120
|
try {
|
|
110
|
-
const session = await connect(wsUrl);
|
|
121
|
+
const session = await connect(wsUrl, { skipInitialResize: true });
|
|
111
122
|
session.proxyChild = child;
|
|
112
123
|
return session;
|
|
113
124
|
}
|
|
@@ -127,6 +138,32 @@ export function getSession() {
|
|
|
127
138
|
export function disconnect() {
|
|
128
139
|
shutdownPreviousSession();
|
|
129
140
|
}
|
|
141
|
+
function estimateFillBatchTimeout(fields) {
|
|
142
|
+
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
143
|
+
let totalTextLength = 0;
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
switch (field.kind) {
|
|
146
|
+
case 'text':
|
|
147
|
+
totalTextLength += field.value.length;
|
|
148
|
+
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
149
|
+
total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
|
|
150
|
+
break;
|
|
151
|
+
case 'choice':
|
|
152
|
+
total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
153
|
+
break;
|
|
154
|
+
case 'toggle':
|
|
155
|
+
total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
|
|
156
|
+
break;
|
|
157
|
+
case 'file':
|
|
158
|
+
total += FILL_BATCH_FILE_FIELD_TIMEOUT_MS;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (fields.length >= 20 || totalTextLength >= 1500) {
|
|
163
|
+
total = Math.max(total, 30_000);
|
|
164
|
+
}
|
|
165
|
+
return Math.min(total, FILL_BATCH_MAX_TIMEOUT_MS);
|
|
166
|
+
}
|
|
130
167
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
131
168
|
return new Promise((resolve) => {
|
|
132
169
|
const check = () => {
|
|
@@ -263,6 +300,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
263
300
|
payload.query = opts.query;
|
|
264
301
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
265
302
|
}
|
|
303
|
+
/** Fill several semantic form fields in one proxy-side batch. */
|
|
304
|
+
export function sendFillFields(session, fields, timeoutMs = estimateFillBatchTimeout(fields)) {
|
|
305
|
+
return sendAndWaitForUpdate(session, { type: 'fillFields', fields }, timeoutMs);
|
|
306
|
+
}
|
|
266
307
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
267
308
|
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
268
309
|
const payload = { type: 'listboxPick', label };
|
|
@@ -1027,7 +1068,8 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1027
1068
|
consumed.add(candidateKey);
|
|
1028
1069
|
}
|
|
1029
1070
|
}
|
|
1030
|
-
const
|
|
1071
|
+
const compactFields = trimSchemaFieldContexts(fields);
|
|
1072
|
+
const filteredFields = compactFields.filter(field => {
|
|
1031
1073
|
if (options?.onlyRequiredFields && !field.required)
|
|
1032
1074
|
return false;
|
|
1033
1075
|
if (options?.onlyInvalidFields && !field.invalid)
|
|
@@ -1040,12 +1082,35 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1040
1082
|
return {
|
|
1041
1083
|
formId: sectionIdForPath('form', formNode.path),
|
|
1042
1084
|
...(name ? { name } : {}),
|
|
1043
|
-
fieldCount:
|
|
1044
|
-
requiredCount:
|
|
1045
|
-
invalidCount:
|
|
1085
|
+
fieldCount: compactFields.length,
|
|
1086
|
+
requiredCount: compactFields.filter(field => field.required).length,
|
|
1087
|
+
invalidCount: compactFields.filter(field => field.invalid).length,
|
|
1046
1088
|
fields: pageFields,
|
|
1047
1089
|
};
|
|
1048
1090
|
}
|
|
1091
|
+
function trimSchemaFieldContexts(fields) {
|
|
1092
|
+
const labelCounts = new Map();
|
|
1093
|
+
for (const field of fields) {
|
|
1094
|
+
const key = normalizeUiText(field.label);
|
|
1095
|
+
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
|
1096
|
+
}
|
|
1097
|
+
return fields.map(field => {
|
|
1098
|
+
if (!field.context)
|
|
1099
|
+
return field;
|
|
1100
|
+
const trimmed = {};
|
|
1101
|
+
if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
1102
|
+
trimmed.prompt = field.context.prompt;
|
|
1103
|
+
}
|
|
1104
|
+
if ((labelCounts.get(normalizeUiText(field.label)) ?? 0) > 1 && field.context.section) {
|
|
1105
|
+
trimmed.section = field.context.section;
|
|
1106
|
+
}
|
|
1107
|
+
if (Object.keys(trimmed).length === 0) {
|
|
1108
|
+
const { context: _context, ...rest } = field;
|
|
1109
|
+
return rest;
|
|
1110
|
+
}
|
|
1111
|
+
return { ...field, context: trimmed };
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1049
1114
|
function toLandmarkModel(node) {
|
|
1050
1115
|
const name = sectionDisplayName(node, 'landmark');
|
|
1051
1116
|
return {
|
|
@@ -1753,6 +1818,7 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1753
1818
|
resolve({
|
|
1754
1819
|
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1755
1820
|
timeoutMs,
|
|
1821
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1756
1822
|
});
|
|
1757
1823
|
}
|
|
1758
1824
|
return;
|
|
@@ -1772,7 +1838,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1772
1838
|
}
|
|
1773
1839
|
else if (msg.type === 'ack') {
|
|
1774
1840
|
cleanup();
|
|
1775
|
-
resolve({
|
|
1841
|
+
resolve({
|
|
1842
|
+
status: 'acknowledged',
|
|
1843
|
+
timeoutMs,
|
|
1844
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1845
|
+
});
|
|
1776
1846
|
}
|
|
1777
1847
|
}
|
|
1778
1848
|
catch { /* ignore */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.15",
|
|
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.15",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|