@geometra/mcp 1.24.0 → 1.26.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 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, 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')])
@@ -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
- }, async ({ name, role, sectionText, promptText, itemText, maxResults, detail }) => {
520
- const session = getSession();
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
- }, async ({ timeoutMs, expectedUrl }) => {
633
- const session = getSession();
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
- }, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail }) => {
704
- const session = getSession();
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
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, detail }) => {
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
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, actions, stopOnError, includeSteps, output, detail }) => {
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
- }, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot }) => {
1196
- const session = getSession();
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
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format }) => {
1225
- const resolved = await ensureToolSession({ url, pageUrl, port, headless, width, height, slowMo }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
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
- }, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
1268
- const session = getSession();
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
- }, async ({ id, role, name, text, contextText, promptText, sectionText, itemText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
1309
- const session = getSession();
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
- }, 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 }) => {
1376
- const session = getSession();
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
- }, async ({ text, timeoutMs, detail }) => {
1471
- const session = getSession();
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
- }, async ({ key, shift, ctrl, meta, alt, timeoutMs, detail }) => {
1498
- const session = getSession();
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
- }, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1535
- const session = getSession();
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
- }, async ({ label, exact, openX, openY, fieldLabel, contextText, sectionText, query, timeoutMs, detail }) => {
1580
- const session = getSession();
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
- }, async ({ x, y, value, label, index, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1629
- const session = getSession();
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
- }, async ({ label, checked, exact, controlType, contextText: _contextText, sectionText: _sectionText, timeoutMs, detail }) => {
1669
- const session = getSession();
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
- }, async ({ deltaY, deltaX, x, y, timeoutMs, detail }) => {
1702
- const session = getSession();
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
- }, async ({ listId: _listId, role, scrollX, scrollY, maxItems, maxScrollSteps, scrollDelta }) => {
1732
- const session = getSession();
1757
+ sessionId: sessionIdInput,
1758
+ }, async ({ 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';
@@ -1740,7 +1767,17 @@ Use this for dropdowns, location pickers, or any scrollable list where items are
1740
1767
  const a11y = await sessionA11yWhenReady(session);
1741
1768
  if (!a11y)
1742
1769
  break;
1743
- const items = findNodes(a11y, { role: itemRole });
1770
+ // Scope to subtree if listId is provided
1771
+ let searchRoot = a11y;
1772
+ if (listId) {
1773
+ const parsed = parseSectionId(listId);
1774
+ if (parsed) {
1775
+ const node = findNodeByPath(a11y, parsed.path);
1776
+ if (node)
1777
+ searchRoot = node;
1778
+ }
1779
+ }
1780
+ const items = findNodes(searchRoot, { role: itemRole });
1744
1781
  let newCount = 0;
1745
1782
  for (const item of items) {
1746
1783
  const id = nodeIdForPath(item.path);
@@ -1793,8 +1830,9 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
1793
1830
  .optional()
1794
1831
  .default(false)
1795
1832
  .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy.'),
1796
- }, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot }) => {
1797
- const session = getSession();
1833
+ sessionId: sessionIdInput,
1834
+ }, async ({ view, maxNodes, formId, maxFields, includeOptions, includeScreenshot, sessionId }) => {
1835
+ const session = getSession(sessionId);
1798
1836
  if (!session)
1799
1837
  return err('Not connected. Call geometra_connect first.');
1800
1838
  const a11y = await sessionA11yWhenReady(session);
@@ -1841,8 +1879,9 @@ For a token-efficient semantic view, use geometra_snapshot (default compact). Fo
1841
1879
 
1842
1880
  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
1881
  clear: z.boolean().optional().default(false).describe('Reset the workflow state'),
1844
- }, async ({ clear }) => {
1845
- const session = getSession();
1882
+ sessionId: sessionIdInput,
1883
+ }, async ({ clear, sessionId }) => {
1884
+ const session = getSession(sessionId);
1846
1885
  if (!session)
1847
1886
  return err('Not connected. Call geometra_connect first.');
1848
1887
  if (clear) {
@@ -1905,8 +1944,9 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
1905
1944
  .optional()
1906
1945
  .default(true)
1907
1946
  .describe('Include background graphics and colors.'),
1908
- }, async ({ html, format, landscape, margin, printBackground }) => {
1909
- const session = getSession();
1947
+ sessionId: sessionIdInput,
1948
+ }, async ({ html, format, landscape, margin, printBackground, sessionId }) => {
1949
+ const session = getSession(sessionId);
1910
1950
  if (!session)
1911
1951
  return err('Not connected. Call geometra_connect first.');
1912
1952
  try {
@@ -1938,10 +1978,18 @@ Returns \`{ pdf, pageUrl }\` where \`pdf\` is the base64-encoded PDF bytes.`, {
1938
1978
  });
1939
1979
  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
1980
  closeBrowser: z.boolean().optional().default(false).describe('Fully close the spawned proxy/browser instead of keeping it warm for reuse'),
1941
- }, async ({ closeBrowser }) => {
1942
- disconnect({ closeProxy: closeBrowser });
1981
+ sessionId: sessionIdInput,
1982
+ }, async ({ closeBrowser, sessionId }) => {
1983
+ disconnect({ closeProxy: closeBrowser, sessionId });
1943
1984
  return ok(closeBrowser ? 'Disconnected and closed browser.' : 'Disconnected.');
1944
1985
  });
1986
+ 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 () => {
1987
+ const sessions = listSessions();
1988
+ return ok(JSON.stringify({
1989
+ defaultSessionId: getDefaultSessionId(),
1990
+ sessions,
1991
+ }));
1992
+ });
1945
1993
  return server;
1946
1994
  }
1947
1995
  // ── Helpers ──────────────────────────────────────────────────────
@@ -1955,6 +2003,7 @@ function connectPayload(session, opts) {
1955
2003
  const a11y = opts.detail === 'verbose' ? sessionA11y(session) : null;
1956
2004
  return {
1957
2005
  connected: true,
2006
+ sessionId: session.id,
1958
2007
  transport: opts.transport,
1959
2008
  wsUrl: session.url,
1960
2009
  ...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
@@ -2133,7 +2182,7 @@ function pageModelResponsePayload(session, options) {
2133
2182
  }
2134
2183
  async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
2135
2184
  if (!target.url && !target.pageUrl) {
2136
- const session = getSession();
2185
+ const session = getSession(target.sessionId);
2137
2186
  if (!session)
2138
2187
  return { ok: false, error: missingConnectionMessage };
2139
2188
  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
  /**
@@ -586,6 +594,10 @@ export declare function sendNavigate(session: Session, url: string, timeoutMs?:
586
594
  */
587
595
  export declare function buildA11yTree(tree: Record<string, unknown>, layout: Record<string, unknown>): A11yNode;
588
596
  export declare function nodeIdForPath(path: number[]): string;
597
+ export declare function parseSectionId(id: string): {
598
+ kind: PageSectionKind;
599
+ path: number[];
600
+ } | null;
589
601
  /**
590
602
  * Flat list of actionable / semantic nodes in the viewport, sorted with focusable first
591
603
  * then top-to-bottom reading order. Intended to minimize LLM tokens vs a full nested tree.
@@ -600,6 +612,7 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
600
612
  context: CompactUiContext;
601
613
  };
602
614
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
615
+ export declare function findNodeByPath(root: A11yNode, path: number[]): A11yNode | null;
603
616
  export declare function nodeContextForNode(root: A11yNode, node: A11yNode): NodeContextModel | undefined;
604
617
  export declare function buildPageModel(root: A11yNode, options?: {
605
618
  maxPrimaryActions?: number;
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
- let activeSession = null;
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 = 2;
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
- return !!activeSession && ((!!entry.child && activeSession.proxyChild === entry.child)
33
- || (!!entry.runtime && activeSession.proxyRuntime === entry.runtime));
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 shutdownPreviousSession(opts) {
160
- const prev = activeSession;
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
- activeSession = null;
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
- const reusedActiveSession = ((proxy.child && activeSession?.proxyChild === proxy.child) ||
342
- (proxy.runtime && activeSession?.proxyRuntime === proxy.runtime))
343
- ? activeSession
344
- : null;
345
- const session = reusedActiveSession ?? await connect(proxy.wsUrl, {
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 = !reusedActiveSession ? session.connectTrace : undefined;
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
- shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
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
- activeSession = session;
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
- activeSession = session;
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 (activeSession === session) {
591
- activeSession = null;
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
- return activeSession;
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
- shutdownPreviousSession({ closeProxy: opts?.closeProxy ?? false });
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
  }
@@ -997,7 +1046,7 @@ function sectionPrefix(kind) {
997
1046
  function sectionIdForPath(kind, path) {
998
1047
  return `${sectionPrefix(kind)}:${encodePath(path)}`;
999
1048
  }
1000
- function parseSectionId(id) {
1049
+ export function parseSectionId(id) {
1001
1050
  const [prefix, encoded] = id.split(':', 2);
1002
1051
  if (!prefix || !encoded)
1003
1052
  return null;
@@ -1265,7 +1314,7 @@ function firstNamedDescendant(node, allowedRoles) {
1265
1314
  }
1266
1315
  return undefined;
1267
1316
  }
1268
- function findNodeByPath(root, path) {
1317
+ export function findNodeByPath(root, path) {
1269
1318
  let current = root;
1270
1319
  for (const index of path) {
1271
1320
  if (!current.children[index])
@@ -2005,10 +2054,25 @@ export function buildPageModel(root, options) {
2005
2054
  };
2006
2055
  }
2007
2056
  export function buildFormSchemas(root, options) {
2008
- const forms = sortByBounds([
2057
+ const explicitForms = [
2009
2058
  ...(root.role === 'form' ? [root] : []),
2010
2059
  ...collectDescendants(root, candidate => candidate.role === 'form'),
2011
- ]);
2060
+ ];
2061
+ // Infer forms from group/region containers with 2+ form fields (e.g. Ashby-style UIs without <form>)
2062
+ const inferredForms = collectDescendants(root, candidate => {
2063
+ if (candidate.role !== 'group' && candidate.role !== 'region')
2064
+ return false;
2065
+ // Skip descendants of explicit forms
2066
+ for (const form of explicitForms) {
2067
+ if (candidate.path.length > form.path.length &&
2068
+ form.path.every((v, i) => candidate.path[i] === v)) {
2069
+ return false;
2070
+ }
2071
+ }
2072
+ const fields = collectDescendants(candidate, child => FORM_FIELD_ROLES.has(child.role));
2073
+ return fields.length >= 2;
2074
+ });
2075
+ const forms = sortByBounds([...explicitForms, ...inferredForms]);
2012
2076
  return forms
2013
2077
  .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
2014
2078
  .map(form => buildFormSchemaForNode(root, form, options));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.24.0",
3
+ "version": "1.26.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",