@geometra/mcp 1.24.0 → 1.25.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/server.js +89 -50
- package/dist/session.d.ts +9 -1
- package/dist/session.js +70 -21
- 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, getSession, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, getSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -84,14 +84,19 @@ const GEOMETRA_WAIT_FILTER_REQUIRED_MESSAGE = 'Provide at least one semantic fil
|
|
|
84
84
|
'This tool uses a strict schema: unknown keys are rejected. There is no textGone parameter — use text with a distinctive substring and present: false to wait until that text is gone ' +
|
|
85
85
|
'(common for “Parsing…”, “Parsing your resume”, or similar). Passing only present/timeoutMs is not enough without a filter.';
|
|
86
86
|
/** Strict input so unknown keys (e.g. textGone) fail parse; empty-filter checks happen in handlers / waitForSemanticCondition. */
|
|
87
|
+
const sessionIdSchemaField = {
|
|
88
|
+
sessionId: z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.'),
|
|
89
|
+
};
|
|
87
90
|
const geometraQueryInputSchema = z.object({
|
|
88
91
|
...nodeFilterShape(),
|
|
89
92
|
maxResults: z.number().int().min(1).max(50).optional().describe('Optional cap on returned matches; terse mode defaults to 8'),
|
|
90
93
|
detail: detailInput(),
|
|
94
|
+
...sessionIdSchemaField,
|
|
91
95
|
}).strict();
|
|
92
96
|
const geometraWaitForInputSchema = z.object({
|
|
93
97
|
...waitConditionShape(),
|
|
94
98
|
detail: detailInput(),
|
|
99
|
+
...sessionIdSchemaField,
|
|
95
100
|
}).strict();
|
|
96
101
|
/** Same upper bound as geometra_wait_for; resume uploads often need the full minute. */
|
|
97
102
|
const geometraWaitForResumeParseInputSchema = z
|
|
@@ -108,6 +113,7 @@ const geometraWaitForResumeParseInputSchema = z
|
|
|
108
113
|
.max(60_000)
|
|
109
114
|
.default(60_000)
|
|
110
115
|
.describe('Maximum time to wait before returning an error (default 60000ms)'),
|
|
116
|
+
...sessionIdSchemaField,
|
|
111
117
|
})
|
|
112
118
|
.strict();
|
|
113
119
|
const timeoutMsInput = z.number().int().min(50).max(60_000).optional();
|
|
@@ -282,6 +288,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
282
288
|
]);
|
|
283
289
|
export function createServer() {
|
|
284
290
|
const server = new McpServer({ name: 'geometra', version: '1.19.21' }, { capabilities: { tools: {} } });
|
|
291
|
+
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
|
|
285
292
|
// ── connect ──────────────────────────────────────────────────
|
|
286
293
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
287
294
|
|
|
@@ -460,8 +467,8 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
460
467
|
|
|
461
468
|
Unknown parameter names are rejected (strict schema). To wait until visible text goes away (e.g. a parsing banner), use geometra_wait_for with that substring in text and present: false — there is no textGone field.`,
|
|
462
469
|
inputSchema: geometraQueryInputSchema,
|
|
463
|
-
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, maxResults, detail }) => {
|
|
464
|
-
const session = getSession();
|
|
470
|
+
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, maxResults, detail, sessionId }) => {
|
|
471
|
+
const session = getSession(sessionId);
|
|
465
472
|
if (!session)
|
|
466
473
|
return err('Not connected. Call geometra_connect first.');
|
|
467
474
|
const a11y = await sessionA11yWhenReady(session);
|
|
@@ -516,8 +523,9 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
|
|
|
516
523
|
itemText: z.string().optional().describe('Nearby card/row/item label to disambiguate repeated actions'),
|
|
517
524
|
maxResults: z.number().int().min(1).max(12).optional().default(6).describe('Maximum number of matches to return'),
|
|
518
525
|
detail: detailInput(),
|
|
519
|
-
|
|
520
|
-
|
|
526
|
+
sessionId: sessionIdInput,
|
|
527
|
+
}, async ({ name, role, sectionText, promptText, itemText, maxResults, detail, sessionId }) => {
|
|
528
|
+
const session = getSession(sessionId);
|
|
521
529
|
if (!session)
|
|
522
530
|
return err('Not connected. Call geometra_connect first.');
|
|
523
531
|
const a11y = await sessionA11yWhenReady(session);
|
|
@@ -551,8 +559,8 @@ Use this when geometra_page_model tells you the page shape, but you want one dir
|
|
|
551
559
|
|
|
552
560
|
The filter matches the same fields as geometra_query (strict schema — unknown keys error). Set \`present: false\` to wait until **no** node matches — for example Ashby/Lever-style “Parsing your resume” or any “Parsing…” banner: \`{ "text": "Parsing", "present": false }\` (tune the substring to the site). Do not use a textGone parameter; use \`text\` + \`present: false\`, or \`geometra_wait_for_resume_parse\` for the usual post-upload parsing banner.`,
|
|
553
561
|
inputSchema: geometraWaitForInputSchema,
|
|
554
|
-
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs, detail }) => {
|
|
555
|
-
const session = getSession();
|
|
562
|
+
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs, detail, sessionId }) => {
|
|
563
|
+
const session = getSession(sessionId);
|
|
556
564
|
if (!session)
|
|
557
565
|
return err('Not connected. Call geometra_connect first.');
|
|
558
566
|
const filterProbe = {
|
|
@@ -603,8 +611,8 @@ The filter matches the same fields as geometra_query (strict schema — unknown
|
|
|
603
611
|
|
|
604
612
|
Equivalent to \`geometra_wait_for\` with \`present: false\` and \`text\` set to a banner substring. Default \`text\` is \`Parsing\` (tune per site). Strict schema (unknown keys rejected).`,
|
|
605
613
|
inputSchema: geometraWaitForResumeParseInputSchema,
|
|
606
|
-
}, async ({ text, timeoutMs }) => {
|
|
607
|
-
const session = getSession();
|
|
614
|
+
}, async ({ text, timeoutMs, sessionId }) => {
|
|
615
|
+
const session = getSession(sessionId);
|
|
608
616
|
if (!session)
|
|
609
617
|
return err('Not connected. Call geometra_connect first.');
|
|
610
618
|
const filter = { text };
|
|
@@ -629,8 +637,9 @@ Captures the current URL, then polls until the URL changes and a stable UI tree
|
|
|
629
637
|
.default(10_000)
|
|
630
638
|
.describe('Max time to wait for navigation + DOM stabilization (default 10s)'),
|
|
631
639
|
expectedUrl: z.string().optional().describe('Optional URL substring to match — keeps waiting if the URL changes to something else (e.g. intermediate redirects)'),
|
|
632
|
-
|
|
633
|
-
|
|
640
|
+
sessionId: sessionIdInput,
|
|
641
|
+
}, async ({ timeoutMs, expectedUrl, sessionId }) => {
|
|
642
|
+
const session = getSession(sessionId);
|
|
634
643
|
if (!session)
|
|
635
644
|
return err('Not connected. Call geometra_connect first.');
|
|
636
645
|
const beforeA11y = sessionA11y(session);
|
|
@@ -700,8 +709,9 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
700
709
|
.default(true)
|
|
701
710
|
.describe('Include per-field step results in the JSON payload (default true). Set false for the smallest batch response.'),
|
|
702
711
|
detail: detailInput(),
|
|
703
|
-
|
|
704
|
-
|
|
712
|
+
sessionId: sessionIdInput,
|
|
713
|
+
}, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail, sessionId }) => {
|
|
714
|
+
const session = getSession(sessionId);
|
|
705
715
|
if (!session)
|
|
706
716
|
return err('Not connected. Call geometra_connect first.');
|
|
707
717
|
const resolvedFields = resolveFillFieldInputs(session, fields);
|
|
@@ -811,11 +821,13 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
811
821
|
.default(false)
|
|
812
822
|
.describe('Skip fields that already contain a matching value. Avoids overwriting good data from resume parsing or previous fills.'),
|
|
813
823
|
detail: detailInput(),
|
|
814
|
-
|
|
824
|
+
sessionId: sessionIdInput,
|
|
825
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, detail, sessionId }) => {
|
|
815
826
|
const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
|
|
816
827
|
? directLabelBatchFields(valuesByLabel)
|
|
817
828
|
: null;
|
|
818
829
|
const resolved = await ensureToolSession({
|
|
830
|
+
sessionId,
|
|
819
831
|
url,
|
|
820
832
|
pageUrl,
|
|
821
833
|
port,
|
|
@@ -1066,8 +1078,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1066
1078
|
.describe('Include per-action step results in the JSON payload (default true). Set false for the smallest batch response.'),
|
|
1067
1079
|
output: z.enum(['full', 'final']).optional().default('full').describe('`full` (default) returns counts and optional step listings. `final` keeps only completion state plus final semantic signals.'),
|
|
1068
1080
|
detail: detailInput(),
|
|
1069
|
-
|
|
1081
|
+
sessionId: sessionIdInput,
|
|
1082
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, actions, stopOnError, includeSteps, output, detail, sessionId }) => {
|
|
1070
1083
|
const resolved = await ensureToolSession({
|
|
1084
|
+
sessionId,
|
|
1071
1085
|
url,
|
|
1072
1086
|
pageUrl,
|
|
1073
1087
|
port,
|
|
@@ -1192,8 +1206,9 @@ Use this first on normal HTML pages when you want to understand the page shape w
|
|
|
1192
1206
|
.optional()
|
|
1193
1207
|
.default(false)
|
|
1194
1208
|
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
|
|
1195
|
-
|
|
1196
|
-
|
|
1209
|
+
sessionId: sessionIdInput,
|
|
1210
|
+
}, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot, sessionId }) => {
|
|
1211
|
+
const session = getSession(sessionId);
|
|
1197
1212
|
if (!session)
|
|
1198
1213
|
return err('Not connected. Call geometra_connect first.');
|
|
1199
1214
|
const a11y = await sessionA11yWhenReady(session);
|
|
@@ -1221,8 +1236,9 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
|
|
|
1221
1236
|
includeContext: formSchemaContextInput(),
|
|
1222
1237
|
sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
|
|
1223
1238
|
format: formSchemaFormatInput(),
|
|
1224
|
-
|
|
1225
|
-
|
|
1239
|
+
sessionId: sessionIdInput,
|
|
1240
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
|
|
1241
|
+
const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
|
|
1226
1242
|
if (!resolved.ok)
|
|
1227
1243
|
return err(resolved.error);
|
|
1228
1244
|
const session = resolved.session;
|
|
@@ -1264,8 +1280,9 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
1264
1280
|
itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
|
|
1265
1281
|
maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
|
|
1266
1282
|
includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
|
|
1267
|
-
|
|
1268
|
-
|
|
1283
|
+
sessionId: sessionIdInput,
|
|
1284
|
+
}, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, sessionId, }) => {
|
|
1285
|
+
const session = getSession(sessionId);
|
|
1269
1286
|
if (!session)
|
|
1270
1287
|
return err('Not connected. Call geometra_connect first.');
|
|
1271
1288
|
const a11y = await sessionA11yWhenReady(session);
|
|
@@ -1305,8 +1322,9 @@ Use the same filters as geometra_query, plus an optional match index when repeat
|
|
|
1305
1322
|
.optional()
|
|
1306
1323
|
.default(2_500)
|
|
1307
1324
|
.describe('Per-scroll wait timeout (default 2500ms)'),
|
|
1308
|
-
|
|
1309
|
-
|
|
1325
|
+
sessionId: sessionIdInput,
|
|
1326
|
+
}, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs, sessionId }) => {
|
|
1327
|
+
const session = getSession(sessionId);
|
|
1310
1328
|
if (!session)
|
|
1311
1329
|
return err('Not connected. Call geometra_connect first.');
|
|
1312
1330
|
const filter = {
|
|
@@ -1372,8 +1390,9 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
1372
1390
|
.optional()
|
|
1373
1391
|
.describe('Optional action wait timeout (use a longer value for slow submits or route transitions)'),
|
|
1374
1392
|
detail: detailInput(),
|
|
1375
|
-
|
|
1376
|
-
|
|
1393
|
+
sessionId: sessionIdInput,
|
|
1394
|
+
}, async ({ x, y, id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxRevealSteps, revealTimeoutMs, waitFor, timeoutMs, detail, sessionId }) => {
|
|
1395
|
+
const session = getSession(sessionId);
|
|
1377
1396
|
if (!session)
|
|
1378
1397
|
return err('Not connected. Call geometra_connect first.');
|
|
1379
1398
|
const before = sessionA11y(session);
|
|
@@ -1467,8 +1486,9 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
1467
1486
|
.optional()
|
|
1468
1487
|
.describe('Optional action wait timeout'),
|
|
1469
1488
|
detail: detailInput(),
|
|
1470
|
-
|
|
1471
|
-
|
|
1489
|
+
sessionId: sessionIdInput,
|
|
1490
|
+
}, async ({ text, timeoutMs, detail, sessionId }) => {
|
|
1491
|
+
const session = getSession(sessionId);
|
|
1472
1492
|
if (!session)
|
|
1473
1493
|
return err('Not connected. Call geometra_connect first.');
|
|
1474
1494
|
const before = sessionA11y(session);
|
|
@@ -1494,8 +1514,9 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
1494
1514
|
.optional()
|
|
1495
1515
|
.describe('Optional action wait timeout'),
|
|
1496
1516
|
detail: detailInput(),
|
|
1497
|
-
|
|
1498
|
-
|
|
1517
|
+
sessionId: sessionIdInput,
|
|
1518
|
+
}, async ({ key, shift, ctrl, meta, alt, timeoutMs, detail, sessionId }) => {
|
|
1519
|
+
const session = getSession(sessionId);
|
|
1499
1520
|
if (!session)
|
|
1500
1521
|
return err('Not connected. Call geometra_connect first.');
|
|
1501
1522
|
const before = sessionA11y(session);
|
|
@@ -1531,8 +1552,9 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
1531
1552
|
.optional()
|
|
1532
1553
|
.describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
|
|
1533
1554
|
detail: detailInput(),
|
|
1534
|
-
|
|
1535
|
-
|
|
1555
|
+
sessionId: sessionIdInput,
|
|
1556
|
+
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1557
|
+
const session = getSession(sessionId);
|
|
1536
1558
|
if (!session)
|
|
1537
1559
|
return err('Not connected. Call geometra_connect first.');
|
|
1538
1560
|
const before = sessionA11y(session);
|
|
@@ -1576,8 +1598,9 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
1576
1598
|
.optional()
|
|
1577
1599
|
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
1578
1600
|
detail: detailInput(),
|
|
1579
|
-
|
|
1580
|
-
|
|
1601
|
+
sessionId: sessionIdInput,
|
|
1602
|
+
}, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail, sessionId }) => {
|
|
1603
|
+
const session = getSession(sessionId);
|
|
1581
1604
|
if (!session)
|
|
1582
1605
|
return err('Not connected. Call geometra_connect first.');
|
|
1583
1606
|
const before = sessionA11y(session);
|
|
@@ -1625,8 +1648,9 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
|
|
|
1625
1648
|
.optional()
|
|
1626
1649
|
.describe('Optional action wait timeout'),
|
|
1627
1650
|
detail: detailInput(),
|
|
1628
|
-
|
|
1629
|
-
|
|
1651
|
+
sessionId: sessionIdInput,
|
|
1652
|
+
}, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1653
|
+
const session = getSession(sessionId);
|
|
1630
1654
|
if (!session)
|
|
1631
1655
|
return err('Not connected. Call geometra_connect first.');
|
|
1632
1656
|
if (value === undefined && label === undefined && index === undefined) {
|
|
@@ -1665,8 +1689,9 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1665
1689
|
.optional()
|
|
1666
1690
|
.describe('Optional action wait timeout'),
|
|
1667
1691
|
detail: detailInput(),
|
|
1668
|
-
|
|
1669
|
-
|
|
1692
|
+
sessionId: sessionIdInput,
|
|
1693
|
+
}, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail, sessionId }) => {
|
|
1694
|
+
const session = getSession(sessionId);
|
|
1670
1695
|
if (!session)
|
|
1671
1696
|
return err('Not connected. Call geometra_connect first.');
|
|
1672
1697
|
const before = sessionA11y(session);
|
|
@@ -1698,8 +1723,9 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
1698
1723
|
.optional()
|
|
1699
1724
|
.describe('Optional action wait timeout'),
|
|
1700
1725
|
detail: detailInput(),
|
|
1701
|
-
|
|
1702
|
-
|
|
1726
|
+
sessionId: sessionIdInput,
|
|
1727
|
+
}, async ({ deltaY, deltaX, x, y, timeoutMs, detail, sessionId }) => {
|
|
1728
|
+
const session = getSession(sessionId);
|
|
1703
1729
|
if (!session)
|
|
1704
1730
|
return err('Not connected. Call geometra_connect first.');
|
|
1705
1731
|
const before = sessionA11y(session);
|
|
@@ -1728,8 +1754,9 @@ Use this for dropdowns, location pickers, or any scrollable list where items are
|
|
|
1728
1754
|
maxItems: z.number().int().min(1).max(500).optional().default(100).describe('Cap collected items (default 100)'),
|
|
1729
1755
|
maxScrollSteps: z.number().int().min(1).max(50).optional().default(20).describe('Max scroll steps before stopping (default 20)'),
|
|
1730
1756
|
scrollDelta: z.number().optional().default(300).describe('Vertical scroll delta per step (default 300)'),
|
|
1731
|
-
|
|
1732
|
-
|
|
1757
|
+
sessionId: sessionIdInput,
|
|
1758
|
+
}, async ({ listId: _listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta, sessionId }) => {
|
|
1759
|
+
const session = getSession(sessionId);
|
|
1733
1760
|
if (!session)
|
|
1734
1761
|
return err('Not connected. Call geometra_connect first.');
|
|
1735
1762
|
const itemRole = role ?? 'listitem';
|
|
@@ -1793,8 +1820,9 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
1793
1820
|
.optional()
|
|
1794
1821
|
.default(false)
|
|
1795
1822
|
.describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
|
|
1796
|
-
|
|
1797
|
-
|
|
1823
|
+
sessionId: sessionIdInput,
|
|
1824
|
+
}, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot, sessionId }) => {
|
|
1825
|
+
const session = getSession(sessionId);
|
|
1798
1826
|
if (!session)
|
|
1799
1827
|
return err('Not connected. Call geometra_connect first.');
|
|
1800
1828
|
const a11y = await sessionA11yWhenReady(session);
|
|
@@ -1841,8 +1869,9 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
|
|
|
1841
1869
|
|
|
1842
1870
|
Use this after navigating to a new page in a multi-step flow (e.g. job applications) to understand what has been completed so far. Pass \`clear: true\` to reset the workflow state.`, {
|
|
1843
1871
|
clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
|
|
1844
|
-
|
|
1845
|
-
|
|
1872
|
+
sessionId: sessionIdInput,
|
|
1873
|
+
}, async ({ clear, sessionId }) => {
|
|
1874
|
+
const session = getSession(sessionId);
|
|
1846
1875
|
if (!session)
|
|
1847
1876
|
return err('Not connected. Call geometra_connect first.');
|
|
1848
1877
|
if (clear) {
|
|
@@ -1905,8 +1934,9 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
|
|
|
1905
1934
|
.optional()
|
|
1906
1935
|
.default(true)
|
|
1907
1936
|
.describe('Include background graphics and colors.'),
|
|
1908
|
-
|
|
1909
|
-
|
|
1937
|
+
sessionId: sessionIdInput,
|
|
1938
|
+
}, async ({ html, format, landscape, margin, printBackground, sessionId }) => {
|
|
1939
|
+
const session = getSession(sessionId);
|
|
1910
1940
|
if (!session)
|
|
1911
1941
|
return err('Not connected. Call geometra_connect first.');
|
|
1912
1942
|
try {
|
|
@@ -1938,10 +1968,18 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
|
|
|
1938
1968
|
});
|
|
1939
1969
|
server.tool('geometra_disconnect', `Disconnect from the Geometra server. Proxy-backed sessions keep compatible browsers alive by default so the next geometra_connect can reuse them quickly; pass closeBrowser=true to fully tear down the warm proxy/browser pool.`, {
|
|
1940
1970
|
closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
|
|
1941
|
-
|
|
1942
|
-
|
|
1971
|
+
sessionId: sessionIdInput,
|
|
1972
|
+
}, async ({ closeBrowser, sessionId }) => {
|
|
1973
|
+
disconnect({ closeProxy: closeBrowser, sessionId });
|
|
1943
1974
|
return ok(closeBrowser ? 'Disconnected and closed browser.' : 'Disconnected.');
|
|
1944
1975
|
});
|
|
1976
|
+
server.tool('geometra_list_sessions', 'List all active Geometra sessions with their IDs and URLs. Use this to discover available sessions when operating on multiple pages in parallel.', {}, async () => {
|
|
1977
|
+
const sessions = listSessions();
|
|
1978
|
+
return ok(JSON.stringify({
|
|
1979
|
+
defaultSessionId: getDefaultSessionId(),
|
|
1980
|
+
sessions,
|
|
1981
|
+
}));
|
|
1982
|
+
});
|
|
1945
1983
|
return server;
|
|
1946
1984
|
}
|
|
1947
1985
|
// ── Helpers ──────────────────────────────────────────────────────
|
|
@@ -1955,6 +1993,7 @@ function connectPayload(session, opts) {
|
|
|
1955
1993
|
const a11y = opts.detail === 'verbose' ? sessionA11y(session) : null;
|
|
1956
1994
|
return {
|
|
1957
1995
|
connected: true,
|
|
1996
|
+
sessionId: session.id,
|
|
1958
1997
|
transport: opts.transport,
|
|
1959
1998
|
wsUrl: session.url,
|
|
1960
1999
|
...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
|
|
@@ -2133,7 +2172,7 @@ function pageModelResponsePayload(session, options) {
|
|
|
2133
2172
|
}
|
|
2134
2173
|
async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
|
|
2135
2174
|
if (!target.url && !target.pageUrl) {
|
|
2136
|
-
const session = getSession();
|
|
2175
|
+
const session = getSession(target.sessionId);
|
|
2137
2176
|
if (!session)
|
|
2138
2177
|
return { ok: false, error: missingConnectionMessage };
|
|
2139
2178
|
return { ok: true, session, autoConnected: false };
|
package/dist/session.d.ts
CHANGED
|
@@ -373,6 +373,8 @@ export interface WorkflowState {
|
|
|
373
373
|
startedAt: number;
|
|
374
374
|
}
|
|
375
375
|
export interface Session {
|
|
376
|
+
/** Short stable identifier (e.g. "s1", "s2") returned by geometra_connect. */
|
|
377
|
+
id: string;
|
|
376
378
|
ws: WebSocket;
|
|
377
379
|
layout: Record<string, unknown> | null;
|
|
378
380
|
tree: Record<string, unknown> | null;
|
|
@@ -486,9 +488,15 @@ export declare function connectThroughProxy(options: {
|
|
|
486
488
|
awaitInitialFrame?: boolean;
|
|
487
489
|
eagerInitialExtract?: boolean;
|
|
488
490
|
}): Promise<Session>;
|
|
489
|
-
export declare function getSession(): Session | null;
|
|
491
|
+
export declare function getSession(id?: string): Session | null;
|
|
492
|
+
export declare function listSessions(): Array<{
|
|
493
|
+
id: string;
|
|
494
|
+
url: string;
|
|
495
|
+
}>;
|
|
496
|
+
export declare function getDefaultSessionId(): string | null;
|
|
490
497
|
export declare function disconnect(opts?: {
|
|
491
498
|
closeProxy?: boolean;
|
|
499
|
+
sessionId?: string;
|
|
492
500
|
}): void;
|
|
493
501
|
export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
|
|
494
502
|
/**
|
package/dist/session.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
2
|
import WebSocket from 'ws';
|
|
3
3
|
import { spawnGeometraProxy, startEmbeddedGeometraProxy } from './proxy-spawn.js';
|
|
4
|
-
|
|
4
|
+
const activeSessions = new Map();
|
|
5
|
+
let defaultSessionId = null;
|
|
6
|
+
const MAX_ACTIVE_SESSIONS = 5;
|
|
7
|
+
let nextSessionId = 0;
|
|
8
|
+
function generateSessionId() { return `s${++nextSessionId}`; }
|
|
5
9
|
let reusableProxies = [];
|
|
6
|
-
const REUSABLE_PROXY_POOL_LIMIT =
|
|
10
|
+
const REUSABLE_PROXY_POOL_LIMIT = 6;
|
|
7
11
|
const trackedReusableProxyChildren = new WeakSet();
|
|
8
12
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
9
13
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
@@ -29,8 +33,13 @@ function reusableProxyEntryForSession(session) {
|
|
|
29
33
|
return reusableProxies.find(entry => (entry.child && session.proxyChild === entry.child) || (entry.runtime && session.proxyRuntime === entry.runtime));
|
|
30
34
|
}
|
|
31
35
|
function reusableProxyEntryIsActive(entry) {
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
for (const session of activeSessions.values()) {
|
|
37
|
+
if ((entry.child && session.proxyChild === entry.child)
|
|
38
|
+
|| (entry.runtime && session.proxyRuntime === entry.runtime)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
34
43
|
}
|
|
35
44
|
function clearReusableProxiesIfExited() {
|
|
36
45
|
reusableProxies = reusableProxies.filter(entry => {
|
|
@@ -156,11 +165,21 @@ function rememberReusableProxyPageUrl(session) {
|
|
|
156
165
|
}
|
|
157
166
|
touchReusableProxy(entry);
|
|
158
167
|
}
|
|
159
|
-
function
|
|
160
|
-
|
|
168
|
+
function promoteDefaultSession() {
|
|
169
|
+
if (activeSessions.size > 0) {
|
|
170
|
+
defaultSessionId = Array.from(activeSessions.keys()).pop();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
defaultSessionId = null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function shutdownSession(id, opts) {
|
|
177
|
+
const prev = activeSessions.get(id);
|
|
161
178
|
if (!prev)
|
|
162
179
|
return;
|
|
163
|
-
|
|
180
|
+
activeSessions.delete(id);
|
|
181
|
+
if (defaultSessionId === id)
|
|
182
|
+
promoteDefaultSession();
|
|
164
183
|
try {
|
|
165
184
|
prev.ws.close();
|
|
166
185
|
}
|
|
@@ -206,6 +225,13 @@ function shutdownPreviousSession(opts) {
|
|
|
206
225
|
void prev.proxyRuntime.close().catch(() => { });
|
|
207
226
|
}
|
|
208
227
|
}
|
|
228
|
+
/** Evict the oldest session when at capacity. */
|
|
229
|
+
function evictOldestSession() {
|
|
230
|
+
if (activeSessions.size < MAX_ACTIVE_SESSIONS)
|
|
231
|
+
return;
|
|
232
|
+
const oldestId = activeSessions.keys().next().value;
|
|
233
|
+
shutdownSession(oldestId, { closeProxy: false });
|
|
234
|
+
}
|
|
209
235
|
function formatUnknownError(err) {
|
|
210
236
|
return err instanceof Error ? err.message : String(err);
|
|
211
237
|
}
|
|
@@ -338,11 +364,14 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
338
364
|
const desiredWidth = options.width ?? proxy.width;
|
|
339
365
|
const desiredHeight = options.height ?? proxy.height;
|
|
340
366
|
const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
367
|
+
let reusedExistingSession = null;
|
|
368
|
+
for (const s of activeSessions.values()) {
|
|
369
|
+
if ((proxy.child && s.proxyChild === proxy.child) || (proxy.runtime && s.proxyRuntime === proxy.runtime)) {
|
|
370
|
+
reusedExistingSession = s;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const session = reusedExistingSession ?? await connect(proxy.wsUrl, {
|
|
346
375
|
skipInitialResize: true,
|
|
347
376
|
closePreviousProxy: false,
|
|
348
377
|
awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
|
|
@@ -375,7 +404,7 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
375
404
|
proxy.pageUrl = options.pageUrl;
|
|
376
405
|
updateReusableProxySnapshotState(proxy, session);
|
|
377
406
|
}
|
|
378
|
-
const baseConnectTrace = !
|
|
407
|
+
const baseConnectTrace = !reusedExistingSession ? session.connectTrace : undefined;
|
|
379
408
|
session.connectTrace = {
|
|
380
409
|
mode: 'reused-proxy',
|
|
381
410
|
reused: true,
|
|
@@ -503,9 +532,10 @@ export function connect(url, opts) {
|
|
|
503
532
|
return new Promise((resolve, reject) => {
|
|
504
533
|
const startedAt = performance.now();
|
|
505
534
|
clearReusableProxiesIfExited();
|
|
506
|
-
|
|
535
|
+
evictOldestSession();
|
|
507
536
|
const ws = new WebSocket(url);
|
|
508
537
|
const session = {
|
|
538
|
+
id: generateSessionId(),
|
|
509
539
|
ws,
|
|
510
540
|
layout: null,
|
|
511
541
|
tree: null,
|
|
@@ -545,7 +575,8 @@ export function connect(url, opts) {
|
|
|
545
575
|
session.connectTrace.resolvedWithoutInitialFrame = true;
|
|
546
576
|
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
547
577
|
}
|
|
548
|
-
|
|
578
|
+
activeSessions.set(session.id, session);
|
|
579
|
+
defaultSessionId = session.id;
|
|
549
580
|
resolve(session);
|
|
550
581
|
}
|
|
551
582
|
});
|
|
@@ -567,7 +598,8 @@ export function connect(url, opts) {
|
|
|
567
598
|
if (session.connectTrace) {
|
|
568
599
|
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
569
600
|
}
|
|
570
|
-
|
|
601
|
+
activeSessions.set(session.id, session);
|
|
602
|
+
defaultSessionId = session.id;
|
|
571
603
|
resolve(session);
|
|
572
604
|
}
|
|
573
605
|
}
|
|
@@ -587,8 +619,10 @@ export function connect(url, opts) {
|
|
|
587
619
|
}
|
|
588
620
|
});
|
|
589
621
|
ws.on('close', () => {
|
|
590
|
-
if (
|
|
591
|
-
|
|
622
|
+
if (activeSessions.get(session.id) === session) {
|
|
623
|
+
activeSessions.delete(session.id);
|
|
624
|
+
if (defaultSessionId === session.id)
|
|
625
|
+
promoteDefaultSession();
|
|
592
626
|
if (session.proxyChild && !session.proxyReusable) {
|
|
593
627
|
try {
|
|
594
628
|
session.proxyChild.kill('SIGTERM');
|
|
@@ -636,11 +670,26 @@ export async function connectThroughProxy(options) {
|
|
|
636
670
|
throw e;
|
|
637
671
|
}
|
|
638
672
|
}
|
|
639
|
-
export function getSession() {
|
|
640
|
-
|
|
673
|
+
export function getSession(id) {
|
|
674
|
+
if (id)
|
|
675
|
+
return activeSessions.get(id) ?? null;
|
|
676
|
+
if (defaultSessionId)
|
|
677
|
+
return activeSessions.get(defaultSessionId) ?? null;
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
export function listSessions() {
|
|
681
|
+
return Array.from(activeSessions.values()).map(s => ({ id: s.id, url: s.url }));
|
|
682
|
+
}
|
|
683
|
+
export function getDefaultSessionId() {
|
|
684
|
+
return defaultSessionId;
|
|
641
685
|
}
|
|
642
686
|
export function disconnect(opts) {
|
|
643
|
-
|
|
687
|
+
if (opts?.sessionId) {
|
|
688
|
+
shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false });
|
|
689
|
+
}
|
|
690
|
+
else if (defaultSessionId) {
|
|
691
|
+
shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false });
|
|
692
|
+
}
|
|
644
693
|
if (opts?.closeProxy)
|
|
645
694
|
closeReusableProxies();
|
|
646
695
|
}
|
package/package.json
CHANGED