@geometra/mcp 1.26.0 → 1.28.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__/server-batch-results.test.js +2 -2
- package/dist/index.js +1 -1
- package/dist/server.js +32 -4
- package/dist/session.d.ts +6 -0
- package/dist/session.js +80 -0
- package/package.json +1 -1
|
@@ -967,7 +967,7 @@ describe('query and reveal tools', () => {
|
|
|
967
967
|
});
|
|
968
968
|
});
|
|
969
969
|
it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
|
|
970
|
-
const handler = getToolHandler('
|
|
970
|
+
const handler = getToolHandler('geometra_scroll_to');
|
|
971
971
|
mockState.currentA11yRoot = node('group', undefined, {
|
|
972
972
|
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
973
973
|
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
@@ -1024,7 +1024,7 @@ describe('query and reveal tools', () => {
|
|
|
1024
1024
|
});
|
|
1025
1025
|
});
|
|
1026
1026
|
it('auto-scales reveal steps for tall forms when maxSteps is omitted', async () => {
|
|
1027
|
-
const handler = getToolHandler('
|
|
1027
|
+
const handler = getToolHandler('geometra_scroll_to');
|
|
1028
1028
|
let scrollY = 0;
|
|
1029
1029
|
const setTree = () => {
|
|
1030
1030
|
mockState.currentA11yRoot = node('group', undefined, {
|
package/dist/index.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -329,8 +329,8 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
329
329
|
returnForms: z
|
|
330
330
|
.boolean()
|
|
331
331
|
.optional()
|
|
332
|
-
.default(
|
|
333
|
-
.describe('Include compact form schema discovery in the connect response
|
|
332
|
+
.default(true)
|
|
333
|
+
.describe('Include compact form schema discovery in the connect response (default true). Set false to skip form discovery for non-form workflows.'),
|
|
334
334
|
returnPageModel: z
|
|
335
335
|
.boolean()
|
|
336
336
|
.optional()
|
|
@@ -753,6 +753,17 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
753
753
|
}
|
|
754
754
|
catch (e) {
|
|
755
755
|
const message = e instanceof Error ? e.message : String(e);
|
|
756
|
+
// Retry once for transient selection failures
|
|
757
|
+
if (message.includes('selection_not_confirmed')) {
|
|
758
|
+
try {
|
|
759
|
+
const retryResult = await executeFillField(session, field, detail);
|
|
760
|
+
steps.push(detail === 'verbose'
|
|
761
|
+
? { index, kind: field.kind, ok: true, summary: retryResult.summary, retried: true }
|
|
762
|
+
: { index, kind: field.kind, ok: true, ...retryResult.compact, retried: true });
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
catch { /* fall through to error handling */ }
|
|
766
|
+
}
|
|
756
767
|
const suggestion = isResolvedFillFieldInput(field) ? suggestRecovery(field, message) : undefined;
|
|
757
768
|
steps.push({ index, kind: field.kind, ok: false, error: message, ...(suggestion ? { suggestion } : {}) });
|
|
758
769
|
if (stopOnError) {
|
|
@@ -901,6 +912,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
901
912
|
});
|
|
902
913
|
if (schemas.length === 0)
|
|
903
914
|
return err('No forms found in the current UI');
|
|
915
|
+
const entryUrl = afterConnect?.meta?.pageUrl;
|
|
904
916
|
const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
|
|
905
917
|
if (!resolution.ok)
|
|
906
918
|
return err(resolution.error);
|
|
@@ -1053,6 +1065,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1053
1065
|
...(verification ? { verification } : {}),
|
|
1054
1066
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1055
1067
|
};
|
|
1068
|
+
// Detect page navigation after fill (e.g. multi-page form submission)
|
|
1069
|
+
const afterUrl = after?.meta?.pageUrl;
|
|
1070
|
+
if (afterUrl && entryUrl && afterUrl !== entryUrl) {
|
|
1071
|
+
;
|
|
1072
|
+
payload.navigated = true;
|
|
1073
|
+
payload.afterUrl = afterUrl;
|
|
1074
|
+
const model = after ? buildPageModel(after) : undefined;
|
|
1075
|
+
if (model) {
|
|
1076
|
+
;
|
|
1077
|
+
payload.pageModel = summarizePageModel(model);
|
|
1078
|
+
if (model.captcha)
|
|
1079
|
+
payload.captcha = model.captcha;
|
|
1080
|
+
if (model.verification)
|
|
1081
|
+
payload.verification = model.verification;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1056
1084
|
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
|
|
1057
1085
|
if (failOnInvalid && invalidRemaining > 0) {
|
|
1058
1086
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
@@ -1307,9 +1335,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
1307
1335
|
return err(`No expandable section found for id ${id}`);
|
|
1308
1336
|
return ok(JSON.stringify(detail));
|
|
1309
1337
|
});
|
|
1310
|
-
server.tool('
|
|
1338
|
+
server.tool('geometra_scroll_to', `Scroll the page until a matching element is visible. Use this to reach off-screen elements like Submit buttons at the bottom of long forms, or fields below the fold.
|
|
1311
1339
|
|
|
1312
|
-
|
|
1340
|
+
This is the preferred approach for scrolling — no need to guess pixel offsets or wheel deltas. Accepts the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label. Auto-scales scroll steps based on distance.`, {
|
|
1313
1341
|
...nodeFilterShape(),
|
|
1314
1342
|
index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
|
|
1315
1343
|
fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
|
package/dist/session.d.ts
CHANGED
|
@@ -130,6 +130,11 @@ export interface CaptchaDetection {
|
|
|
130
130
|
type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
|
|
131
131
|
hint?: string;
|
|
132
132
|
}
|
|
133
|
+
export interface VerificationDetection {
|
|
134
|
+
detected: boolean;
|
|
135
|
+
type?: 'email_code' | 'sms_code' | 'security_question' | 'unknown';
|
|
136
|
+
hint?: string;
|
|
137
|
+
}
|
|
133
138
|
export interface PageModel {
|
|
134
139
|
viewport: {
|
|
135
140
|
width: number;
|
|
@@ -144,6 +149,7 @@ export interface PageModel {
|
|
|
144
149
|
focusableCount: number;
|
|
145
150
|
};
|
|
146
151
|
captcha?: CaptchaDetection;
|
|
152
|
+
verification?: VerificationDetection;
|
|
147
153
|
primaryActions: PagePrimaryAction[];
|
|
148
154
|
landmarks: PageLandmark[];
|
|
149
155
|
forms: PageFormModel[];
|
package/dist/session.js
CHANGED
|
@@ -8,6 +8,9 @@ let nextSessionId = 0;
|
|
|
8
8
|
function generateSessionId() { return `s${++nextSessionId}`; }
|
|
9
9
|
let reusableProxies = [];
|
|
10
10
|
const REUSABLE_PROXY_POOL_LIMIT = 6;
|
|
11
|
+
/** Close idle reusable proxies after 5 minutes of inactivity. */
|
|
12
|
+
const REUSABLE_PROXY_IDLE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
let idleProxyTimer = null;
|
|
11
14
|
const trackedReusableProxyChildren = new WeakSet();
|
|
12
15
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
13
16
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
@@ -66,9 +69,11 @@ function closeReusableProxy(entry) {
|
|
|
66
69
|
catch {
|
|
67
70
|
/* ignore */
|
|
68
71
|
}
|
|
72
|
+
ensureIdleProxyTimer();
|
|
69
73
|
return;
|
|
70
74
|
}
|
|
71
75
|
void entry.runtime?.close().catch(() => { });
|
|
76
|
+
ensureIdleProxyTimer();
|
|
72
77
|
}
|
|
73
78
|
function closeReusableProxies() {
|
|
74
79
|
clearReusableProxiesIfExited();
|
|
@@ -87,6 +92,25 @@ function closeReusableProxies() {
|
|
|
87
92
|
void entry.runtime?.close().catch(() => { });
|
|
88
93
|
}
|
|
89
94
|
}
|
|
95
|
+
function evictIdleReusableProxies() {
|
|
96
|
+
clearReusableProxiesIfExited();
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const stale = reusableProxies.filter(entry => !reusableProxyEntryIsActive(entry) && (now - entry.lastUsedAt) > REUSABLE_PROXY_IDLE_TTL_MS);
|
|
99
|
+
for (const entry of stale) {
|
|
100
|
+
closeReusableProxy(entry);
|
|
101
|
+
}
|
|
102
|
+
ensureIdleProxyTimer();
|
|
103
|
+
}
|
|
104
|
+
function ensureIdleProxyTimer() {
|
|
105
|
+
if (reusableProxies.length > 0 && !idleProxyTimer) {
|
|
106
|
+
idleProxyTimer = setInterval(evictIdleReusableProxies, 60_000);
|
|
107
|
+
idleProxyTimer.unref();
|
|
108
|
+
}
|
|
109
|
+
else if (reusableProxies.length === 0 && idleProxyTimer) {
|
|
110
|
+
clearInterval(idleProxyTimer);
|
|
111
|
+
idleProxyTimer = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
90
114
|
function enforceReusableProxyPoolLimit() {
|
|
91
115
|
clearReusableProxiesIfExited();
|
|
92
116
|
if (reusableProxies.length <= REUSABLE_PROXY_POOL_LIMIT)
|
|
@@ -139,6 +163,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
139
163
|
child.once('error', clear);
|
|
140
164
|
}
|
|
141
165
|
enforceReusableProxyPoolLimit();
|
|
166
|
+
ensureIdleProxyTimer();
|
|
142
167
|
return;
|
|
143
168
|
}
|
|
144
169
|
reusableProxies.push({
|
|
@@ -153,6 +178,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
153
178
|
lastUsedAt: now,
|
|
154
179
|
});
|
|
155
180
|
enforceReusableProxyPoolLimit();
|
|
181
|
+
ensureIdleProxyTimer();
|
|
156
182
|
}
|
|
157
183
|
function rememberReusableProxyPageUrl(session) {
|
|
158
184
|
const entry = reusableProxyEntryForSession(session);
|
|
@@ -552,6 +578,21 @@ export function connect(url, opts) {
|
|
|
552
578
|
cachedFormSchemas: new Map(),
|
|
553
579
|
};
|
|
554
580
|
let resolved = false;
|
|
581
|
+
let lastMessageAt = Date.now();
|
|
582
|
+
let heartbeatInterval = null;
|
|
583
|
+
function startHeartbeat() {
|
|
584
|
+
if (heartbeatInterval)
|
|
585
|
+
return;
|
|
586
|
+
heartbeatInterval = setInterval(() => {
|
|
587
|
+
if (Date.now() - lastMessageAt > 30_000) {
|
|
588
|
+
try {
|
|
589
|
+
ws.close();
|
|
590
|
+
}
|
|
591
|
+
catch { /* ignore */ }
|
|
592
|
+
}
|
|
593
|
+
}, 30_000);
|
|
594
|
+
heartbeatInterval.unref();
|
|
595
|
+
}
|
|
555
596
|
const timeout = setTimeout(() => {
|
|
556
597
|
if (!resolved) {
|
|
557
598
|
resolved = true;
|
|
@@ -577,10 +618,12 @@ export function connect(url, opts) {
|
|
|
577
618
|
}
|
|
578
619
|
activeSessions.set(session.id, session);
|
|
579
620
|
defaultSessionId = session.id;
|
|
621
|
+
startHeartbeat();
|
|
580
622
|
resolve(session);
|
|
581
623
|
}
|
|
582
624
|
});
|
|
583
625
|
ws.on('message', (data) => {
|
|
626
|
+
lastMessageAt = Date.now();
|
|
584
627
|
try {
|
|
585
628
|
const msg = JSON.parse(String(data));
|
|
586
629
|
if (msg.type === 'frame') {
|
|
@@ -600,6 +643,7 @@ export function connect(url, opts) {
|
|
|
600
643
|
}
|
|
601
644
|
activeSessions.set(session.id, session);
|
|
602
645
|
defaultSessionId = session.id;
|
|
646
|
+
startHeartbeat();
|
|
603
647
|
resolve(session);
|
|
604
648
|
}
|
|
605
649
|
}
|
|
@@ -619,6 +663,10 @@ export function connect(url, opts) {
|
|
|
619
663
|
}
|
|
620
664
|
});
|
|
621
665
|
ws.on('close', () => {
|
|
666
|
+
if (heartbeatInterval) {
|
|
667
|
+
clearInterval(heartbeatInterval);
|
|
668
|
+
heartbeatInterval = null;
|
|
669
|
+
}
|
|
622
670
|
if (activeSessions.get(session.id) === session) {
|
|
623
671
|
activeSessions.delete(session.id);
|
|
624
672
|
if (defaultSessionId === session.id)
|
|
@@ -1963,6 +2011,36 @@ function detectCaptcha(root) {
|
|
|
1963
2011
|
}
|
|
1964
2012
|
return found ?? { detected: false };
|
|
1965
2013
|
}
|
|
2014
|
+
const VERIFICATION_FIELD_PATTERN = /verif|security.?code|confirm.*(code|email)|one.?time|otp|2fa|mfa|passcode/i;
|
|
2015
|
+
const VERIFICATION_CONTEXT_PATTERN = /sent.*(code|email|sms|text)|enter.*code|check.your.(email|phone|inbox)|we.sent|verification/i;
|
|
2016
|
+
function detectVerification(root) {
|
|
2017
|
+
let found;
|
|
2018
|
+
function walk(node) {
|
|
2019
|
+
if (found)
|
|
2020
|
+
return;
|
|
2021
|
+
const name = node.name ?? '';
|
|
2022
|
+
if (node.role === 'textbox' && VERIFICATION_FIELD_PATTERN.test(name)) {
|
|
2023
|
+
const type = /email|inbox/i.test(name) ? 'email_code'
|
|
2024
|
+
: /sms|phone|text/i.test(name) ? 'sms_code'
|
|
2025
|
+
: /security.?question/i.test(name) ? 'security_question'
|
|
2026
|
+
: 'unknown';
|
|
2027
|
+
found = { detected: true, type, hint: `Verification field: "${name}"` };
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const text = [name, node.value].filter(Boolean).join(' ');
|
|
2031
|
+
if (VERIFICATION_CONTEXT_PATTERN.test(text)) {
|
|
2032
|
+
const type = /email|inbox/i.test(text) ? 'email_code'
|
|
2033
|
+
: /sms|phone|text.message/i.test(text) ? 'sms_code'
|
|
2034
|
+
: 'unknown';
|
|
2035
|
+
found = { detected: true, type, hint: text.slice(0, 120) };
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
for (const child of node.children)
|
|
2039
|
+
walk(child);
|
|
2040
|
+
}
|
|
2041
|
+
walk(root);
|
|
2042
|
+
return found ?? { detected: false };
|
|
2043
|
+
}
|
|
1966
2044
|
export function buildPageModel(root, options) {
|
|
1967
2045
|
const maxPrimaryActions = options?.maxPrimaryActions ?? 6;
|
|
1968
2046
|
const maxSectionsPerKind = options?.maxSectionsPerKind ?? 8;
|
|
@@ -2047,9 +2125,11 @@ export function buildPageModel(root, options) {
|
|
|
2047
2125
|
lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
|
|
2048
2126
|
};
|
|
2049
2127
|
const captcha = detectCaptcha(root);
|
|
2128
|
+
const verification = detectVerification(root);
|
|
2050
2129
|
return {
|
|
2051
2130
|
...baseModel,
|
|
2052
2131
|
...(captcha.detected ? { captcha } : {}),
|
|
2132
|
+
...(verification.detected ? { verification } : {}),
|
|
2053
2133
|
archetypes: inferPageArchetypes(baseModel),
|
|
2054
2134
|
};
|
|
2055
2135
|
}
|
package/package.json
CHANGED