@geometra/mcp 1.52.0 → 1.54.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.
@@ -982,6 +982,279 @@ describe('batch MCP result shaping', () => {
982
982
  });
983
983
  });
984
984
  });
985
+ describe('submit_form tool', () => {
986
+ beforeEach(() => {
987
+ vi.clearAllMocks();
988
+ resetMockSessionCaches();
989
+ mockState.formSchemas = [];
990
+ });
991
+ it('combines fill + submit-click + post-submit wait in one call', async () => {
992
+ const handler = getToolHandler('geometra_submit_form');
993
+ mockState.currentA11yRoot = node('group', undefined, {
994
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
995
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
996
+ children: [
997
+ node('textbox', 'Full name', { value: '', path: [0] }),
998
+ node('textbox', 'Email', { value: '', path: [1] }),
999
+ node('button', 'Submit application', {
1000
+ bounds: { x: 60, y: 480, width: 180, height: 40 },
1001
+ path: [2],
1002
+ }),
1003
+ ],
1004
+ });
1005
+ mockState.formSchemas = [{
1006
+ formId: 'fm:0',
1007
+ name: 'Application',
1008
+ fieldCount: 2,
1009
+ requiredCount: 2,
1010
+ invalidCount: 2,
1011
+ fields: [
1012
+ { id: 'ff:0.0', kind: 'text', label: 'Full name' },
1013
+ { id: 'ff:0.1', kind: 'text', label: 'Email' },
1014
+ ],
1015
+ }];
1016
+ mockState.sendFillFields.mockImplementationOnce(async () => ({
1017
+ status: 'acknowledged',
1018
+ timeoutMs: 6000,
1019
+ result: { invalidCount: 0, alertCount: 0, dialogCount: 0, busyCount: 0, pageUrl: 'https://jobs.example.com/application' },
1020
+ }));
1021
+ mockState.sendClick.mockImplementationOnce(async () => {
1022
+ mockState.currentA11yRoot = node('group', undefined, {
1023
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1024
+ meta: { pageUrl: 'https://jobs.example.com/confirm', scrollX: 0, scrollY: 0 },
1025
+ children: [
1026
+ node('dialog', 'Application submitted', {
1027
+ bounds: { x: 240, y: 140, width: 420, height: 260 },
1028
+ path: [0],
1029
+ }),
1030
+ ],
1031
+ });
1032
+ bumpMockUiRevision();
1033
+ return { status: 'updated', timeoutMs: 2000 };
1034
+ });
1035
+ const result = await handler({
1036
+ valuesByLabel: { 'Full name': 'Taylor Applicant', Email: 'taylor@example.com' },
1037
+ submit: { role: 'button', name: 'Submit application' },
1038
+ waitFor: { role: 'dialog', name: 'Application submitted', timeoutMs: 5000 },
1039
+ detail: 'minimal',
1040
+ });
1041
+ const payload = JSON.parse(result.content[0].text);
1042
+ expect(payload).toMatchObject({
1043
+ completed: true,
1044
+ fill: { fieldCount: 2, formId: 'fm:0' },
1045
+ submit: { target: { role: 'button', name: 'Submit application' } },
1046
+ waitFor: { present: true, matchCount: 1 },
1047
+ navigated: true,
1048
+ afterUrl: 'https://jobs.example.com/confirm',
1049
+ });
1050
+ expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
1051
+ expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1052
+ });
1053
+ it('rejects missing values when skipFill is false', async () => {
1054
+ const handler = getToolHandler('geometra_submit_form');
1055
+ mockState.currentA11yRoot = node('group', undefined, {
1056
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1057
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1058
+ children: [
1059
+ node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
1060
+ ],
1061
+ });
1062
+ const result = await handler({ detail: 'minimal' });
1063
+ expect(result.content[0].text).toContain('Provide at least one value');
1064
+ });
1065
+ it('skipFill: true goes straight to submit + wait', async () => {
1066
+ const handler = getToolHandler('geometra_submit_form');
1067
+ mockState.currentA11yRoot = node('group', undefined, {
1068
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1069
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1070
+ children: [
1071
+ node('button', 'Submit', { bounds: { x: 60, y: 480, width: 100, height: 40 }, path: [0] }),
1072
+ ],
1073
+ });
1074
+ mockState.sendClick.mockImplementationOnce(async () => {
1075
+ bumpMockUiRevision();
1076
+ return { status: 'updated', timeoutMs: 2000 };
1077
+ });
1078
+ const result = await handler({
1079
+ skipFill: true,
1080
+ submit: { role: 'button', name: 'Submit' },
1081
+ detail: 'minimal',
1082
+ });
1083
+ const payload = JSON.parse(result.content[0].text);
1084
+ expect(payload).toMatchObject({ completed: true });
1085
+ expect(payload).not.toHaveProperty('fill');
1086
+ expect(mockState.sendFillFields).not.toHaveBeenCalled();
1087
+ expect(mockState.sendClick).toHaveBeenCalledTimes(1);
1088
+ });
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
+ });
1192
+ describe('click transparent fallback', () => {
1193
+ beforeEach(() => {
1194
+ vi.clearAllMocks();
1195
+ resetMockSessionCaches();
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
+ });
1222
+ it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
1223
+ const handler = getToolHandler('geometra_click');
1224
+ // First tree: target exists but is offscreen below the viewport, so a
1225
+ // fullyVisible requirement cannot be satisfied before the reveal budget runs out.
1226
+ // The relaxed-visibility fallback drops the fullyVisible requirement and tries
1227
+ // once more with a larger reveal budget.
1228
+ const offscreenTree = node('group', undefined, {
1229
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1230
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1231
+ children: [
1232
+ node('button', 'Submit', {
1233
+ // Starts fully offscreen-below and wheel stubs don't move it in tests,
1234
+ // so the fullyVisible attempt will fail. Relaxed-visibility sees it
1235
+ // intersect enough to count as revealed.
1236
+ bounds: { x: 60, y: 780, width: 180, height: 60 },
1237
+ path: [0],
1238
+ }),
1239
+ ],
1240
+ });
1241
+ mockState.currentA11yRoot = offscreenTree;
1242
+ mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
1243
+ const result = await handler({
1244
+ role: 'button',
1245
+ name: 'Submit',
1246
+ fullyVisible: true,
1247
+ maxRevealSteps: 1,
1248
+ revealTimeoutMs: 100,
1249
+ detail: 'terse',
1250
+ });
1251
+ const payload = JSON.parse(result.content[0].text);
1252
+ expect(payload).toMatchObject({
1253
+ target: { role: 'button', name: 'Submit' },
1254
+ fallback: { attempted: true, used: true, reason: 'relaxed-visibility' },
1255
+ });
1256
+ });
1257
+ });
985
1258
  describe('query and reveal tools', () => {
986
1259
  beforeEach(() => {
987
1260
  vi.clearAllMocks();
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.21' }, { capabilities: { tools: {} } });
333
+ const server = new McpServer({ name: 'geometra', version: '1.19.23' }, { 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
  };
@@ -1156,6 +1166,164 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1156
1166
  }
1157
1167
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1158
1168
  });
1169
+ // ── fill + submit + wait ──────────────────────────────────────
1170
+ server.tool('geometra_submit_form', `Fill a form, click its submit button, and optionally wait for the post-submit UI state — all in one MCP call. This is the preferred path for the canonical ATS / sign-in flow when the whole sequence should run server-side.
1171
+
1172
+ Pass \`valuesById\` or \`valuesByLabel\` to populate fields, \`submit\` to target the submit button (default: semantic \`{ role: 'button', name: 'Submit' }\`), and \`waitFor\` to block on the post-submit state (success banner, navigation, submit button gone, etc.). Navigation is detected automatically and surfaced as \`navigated: true\` with \`afterUrl\`.
1173
+
1174
+ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: true\` for safe parallel submissions.`, {
1175
+ url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before submitting.'),
1176
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
1177
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
1178
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1179
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1180
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1181
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1182
+ isolated: z.boolean().optional().default(false).describe('When auto-connecting via pageUrl/url, request an isolated proxy. Required for safe parallel form submission.'),
1183
+ formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
1184
+ valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
1185
+ valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
1186
+ submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
1187
+ submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
1188
+ submitTimeoutMs: z.number().int().min(50).max(60_000).optional().default(15_000).describe('Action wait timeout for the submit click (default 15000ms). Increase for slow backends.'),
1189
+ waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner, navigation, submit gone, etc.)'),
1190
+ skipFill: z.boolean().optional().default(false).describe('Skip the fill phase and go straight to submit+wait. Use when values have already been filled by a previous call.'),
1191
+ failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
1192
+ detail: detailInput(),
1193
+ sessionId: sessionIdInput,
1194
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, failOnInvalid, detail, sessionId }) => {
1195
+ const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_submit_form.');
1196
+ if (!resolved.ok)
1197
+ return err(resolved.error);
1198
+ const session = resolved.session;
1199
+ const connection = autoConnectionPayload(resolved);
1200
+ if (!session.tree || !session.layout) {
1201
+ await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
1202
+ }
1203
+ const entryA11y = sessionA11y(session);
1204
+ if (!entryA11y)
1205
+ return err('No UI tree available for form submission');
1206
+ const entryUrl = entryA11y.meta?.pageUrl;
1207
+ let fillSummary;
1208
+ if (!skipFill) {
1209
+ const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
1210
+ if (entryCount === 0) {
1211
+ return err('Provide at least one value in valuesById or valuesByLabel, or set skipFill: true to submit already-filled values.');
1212
+ }
1213
+ const schemas = getSessionFormSchemas(session, { includeOptions: true, includeContext: 'auto' });
1214
+ if (schemas.length === 0)
1215
+ return err('No forms found in the current UI');
1216
+ const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
1217
+ if (!resolution.ok)
1218
+ return err(resolution.error);
1219
+ const schema = resolution.schema;
1220
+ const planned = planFormFill(schema, { valuesById, valuesByLabel });
1221
+ if (!planned.ok)
1222
+ return err(planned.error);
1223
+ try {
1224
+ const startRevision = session.updateRevision;
1225
+ const wait = await sendFillFields(session, planned.fields);
1226
+ const ack = parseProxyFillAckResult(wait.result);
1227
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
1228
+ fillSummary = {
1229
+ formId: schema.formId,
1230
+ fieldCount: planned.fields.length,
1231
+ ...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
1232
+ ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1233
+ };
1234
+ }
1235
+ catch (e) {
1236
+ const message = e instanceof Error ? e.message : String(e);
1237
+ return err(`Failed to fill form before submit: ${message}`);
1238
+ }
1239
+ }
1240
+ const submitFilter = submit ?? { role: 'button', name: 'Submit' };
1241
+ const resolvedClick = await resolveClickLocation(session, {
1242
+ filter: submitFilter,
1243
+ index: submitIndex,
1244
+ fullyVisible: true,
1245
+ revealTimeoutMs: 2_500,
1246
+ });
1247
+ if (!resolvedClick.ok)
1248
+ return err(`Submit target not found: ${resolvedClick.error}`);
1249
+ const beforeSubmit = sessionA11y(session);
1250
+ const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, submitTimeoutMs);
1251
+ let waitResult;
1252
+ if (waitFor) {
1253
+ const postWait = await waitForSemanticCondition(session, {
1254
+ filter: {
1255
+ id: waitFor.id,
1256
+ role: waitFor.role,
1257
+ name: waitFor.name,
1258
+ text: waitFor.text,
1259
+ contextText: waitFor.contextText,
1260
+ promptText: waitFor.promptText,
1261
+ sectionText: waitFor.sectionText,
1262
+ itemText: waitFor.itemText,
1263
+ value: waitFor.value,
1264
+ checked: waitFor.checked,
1265
+ disabled: waitFor.disabled,
1266
+ focused: waitFor.focused,
1267
+ selected: waitFor.selected,
1268
+ expanded: waitFor.expanded,
1269
+ invalid: waitFor.invalid,
1270
+ required: waitFor.required,
1271
+ busy: waitFor.busy,
1272
+ },
1273
+ present: waitFor.present ?? true,
1274
+ timeoutMs: waitFor.timeoutMs ?? 15_000,
1275
+ });
1276
+ if (!postWait.ok) {
1277
+ const payload = {
1278
+ ...connection,
1279
+ completed: false,
1280
+ ...(fillSummary ? { fill: fillSummary } : {}),
1281
+ submit: {
1282
+ at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1283
+ ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1284
+ ...waitStatusPayload(clickWait),
1285
+ },
1286
+ waitFor: { ok: false, error: postWait.error },
1287
+ };
1288
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1289
+ }
1290
+ waitResult = postWait.value;
1291
+ }
1292
+ const after = sessionA11y(session);
1293
+ const signals = after ? collectSessionSignals(after) : undefined;
1294
+ const afterUrl = after?.meta?.pageUrl;
1295
+ const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
1296
+ const payload = {
1297
+ ...connection,
1298
+ completed: true,
1299
+ ...(fillSummary ? { fill: fillSummary } : {}),
1300
+ submit: {
1301
+ at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1302
+ ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
1303
+ ...waitStatusPayload(clickWait),
1304
+ },
1305
+ ...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
1306
+ ...(navigated ? { navigated: true, afterUrl } : {}),
1307
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1308
+ };
1309
+ // Pull in page model hints on navigation to mirror fill_form behavior.
1310
+ if (navigated && after) {
1311
+ const model = buildPageModel(after);
1312
+ if (model.captcha)
1313
+ payload.captcha = model.captcha;
1314
+ if (model.verification)
1315
+ payload.verification = model.verification;
1316
+ }
1317
+ if (failOnInvalid && signals && signals.invalidFields.length > 0) {
1318
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1319
+ }
1320
+ // Swallow the unused `beforeSubmit` binding; it anchors that the a11y tree was
1321
+ // captured pre-click and keeps the pattern consistent with other tools that
1322
+ // diff before/after for summaries (we rely on the waitFor / final signals
1323
+ // for the actual comparison here).
1324
+ void beforeSubmit;
1325
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1326
+ });
1159
1327
  server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
1160
1328
 
1161
1329
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, \`expand_section\`, and \`fill_fields\`. \`click\` steps can also carry a nested \`waitFor\` condition. \`fill_fields\` steps can carry \`verifyFills: true\` to batch fill + read-back verification in one step (same semantics as \`geometra_fill_form\`'s \`verifyFills\`). \`expand_section\` takes a stable section id from \`geometra_page_model\` and returns the same payload as \`geometra_expand_section\`, eliminating a round-trip when drilling into a form/dialog before acting on it. Pass \`pageUrl\` / \`url\` to auto-connect so an entire flow can run in one MCP call.`, {
@@ -1201,6 +1369,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1201
1369
  const steps = [];
1202
1370
  let stoppedAt;
1203
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 = [];
1204
1376
  for (let index = 0; index < actions.length; index++) {
1205
1377
  const action = actions[index];
1206
1378
  const startedAt = performance.now();
@@ -1212,6 +1384,17 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1212
1384
  uiTreeWaitMs = performance.now() - uiTreeWaitStartedAt;
1213
1385
  }
1214
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
+ }
1215
1398
  const elapsedMs = Number((performance.now() - startedAt).toFixed(1));
1216
1399
  const cumulativeMs = Number((performance.now() - batchStartedAt).toFixed(1));
1217
1400
  const stepSignals = includeSteps ? (() => {
@@ -1270,6 +1453,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1270
1453
  ...connection,
1271
1454
  completed: stoppedAt === undefined && steps.length === actions.length,
1272
1455
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1456
+ ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1273
1457
  ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
1274
1458
  }
1275
1459
  : {
@@ -1280,6 +1464,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1280
1464
  errorCount,
1281
1465
  ...(includeSteps ? { steps } : {}),
1282
1466
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
1467
+ ...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
1283
1468
  ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
1284
1469
  };
1285
1470
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -1508,7 +1693,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1508
1693
  return sessionResult.error;
1509
1694
  const session = sessionResult.session;
1510
1695
  const before = sessionA11y(session);
1511
- const resolved = await resolveClickLocation(session, {
1696
+ const resolved = await resolveClickLocationWithFallback(session, {
1512
1697
  x,
1513
1698
  y,
1514
1699
  filter: {
@@ -1536,7 +1721,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1536
1721
  revealTimeoutMs,
1537
1722
  });
1538
1723
  if (!resolved.ok)
1539
- return err(resolved.error);
1724
+ return err(clickFallbackErrorMessage(resolved));
1540
1725
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
1541
1726
  const summary = postActionSummary(session, before, wait, detail);
1542
1727
  const clickLine = !resolved.value.target
@@ -1574,6 +1759,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1574
1759
  at: { x: resolved.value.x, y: resolved.value.y },
1575
1760
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
1576
1761
  ...waitStatusPayload(wait),
1762
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
1577
1763
  postWait: waitConditionCompact(postWait.value),
1578
1764
  };
1579
1765
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
@@ -1582,6 +1768,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1582
1768
  at: { x: resolved.value.x, y: resolved.value.y },
1583
1769
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
1584
1770
  ...waitStatusPayload(wait),
1771
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
1585
1772
  };
1586
1773
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
1587
1774
  });
@@ -2817,6 +3004,72 @@ async function resolveClickLocation(session, options) {
2817
3004
  },
2818
3005
  };
2819
3006
  }
3007
+ async function resolveClickLocationWithFallback(session, options) {
3008
+ const first = await resolveClickLocation(session, options);
3009
+ if (first.ok)
3010
+ return first;
3011
+ // Fallback only applies to semantic resolves. Explicit coordinates never enter
3012
+ // the reveal path, so there is nothing to retry.
3013
+ const hasExplicitCoordinates = options.x !== undefined || options.y !== undefined;
3014
+ if (hasExplicitCoordinates)
3015
+ return first;
3016
+ if (!hasNodeFilter(options.filter))
3017
+ return first;
3018
+ const reasonsTried = [];
3019
+ let attempts = 1;
3020
+ const startRevision = session.updateRevision;
3021
+ const revisionAdvanced = await waitForUiCondition(session, () => session.updateRevision > startRevision, 600);
3022
+ if (revisionAdvanced) {
3023
+ attempts += 1;
3024
+ reasonsTried.push('revision-retry');
3025
+ const retry = await resolveClickLocation(session, options);
3026
+ if (retry.ok) {
3027
+ return {
3028
+ ok: true,
3029
+ value: retry.value,
3030
+ fallback: { attempted: true, used: true, reason: 'revision-retry', attempts },
3031
+ };
3032
+ }
3033
+ }
3034
+ if (options.fullyVisible !== false) {
3035
+ attempts += 1;
3036
+ reasonsTried.push('relaxed-visibility');
3037
+ const relaxed = await resolveClickLocation(session, {
3038
+ ...options,
3039
+ fullyVisible: false,
3040
+ maxRevealSteps: Math.max(options.maxRevealSteps ?? 0, 24),
3041
+ });
3042
+ if (relaxed.ok) {
3043
+ return {
3044
+ ok: true,
3045
+ value: relaxed.value,
3046
+ fallback: { attempted: true, used: true, reason: 'relaxed-visibility', attempts },
3047
+ };
3048
+ }
3049
+ }
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 });
3072
+ }
2820
3073
  function describeFormattedNode(node) {
2821
3074
  return `${node.role}${node.name ? ` ${JSON.stringify(node.name)}` : ''} (${node.id})`;
2822
3075
  }
@@ -3286,7 +3539,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3286
3539
  switch (action.type) {
3287
3540
  case 'click': {
3288
3541
  const before = sessionA11y(session);
3289
- const resolved = await resolveClickLocation(session, {
3542
+ const resolved = await resolveClickLocationWithFallback(session, {
3290
3543
  x: action.x,
3291
3544
  y: action.y,
3292
3545
  filter: {
@@ -3314,7 +3567,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3314
3567
  revealTimeoutMs: action.revealTimeoutMs,
3315
3568
  });
3316
3569
  if (!resolved.ok)
3317
- throw new Error(resolved.error);
3570
+ throw new Error(clickFallbackErrorMessage(resolved));
3318
3571
  const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
3319
3572
  const targetSummary = resolved.value.target
3320
3573
  ? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
@@ -3357,6 +3610,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3357
3610
  at: { x: resolved.value.x, y: resolved.value.y },
3358
3611
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
3359
3612
  ...waitStatusPayload(wait),
3613
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
3360
3614
  ...(postWaitCompact ? { postWait: postWaitCompact } : {}),
3361
3615
  },
3362
3616
  };
@@ -3559,6 +3813,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3559
3813
  const verifyFillsFn = action.verifyFills
3560
3814
  ? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
3561
3815
  : undefined;
3816
+ let fallbackFromBatch;
3562
3817
  if (!includeSteps) {
3563
3818
  const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
3564
3819
  if (batched.ok) {
@@ -3574,6 +3829,10 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3574
3829
  },
3575
3830
  };
3576
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 };
3577
3836
  }
3578
3837
  const steps = [];
3579
3838
  for (let index = 0; index < resolvedFields.fields.length; index++) {
@@ -3602,6 +3861,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3602
3861
  compact: {
3603
3862
  fieldCount: resolvedFields.fields.length,
3604
3863
  ...(includeSteps ? { steps } : {}),
3864
+ ...(fallbackFromBatch ? { fallback: fallbackFromBatch } : {}),
3605
3865
  ...(verification ? { verification } : {}),
3606
3866
  },
3607
3867
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.52.0",
3
+ "version": "1.54.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",