@geometra/mcp 1.19.7 → 1.19.8

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,59 @@
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import { WebSocketServer } from 'ws';
3
+ import { connect, disconnect, sendListboxPick } from '../session.js';
4
+ describe('proxy-backed MCP actions', () => {
5
+ afterAll(() => {
6
+ disconnect();
7
+ });
8
+ it('waits for final listbox outcome instead of resolving on intermediate updates', async () => {
9
+ const wss = new WebSocketServer({ port: 0 });
10
+ wss.on('connection', ws => {
11
+ ws.on('message', raw => {
12
+ const msg = JSON.parse(String(raw));
13
+ if (msg.type === 'resize') {
14
+ ws.send(JSON.stringify({
15
+ type: 'frame',
16
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
17
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
18
+ }));
19
+ return;
20
+ }
21
+ if (msg.type === 'listboxPick') {
22
+ ws.send(JSON.stringify({
23
+ type: 'frame',
24
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
25
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
26
+ }));
27
+ setTimeout(() => {
28
+ ws.send(JSON.stringify({
29
+ type: 'error',
30
+ requestId: msg.requestId,
31
+ message: 'listboxPick: no visible option matching \"Japan\"',
32
+ }));
33
+ }, 20);
34
+ }
35
+ });
36
+ });
37
+ const port = await new Promise((resolve, reject) => {
38
+ wss.once('listening', () => {
39
+ const address = wss.address();
40
+ if (typeof address === 'object' && address)
41
+ resolve(address.port);
42
+ else
43
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
44
+ });
45
+ wss.once('error', reject);
46
+ });
47
+ try {
48
+ const session = await connect(`ws://127.0.0.1:${port}`);
49
+ await expect(sendListboxPick(session, 'Japan', {
50
+ fieldLabel: 'Country',
51
+ exact: true,
52
+ })).rejects.toThrow('listboxPick: no visible option matching "Japan"');
53
+ }
54
+ finally {
55
+ disconnect();
56
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
57
+ }
58
+ });
59
+ });
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
4
4
  import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, } from './session.js';
5
5
  export function createServer() {
6
- const server = new McpServer({ name: 'geometra', version: '1.19.7' }, { capabilities: { tools: {} } });
6
+ const server = new McpServer({ name: 'geometra', version: '1.19.8' }, { capabilities: { tools: {} } });
7
7
  // ── connect ──────────────────────────────────────────────────
8
8
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
9
9
 
@@ -69,7 +69,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
69
69
  }
70
70
  });
71
71
  // ── query ────────────────────────────────────────────────────
72
- server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, role, name, and tree path.
72
+ server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, and tree path.
73
73
 
74
74
  This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, {
75
75
  id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
@@ -223,7 +223,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
223
223
  });
224
224
  server.tool('geometra_pick_listbox_option', `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
225
225
 
226
- Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true.`, {
226
+ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true, prefers the popup nearest the opened field, and handles a few short affirmative/negative aliases such as \`Yes\` /\`No\` for consent-style copy.`, {
227
227
  label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
228
228
  exact: z.boolean().optional().describe('Exact name match'),
229
229
  openX: z.number().optional().describe('Click to open dropdown'),
@@ -450,12 +450,18 @@ function formatNode(node, viewport) {
450
450
  const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
451
451
  const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
452
452
  const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
453
+ const fullyVisible = node.bounds.x >= 0 &&
454
+ node.bounds.y >= 0 &&
455
+ node.bounds.x + node.bounds.width <= viewport.width &&
456
+ node.bounds.y + node.bounds.height <= viewport.height;
453
457
  const centerX = hasVisibleIntersection
454
458
  ? Math.round((visibleLeft + visibleRight) / 2)
455
459
  : Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
456
460
  const centerY = hasVisibleIntersection
457
461
  ? Math.round((visibleTop + visibleBottom) / 2)
458
462
  : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
463
+ const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
464
+ const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
459
465
  return {
460
466
  id: nodeIdForPath(node.path),
461
467
  role: node.role,
@@ -471,6 +477,19 @@ function formatNode(node, viewport) {
471
477
  x: centerX,
472
478
  y: centerY,
473
479
  },
480
+ visibility: {
481
+ intersectsViewport: hasVisibleIntersection,
482
+ fullyVisible,
483
+ offscreenAbove: node.bounds.y + node.bounds.height <= 0,
484
+ offscreenBelow: node.bounds.y >= viewport.height,
485
+ offscreenLeft: node.bounds.x + node.bounds.width <= 0,
486
+ offscreenRight: node.bounds.x >= viewport.width,
487
+ },
488
+ scrollHint: {
489
+ status: fullyVisible ? 'visible' : hasVisibleIntersection ? 'partial' : 'offscreen',
490
+ revealDeltaX,
491
+ revealDeltaY,
492
+ },
474
493
  focusable: node.focusable,
475
494
  ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
476
495
  path: node.path,
package/dist/session.d.ts CHANGED
@@ -223,6 +223,7 @@ export interface Session {
223
223
  layout: Record<string, unknown> | null;
224
224
  tree: Record<string, unknown> | null;
225
225
  url: string;
226
+ updateRevision: number;
226
227
  /** Present when this session owns a child geometra-proxy process (pageUrl connect). */
227
228
  proxyChild?: ChildProcess;
228
229
  }
package/dist/session.js CHANGED
@@ -2,6 +2,8 @@ import WebSocket from 'ws';
2
2
  import { spawnGeometraProxy } from './proxy-spawn.js';
3
3
  let activeSession = null;
4
4
  const ACTION_UPDATE_TIMEOUT_MS = 2000;
5
+ const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
6
+ let nextRequestSequence = 0;
5
7
  function shutdownPreviousSession() {
6
8
  const prev = activeSession;
7
9
  if (!prev)
@@ -30,7 +32,7 @@ export function connect(url) {
30
32
  return new Promise((resolve, reject) => {
31
33
  shutdownPreviousSession();
32
34
  const ws = new WebSocket(url);
33
- const session = { ws, layout: null, tree: null, url };
35
+ const session = { ws, layout: null, tree: null, url, updateRevision: 0 };
34
36
  let resolved = false;
35
37
  const timeout = setTimeout(() => {
36
38
  if (!resolved) {
@@ -49,6 +51,7 @@ export function connect(url) {
49
51
  if (msg.type === 'frame') {
50
52
  session.layout = msg.layout;
51
53
  session.tree = msg.tree;
54
+ session.updateRevision++;
52
55
  if (!resolved) {
53
56
  resolved = true;
54
57
  clearTimeout(timeout);
@@ -58,6 +61,7 @@ export function connect(url) {
58
61
  }
59
62
  else if (msg.type === 'patch' && session.layout) {
60
63
  applyPatches(session.layout, msg.patches);
64
+ session.updateRevision++;
61
65
  }
62
66
  }
63
67
  catch { /* ignore malformed messages */ }
@@ -208,7 +212,7 @@ export function sendListboxPick(session, label, opts) {
208
212
  payload.fieldLabel = opts.fieldLabel;
209
213
  if (opts?.query)
210
214
  payload.query = opts.query;
211
- return sendAndWaitForUpdate(session, payload);
215
+ return sendAndWaitForUpdate(session, payload, LISTBOX_UPDATE_TIMEOUT_MS);
212
216
  }
213
217
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
214
218
  export function sendSelectOption(session, x, y, option) {
@@ -1258,40 +1262,55 @@ function applyPatches(layout, patches) {
1258
1262
  node.height = patch.height;
1259
1263
  }
1260
1264
  }
1261
- function sendAndWaitForUpdate(session, message) {
1265
+ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
1262
1266
  return new Promise((resolve, reject) => {
1263
1267
  if (session.ws.readyState !== WebSocket.OPEN) {
1264
1268
  reject(new Error('Not connected'));
1265
1269
  return;
1266
1270
  }
1267
- session.ws.send(JSON.stringify(message));
1268
- waitForNextUpdate(session).then(resolve).catch(reject);
1271
+ const requestId = `req-${++nextRequestSequence}`;
1272
+ const startRevision = session.updateRevision;
1273
+ session.ws.send(JSON.stringify({ ...message, requestId }));
1274
+ waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
1269
1275
  });
1270
1276
  }
1271
- function waitForNextUpdate(session) {
1277
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
1272
1278
  return new Promise((resolve, reject) => {
1273
1279
  const onMessage = (data) => {
1274
1280
  try {
1275
1281
  const msg = JSON.parse(String(data));
1282
+ const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1283
+ if (requestId) {
1284
+ if (msg.type === 'error' && messageRequestId === requestId) {
1285
+ cleanup();
1286
+ reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1287
+ return;
1288
+ }
1289
+ if (msg.type === 'ack' && messageRequestId === requestId) {
1290
+ cleanup();
1291
+ resolve({
1292
+ status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
1293
+ timeoutMs,
1294
+ });
1295
+ }
1296
+ return;
1297
+ }
1276
1298
  if (msg.type === 'error') {
1277
1299
  cleanup();
1278
1300
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1279
1301
  return;
1280
1302
  }
1281
1303
  if (msg.type === 'frame') {
1282
- session.layout = msg.layout;
1283
- session.tree = msg.tree;
1284
1304
  cleanup();
1285
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1305
+ resolve({ status: 'updated', timeoutMs });
1286
1306
  }
1287
1307
  else if (msg.type === 'patch' && session.layout) {
1288
- applyPatches(session.layout, msg.patches);
1289
1308
  cleanup();
1290
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1309
+ resolve({ status: 'updated', timeoutMs });
1291
1310
  }
1292
1311
  else if (msg.type === 'ack') {
1293
1312
  cleanup();
1294
- resolve({ status: 'acknowledged', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1313
+ resolve({ status: 'acknowledged', timeoutMs });
1295
1314
  }
1296
1315
  }
1297
1316
  catch { /* ignore */ }
@@ -1299,8 +1318,8 @@ function waitForNextUpdate(session) {
1299
1318
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1300
1319
  const timeout = setTimeout(() => {
1301
1320
  cleanup();
1302
- resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1303
- }, ACTION_UPDATE_TIMEOUT_MS);
1321
+ resolve({ status: 'timed_out', timeoutMs });
1322
+ }, timeoutMs);
1304
1323
  function cleanup() {
1305
1324
  clearTimeout(timeout);
1306
1325
  session.ws.off('message', onMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.7",
3
+ "version": "1.19.8",
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",
@@ -30,7 +30,7 @@
30
30
  "ui-testing"
31
31
  ],
32
32
  "dependencies": {
33
- "@geometra/proxy": "^1.19.7",
33
+ "@geometra/proxy": "^1.19.8",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"