@geometra/mcp 1.19.13 → 1.19.14
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('sends batched fillFields messages and resolves from the resulting update', 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: 'ack',
|
|
118
|
+
requestId: msg.requestId,
|
|
119
|
+
result: {
|
|
120
|
+
pageUrl: 'https://jobs.example.com/application',
|
|
121
|
+
invalidCount: 0,
|
|
122
|
+
alertCount: 0,
|
|
123
|
+
dialogCount: 0,
|
|
124
|
+
busyCount: 0,
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
ws.send(JSON.stringify({
|
|
128
|
+
type: 'frame',
|
|
129
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
130
|
+
tree: {
|
|
131
|
+
kind: 'box',
|
|
132
|
+
props: {},
|
|
133
|
+
semantic: { tag: 'body', role: 'group', ariaLabel: 'Filled' },
|
|
134
|
+
children: [],
|
|
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: 'acknowledged',
|
|
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(() => {
|
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,58 @@ 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
|
+
try {
|
|
422
|
+
const startRevision = session.updateRevision;
|
|
423
|
+
const wait = await sendFillFields(session, planned.fields);
|
|
424
|
+
const ackResult = parseProxyFillAckResult(wait.result);
|
|
425
|
+
if (ackResult && ackResult.invalidCount === 0) {
|
|
426
|
+
usedBatch = true;
|
|
427
|
+
const payload = {
|
|
428
|
+
completed: true,
|
|
429
|
+
execution: 'batched',
|
|
430
|
+
finalSource: 'proxy',
|
|
431
|
+
formId: schema.formId,
|
|
432
|
+
requestedValueCount: entryCount,
|
|
433
|
+
fieldCount: planned.fields.length,
|
|
434
|
+
successCount: planned.fields.length,
|
|
435
|
+
errorCount: 0,
|
|
436
|
+
final: ackResult,
|
|
437
|
+
};
|
|
438
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
439
|
+
}
|
|
440
|
+
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
441
|
+
await waitForBatchFieldReadback(session, planned.fields);
|
|
442
|
+
usedBatch = true;
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
if (!canFallbackToSequentialFill(e)) {
|
|
446
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
447
|
+
return err(message);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (usedBatch) {
|
|
451
|
+
const after = sessionA11y(session);
|
|
452
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
453
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
454
|
+
const payload = {
|
|
455
|
+
completed: true,
|
|
456
|
+
execution: 'batched',
|
|
457
|
+
finalSource: 'session',
|
|
458
|
+
formId: schema.formId,
|
|
459
|
+
requestedValueCount: entryCount,
|
|
460
|
+
fieldCount: planned.fields.length,
|
|
461
|
+
successCount: planned.fields.length,
|
|
462
|
+
errorCount: 0,
|
|
463
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
464
|
+
};
|
|
465
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
466
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
467
|
+
}
|
|
468
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
416
471
|
const steps = [];
|
|
417
472
|
let stoppedAt;
|
|
418
473
|
for (let index = 0; index < planned.fields.length; index++) {
|
|
@@ -439,6 +494,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
439
494
|
const errorCount = steps.length - successCount;
|
|
440
495
|
const payload = {
|
|
441
496
|
completed: stoppedAt === undefined && steps.length === planned.fields.length,
|
|
497
|
+
execution: 'sequential',
|
|
442
498
|
formId: schema.formId,
|
|
443
499
|
requestedValueCount: entryCount,
|
|
444
500
|
fieldCount: planned.fields.length,
|
|
@@ -1314,6 +1370,69 @@ function planFormFill(schema, opts) {
|
|
|
1314
1370
|
}
|
|
1315
1371
|
return { ok: true, fields: planned };
|
|
1316
1372
|
}
|
|
1373
|
+
function canFallbackToSequentialFill(error) {
|
|
1374
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1375
|
+
return (message.includes('Unsupported client message type "fillFields"') ||
|
|
1376
|
+
message.includes('Client message type "fillFields" is not supported'));
|
|
1377
|
+
}
|
|
1378
|
+
function parseProxyFillAckResult(value) {
|
|
1379
|
+
if (!value || typeof value !== 'object')
|
|
1380
|
+
return undefined;
|
|
1381
|
+
const candidate = value;
|
|
1382
|
+
if (typeof candidate.invalidCount !== 'number' ||
|
|
1383
|
+
typeof candidate.alertCount !== 'number' ||
|
|
1384
|
+
typeof candidate.dialogCount !== 'number' ||
|
|
1385
|
+
typeof candidate.busyCount !== 'number') {
|
|
1386
|
+
return undefined;
|
|
1387
|
+
}
|
|
1388
|
+
return {
|
|
1389
|
+
...(typeof candidate.pageUrl === 'string' ? { pageUrl: candidate.pageUrl } : {}),
|
|
1390
|
+
invalidCount: candidate.invalidCount,
|
|
1391
|
+
alertCount: candidate.alertCount,
|
|
1392
|
+
dialogCount: candidate.dialogCount,
|
|
1393
|
+
busyCount: candidate.busyCount,
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
async function waitForDeferredBatchUpdate(session, startRevision, wait) {
|
|
1397
|
+
if (wait.status !== 'acknowledged' || session.updateRevision > startRevision)
|
|
1398
|
+
return;
|
|
1399
|
+
await waitForUiCondition(session, () => session.updateRevision > startRevision, 750);
|
|
1400
|
+
}
|
|
1401
|
+
async function waitForBatchFieldReadback(session, fields) {
|
|
1402
|
+
await waitForUiCondition(session, () => {
|
|
1403
|
+
const a11y = sessionA11y(session);
|
|
1404
|
+
if (!a11y)
|
|
1405
|
+
return false;
|
|
1406
|
+
return fields.every(field => batchFieldReadbackMatches(a11y, field));
|
|
1407
|
+
}, 1500);
|
|
1408
|
+
}
|
|
1409
|
+
function batchFieldReadbackMatches(a11y, field) {
|
|
1410
|
+
switch (field.kind) {
|
|
1411
|
+
case 'text': {
|
|
1412
|
+
const matches = findNodes(a11y, { name: field.fieldLabel, role: 'textbox' });
|
|
1413
|
+
return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1414
|
+
}
|
|
1415
|
+
case 'choice': {
|
|
1416
|
+
const directMatches = [
|
|
1417
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
|
|
1418
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1419
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1420
|
+
];
|
|
1421
|
+
if (directMatches.length === 0)
|
|
1422
|
+
return true;
|
|
1423
|
+
return directMatches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
1424
|
+
}
|
|
1425
|
+
case 'toggle':
|
|
1426
|
+
return true;
|
|
1427
|
+
case 'file': {
|
|
1428
|
+
const matches = [
|
|
1429
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
1430
|
+
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
1431
|
+
];
|
|
1432
|
+
return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1317
1436
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
1318
1437
|
switch (action.type) {
|
|
1319
1438
|
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,12 @@ 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 = 250;
|
|
8
|
+
const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 450;
|
|
9
|
+
const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 200;
|
|
10
|
+
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
11
|
+
const FILL_BATCH_MAX_TIMEOUT_MS = 30_000;
|
|
6
12
|
let nextRequestSequence = 0;
|
|
7
13
|
function shutdownPreviousSession() {
|
|
8
14
|
const prev = activeSession;
|
|
@@ -28,7 +34,7 @@ function shutdownPreviousSession() {
|
|
|
28
34
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
29
35
|
* layout/tree state is available immediately after connection.
|
|
30
36
|
*/
|
|
31
|
-
export function connect(url) {
|
|
37
|
+
export function connect(url, opts) {
|
|
32
38
|
return new Promise((resolve, reject) => {
|
|
33
39
|
shutdownPreviousSession();
|
|
34
40
|
const ws = new WebSocket(url);
|
|
@@ -42,8 +48,11 @@ export function connect(url) {
|
|
|
42
48
|
}
|
|
43
49
|
}, 10_000);
|
|
44
50
|
ws.on('open', () => {
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
if (opts?.skipInitialResize)
|
|
52
|
+
return;
|
|
53
|
+
const width = opts?.width ?? 1024;
|
|
54
|
+
const height = opts?.height ?? 768;
|
|
55
|
+
ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
47
56
|
});
|
|
48
57
|
ws.on('message', (data) => {
|
|
49
58
|
try {
|
|
@@ -107,7 +116,7 @@ export async function connectThroughProxy(options) {
|
|
|
107
116
|
slowMo: options.slowMo,
|
|
108
117
|
});
|
|
109
118
|
try {
|
|
110
|
-
const session = await connect(wsUrl);
|
|
119
|
+
const session = await connect(wsUrl, { skipInitialResize: true });
|
|
111
120
|
session.proxyChild = child;
|
|
112
121
|
return session;
|
|
113
122
|
}
|
|
@@ -127,6 +136,26 @@ export function getSession() {
|
|
|
127
136
|
export function disconnect() {
|
|
128
137
|
shutdownPreviousSession();
|
|
129
138
|
}
|
|
139
|
+
function estimateFillBatchTimeout(fields) {
|
|
140
|
+
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
141
|
+
for (const field of fields) {
|
|
142
|
+
switch (field.kind) {
|
|
143
|
+
case 'text':
|
|
144
|
+
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
145
|
+
break;
|
|
146
|
+
case 'choice':
|
|
147
|
+
total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
148
|
+
break;
|
|
149
|
+
case 'toggle':
|
|
150
|
+
total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
|
|
151
|
+
break;
|
|
152
|
+
case 'file':
|
|
153
|
+
total += FILL_BATCH_FILE_FIELD_TIMEOUT_MS;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return Math.min(total, FILL_BATCH_MAX_TIMEOUT_MS);
|
|
158
|
+
}
|
|
130
159
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
131
160
|
return new Promise((resolve) => {
|
|
132
161
|
const check = () => {
|
|
@@ -263,6 +292,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
263
292
|
payload.query = opts.query;
|
|
264
293
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
265
294
|
}
|
|
295
|
+
/** Fill several semantic form fields in one proxy-side batch. */
|
|
296
|
+
export function sendFillFields(session, fields, timeoutMs = estimateFillBatchTimeout(fields)) {
|
|
297
|
+
return sendAndWaitForUpdate(session, { type: 'fillFields', fields }, timeoutMs);
|
|
298
|
+
}
|
|
266
299
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
267
300
|
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
268
301
|
const payload = { type: 'listboxPick', label };
|
|
@@ -1027,7 +1060,8 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1027
1060
|
consumed.add(candidateKey);
|
|
1028
1061
|
}
|
|
1029
1062
|
}
|
|
1030
|
-
const
|
|
1063
|
+
const compactFields = trimSchemaFieldContexts(fields);
|
|
1064
|
+
const filteredFields = compactFields.filter(field => {
|
|
1031
1065
|
if (options?.onlyRequiredFields && !field.required)
|
|
1032
1066
|
return false;
|
|
1033
1067
|
if (options?.onlyInvalidFields && !field.invalid)
|
|
@@ -1040,12 +1074,35 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1040
1074
|
return {
|
|
1041
1075
|
formId: sectionIdForPath('form', formNode.path),
|
|
1042
1076
|
...(name ? { name } : {}),
|
|
1043
|
-
fieldCount:
|
|
1044
|
-
requiredCount:
|
|
1045
|
-
invalidCount:
|
|
1077
|
+
fieldCount: compactFields.length,
|
|
1078
|
+
requiredCount: compactFields.filter(field => field.required).length,
|
|
1079
|
+
invalidCount: compactFields.filter(field => field.invalid).length,
|
|
1046
1080
|
fields: pageFields,
|
|
1047
1081
|
};
|
|
1048
1082
|
}
|
|
1083
|
+
function trimSchemaFieldContexts(fields) {
|
|
1084
|
+
const labelCounts = new Map();
|
|
1085
|
+
for (const field of fields) {
|
|
1086
|
+
const key = normalizeUiText(field.label);
|
|
1087
|
+
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
|
1088
|
+
}
|
|
1089
|
+
return fields.map(field => {
|
|
1090
|
+
if (!field.context)
|
|
1091
|
+
return field;
|
|
1092
|
+
const trimmed = {};
|
|
1093
|
+
if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
1094
|
+
trimmed.prompt = field.context.prompt;
|
|
1095
|
+
}
|
|
1096
|
+
if ((labelCounts.get(normalizeUiText(field.label)) ?? 0) > 1 && field.context.section) {
|
|
1097
|
+
trimmed.section = field.context.section;
|
|
1098
|
+
}
|
|
1099
|
+
if (Object.keys(trimmed).length === 0) {
|
|
1100
|
+
const { context: _context, ...rest } = field;
|
|
1101
|
+
return rest;
|
|
1102
|
+
}
|
|
1103
|
+
return { ...field, context: trimmed };
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1049
1106
|
function toLandmarkModel(node) {
|
|
1050
1107
|
const name = sectionDisplayName(node, 'landmark');
|
|
1051
1108
|
return {
|
|
@@ -1753,6 +1810,7 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1753
1810
|
resolve({
|
|
1754
1811
|
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1755
1812
|
timeoutMs,
|
|
1813
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1756
1814
|
});
|
|
1757
1815
|
}
|
|
1758
1816
|
return;
|
|
@@ -1772,7 +1830,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1772
1830
|
}
|
|
1773
1831
|
else if (msg.type === 'ack') {
|
|
1774
1832
|
cleanup();
|
|
1775
|
-
resolve({
|
|
1833
|
+
resolve({
|
|
1834
|
+
status: 'acknowledged',
|
|
1835
|
+
timeoutMs,
|
|
1836
|
+
...(msg.result !== undefined ? { result: msg.result } : {}),
|
|
1837
|
+
});
|
|
1776
1838
|
}
|
|
1777
1839
|
}
|
|
1778
1840
|
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.14",
|
|
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.14",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|