@geometra/mcp 1.53.0 → 1.55.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.
@@ -1087,11 +1087,138 @@ describe('submit_form tool', () => {
1087
1087
  expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1088
1088
  });
1089
1089
  });
1090
+ describe('fill transparent fallback', () => {
1091
+ beforeEach(() => {
1092
+ vi.clearAllMocks();
1093
+ resetMockSessionCaches();
1094
+ });
1095
+ it('geometra_run_actions aggregates step-level fill fallback metadata into top-level fallbacks', async () => {
1096
+ const handler = getToolHandler('geometra_run_actions');
1097
+ mockState.currentA11yRoot = node('group', undefined, {
1098
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1099
+ children: [
1100
+ node('textbox', 'Full name', { value: '', path: [0] }),
1101
+ ],
1102
+ });
1103
+ // Force the batched path to reject with a recoverable error so the
1104
+ // fill_fields step falls through to the sequential loop and tags the step.
1105
+ mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1106
+ // includeSteps:false makes the fill_fields step handler prefer the batched
1107
+ // fast-path. When that path is unavailable, the step flips to sequential
1108
+ // and emits fallback metadata that run_actions lifts into the top-level
1109
+ // `fallbacks` array.
1110
+ const result = await handler({
1111
+ actions: [
1112
+ {
1113
+ type: 'fill_fields',
1114
+ fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
1115
+ },
1116
+ ],
1117
+ stopOnError: true,
1118
+ includeSteps: false,
1119
+ detail: 'minimal',
1120
+ });
1121
+ const payload = JSON.parse(result.content[0].text);
1122
+ expect(payload).toMatchObject({
1123
+ completed: true,
1124
+ stepCount: 1,
1125
+ successCount: 1,
1126
+ fallbacks: [
1127
+ { stepIndex: 0, type: 'fill_fields', attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
1128
+ ],
1129
+ });
1130
+ });
1131
+ it('geometra_fill_fields surfaces fallback metadata when the batched path is unavailable', async () => {
1132
+ const handler = getToolHandler('geometra_fill_fields');
1133
+ mockState.currentA11yRoot = node('group', undefined, {
1134
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1135
+ children: [
1136
+ node('textbox', 'Full name', { value: '', path: [0] }),
1137
+ ],
1138
+ });
1139
+ // Force the batched path to throw a recoverable error so the handler
1140
+ // falls through to the sequential loop and tags the fallback.
1141
+ mockState.sendFillFields.mockRejectedValueOnce(new Error('Unsupported client message type "fillFields"'));
1142
+ const result = await handler({
1143
+ fields: [{ kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' }],
1144
+ stopOnError: true,
1145
+ failOnInvalid: false,
1146
+ includeSteps: false,
1147
+ detail: 'minimal',
1148
+ });
1149
+ const payload = JSON.parse(result.content[0].text);
1150
+ expect(payload).toMatchObject({
1151
+ completed: true,
1152
+ fieldCount: 1,
1153
+ successCount: 1,
1154
+ errorCount: 0,
1155
+ fallback: { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 },
1156
+ });
1157
+ });
1158
+ it('geometra_fill_form surfaces fallback metadata when batched throws recoverable error', async () => {
1159
+ const handler = getToolHandler('geometra_fill_form');
1160
+ mockState.currentA11yRoot = node('group', undefined, {
1161
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1162
+ children: [
1163
+ node('textbox', 'Full name', { value: '', path: [0] }),
1164
+ ],
1165
+ });
1166
+ mockState.formSchemas = [{
1167
+ formId: 'fm:0',
1168
+ name: 'Application',
1169
+ fieldCount: 1,
1170
+ requiredCount: 1,
1171
+ invalidCount: 1,
1172
+ fields: [
1173
+ { id: 'ff:0.0', kind: 'text', label: 'Full name' },
1174
+ ],
1175
+ }];
1176
+ // Both the batched-direct path and the schema-backed batched path call
1177
+ // sendFillFields. Reject all calls so both hit the recoverable-error
1178
+ // branch and the handler lands in the sequential loop.
1179
+ mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
1180
+ const result = await handler({
1181
+ valuesByLabel: { 'Full name': 'Taylor Applicant' },
1182
+ includeSteps: false,
1183
+ detail: 'minimal',
1184
+ });
1185
+ const payload = JSON.parse(result.content[0].text);
1186
+ expect(payload).toMatchObject({
1187
+ execution: 'sequential',
1188
+ fallback: { attempted: true, used: true, reason: 'batched-threw', attempts: 2 },
1189
+ });
1190
+ });
1191
+ });
1090
1192
  describe('click transparent fallback', () => {
1091
1193
  beforeEach(() => {
1092
1194
  vi.clearAllMocks();
1093
1195
  resetMockSessionCaches();
1094
1196
  });
1197
+ it('surfaces fallback.attempted:false when click fallback attempted and failed', async () => {
1198
+ const handler = getToolHandler('geometra_click');
1199
+ mockState.currentA11yRoot = node('group', undefined, {
1200
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1201
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1202
+ children: [],
1203
+ });
1204
+ const result = await handler({
1205
+ role: 'button',
1206
+ name: 'Does not exist',
1207
+ fullyVisible: true,
1208
+ maxRevealSteps: 1,
1209
+ revealTimeoutMs: 50,
1210
+ detail: 'terse',
1211
+ });
1212
+ // Fallback was attempted (both revision-retry — if mockWaitForUiCondition
1213
+ // returns true — and relaxed-visibility) but neither phase recovered the
1214
+ // missing target, so the handler returns a structured error carrying the
1215
+ // attempted-but-failed telemetry.
1216
+ const errorText = result.content[0].text;
1217
+ expect(errorText).toContain('"fallback"');
1218
+ const parsed = JSON.parse(errorText);
1219
+ expect(parsed.fallback).toMatchObject({ attempted: true, used: false });
1220
+ expect(parsed.fallback.reasonsTried.length).toBeGreaterThan(0);
1221
+ });
1095
1222
  it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
1096
1223
  const handler = getToolHandler('geometra_click');
1097
1224
  // First tree: target exists but is offscreen below the viewport, so a
@@ -1124,7 +1251,7 @@ describe('click transparent fallback', () => {
1124
1251
  const payload = JSON.parse(result.content[0].text);
1125
1252
  expect(payload).toMatchObject({
1126
1253
  target: { role: 'button', name: 'Submit' },
1127
- fallback: { used: true, reason: 'relaxed-visibility' },
1254
+ fallback: { attempted: true, used: true, reason: 'relaxed-visibility' },
1128
1255
  });
1129
1256
  });
1130
1257
  });
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.22' }, { capabilities: { tools: {} } });
333
+ const server = new McpServer({ name: 'geometra', version: '1.19.24' }, { 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.
@@ -774,6 +774,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
774
774
  const resolvedFields = resolveFillFieldInputs(session, fields);
775
775
  if (!resolvedFields.ok)
776
776
  return err(resolvedFields.error);
777
+ let fallbackFromBatch;
777
778
  if (!includeSteps) {
778
779
  try {
779
780
  const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
@@ -792,6 +793,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
792
793
  }
793
794
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
794
795
  }
796
+ // Batched path returned {ok: false} — this is the transparent fallback
797
+ // case. Continue into the sequential loop and mark the result so
798
+ // operators can aggregate how often it fires.
799
+ fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
795
800
  }
796
801
  catch (e) {
797
802
  const message = e instanceof Error ? e.message : String(e);
@@ -847,6 +852,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
847
852
  errorCount,
848
853
  ...(includeSteps ? { steps } : {}),
849
854
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
855
+ ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
850
856
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
851
857
  };
852
858
  if (failOnInvalid && invalidRemaining > 0) {
@@ -1017,6 +1023,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1017
1023
  planned.fields = planned.planned.map(p => p.field);
1018
1024
  }
1019
1025
  }
1026
+ let fallbackFromBatch;
1020
1027
  if (!includeSteps) {
1021
1028
  let usedBatch = false;
1022
1029
  let batchAckResult;
@@ -1050,6 +1057,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1050
1057
  const message = e instanceof Error ? e.message : String(e);
1051
1058
  return err(message);
1052
1059
  }
1060
+ fallbackFromBatch = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
1053
1061
  }
1054
1062
  if (usedBatch) {
1055
1063
  const after = sessionA11y(session);
@@ -1057,6 +1065,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1057
1065
  const invalidRemaining = signals?.invalidFields.length ?? 0;
1058
1066
  if ((!batchAckResult || batchAckResult.invalidCount > 0) && invalidRemaining > 0) {
1059
1067
  usedBatch = false;
1068
+ fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
1060
1069
  }
1061
1070
  }
1062
1071
  if (usedBatch) {
@@ -1131,6 +1140,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1131
1140
  ...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
1132
1141
  ...(includeSteps ? { steps } : {}),
1133
1142
  ...(stoppedAt !== undefined ? { stoppedAt, resumeFromIndex: stoppedAt + 1 } : {}),
1143
+ ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
1134
1144
  ...(verification ? { verification } : {}),
1135
1145
  ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1136
1146
  };
@@ -1359,6 +1369,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1359
1369
  const steps = [];
1360
1370
  let stoppedAt;
1361
1371
  const batchStartedAt = performance.now();
1372
+ // Collect transparent-fallback signals from each step so run_actions
1373
+ // surfaces them at top level regardless of `includeSteps` — otherwise
1374
+ // the telemetry is dead code when callers opt out of the steps listing.
1375
+ const fallbackRecords = [];
1362
1376
  for (let index = 0; index < actions.length; index++) {
1363
1377
  const action = actions[index];
1364
1378
  const startedAt = performance.now();
@@ -1370,6 +1384,17 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1370
1384
  uiTreeWaitMs = performance.now() - uiTreeWaitStartedAt;
1371
1385
  }
1372
1386
  const result = await executeBatchAction(session, action, detail, includeSteps);
1387
+ const stepFallback = result.compact.fallback;
1388
+ if (stepFallback?.used) {
1389
+ fallbackRecords.push({
1390
+ stepIndex: index,
1391
+ type: action.type,
1392
+ attempted: true,
1393
+ used: true,
1394
+ reason: stepFallback.reason,
1395
+ attempts: stepFallback.attempts,
1396
+ });
1397
+ }
1373
1398
  const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
1374
1399
  const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
1375
1400
  const stepSignals = includeSteps ? (() => {
@@ -1428,6 +1453,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1428
1453
  ...connection,
1429
1454
  completed: stoppedAt === undefined && steps.length === actions.length,
1430
1455
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1456
+ ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1431
1457
  ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
1432
1458
  }
1433
1459
  : {
@@ -1438,6 +1464,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1438
1464
  errorCount,
1439
1465
  ...(includeSteps ? { steps } : {}),
1440
1466
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1467
+ ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1441
1468
  ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
1442
1469
  };
1443
1470
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -1694,7 +1721,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1694
1721
  revealTimeoutMs,
1695
1722
  });
1696
1723
  if (!resolved.ok)
1697
- return err(resolved.error);
1724
+ return err(clickFallbackErrorMessage(resolved));
1698
1725
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
1699
1726
  const summary = postActionSummary(session, before, wait, detail);
1700
1727
  const clickLine = !resolved.value.target
@@ -2988,22 +3015,25 @@ async function resolveClickLocationWithFallback(session, options) {
2988
3015
  return first;
2989
3016
  if (!hasNodeFilter(options.filter))
2990
3017
  return first;
3018
+ const reasonsTried = [];
2991
3019
  let attempts = 1;
2992
3020
  const startRevision = session.updateRevision;
2993
3021
  const revisionAdvanced = await waitForUiCondition(session, () => session.updateRevision > startRevision, 600);
2994
3022
  if (revisionAdvanced) {
2995
3023
  attempts += 1;
3024
+ reasonsTried.push('revision-retry');
2996
3025
  const retry = await resolveClickLocation(session, options);
2997
3026
  if (retry.ok) {
2998
3027
  return {
2999
3028
  ok: true,
3000
3029
  value: retry.value,
3001
- fallback: { used: true, reason: 'revision-retry', attempts },
3030
+ fallback: { attempted: true, used: true, reason: 'revision-retry', attempts },
3002
3031
  };
3003
3032
  }
3004
3033
  }
3005
3034
  if (options.fullyVisible !== false) {
3006
3035
  attempts += 1;
3036
+ reasonsTried.push('relaxed-visibility');
3007
3037
  const relaxed = await resolveClickLocation(session, {
3008
3038
  ...options,
3009
3039
  fullyVisible: false,
@@ -3013,11 +3043,32 @@ async function resolveClickLocationWithFallback(session, options) {
3013
3043
  return {
3014
3044
  ok: true,
3015
3045
  value: relaxed.value,
3016
- fallback: { used: true, reason: 'relaxed-visibility', attempts },
3046
+ fallback: { attempted: true, used: true, reason: 'relaxed-visibility', attempts },
3017
3047
  };
3018
3048
  }
3019
3049
  }
3020
- return first;
3050
+ // All fallback phases tried and none recovered. Carry the trace of what we
3051
+ // tried so operators see the attempted-but-failed signal alongside the
3052
+ // successful-recovery signal.
3053
+ if (reasonsTried.length === 0)
3054
+ return first;
3055
+ return {
3056
+ ok: false,
3057
+ error: first.error,
3058
+ fallback: { attempted: true, used: false, reasonsTried, attempts },
3059
+ };
3060
+ }
3061
+ /**
3062
+ * Expose the attempted-but-failed fallback telemetry on error responses. When
3063
+ * `resolved.fallback` is present on a failure, return a structured JSON error
3064
+ * so operators can aggregate recovery failures the same way they aggregate
3065
+ * successful recoveries. Plain-text errors are preserved for the no-fallback
3066
+ * case to avoid churning that error contract.
3067
+ */
3068
+ function clickFallbackErrorMessage(resolved) {
3069
+ if (!resolved.fallback)
3070
+ return resolved.error;
3071
+ return JSON.stringify({ error: resolved.error, fallback: resolved.fallback });
3021
3072
  }
3022
3073
  function describeFormattedNode(node) {
3023
3074
  return `${node.role}${node.name ? ` ${JSON.stringify(node.name)}` : ''} (${node.id})`;
@@ -3516,7 +3567,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3516
3567
  revealTimeoutMs: action.revealTimeoutMs,
3517
3568
  });
3518
3569
  if (!resolved.ok)
3519
- throw new Error(resolved.error);
3570
+ throw new Error(clickFallbackErrorMessage(resolved));
3520
3571
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
3521
3572
  const targetSummary = resolved.value.target
3522
3573
  ? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
@@ -3762,6 +3813,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3762
3813
  const verifyFillsFn = action.verifyFills
3763
3814
  ? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
3764
3815
  : undefined;
3816
+ let fallbackFromBatch;
3765
3817
  if (!includeSteps) {
3766
3818
  const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
3767
3819
  if (batched.ok) {
@@ -3777,6 +3829,10 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3777
3829
  },
3778
3830
  };
3779
3831
  }
3832
+ // Batched path unavailable — fall through into sequential and tag the
3833
+ // step so `geometra_run_actions` result aggregation matches the shape
3834
+ // emitted by standalone `geometra_fill_fields` / `geometra_fill_form`.
3835
+ fallbackFromBatch = { attempted: true, used: true, reason: 'batched-unavailable', attempts: 2 };
3780
3836
  }
3781
3837
  const steps = [];
3782
3838
  for (let index = 0; index < resolvedFields.fields.length; index++) {
@@ -3805,6 +3861,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3805
3861
  compact: {
3806
3862
  fieldCount: resolvedFields.fields.length,
3807
3863
  ...(includeSteps ? { steps } : {}),
3864
+ ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
3808
3865
  ...(verification ? { verification } : {}),
3809
3866
  },
3810
3867
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.53.0",
3
+ "version": "1.55.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",