@geometra/mcp 1.52.0 → 1.53.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,152 @@ 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('click transparent fallback', () => {
1091
+ beforeEach(() => {
1092
+ vi.clearAllMocks();
1093
+ resetMockSessionCaches();
1094
+ });
1095
+ it('surfaces fallback.used when relaxed-visibility lets an offscreen submit resolve', async () => {
1096
+ const handler = getToolHandler('geometra_click');
1097
+ // First tree: target exists but is offscreen below the viewport, so a
1098
+ // fullyVisible requirement cannot be satisfied before the reveal budget runs out.
1099
+ // The relaxed-visibility fallback drops the fullyVisible requirement and tries
1100
+ // once more with a larger reveal budget.
1101
+ const offscreenTree = node('group', undefined, {
1102
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
1103
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
1104
+ children: [
1105
+ node('button', 'Submit', {
1106
+ // Starts fully offscreen-below and wheel stubs don't move it in tests,
1107
+ // so the fullyVisible attempt will fail. Relaxed-visibility sees it
1108
+ // intersect enough to count as revealed.
1109
+ bounds: { x: 60, y: 780, width: 180, height: 60 },
1110
+ path: [0],
1111
+ }),
1112
+ ],
1113
+ });
1114
+ mockState.currentA11yRoot = offscreenTree;
1115
+ mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
1116
+ const result = await handler({
1117
+ role: 'button',
1118
+ name: 'Submit',
1119
+ fullyVisible: true,
1120
+ maxRevealSteps: 1,
1121
+ revealTimeoutMs: 100,
1122
+ detail: 'terse',
1123
+ });
1124
+ const payload = JSON.parse(result.content[0].text);
1125
+ expect(payload).toMatchObject({
1126
+ target: { role: 'button', name: 'Submit' },
1127
+ fallback: { used: true, reason: 'relaxed-visibility' },
1128
+ });
1129
+ });
1130
+ });
985
1131
  describe('query and reveal tools', () => {
986
1132
  beforeEach(() => {
987
1133
  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.22' }, { 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.
@@ -1156,6 +1156,164 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1156
1156
  }
1157
1157
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1158
1158
  });
1159
+ // ── fill + submit + wait ──────────────────────────────────────
1160
+ 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.
1161
+
1162
+ 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\`.
1163
+
1164
+ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: true\` for safe parallel submissions.`, {
1165
+ 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.'),
1166
+ pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
1167
+ port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
1168
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1169
+ width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1170
+ height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1171
+ slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1172
+ isolated: z.boolean().optional().default(false).describe('When auto-connecting via pageUrl/url, request an isolated proxy. Required for safe parallel form submission.'),
1173
+ formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
1174
+ valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
1175
+ valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
1176
+ submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
1177
+ submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
1178
+ 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.'),
1179
+ waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner, navigation, submit gone, etc.)'),
1180
+ 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.'),
1181
+ failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
1182
+ detail: detailInput(),
1183
+ sessionId: sessionIdInput,
1184
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, failOnInvalid, detail, sessionId }) => {
1185
+ 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.');
1186
+ if (!resolved.ok)
1187
+ return err(resolved.error);
1188
+ const session = resolved.session;
1189
+ const connection = autoConnectionPayload(resolved);
1190
+ if (!session.tree || !session.layout) {
1191
+ await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
1192
+ }
1193
+ const entryA11y = sessionA11y(session);
1194
+ if (!entryA11y)
1195
+ return err('No UI tree available for form submission');
1196
+ const entryUrl = entryA11y.meta?.pageUrl;
1197
+ let fillSummary;
1198
+ if (!skipFill) {
1199
+ const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
1200
+ if (entryCount === 0) {
1201
+ return err('Provide at least one value in valuesById or valuesByLabel, or set skipFill: true to submit already-filled values.');
1202
+ }
1203
+ const schemas = getSessionFormSchemas(session, { includeOptions: true, includeContext: 'auto' });
1204
+ if (schemas.length === 0)
1205
+ return err('No forms found in the current UI');
1206
+ const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
1207
+ if (!resolution.ok)
1208
+ return err(resolution.error);
1209
+ const schema = resolution.schema;
1210
+ const planned = planFormFill(schema, { valuesById, valuesByLabel });
1211
+ if (!planned.ok)
1212
+ return err(planned.error);
1213
+ try {
1214
+ const startRevision = session.updateRevision;
1215
+ const wait = await sendFillFields(session, planned.fields);
1216
+ const ack = parseProxyFillAckResult(wait.result);
1217
+ await waitForDeferredBatchUpdate(session, startRevision, wait);
1218
+ fillSummary = {
1219
+ formId: schema.formId,
1220
+ fieldCount: planned.fields.length,
1221
+ ...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
1222
+ ...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
1223
+ };
1224
+ }
1225
+ catch (e) {
1226
+ const message = e instanceof Error ? e.message : String(e);
1227
+ return err(`Failed to fill form before submit: ${message}`);
1228
+ }
1229
+ }
1230
+ const submitFilter = submit ?? { role: 'button', name: 'Submit' };
1231
+ const resolvedClick = await resolveClickLocation(session, {
1232
+ filter: submitFilter,
1233
+ index: submitIndex,
1234
+ fullyVisible: true,
1235
+ revealTimeoutMs: 2_500,
1236
+ });
1237
+ if (!resolvedClick.ok)
1238
+ return err(`Submit target not found: ${resolvedClick.error}`);
1239
+ const beforeSubmit = sessionA11y(session);
1240
+ const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, submitTimeoutMs);
1241
+ let waitResult;
1242
+ if (waitFor) {
1243
+ const postWait = await waitForSemanticCondition(session, {
1244
+ filter: {
1245
+ id: waitFor.id,
1246
+ role: waitFor.role,
1247
+ name: waitFor.name,
1248
+ text: waitFor.text,
1249
+ contextText: waitFor.contextText,
1250
+ promptText: waitFor.promptText,
1251
+ sectionText: waitFor.sectionText,
1252
+ itemText: waitFor.itemText,
1253
+ value: waitFor.value,
1254
+ checked: waitFor.checked,
1255
+ disabled: waitFor.disabled,
1256
+ focused: waitFor.focused,
1257
+ selected: waitFor.selected,
1258
+ expanded: waitFor.expanded,
1259
+ invalid: waitFor.invalid,
1260
+ required: waitFor.required,
1261
+ busy: waitFor.busy,
1262
+ },
1263
+ present: waitFor.present ?? true,
1264
+ timeoutMs: waitFor.timeoutMs ?? 15_000,
1265
+ });
1266
+ if (!postWait.ok) {
1267
+ const payload = {
1268
+ ...connection,
1269
+ completed: false,
1270
+ ...(fillSummary ? { fill: fillSummary } : {}),
1271
+ submit: {
1272
+ at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1273
+ ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
1274
+ ...waitStatusPayload(clickWait),
1275
+ },
1276
+ waitFor: { ok: false, error: postWait.error },
1277
+ };
1278
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1279
+ }
1280
+ waitResult = postWait.value;
1281
+ }
1282
+ const after = sessionA11y(session);
1283
+ const signals = after ? collectSessionSignals(after) : undefined;
1284
+ const afterUrl = after?.meta?.pageUrl;
1285
+ const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
1286
+ const payload = {
1287
+ ...connection,
1288
+ completed: true,
1289
+ ...(fillSummary ? { fill: fillSummary } : {}),
1290
+ submit: {
1291
+ at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
1292
+ ...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
1293
+ ...waitStatusPayload(clickWait),
1294
+ },
1295
+ ...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
1296
+ ...(navigated ? { navigated: true, afterUrl } : {}),
1297
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
1298
+ };
1299
+ // Pull in page model hints on navigation to mirror fill_form behavior.
1300
+ if (navigated && after) {
1301
+ const model = buildPageModel(after);
1302
+ if (model.captcha)
1303
+ payload.captcha = model.captcha;
1304
+ if (model.verification)
1305
+ payload.verification = model.verification;
1306
+ }
1307
+ if (failOnInvalid && signals && signals.invalidFields.length > 0) {
1308
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1309
+ }
1310
+ // Swallow the unused `beforeSubmit` binding; it anchors that the a11y tree was
1311
+ // captured pre-click and keeps the pattern consistent with other tools that
1312
+ // diff before/after for summaries (we rely on the waitFor / final signals
1313
+ // for the actual comparison here).
1314
+ void beforeSubmit;
1315
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
1316
+ });
1159
1317
  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
1318
 
1161
1319
  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.`, {
@@ -1508,7 +1666,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1508
1666
  return sessionResult.error;
1509
1667
  const session = sessionResult.session;
1510
1668
  const before = sessionA11y(session);
1511
- const resolved = await resolveClickLocation(session, {
1669
+ const resolved = await resolveClickLocationWithFallback(session, {
1512
1670
  x,
1513
1671
  y,
1514
1672
  filter: {
@@ -1574,6 +1732,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1574
1732
  at: { x: resolved.value.x, y: resolved.value.y },
1575
1733
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
1576
1734
  ...waitStatusPayload(wait),
1735
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
1577
1736
  postWait: waitConditionCompact(postWait.value),
1578
1737
  };
1579
1738
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
@@ -1582,6 +1741,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
1582
1741
  at: { x: resolved.value.x, y: resolved.value.y },
1583
1742
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
1584
1743
  ...waitStatusPayload(wait),
1744
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
1585
1745
  };
1586
1746
  return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
1587
1747
  });
@@ -2817,6 +2977,48 @@ async function resolveClickLocation(session, options) {
2817
2977
  },
2818
2978
  };
2819
2979
  }
2980
+ async function resolveClickLocationWithFallback(session, options) {
2981
+ const first = await resolveClickLocation(session, options);
2982
+ if (first.ok)
2983
+ return first;
2984
+ // Fallback only applies to semantic resolves. Explicit coordinates never enter
2985
+ // the reveal path, so there is nothing to retry.
2986
+ const hasExplicitCoordinates = options.x !== undefined || options.y !== undefined;
2987
+ if (hasExplicitCoordinates)
2988
+ return first;
2989
+ if (!hasNodeFilter(options.filter))
2990
+ return first;
2991
+ let attempts = 1;
2992
+ const startRevision = session.updateRevision;
2993
+ const revisionAdvanced = await waitForUiCondition(session, () => session.updateRevision > startRevision, 600);
2994
+ if (revisionAdvanced) {
2995
+ attempts += 1;
2996
+ const retry = await resolveClickLocation(session, options);
2997
+ if (retry.ok) {
2998
+ return {
2999
+ ok: true,
3000
+ value: retry.value,
3001
+ fallback: { used: true, reason: 'revision-retry', attempts },
3002
+ };
3003
+ }
3004
+ }
3005
+ if (options.fullyVisible !== false) {
3006
+ attempts += 1;
3007
+ const relaxed = await resolveClickLocation(session, {
3008
+ ...options,
3009
+ fullyVisible: false,
3010
+ maxRevealSteps: Math.max(options.maxRevealSteps ?? 0, 24),
3011
+ });
3012
+ if (relaxed.ok) {
3013
+ return {
3014
+ ok: true,
3015
+ value: relaxed.value,
3016
+ fallback: { used: true, reason: 'relaxed-visibility', attempts },
3017
+ };
3018
+ }
3019
+ }
3020
+ return first;
3021
+ }
2820
3022
  function describeFormattedNode(node) {
2821
3023
  return `${node.role}${node.name ? ` ${JSON.stringify(node.name)}` : ''} (${node.id})`;
2822
3024
  }
@@ -3286,7 +3488,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3286
3488
  switch (action.type) {
3287
3489
  case 'click': {
3288
3490
  const before = sessionA11y(session);
3289
- const resolved = await resolveClickLocation(session, {
3491
+ const resolved = await resolveClickLocationWithFallback(session, {
3290
3492
  x: action.x,
3291
3493
  y: action.y,
3292
3494
  filter: {
@@ -3357,6 +3559,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
3357
3559
  at: { x: resolved.value.x, y: resolved.value.y },
3358
3560
  ...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
3359
3561
  ...waitStatusPayload(wait),
3562
+ ...(resolved.fallback ? { fallback: resolved.fallback } : {}),
3360
3563
  ...(postWaitCompact ? { postWait: postWaitCompact } : {}),
3361
3564
  },
3362
3565
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.52.0",
3
+ "version": "1.53.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",