@geometra/mcp 1.49.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.
- package/dist/__tests__/ats-integration.test.js +1 -0
- package/dist/__tests__/connect-utils.test.js +82 -3
- package/dist/__tests__/proxy-session-actions.test.js +66 -0
- package/dist/__tests__/server-batch-results.test.js +273 -1
- package/dist/__tests__/server-session-resolution.test.d.ts +1 -0
- package/dist/__tests__/server-session-resolution.test.js +88 -0
- package/dist/connect-utils.d.ts +2 -0
- package/dist/connect-utils.js +5 -3
- package/dist/server.js +295 -7
- package/dist/session.d.ts +6 -0
- package/dist/session.js +158 -25
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -124,6 +124,17 @@ const fillFieldSchema = z.union([
|
|
|
124
124
|
fieldLabel: z.string().describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
125
125
|
value: z.string().describe('Text value to set'),
|
|
126
126
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
127
|
+
typingDelayMs: z
|
|
128
|
+
.number()
|
|
129
|
+
.int()
|
|
130
|
+
.min(0)
|
|
131
|
+
.max(500)
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('Milliseconds between keystrokes when the proxy falls back to keyboard typing (masked inputs).'),
|
|
134
|
+
imeFriendly: z
|
|
135
|
+
.boolean()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
127
138
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
128
139
|
}),
|
|
129
140
|
z.object({
|
|
@@ -132,6 +143,17 @@ const fillFieldSchema = z.union([
|
|
|
132
143
|
fieldLabel: z.string().optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
133
144
|
value: z.string().describe('Text value to set'),
|
|
134
145
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
146
|
+
typingDelayMs: z
|
|
147
|
+
.number()
|
|
148
|
+
.int()
|
|
149
|
+
.min(0)
|
|
150
|
+
.max(500)
|
|
151
|
+
.optional()
|
|
152
|
+
.describe('Milliseconds between keystrokes when the proxy falls back to keyboard typing (masked inputs).'),
|
|
153
|
+
imeFriendly: z
|
|
154
|
+
.boolean()
|
|
155
|
+
.optional()
|
|
156
|
+
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
135
157
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
136
158
|
}),
|
|
137
159
|
z.object({
|
|
@@ -284,10 +306,31 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
284
306
|
z.object({
|
|
285
307
|
type: z.literal('fill_fields'),
|
|
286
308
|
fields: z.array(fillFieldSchema).min(1).max(80),
|
|
309
|
+
verifyFills: z
|
|
310
|
+
.boolean()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe('After filling, read each text/choice field back and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a `verification` entry to the step.'),
|
|
313
|
+
}),
|
|
314
|
+
z.object({
|
|
315
|
+
type: z.literal('expand_section'),
|
|
316
|
+
id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
|
|
317
|
+
maxHeadings: z.number().int().min(1).max(20).optional(),
|
|
318
|
+
maxFields: z.number().int().min(1).max(40).optional(),
|
|
319
|
+
fieldOffset: z.number().int().min(0).optional(),
|
|
320
|
+
onlyRequiredFields: z.boolean().optional(),
|
|
321
|
+
onlyInvalidFields: z.boolean().optional(),
|
|
322
|
+
maxActions: z.number().int().min(1).max(30).optional(),
|
|
323
|
+
actionOffset: z.number().int().min(0).optional(),
|
|
324
|
+
maxLists: z.number().int().min(0).max(20).optional(),
|
|
325
|
+
listOffset: z.number().int().min(0).optional(),
|
|
326
|
+
maxItems: z.number().int().min(0).max(50).optional(),
|
|
327
|
+
itemOffset: z.number().int().min(0).optional(),
|
|
328
|
+
maxTextPreview: z.number().int().min(0).max(20).optional(),
|
|
329
|
+
includeBounds: z.boolean().optional(),
|
|
287
330
|
}),
|
|
288
331
|
]);
|
|
289
332
|
export function createServer() {
|
|
290
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
333
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.22' }, { capabilities: { tools: {} } });
|
|
291
334
|
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
|
|
292
335
|
// ── connect ──────────────────────────────────────────────────
|
|
293
336
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
@@ -1113,9 +1156,167 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1113
1156
|
}
|
|
1114
1157
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1115
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
|
+
});
|
|
1116
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.
|
|
1117
1318
|
|
|
1118
|
-
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`. \`click\` steps can also carry a nested \`waitFor\` condition. Pass \`pageUrl\` / \`url\` to auto-connect so an entire flow can run in one MCP call.`, {
|
|
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.`, {
|
|
1119
1320
|
url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before running actions.'),
|
|
1120
1321
|
pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before running actions. Prefer this over url for browser pages.'),
|
|
1121
1322
|
port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
|
|
@@ -1465,7 +1666,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1465
1666
|
return sessionResult.error;
|
|
1466
1667
|
const session = sessionResult.session;
|
|
1467
1668
|
const before = sessionA11y(session);
|
|
1468
|
-
const resolved = await
|
|
1669
|
+
const resolved = await resolveClickLocationWithFallback(session, {
|
|
1469
1670
|
x,
|
|
1470
1671
|
y,
|
|
1471
1672
|
filter: {
|
|
@@ -1531,6 +1732,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1531
1732
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
1532
1733
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
1533
1734
|
...waitStatusPayload(wait),
|
|
1735
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
1534
1736
|
postWait: waitConditionCompact(postWait.value),
|
|
1535
1737
|
};
|
|
1536
1738
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
@@ -1539,6 +1741,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1539
1741
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
1540
1742
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
1541
1743
|
...waitStatusPayload(wait),
|
|
1744
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
1542
1745
|
};
|
|
1543
1746
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
1544
1747
|
});
|
|
@@ -2774,6 +2977,48 @@ async function resolveClickLocation(session, options) {
|
|
|
2774
2977
|
},
|
|
2775
2978
|
};
|
|
2776
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
|
+
}
|
|
2777
3022
|
function describeFormattedNode(node) {
|
|
2778
3023
|
return `${node.role}${node.name ? ` ${JSON.stringify(node.name)}` : ''} (${node.id})`;
|
|
2779
3024
|
}
|
|
@@ -3225,6 +3470,7 @@ function batchFieldReadbackMatches(a11y, field) {
|
|
|
3225
3470
|
function actionNeedsUiTree(action) {
|
|
3226
3471
|
switch (action.type) {
|
|
3227
3472
|
case 'wait_for':
|
|
3473
|
+
case 'expand_section':
|
|
3228
3474
|
return true;
|
|
3229
3475
|
case 'click':
|
|
3230
3476
|
return action.x === undefined || action.y === undefined || Boolean(action.waitFor);
|
|
@@ -3242,7 +3488,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3242
3488
|
switch (action.type) {
|
|
3243
3489
|
case 'click': {
|
|
3244
3490
|
const before = sessionA11y(session);
|
|
3245
|
-
const resolved = await
|
|
3491
|
+
const resolved = await resolveClickLocationWithFallback(session, {
|
|
3246
3492
|
x: action.x,
|
|
3247
3493
|
y: action.y,
|
|
3248
3494
|
filter: {
|
|
@@ -3313,6 +3559,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3313
3559
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
3314
3560
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
3315
3561
|
...waitStatusPayload(wait),
|
|
3562
|
+
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
3316
3563
|
...(postWaitCompact ? { postWait: postWaitCompact } : {}),
|
|
3317
3564
|
},
|
|
3318
3565
|
};
|
|
@@ -3480,13 +3727,45 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3480
3727
|
compact: waitConditionCompact(waited.value),
|
|
3481
3728
|
};
|
|
3482
3729
|
}
|
|
3730
|
+
case 'expand_section': {
|
|
3731
|
+
const a11y = sessionA11y(session);
|
|
3732
|
+
if (!a11y)
|
|
3733
|
+
throw new Error('No UI tree available to expand section');
|
|
3734
|
+
const sectionDetail = expandPageSection(a11y, action.id, {
|
|
3735
|
+
maxHeadings: action.maxHeadings,
|
|
3736
|
+
maxFields: action.maxFields,
|
|
3737
|
+
fieldOffset: action.fieldOffset,
|
|
3738
|
+
onlyRequiredFields: action.onlyRequiredFields,
|
|
3739
|
+
onlyInvalidFields: action.onlyInvalidFields,
|
|
3740
|
+
maxActions: action.maxActions,
|
|
3741
|
+
actionOffset: action.actionOffset,
|
|
3742
|
+
maxLists: action.maxLists,
|
|
3743
|
+
listOffset: action.listOffset,
|
|
3744
|
+
maxItems: action.maxItems,
|
|
3745
|
+
itemOffset: action.itemOffset,
|
|
3746
|
+
maxTextPreview: action.maxTextPreview,
|
|
3747
|
+
includeBounds: action.includeBounds,
|
|
3748
|
+
});
|
|
3749
|
+
if (!sectionDetail)
|
|
3750
|
+
throw new Error(`No expandable section found for id ${action.id}`);
|
|
3751
|
+
return {
|
|
3752
|
+
summary: detail === 'verbose'
|
|
3753
|
+
? JSON.stringify(sectionDetail, null, 2)
|
|
3754
|
+
: `Expanded section "${action.id}".`,
|
|
3755
|
+
compact: { id: action.id, detail: sectionDetail },
|
|
3756
|
+
};
|
|
3757
|
+
}
|
|
3483
3758
|
case 'fill_fields': {
|
|
3484
3759
|
const resolvedFields = resolveFillFieldInputs(session, action.fields);
|
|
3485
3760
|
if (!resolvedFields.ok)
|
|
3486
3761
|
throw new Error(resolvedFields.error);
|
|
3762
|
+
const verifyFillsFn = action.verifyFills
|
|
3763
|
+
? () => verifyFormFills(session, resolvedFields.fields.map(field => ({ field, confidence: 1.0, matchMethod: 'label-exact' })))
|
|
3764
|
+
: undefined;
|
|
3487
3765
|
if (!includeSteps) {
|
|
3488
3766
|
const batched = await tryBatchedResolvedFields(session, resolvedFields.fields, detail);
|
|
3489
3767
|
if (batched.ok) {
|
|
3768
|
+
const verification = verifyFillsFn?.();
|
|
3490
3769
|
return {
|
|
3491
3770
|
summary: `Filled ${resolvedFields.fields.length} field(s) in one proxy batch.`,
|
|
3492
3771
|
compact: {
|
|
@@ -3494,6 +3773,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3494
3773
|
execution: 'batched',
|
|
3495
3774
|
finalSource: batched.finalSource,
|
|
3496
3775
|
final: batched.final,
|
|
3776
|
+
...(verification ? { verification } : {}),
|
|
3497
3777
|
},
|
|
3498
3778
|
};
|
|
3499
3779
|
}
|
|
@@ -3519,11 +3799,13 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
3519
3799
|
? { index, kind: field.kind, ok, summary: result.summary }
|
|
3520
3800
|
: { index, kind: field.kind, ok, ...result.compact });
|
|
3521
3801
|
}
|
|
3802
|
+
const verification = verifyFillsFn?.();
|
|
3522
3803
|
return {
|
|
3523
3804
|
summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
|
|
3524
3805
|
compact: {
|
|
3525
3806
|
fieldCount: resolvedFields.fields.length,
|
|
3526
3807
|
...(includeSteps ? { steps } : {}),
|
|
3808
|
+
...(verification ? { verification } : {}),
|
|
3527
3809
|
},
|
|
3528
3810
|
};
|
|
3529
3811
|
}
|
|
@@ -3674,7 +3956,12 @@ async function executeFillField(session, field, detail) {
|
|
|
3674
3956
|
switch (field.kind) {
|
|
3675
3957
|
case 'text': {
|
|
3676
3958
|
const before = sessionA11y(session);
|
|
3677
|
-
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
3959
|
+
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
3960
|
+
exact: field.exact,
|
|
3961
|
+
fieldId: field.fieldId,
|
|
3962
|
+
typingDelayMs: field.typingDelayMs,
|
|
3963
|
+
imeFriendly: field.imeFriendly,
|
|
3964
|
+
}, field.timeoutMs);
|
|
3678
3965
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
3679
3966
|
return {
|
|
3680
3967
|
summary: [
|
|
@@ -3806,6 +4093,7 @@ function err(text) {
|
|
|
3806
4093
|
* const session = sessionResult.session
|
|
3807
4094
|
*/
|
|
3808
4095
|
function resolveToolSession(sessionId) {
|
|
4096
|
+
pruneDisconnectedSessions();
|
|
3809
4097
|
const result = resolveSession(sessionId);
|
|
3810
4098
|
switch (result.kind) {
|
|
3811
4099
|
case 'ok':
|
|
@@ -3814,7 +4102,7 @@ function resolveToolSession(sessionId) {
|
|
|
3814
4102
|
return { error: err('Not connected. Call geometra_connect first.') };
|
|
3815
4103
|
case 'not_found':
|
|
3816
4104
|
return {
|
|
3817
|
-
error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}.
|
|
4105
|
+
error: err(`session_not_found: no active session with id "${result.id}". Active sessions: ${result.activeIds.length > 0 ? result.activeIds.join(', ') : '(none)'}. The requested session may have disconnected or expired; call geometra_connect again to start a new session — the MCP server never silently routes an explicit sessionId onto a different session.`),
|
|
3818
4106
|
};
|
|
3819
4107
|
case 'ambiguous': {
|
|
3820
4108
|
const isolatedSuffix = result.isolatedIds.length > 0
|
package/dist/session.d.ts
CHANGED
|
@@ -420,6 +420,7 @@ export interface Session {
|
|
|
420
420
|
forms: FormSchemaModel[];
|
|
421
421
|
}>;
|
|
422
422
|
workflowState?: WorkflowState;
|
|
423
|
+
reconnectInFlight?: Promise<boolean>;
|
|
423
424
|
}
|
|
424
425
|
export interface SessionConnectTrace {
|
|
425
426
|
mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
|
|
@@ -453,6 +454,8 @@ export type ProxyFillField = {
|
|
|
453
454
|
fieldLabel: string;
|
|
454
455
|
value: string;
|
|
455
456
|
exact?: boolean;
|
|
457
|
+
typingDelayMs?: number;
|
|
458
|
+
imeFriendly?: boolean;
|
|
456
459
|
} | {
|
|
457
460
|
kind: 'choice';
|
|
458
461
|
fieldId?: string;
|
|
@@ -526,6 +529,7 @@ export declare function connectThroughProxy(options: {
|
|
|
526
529
|
isolated?: boolean;
|
|
527
530
|
}): Promise<Session>;
|
|
528
531
|
export declare function getSession(id?: string): Session | null;
|
|
532
|
+
export declare function pruneDisconnectedSessions(): string[];
|
|
529
533
|
/**
|
|
530
534
|
* Tool-side session resolution with strict routing semantics.
|
|
531
535
|
*
|
|
@@ -608,6 +612,8 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
608
612
|
export declare function sendFieldText(session: Session, fieldLabel: string, value: string, opts?: {
|
|
609
613
|
exact?: boolean;
|
|
610
614
|
fieldId?: string;
|
|
615
|
+
typingDelayMs?: number;
|
|
616
|
+
imeFriendly?: boolean;
|
|
611
617
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
612
618
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
613
619
|
export declare function sendFieldChoice(session: Session, fieldLabel: string, value: string, opts?: {
|