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