@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.13' }, { capabilities: { tools: {} } });
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): Promise<Session>;
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
- // Send initial resize so server computes layout
46
- ws.send(JSON.stringify({ type: 'resize', width: 1024, height: 768 }));
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 filteredFields = fields.filter(field => {
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: fields.length,
1044
- requiredCount: fields.filter(field => field.required).length,
1045
- invalidCount: fields.filter(field => field.invalid).length,
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({ status: 'acknowledged', timeoutMs });
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.13",
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.13",
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"