@geometra/mcp 1.55.0 → 1.56.0

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.
@@ -1086,6 +1086,53 @@ describe('submit_form tool', () => {
1086
1086
  expect(mockState.sendFillFields).not.toHaveBeenCalled();
1087
1087
  expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1088
1088
  });
1089
+ it('aggregates fill + submit fallback into a top-level fallbacks[] on the submit_form result', async () => {
1090
+ const handler = getToolHandler('geometra_submit_form');
1091
+ mockState.currentA11yRoot = node('group', undefined, {
1092
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1093
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1094
+ children: [
1095
+ node('textbox', 'Full name', { value: '', path: [0] }),
1096
+ node('button', 'Submit', {
1097
+ // Fully offscreen so the fullyVisible resolve misses and the
1098
+ // relaxed-visibility fallback picks it up — mirrors the click test above.
1099
+ bounds: { x: 60, y: 780, width: 180, height: 60 },
1100
+ path: [1],
1101
+ }),
1102
+ ],
1103
+ });
1104
+ mockState.formSchemas = [{
1105
+ formId: 'fm:0',
1106
+ name: 'Application',
1107
+ fieldCount: 1,
1108
+ requiredCount: 1,
1109
+ invalidCount: 1,
1110
+ fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name' }],
1111
+ }];
1112
+ // Force the fill batched path to throw a recoverable error so submit_form
1113
+ // falls through to the sequential fill loop and tags fill fallback.
1114
+ mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1115
+ mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
1116
+ const result = await handler({
1117
+ valuesByLabel: { 'Full name': 'Taylor Applicant' },
1118
+ submit: { role: 'button', name: 'Submit' },
1119
+ submitTimeoutMs: 1000,
1120
+ detail: 'minimal',
1121
+ });
1122
+ const payload = JSON.parse(result.content[0].text);
1123
+ const fallbacks = payload.fallbacks;
1124
+ expect(Array.isArray(fallbacks)).toBe(true);
1125
+ const phases = fallbacks.map(f => f.phase);
1126
+ expect(phases).toContain('fill');
1127
+ expect(phases).toContain('submit');
1128
+ expect(fallbacks.find(f => f.phase === 'fill')).toMatchObject({
1129
+ attempted: true, used: true, reason: 'batched-threw',
1130
+ });
1131
+ expect(fallbacks.find(f => f.phase === 'submit')).toMatchObject({
1132
+ attempted: true, used: true, reason: 'relaxed-visibility',
1133
+ });
1134
+ expect(payload.fill).toMatchObject({ execution: 'sequential' });
1135
+ });
1089
1136
  });
1090
1137
  describe('fill transparent fallback', () => {
1091
1138
  beforeEach(() => {
package/dist/server.js CHANGED
@@ -330,7 +330,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
330
330
  }),
331
331
  ]);
332
332
  export function createServer() {
333
- const server = new McpServer({ name: 'geometra', version: '1.19.24' }, { capabilities: { tools: {} } });
333
+ const server = new McpServer({ name: 'geometra', version: '1.19.25' }, { capabilities: { tools: {} } });
334
334
  const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
335
335
  // ── connect ──────────────────────────────────────────────────
336
336
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
@@ -1205,6 +1205,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1205
1205
  return err('No UI tree available for form submission');
1206
1206
  const entryUrl = entryA11y.meta?.pageUrl;
1207
1207
  let fillSummary;
1208
+ let fillFallback;
1208
1209
  if (!skipFill) {
1209
1210
  const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
1210
1211
  if (entryCount === 0) {
@@ -1220,6 +1221,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1220
1221
  const planned = planFormFill(schema, { valuesById, valuesByLabel });
1221
1222
  if (!planned.ok)
1222
1223
  return err(planned.error);
1224
+ let usedBatch = true;
1223
1225
  try {
1224
1226
  const startRevision = session.updateRevision;
1225
1227
  const wait = await sendFillFields(session, planned.fields);
@@ -1227,25 +1229,63 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1227
1229
  await waitForDeferredBatchUpdate(session, startRevision, wait);
1228
1230
  fillSummary = {
1229
1231
  formId: schema.formId,
1232
+ execution: 'batched',
1230
1233
  fieldCount: planned.fields.length,
1231
1234
  ...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
1232
1235
  ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1233
1236
  };
1234
1237
  }
1235
1238
  catch (e) {
1236
- const message = e instanceof Error ? e.message : String(e);
1237
- return err(`Failed to fill form before submit: ${message}`);
1239
+ if (!canFallbackToSequentialFill(e)) {
1240
+ const message = e instanceof Error ? e.message : String(e);
1241
+ return err(`Failed to fill form before submit: ${message}`);
1242
+ }
1243
+ usedBatch = false;
1244
+ fillFallback = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
1245
+ }
1246
+ if (!usedBatch) {
1247
+ let successCount = 0;
1248
+ let firstErr;
1249
+ for (const field of planned.fields) {
1250
+ try {
1251
+ await executeFillField(session, field, detail);
1252
+ successCount += 1;
1253
+ }
1254
+ catch (e) {
1255
+ firstErr = e instanceof Error ? e.message : String(e);
1256
+ break;
1257
+ }
1258
+ }
1259
+ if (firstErr !== undefined && successCount === 0) {
1260
+ return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
1261
+ }
1262
+ fillSummary = {
1263
+ formId: schema.formId,
1264
+ execution: 'sequential',
1265
+ fieldCount: planned.fields.length,
1266
+ successCount,
1267
+ ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1268
+ };
1238
1269
  }
1239
1270
  }
1240
1271
  const submitFilter = submit ?? { role: 'button', name: 'Submit' };
1241
- const resolvedClick = await resolveClickLocation(session, {
1272
+ const resolvedClick = await resolveClickLocationWithFallback(session, {
1242
1273
  filter: submitFilter,
1243
1274
  index: submitIndex,
1244
1275
  fullyVisible: true,
1245
1276
  revealTimeoutMs: 2_500,
1246
1277
  });
1247
- if (!resolvedClick.ok)
1248
- return err(`Submit target not found: ${resolvedClick.error}`);
1278
+ if (!resolvedClick.ok) {
1279
+ const payload = {
1280
+ ...connection,
1281
+ completed: false,
1282
+ ...(fillSummary ? { fill: fillSummary } : {}),
1283
+ ...(fillFallback ? { fill_fallback: fillFallback } : {}),
1284
+ submit: { ok: false, error: `Submit target not found: ${resolvedClick.error}` },
1285
+ ...(resolvedClick.fallback ? { submit_fallback: resolvedClick.fallback } : {}),
1286
+ };
1287
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1288
+ }
1249
1289
  const beforeSubmit = sessionA11y(session);
1250
1290
  const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, submitTimeoutMs);
1251
1291
  let waitResult;
@@ -1274,6 +1314,11 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1274
1314
  timeoutMs: waitFor.timeoutMs ?? 15_000,
1275
1315
  });
1276
1316
  if (!postWait.ok) {
1317
+ const preErrFallbacks = [];
1318
+ if (fillFallback)
1319
+ preErrFallbacks.push({ phase: 'fill', ...fillFallback });
1320
+ if (resolvedClick.fallback)
1321
+ preErrFallbacks.push({ phase: 'submit', ...resolvedClick.fallback });
1277
1322
  const payload = {
1278
1323
  ...connection,
1279
1324
  completed: false,
@@ -1283,6 +1328,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1283
1328
  ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1284
1329
  ...waitStatusPayload(clickWait),
1285
1330
  },
1331
+ ...(preErrFallbacks.length > 0 ? { fallbacks: preErrFallbacks } : {}),
1286
1332
  waitFor: { ok: false, error: postWait.error },
1287
1333
  };
1288
1334
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -1293,6 +1339,16 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1293
1339
  const signals = after ? collectSessionSignals(after) : undefined;
1294
1340
  const afterUrl = after?.meta?.pageUrl;
1295
1341
  const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
1342
+ // Aggregate all fallback usage into a single top-level `fallbacks[]`
1343
+ // array so this tool matches the shape emitted by `geometra_run_actions`
1344
+ // and can be aggregated the same way by operators.
1345
+ const fallbackRecords = [];
1346
+ if (fillFallback) {
1347
+ fallbackRecords.push({ phase: 'fill', ...fillFallback });
1348
+ }
1349
+ if (resolvedClick.fallback) {
1350
+ fallbackRecords.push({ phase: 'submit', ...resolvedClick.fallback });
1351
+ }
1296
1352
  const payload = {
1297
1353
  ...connection,
1298
1354
  completed: true,
@@ -1303,6 +1359,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1303
1359
  ...waitStatusPayload(clickWait),
1304
1360
  },
1305
1361
  ...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
1362
+ ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1306
1363
  ...(navigated ? { navigated: true, afterUrl } : {}),
1307
1364
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1308
1365
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.55.0",
3
+ "version": "1.56.0",
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",