@geometra/mcp 1.19.6 → 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
+ });
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
2
+ import { buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
3
3
  function node(role, name, bounds, options) {
4
4
  return {
5
5
  role,
6
6
  ...(name ? { name } : {}),
7
7
  ...(options?.state ? { state: options.state } : {}),
8
+ ...(options?.meta ? { meta: options.meta } : {}),
8
9
  bounds,
9
10
  path: options?.path ?? [],
10
11
  children: options?.children ?? [],
@@ -222,4 +223,61 @@ describe('buildUiDelta', () => {
222
223
  expect(delta.updated.some(update => update.after.role === 'checkbox' && update.changes.includes('checked false -> true'))).toBe(true);
223
224
  expect(summary).toContain('~ n:0.0 checkbox "New York, NY": checked false -> true');
224
225
  });
226
+ it('keeps pinned context nodes and reports viewport/focus/navigation drift', () => {
227
+ const before = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
228
+ meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 120 },
229
+ children: [
230
+ node('tablist', 'Application tabs', { x: 16, y: -64, width: 420, height: 40 }, { path: [0] }),
231
+ node('form', 'Application', { x: 24, y: -20, width: 760, height: 1400 }, {
232
+ path: [1],
233
+ children: [
234
+ node('textbox', 'Full name', { x: 48, y: 140, width: 320, height: 36 }, {
235
+ path: [1, 0],
236
+ focusable: true,
237
+ state: { focused: true },
238
+ }),
239
+ ],
240
+ }),
241
+ ],
242
+ });
243
+ const after = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
244
+ meta: { pageUrl: 'https://jobs.example.com/apply?step=details', scrollX: 0, scrollY: 420 },
245
+ children: [
246
+ node('tablist', 'Application tabs', { x: 16, y: -96, width: 420, height: 40 }, { path: [0] }),
247
+ node('form', 'Application', { x: 24, y: -320, width: 760, height: 1400 }, {
248
+ path: [1],
249
+ children: [
250
+ node('textbox', 'Country', { x: 48, y: 182, width: 320, height: 36 }, {
251
+ path: [1, 1],
252
+ focusable: true,
253
+ state: { focused: true },
254
+ }),
255
+ ],
256
+ }),
257
+ ],
258
+ });
259
+ const compact = buildCompactUiIndex(before, { maxNodes: 20 });
260
+ expect(compact.context.pageUrl).toBe('https://jobs.example.com/apply');
261
+ expect(compact.context.scrollY).toBe(120);
262
+ expect(compact.context.focusedNode?.name).toBe('Full name');
263
+ expect(compact.nodes.some(item => item.role === 'tablist' && item.pinned)).toBe(true);
264
+ expect(compact.nodes.some(item => item.role === 'form' && item.pinned)).toBe(true);
265
+ const delta = buildUiDelta(before, after);
266
+ const summary = summarizeUiDelta(delta);
267
+ expect(delta.navigation).toEqual({
268
+ beforeUrl: 'https://jobs.example.com/apply',
269
+ afterUrl: 'https://jobs.example.com/apply?step=details',
270
+ });
271
+ expect(delta.viewport).toEqual({
272
+ beforeScrollX: 0,
273
+ beforeScrollY: 120,
274
+ afterScrollX: 0,
275
+ afterScrollY: 420,
276
+ });
277
+ expect(delta.focus?.before?.name).toBe('Full name');
278
+ expect(delta.focus?.after?.name).toBe('Country');
279
+ expect(summary).toContain('~ viewport scroll (0,120) -> (0,420)');
280
+ expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
281
+ expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
282
+ });
225
283
  });
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.6' }, { 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}, 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'),
@@ -85,7 +85,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
85
85
  if (matches.length === 0) {
86
86
  return ok(`No elements found matching ${JSON.stringify({ id, role, name, text })}`);
87
87
  }
88
- const result = matches.map(formatNode);
88
+ const result = matches.map(node => formatNode(node, a11y.bounds));
89
89
  return ok(JSON.stringify(result, null, 2));
90
90
  });
91
91
  // ── page model ────────────────────────────────────────────────
@@ -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'),
@@ -317,7 +317,7 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
317
317
  }
318
318
  });
319
319
  // ── snapshot ─────────────────────────────────────────────────
320
- server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes (links, buttons, inputs, headings, landmarks, text leaves, focusable elements) with bounds and tree paths — far fewer tokens than a full nested tree. Use **full** for complete nested a11y + every wrapper when debugging layout.
320
+ server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes plus a few pinned context anchors (for example tab strips / form roots) and root context like URL, scroll, and focus — far fewer tokens than a full nested tree. Use **full** for complete nested a11y + every wrapper when debugging layout.
321
321
 
322
322
  JSON is minified in compact view to save tokens. For a summary-first overview, use geometra_page_model, then geometra_expand_section for just the part you want.`, {
323
323
  view: z
@@ -341,10 +341,11 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
341
341
  if (view === 'full') {
342
342
  return ok(JSON.stringify(a11y, null, 2));
343
343
  }
344
- const { nodes, truncated } = buildCompactUiIndex(a11y, { maxNodes });
344
+ const { nodes, truncated, context } = buildCompactUiIndex(a11y, { maxNodes });
345
345
  const payload = {
346
346
  view: 'compact',
347
347
  viewport: { width: a11y.bounds.width, height: a11y.bounds.height },
348
+ context,
348
349
  nodes,
349
350
  truncated,
350
351
  };
@@ -380,13 +381,17 @@ function sessionA11y(session) {
380
381
  }
381
382
  function sessionOverviewFromA11y(a11y) {
382
383
  const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
383
- const { nodes } = buildCompactUiIndex(a11y, { maxNodes: 32 });
384
+ const { nodes, context } = buildCompactUiIndex(a11y, { maxNodes: 32 });
385
+ const contextSummary = summarizeCompactContext(context);
384
386
  const keyNodes = nodes.length > 0 ? `Key nodes:\n${summarizeCompactIndex(nodes, 18)}` : '';
385
- return [pageSummary, keyNodes].filter(Boolean).join('\n');
387
+ return [pageSummary, contextSummary, keyNodes].filter(Boolean).join('\n');
386
388
  }
387
389
  function postActionSummary(session, before, wait) {
388
390
  const after = sessionA11y(session);
389
391
  const notes = [];
392
+ if (wait?.status === 'acknowledged') {
393
+ notes.push('Proxy acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.');
394
+ }
390
395
  if (wait?.status === 'timed_out') {
391
396
  notes.push(`No frame or patch arrived within ${wait.timeoutMs}ms after the action. The action may still have succeeded if it did not change geometry or semantics.`);
392
397
  }
@@ -400,6 +405,19 @@ function postActionSummary(session, before, wait) {
400
405
  }
401
406
  return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
402
407
  }
408
+ function summarizeCompactContext(context) {
409
+ const parts = [];
410
+ if (context.pageUrl)
411
+ parts.push(`url=${context.pageUrl}`);
412
+ if (typeof context.scrollX === 'number' || typeof context.scrollY === 'number') {
413
+ parts.push(`scroll=(${context.scrollX ?? 0},${context.scrollY ?? 0})`);
414
+ }
415
+ if (context.focusedNode) {
416
+ const focusName = context.focusedNode.name ? ` "${context.focusedNode.name}"` : '';
417
+ parts.push(`focus=${context.focusedNode.role}${focusName}`);
418
+ }
419
+ return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
420
+ }
403
421
  function ok(text) {
404
422
  return { content: [{ type: 'text', text }] };
405
423
  }
@@ -426,15 +444,51 @@ function findNodes(node, filter) {
426
444
  walk(node);
427
445
  return matches;
428
446
  }
429
- function formatNode(node) {
447
+ function formatNode(node, viewport) {
448
+ const visibleLeft = Math.max(0, node.bounds.x);
449
+ const visibleTop = Math.max(0, node.bounds.y);
450
+ const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
451
+ const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
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;
457
+ const centerX = hasVisibleIntersection
458
+ ? Math.round((visibleLeft + visibleRight) / 2)
459
+ : Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
460
+ const centerY = hasVisibleIntersection
461
+ ? Math.round((visibleTop + visibleBottom) / 2)
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);
430
465
  return {
431
466
  id: nodeIdForPath(node.path),
432
467
  role: node.role,
433
468
  name: node.name,
434
469
  bounds: node.bounds,
470
+ visibleBounds: {
471
+ x: visibleLeft,
472
+ y: visibleTop,
473
+ width: Math.max(0, visibleRight - visibleLeft),
474
+ height: Math.max(0, visibleBottom - visibleTop),
475
+ },
435
476
  center: {
436
- x: Math.round(node.bounds.x + node.bounds.width / 2),
437
- y: Math.round(node.bounds.y + node.bounds.height / 2),
477
+ x: centerX,
478
+ y: centerY,
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,
438
492
  },
439
493
  focusable: node.focusable,
440
494
  ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
package/dist/session.d.ts CHANGED
@@ -13,6 +13,12 @@ export interface A11yNode {
13
13
  expanded?: boolean;
14
14
  selected?: boolean;
15
15
  checked?: boolean | 'mixed';
16
+ focused?: boolean;
17
+ };
18
+ meta?: {
19
+ pageUrl?: string;
20
+ scrollX?: number;
21
+ scrollY?: number;
16
22
  };
17
23
  bounds: {
18
24
  x: number;
@@ -30,6 +36,7 @@ export interface CompactUiNode {
30
36
  role: string;
31
37
  name?: string;
32
38
  state?: A11yNode['state'];
39
+ pinned?: boolean;
33
40
  bounds: {
34
41
  x: number;
35
42
  y: number;
@@ -39,6 +46,12 @@ export interface CompactUiNode {
39
46
  path: number[];
40
47
  focusable: boolean;
41
48
  }
49
+ export interface CompactUiContext {
50
+ pageUrl?: string;
51
+ scrollX?: number;
52
+ scrollY?: number;
53
+ focusedNode?: CompactUiNode;
54
+ }
42
55
  export type PageSectionKind = 'landmark' | 'form' | 'dialog' | 'list';
43
56
  export type PageArchetype = 'shell' | 'form' | 'dialog' | 'results' | 'content' | 'dashboard';
44
57
  interface PageSectionSummaryBase {
@@ -177,6 +190,20 @@ export interface UiListCountChange {
177
190
  beforeCount: number;
178
191
  afterCount: number;
179
192
  }
193
+ export interface UiNavigationChange {
194
+ beforeUrl?: string;
195
+ afterUrl?: string;
196
+ }
197
+ export interface UiViewportChange {
198
+ beforeScrollX?: number;
199
+ beforeScrollY?: number;
200
+ afterScrollX?: number;
201
+ afterScrollY?: number;
202
+ }
203
+ export interface UiFocusChange {
204
+ before?: CompactUiNode;
205
+ after?: CompactUiNode;
206
+ }
180
207
  /** Semantic delta between two compact viewport models. */
181
208
  export interface UiDelta {
182
209
  added: CompactUiNode[];
@@ -187,12 +214,16 @@ export interface UiDelta {
187
214
  formsAppeared: PageFormModel[];
188
215
  formsRemoved: PageFormModel[];
189
216
  listCountsChanged: UiListCountChange[];
217
+ navigation?: UiNavigationChange;
218
+ viewport?: UiViewportChange;
219
+ focus?: UiFocusChange;
190
220
  }
191
221
  export interface Session {
192
222
  ws: WebSocket;
193
223
  layout: Record<string, unknown> | null;
194
224
  tree: Record<string, unknown> | null;
195
225
  url: string;
226
+ updateRevision: number;
196
227
  /** Present when this session owns a child geometra-proxy process (pageUrl connect). */
197
228
  proxyChild?: ChildProcess;
198
229
  }
@@ -297,6 +328,7 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
297
328
  }): {
298
329
  nodes: CompactUiNode[];
299
330
  truncated: boolean;
331
+ context: CompactUiContext;
300
332
  };
301
333
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
302
334
  /**
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) {
@@ -262,8 +266,18 @@ const COMPACT_INDEX_ROLES = new Set([
262
266
  'main',
263
267
  'form',
264
268
  'article',
269
+ 'tablist',
270
+ 'tab',
265
271
  'listitem',
266
272
  ]);
273
+ const PINNED_CONTEXT_ROLES = new Set([
274
+ 'navigation',
275
+ 'main',
276
+ 'form',
277
+ 'dialog',
278
+ 'tablist',
279
+ 'tab',
280
+ ]);
267
281
  const LANDMARK_ROLES = new Set([
268
282
  'banner',
269
283
  'navigation',
@@ -391,6 +405,45 @@ function intersectsViewport(b, vw, vh) {
391
405
  b.x < vw &&
392
406
  b.y < vh);
393
407
  }
408
+ function intersectsViewportWithMargin(b, vw, vh, marginY) {
409
+ return (b.width > 0 &&
410
+ b.height > 0 &&
411
+ b.x + b.width > 0 &&
412
+ b.x < vw &&
413
+ b.y + b.height > -marginY &&
414
+ b.y < vh + marginY);
415
+ }
416
+ function compactNodeFromA11y(node, pinned = false) {
417
+ const name = sanitizeInlineName(node.name, 240);
418
+ return {
419
+ id: nodeIdForPath(node.path),
420
+ role: node.role,
421
+ ...(name ? { name } : {}),
422
+ ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
423
+ ...(pinned ? { pinned: true } : {}),
424
+ bounds: { ...node.bounds },
425
+ path: node.path,
426
+ focusable: node.focusable,
427
+ };
428
+ }
429
+ function pinnedRolePriority(role) {
430
+ if (role === 'tablist')
431
+ return 0;
432
+ if (role === 'tab')
433
+ return 1;
434
+ if (role === 'form')
435
+ return 2;
436
+ if (role === 'dialog')
437
+ return 3;
438
+ if (role === 'navigation')
439
+ return 4;
440
+ if (role === 'main')
441
+ return 5;
442
+ return 6;
443
+ }
444
+ function shouldPinCompactContextNode(node) {
445
+ return PINNED_CONTEXT_ROLES.has(node.role) || node.state?.focused === true;
446
+ }
394
447
  function includeInCompactIndex(n) {
395
448
  if (n.focusable)
396
449
  return true;
@@ -408,34 +461,60 @@ export function buildCompactUiIndex(root, options) {
408
461
  const vw = options?.viewportWidth ?? root.bounds.width;
409
462
  const vh = options?.viewportHeight ?? root.bounds.height;
410
463
  const maxNodes = options?.maxNodes ?? 400;
411
- const acc = [];
412
- function walk(n) {
413
- if (includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh)) {
414
- const name = sanitizeInlineName(n.name, 240);
415
- acc.push({
416
- id: nodeIdForPath(n.path),
417
- role: n.role,
418
- ...(name ? { name } : {}),
419
- ...(n.state && Object.keys(n.state).length > 0 ? { state: n.state } : {}),
420
- bounds: { ...n.bounds },
421
- path: n.path,
422
- focusable: n.focusable,
423
- });
464
+ const visibleNodes = [];
465
+ const pinnedNodes = new Map();
466
+ const marginY = Math.round(vh * 0.6);
467
+ function pinNode(node) {
468
+ if (!shouldPinCompactContextNode(node))
469
+ return;
470
+ pinnedNodes.set(nodeIdForPath(node.path), compactNodeFromA11y(node, true));
471
+ }
472
+ function walk(n, ancestors) {
473
+ const visibleSelf = includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh);
474
+ if (visibleSelf) {
475
+ visibleNodes.push(compactNodeFromA11y(n));
476
+ for (const ancestor of ancestors) {
477
+ pinNode(ancestor);
478
+ }
479
+ }
480
+ if (shouldPinCompactContextNode(n) && intersectsViewportWithMargin(n.bounds, vw, vh, marginY)) {
481
+ pinNode(n);
424
482
  }
425
483
  for (const c of n.children)
426
- walk(c);
484
+ walk(c, [...ancestors, n]);
427
485
  }
428
- walk(root);
429
- acc.sort((a, b) => {
486
+ walk(root, []);
487
+ const merged = new Map();
488
+ for (const node of pinnedNodes.values()) {
489
+ merged.set(node.id, node);
490
+ }
491
+ for (const node of visibleNodes) {
492
+ const existing = merged.get(node.id);
493
+ merged.set(node.id, existing?.pinned ? { ...node, pinned: true } : node);
494
+ }
495
+ const nodes = [...merged.values()];
496
+ nodes.sort((a, b) => {
497
+ if ((a.pinned ?? false) !== (b.pinned ?? false))
498
+ return a.pinned ? -1 : 1;
499
+ if (a.pinned && b.pinned && a.role !== b.role) {
500
+ return pinnedRolePriority(a.role) - pinnedRolePriority(b.role);
501
+ }
430
502
  if (a.focusable !== b.focusable)
431
503
  return a.focusable ? -1 : 1;
432
504
  if (a.bounds.y !== b.bounds.y)
433
505
  return a.bounds.y - b.bounds.y;
434
506
  return a.bounds.x - b.bounds.x;
435
507
  });
436
- if (acc.length > maxNodes)
437
- return { nodes: acc.slice(0, maxNodes), truncated: true };
438
- return { nodes: acc, truncated: false };
508
+ const focusedNode = nodes.find(node => node.state?.focused);
509
+ const context = {
510
+ ...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
511
+ ...(typeof root.meta?.scrollX === 'number' ? { scrollX: root.meta.scrollX } : {}),
512
+ ...(typeof root.meta?.scrollY === 'number' ? { scrollY: root.meta.scrollY } : {}),
513
+ ...(focusedNode ? { focusedNode } : {}),
514
+ };
515
+ if (nodes.length > maxNodes)
516
+ return { nodes: nodes.slice(0, maxNodes), truncated: true, context };
517
+ return { nodes, truncated: false, context };
439
518
  }
440
519
  export function summarizeCompactIndex(nodes, maxLines = 80) {
441
520
  const lines = [];
@@ -444,8 +523,9 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
444
523
  const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
445
524
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
446
525
  const foc = n.focusable ? ' *' : '';
526
+ const pin = n.pinned ? ' [pinned]' : '';
447
527
  const b = n.bounds;
448
- lines.push(`${n.id} ${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
528
+ lines.push(`${n.id} ${n.role}${nm}${pin} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
449
529
  }
450
530
  if (nodes.length > maxLines) {
451
531
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -467,6 +547,8 @@ function cloneState(state) {
467
547
  next.selected = state.selected;
468
548
  if (state.checked !== undefined)
469
549
  next.checked = state.checked;
550
+ if (state.focused !== undefined)
551
+ next.focused = state.focused;
470
552
  return Object.keys(next).length > 0 ? next : undefined;
471
553
  }
472
554
  function clonePath(path) {
@@ -852,7 +934,7 @@ function diffCompactNodes(before, after) {
852
934
  }
853
935
  const beforeState = before.state ?? {};
854
936
  const afterState = after.state ?? {};
855
- for (const key of ['disabled', 'expanded', 'selected', 'checked']) {
937
+ for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
856
938
  if (beforeState[key] !== afterState[key]) {
857
939
  changes.push(`${key} ${formatStateValue(beforeState[key])} -> ${formatStateValue(afterState[key])}`);
858
940
  }
@@ -873,8 +955,10 @@ function pageContainerKey(value) {
873
955
  */
874
956
  export function buildUiDelta(before, after, options) {
875
957
  const maxNodes = options?.maxNodes ?? 250;
876
- const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
877
- const afterCompact = buildCompactUiIndex(after, { maxNodes }).nodes;
958
+ const beforeIndex = buildCompactUiIndex(before, { maxNodes });
959
+ const afterIndex = buildCompactUiIndex(after, { maxNodes });
960
+ const beforeCompact = beforeIndex.nodes;
961
+ const afterCompact = afterIndex.nodes;
878
962
  const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
879
963
  const afterMap = new Map(afterCompact.map(node => [node.id, node]));
880
964
  const added = [];
@@ -926,6 +1010,26 @@ export function buildUiDelta(before, after, options) {
926
1010
  });
927
1011
  }
928
1012
  }
1013
+ const navigation = beforeIndex.context.pageUrl !== afterIndex.context.pageUrl
1014
+ ? {
1015
+ beforeUrl: beforeIndex.context.pageUrl,
1016
+ afterUrl: afterIndex.context.pageUrl,
1017
+ }
1018
+ : undefined;
1019
+ const viewport = beforeIndex.context.scrollX !== afterIndex.context.scrollX || beforeIndex.context.scrollY !== afterIndex.context.scrollY
1020
+ ? {
1021
+ beforeScrollX: beforeIndex.context.scrollX,
1022
+ beforeScrollY: beforeIndex.context.scrollY,
1023
+ afterScrollX: afterIndex.context.scrollX,
1024
+ afterScrollY: afterIndex.context.scrollY,
1025
+ }
1026
+ : undefined;
1027
+ const focus = beforeIndex.context.focusedNode?.id !== afterIndex.context.focusedNode?.id
1028
+ ? {
1029
+ before: beforeIndex.context.focusedNode,
1030
+ after: afterIndex.context.focusedNode,
1031
+ }
1032
+ : undefined;
929
1033
  return {
930
1034
  added,
931
1035
  removed,
@@ -935,6 +1039,9 @@ export function buildUiDelta(before, after, options) {
935
1039
  formsAppeared,
936
1040
  formsRemoved,
937
1041
  listCountsChanged,
1042
+ ...(navigation ? { navigation } : {}),
1043
+ ...(viewport ? { viewport } : {}),
1044
+ ...(focus ? { focus } : {}),
938
1045
  };
939
1046
  }
940
1047
  export function hasUiDelta(delta) {
@@ -945,10 +1052,24 @@ export function hasUiDelta(delta) {
945
1052
  delta.dialogsClosed.length > 0 ||
946
1053
  delta.formsAppeared.length > 0 ||
947
1054
  delta.formsRemoved.length > 0 ||
948
- delta.listCountsChanged.length > 0);
1055
+ delta.listCountsChanged.length > 0 ||
1056
+ !!delta.navigation ||
1057
+ !!delta.viewport ||
1058
+ !!delta.focus);
949
1059
  }
950
1060
  export function summarizeUiDelta(delta, maxLines = 14) {
951
1061
  const lines = [];
1062
+ if (delta.navigation) {
1063
+ lines.push(`~ navigation ${JSON.stringify(delta.navigation.beforeUrl ?? 'unknown')} -> ${JSON.stringify(delta.navigation.afterUrl ?? 'unknown')}`);
1064
+ }
1065
+ if (delta.viewport) {
1066
+ lines.push(`~ viewport scroll (${delta.viewport.beforeScrollX ?? 0},${delta.viewport.beforeScrollY ?? 0}) -> (${delta.viewport.afterScrollX ?? 0},${delta.viewport.afterScrollY ?? 0})`);
1067
+ }
1068
+ if (delta.focus) {
1069
+ const beforeLabel = delta.focus.before ? compactNodeLabel(delta.focus.before) : 'unset';
1070
+ const afterLabel = delta.focus.after ? compactNodeLabel(delta.focus.after) : 'unset';
1071
+ lines.push(`~ focus ${beforeLabel} -> ${afterLabel}`);
1072
+ }
952
1073
  for (const dialog of delta.dialogsOpened.slice(0, 2)) {
953
1074
  lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
954
1075
  }
@@ -1044,6 +1165,15 @@ function walkNode(element, layout, path) {
1044
1165
  const checked = normalizeCheckedState(semantic?.ariaChecked);
1045
1166
  if (checked !== undefined)
1046
1167
  state.checked = checked;
1168
+ if (semantic?.focused !== undefined)
1169
+ state.focused = !!semantic.focused;
1170
+ const meta = {};
1171
+ if (typeof semantic?.pageUrl === 'string')
1172
+ meta.pageUrl = semantic.pageUrl;
1173
+ if (typeof semantic?.scrollX === 'number' && Number.isFinite(semantic.scrollX))
1174
+ meta.scrollX = semantic.scrollX;
1175
+ if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
1176
+ meta.scrollY = semantic.scrollY;
1047
1177
  const children = [];
1048
1178
  const elementChildren = element.children;
1049
1179
  const layoutChildren = layout.children;
@@ -1058,6 +1188,7 @@ function walkNode(element, layout, path) {
1058
1188
  role,
1059
1189
  ...(name ? { name } : {}),
1060
1190
  ...(Object.keys(state).length > 0 ? { state } : {}),
1191
+ ...(Object.keys(meta).length > 0 ? { meta } : {}),
1061
1192
  bounds,
1062
1193
  path,
1063
1194
  children,
@@ -1131,40 +1262,55 @@ function applyPatches(layout, patches) {
1131
1262
  node.height = patch.height;
1132
1263
  }
1133
1264
  }
1134
- function sendAndWaitForUpdate(session, message) {
1265
+ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
1135
1266
  return new Promise((resolve, reject) => {
1136
1267
  if (session.ws.readyState !== WebSocket.OPEN) {
1137
1268
  reject(new Error('Not connected'));
1138
1269
  return;
1139
1270
  }
1140
- session.ws.send(JSON.stringify(message));
1141
- 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);
1142
1275
  });
1143
1276
  }
1144
- function waitForNextUpdate(session) {
1277
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
1145
1278
  return new Promise((resolve, reject) => {
1146
1279
  const onMessage = (data) => {
1147
1280
  try {
1148
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
+ }
1149
1298
  if (msg.type === 'error') {
1150
1299
  cleanup();
1151
1300
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1152
1301
  return;
1153
1302
  }
1154
1303
  if (msg.type === 'frame') {
1155
- session.layout = msg.layout;
1156
- session.tree = msg.tree;
1157
1304
  cleanup();
1158
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1305
+ resolve({ status: 'updated', timeoutMs });
1159
1306
  }
1160
1307
  else if (msg.type === 'patch' && session.layout) {
1161
- applyPatches(session.layout, msg.patches);
1162
1308
  cleanup();
1163
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1309
+ resolve({ status: 'updated', timeoutMs });
1164
1310
  }
1165
1311
  else if (msg.type === 'ack') {
1166
1312
  cleanup();
1167
- resolve({ status: 'acknowledged', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1313
+ resolve({ status: 'acknowledged', timeoutMs });
1168
1314
  }
1169
1315
  }
1170
1316
  catch { /* ignore */ }
@@ -1172,8 +1318,8 @@ function waitForNextUpdate(session) {
1172
1318
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1173
1319
  const timeout = setTimeout(() => {
1174
1320
  cleanup();
1175
- resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1176
- }, ACTION_UPDATE_TIMEOUT_MS);
1321
+ resolve({ status: 'timed_out', timeoutMs });
1322
+ }, timeoutMs);
1177
1323
  function cleanup() {
1178
1324
  clearTimeout(timeout);
1179
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.6",
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.6",
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"