@geometra/mcp 1.19.5 → 1.19.7

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/README.md CHANGED
@@ -26,7 +26,7 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
26
26
  | `geometra_type` | Type text into the focused element |
27
27
  | `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
28
28
  | `geometra_upload_files` | Attach files: auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
29
- | `geometra_pick_listbox_option` | Pick `role=option` (React Select, Headless UI, etc.; `@geometra/proxy` only) |
29
+ | `geometra_pick_listbox_option` | Pick an option from a custom dropdown/searchable combobox; can open by field label (`@geometra/proxy` only) |
30
30
  | `geometra_select_option` | Choose an option on a native `<select>` (`@geometra/proxy` only) |
31
31
  | `geometra_set_checked` | Set a checkbox or radio by label instead of coordinate clicks (`@geometra/proxy` only) |
32
32
  | `geometra_wheel` | Mouse wheel / scroll (`@geometra/proxy` only) |
@@ -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.5' }, { capabilities: { tools: {} } });
6
+ const server = new McpServer({ name: 'geometra', version: '1.19.7' }, { 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, 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 ────────────────────────────────────────────────
@@ -221,14 +221,16 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
221
221
  return err(e.message);
222
222
  }
223
223
  });
224
- server.tool('geometra_pick_listbox_option', `Pick a visible \`role=option\` (Headless UI, React Select, Radix, etc.). Requires \`@geometra/proxy\`.
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
- Optional openX,openY clicks the combobox first if the list is not open. 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.`, {
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'),
230
230
  openY: z.number().optional().describe('Click to open dropdown'),
231
- }, async ({ label, exact, openX, openY }) => {
231
+ fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
232
+ query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
233
+ }, async ({ label, exact, openX, openY, fieldLabel, query }) => {
232
234
  const session = getSession();
233
235
  if (!session)
234
236
  return err('Not connected. Call geometra_connect first.');
@@ -237,6 +239,8 @@ Optional openX,openY clicks the combobox first if the list is not open. Uses sub
237
239
  const wait = await sendListboxPick(session, label, {
238
240
  exact,
239
241
  open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
242
+ fieldLabel,
243
+ query,
240
244
  });
241
245
  const summary = postActionSummary(session, before, wait);
242
246
  return ok(`Picked listbox option "${label}".\n${summary}`);
@@ -313,7 +317,7 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
313
317
  }
314
318
  });
315
319
  // ── snapshot ─────────────────────────────────────────────────
316
- 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.
317
321
 
318
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.`, {
319
323
  view: z
@@ -337,10 +341,11 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
337
341
  if (view === 'full') {
338
342
  return ok(JSON.stringify(a11y, null, 2));
339
343
  }
340
- const { nodes, truncated } = buildCompactUiIndex(a11y, { maxNodes });
344
+ const { nodes, truncated, context } = buildCompactUiIndex(a11y, { maxNodes });
341
345
  const payload = {
342
346
  view: 'compact',
343
347
  viewport: { width: a11y.bounds.width, height: a11y.bounds.height },
348
+ context,
344
349
  nodes,
345
350
  truncated,
346
351
  };
@@ -376,13 +381,17 @@ function sessionA11y(session) {
376
381
  }
377
382
  function sessionOverviewFromA11y(a11y) {
378
383
  const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
379
- const { nodes } = buildCompactUiIndex(a11y, { maxNodes: 32 });
384
+ const { nodes, context } = buildCompactUiIndex(a11y, { maxNodes: 32 });
385
+ const contextSummary = summarizeCompactContext(context);
380
386
  const keyNodes = nodes.length > 0 ? `Key nodes:\n${summarizeCompactIndex(nodes, 18)}` : '';
381
- return [pageSummary, keyNodes].filter(Boolean).join('\n');
387
+ return [pageSummary, contextSummary, keyNodes].filter(Boolean).join('\n');
382
388
  }
383
389
  function postActionSummary(session, before, wait) {
384
390
  const after = sessionA11y(session);
385
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
+ }
386
395
  if (wait?.status === 'timed_out') {
387
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.`);
388
397
  }
@@ -396,6 +405,19 @@ function postActionSummary(session, before, wait) {
396
405
  }
397
406
  return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
398
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
+ }
399
421
  function ok(text) {
400
422
  return { content: [{ type: 'text', text }] };
401
423
  }
@@ -422,15 +444,32 @@ function findNodes(node, filter) {
422
444
  walk(node);
423
445
  return matches;
424
446
  }
425
- 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 centerX = hasVisibleIntersection
454
+ ? Math.round((visibleLeft + visibleRight) / 2)
455
+ : Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
456
+ const centerY = hasVisibleIntersection
457
+ ? Math.round((visibleTop + visibleBottom) / 2)
458
+ : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
426
459
  return {
427
460
  id: nodeIdForPath(node.path),
428
461
  role: node.role,
429
462
  name: node.name,
430
463
  bounds: node.bounds,
464
+ visibleBounds: {
465
+ x: visibleLeft,
466
+ y: visibleTop,
467
+ width: Math.max(0, visibleRight - visibleLeft),
468
+ height: Math.max(0, visibleBottom - visibleTop),
469
+ },
431
470
  center: {
432
- x: Math.round(node.bounds.x + node.bounds.width / 2),
433
- y: Math.round(node.bounds.y + node.bounds.height / 2),
471
+ x: centerX,
472
+ y: centerY,
434
473
  },
435
474
  focusable: node.focusable,
436
475
  ...(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,6 +214,9 @@ 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;
@@ -197,7 +227,7 @@ export interface Session {
197
227
  proxyChild?: ChildProcess;
198
228
  }
199
229
  export interface UpdateWaitResult {
200
- status: 'updated' | 'timed_out';
230
+ status: 'updated' | 'acknowledged' | 'timed_out';
201
231
  timeoutMs: number;
202
232
  }
203
233
  /**
@@ -258,6 +288,8 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
258
288
  x: number;
259
289
  y: number;
260
290
  };
291
+ fieldLabel?: string;
292
+ query?: string;
261
293
  }): Promise<UpdateWaitResult>;
262
294
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
263
295
  export declare function sendSelectOption(session: Session, x: number, y: number, option: {
@@ -295,6 +327,7 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
295
327
  }): {
296
328
  nodes: CompactUiNode[];
297
329
  truncated: boolean;
330
+ context: CompactUiContext;
298
331
  };
299
332
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
300
333
  /**
package/dist/session.js CHANGED
@@ -204,6 +204,10 @@ export function sendListboxPick(session, label, opts) {
204
204
  payload.openX = opts.open.x;
205
205
  payload.openY = opts.open.y;
206
206
  }
207
+ if (opts?.fieldLabel)
208
+ payload.fieldLabel = opts.fieldLabel;
209
+ if (opts?.query)
210
+ payload.query = opts.query;
207
211
  return sendAndWaitForUpdate(session, payload);
208
212
  }
209
213
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
@@ -258,8 +262,18 @@ const COMPACT_INDEX_ROLES = new Set([
258
262
  'main',
259
263
  'form',
260
264
  'article',
265
+ 'tablist',
266
+ 'tab',
261
267
  'listitem',
262
268
  ]);
269
+ const PINNED_CONTEXT_ROLES = new Set([
270
+ 'navigation',
271
+ 'main',
272
+ 'form',
273
+ 'dialog',
274
+ 'tablist',
275
+ 'tab',
276
+ ]);
263
277
  const LANDMARK_ROLES = new Set([
264
278
  'banner',
265
279
  'navigation',
@@ -387,6 +401,45 @@ function intersectsViewport(b, vw, vh) {
387
401
  b.x < vw &&
388
402
  b.y < vh);
389
403
  }
404
+ function intersectsViewportWithMargin(b, vw, vh, marginY) {
405
+ return (b.width > 0 &&
406
+ b.height > 0 &&
407
+ b.x + b.width > 0 &&
408
+ b.x < vw &&
409
+ b.y + b.height > -marginY &&
410
+ b.y < vh + marginY);
411
+ }
412
+ function compactNodeFromA11y(node, pinned = false) {
413
+ const name = sanitizeInlineName(node.name, 240);
414
+ return {
415
+ id: nodeIdForPath(node.path),
416
+ role: node.role,
417
+ ...(name ? { name } : {}),
418
+ ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
419
+ ...(pinned ? { pinned: true } : {}),
420
+ bounds: { ...node.bounds },
421
+ path: node.path,
422
+ focusable: node.focusable,
423
+ };
424
+ }
425
+ function pinnedRolePriority(role) {
426
+ if (role === 'tablist')
427
+ return 0;
428
+ if (role === 'tab')
429
+ return 1;
430
+ if (role === 'form')
431
+ return 2;
432
+ if (role === 'dialog')
433
+ return 3;
434
+ if (role === 'navigation')
435
+ return 4;
436
+ if (role === 'main')
437
+ return 5;
438
+ return 6;
439
+ }
440
+ function shouldPinCompactContextNode(node) {
441
+ return PINNED_CONTEXT_ROLES.has(node.role) || node.state?.focused === true;
442
+ }
390
443
  function includeInCompactIndex(n) {
391
444
  if (n.focusable)
392
445
  return true;
@@ -404,34 +457,60 @@ export function buildCompactUiIndex(root, options) {
404
457
  const vw = options?.viewportWidth ?? root.bounds.width;
405
458
  const vh = options?.viewportHeight ?? root.bounds.height;
406
459
  const maxNodes = options?.maxNodes ?? 400;
407
- const acc = [];
408
- function walk(n) {
409
- if (includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh)) {
410
- const name = sanitizeInlineName(n.name, 240);
411
- acc.push({
412
- id: nodeIdForPath(n.path),
413
- role: n.role,
414
- ...(name ? { name } : {}),
415
- ...(n.state && Object.keys(n.state).length > 0 ? { state: n.state } : {}),
416
- bounds: { ...n.bounds },
417
- path: n.path,
418
- focusable: n.focusable,
419
- });
460
+ const visibleNodes = [];
461
+ const pinnedNodes = new Map();
462
+ const marginY = Math.round(vh * 0.6);
463
+ function pinNode(node) {
464
+ if (!shouldPinCompactContextNode(node))
465
+ return;
466
+ pinnedNodes.set(nodeIdForPath(node.path), compactNodeFromA11y(node, true));
467
+ }
468
+ function walk(n, ancestors) {
469
+ const visibleSelf = includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh);
470
+ if (visibleSelf) {
471
+ visibleNodes.push(compactNodeFromA11y(n));
472
+ for (const ancestor of ancestors) {
473
+ pinNode(ancestor);
474
+ }
475
+ }
476
+ if (shouldPinCompactContextNode(n) && intersectsViewportWithMargin(n.bounds, vw, vh, marginY)) {
477
+ pinNode(n);
420
478
  }
421
479
  for (const c of n.children)
422
- walk(c);
480
+ walk(c, [...ancestors, n]);
423
481
  }
424
- walk(root);
425
- acc.sort((a, b) => {
482
+ walk(root, []);
483
+ const merged = new Map();
484
+ for (const node of pinnedNodes.values()) {
485
+ merged.set(node.id, node);
486
+ }
487
+ for (const node of visibleNodes) {
488
+ const existing = merged.get(node.id);
489
+ merged.set(node.id, existing?.pinned ? { ...node, pinned: true } : node);
490
+ }
491
+ const nodes = [...merged.values()];
492
+ nodes.sort((a, b) => {
493
+ if ((a.pinned ?? false) !== (b.pinned ?? false))
494
+ return a.pinned ? -1 : 1;
495
+ if (a.pinned && b.pinned && a.role !== b.role) {
496
+ return pinnedRolePriority(a.role) - pinnedRolePriority(b.role);
497
+ }
426
498
  if (a.focusable !== b.focusable)
427
499
  return a.focusable ? -1 : 1;
428
500
  if (a.bounds.y !== b.bounds.y)
429
501
  return a.bounds.y - b.bounds.y;
430
502
  return a.bounds.x - b.bounds.x;
431
503
  });
432
- if (acc.length > maxNodes)
433
- return { nodes: acc.slice(0, maxNodes), truncated: true };
434
- return { nodes: acc, truncated: false };
504
+ const focusedNode = nodes.find(node => node.state?.focused);
505
+ const context = {
506
+ ...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
507
+ ...(typeof root.meta?.scrollX === 'number' ? { scrollX: root.meta.scrollX } : {}),
508
+ ...(typeof root.meta?.scrollY === 'number' ? { scrollY: root.meta.scrollY } : {}),
509
+ ...(focusedNode ? { focusedNode } : {}),
510
+ };
511
+ if (nodes.length > maxNodes)
512
+ return { nodes: nodes.slice(0, maxNodes), truncated: true, context };
513
+ return { nodes, truncated: false, context };
435
514
  }
436
515
  export function summarizeCompactIndex(nodes, maxLines = 80) {
437
516
  const lines = [];
@@ -440,8 +519,9 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
440
519
  const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
441
520
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
442
521
  const foc = n.focusable ? ' *' : '';
522
+ const pin = n.pinned ? ' [pinned]' : '';
443
523
  const b = n.bounds;
444
- lines.push(`${n.id} ${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
524
+ lines.push(`${n.id} ${n.role}${nm}${pin} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
445
525
  }
446
526
  if (nodes.length > maxLines) {
447
527
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -463,6 +543,8 @@ function cloneState(state) {
463
543
  next.selected = state.selected;
464
544
  if (state.checked !== undefined)
465
545
  next.checked = state.checked;
546
+ if (state.focused !== undefined)
547
+ next.focused = state.focused;
466
548
  return Object.keys(next).length > 0 ? next : undefined;
467
549
  }
468
550
  function clonePath(path) {
@@ -848,7 +930,7 @@ function diffCompactNodes(before, after) {
848
930
  }
849
931
  const beforeState = before.state ?? {};
850
932
  const afterState = after.state ?? {};
851
- for (const key of ['disabled', 'expanded', 'selected', 'checked']) {
933
+ for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
852
934
  if (beforeState[key] !== afterState[key]) {
853
935
  changes.push(`${key} ${formatStateValue(beforeState[key])} -> ${formatStateValue(afterState[key])}`);
854
936
  }
@@ -869,8 +951,10 @@ function pageContainerKey(value) {
869
951
  */
870
952
  export function buildUiDelta(before, after, options) {
871
953
  const maxNodes = options?.maxNodes ?? 250;
872
- const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
873
- const afterCompact = buildCompactUiIndex(after, { maxNodes }).nodes;
954
+ const beforeIndex = buildCompactUiIndex(before, { maxNodes });
955
+ const afterIndex = buildCompactUiIndex(after, { maxNodes });
956
+ const beforeCompact = beforeIndex.nodes;
957
+ const afterCompact = afterIndex.nodes;
874
958
  const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
875
959
  const afterMap = new Map(afterCompact.map(node => [node.id, node]));
876
960
  const added = [];
@@ -922,6 +1006,26 @@ export function buildUiDelta(before, after, options) {
922
1006
  });
923
1007
  }
924
1008
  }
1009
+ const navigation = beforeIndex.context.pageUrl !== afterIndex.context.pageUrl
1010
+ ? {
1011
+ beforeUrl: beforeIndex.context.pageUrl,
1012
+ afterUrl: afterIndex.context.pageUrl,
1013
+ }
1014
+ : undefined;
1015
+ const viewport = beforeIndex.context.scrollX !== afterIndex.context.scrollX || beforeIndex.context.scrollY !== afterIndex.context.scrollY
1016
+ ? {
1017
+ beforeScrollX: beforeIndex.context.scrollX,
1018
+ beforeScrollY: beforeIndex.context.scrollY,
1019
+ afterScrollX: afterIndex.context.scrollX,
1020
+ afterScrollY: afterIndex.context.scrollY,
1021
+ }
1022
+ : undefined;
1023
+ const focus = beforeIndex.context.focusedNode?.id !== afterIndex.context.focusedNode?.id
1024
+ ? {
1025
+ before: beforeIndex.context.focusedNode,
1026
+ after: afterIndex.context.focusedNode,
1027
+ }
1028
+ : undefined;
925
1029
  return {
926
1030
  added,
927
1031
  removed,
@@ -931,6 +1035,9 @@ export function buildUiDelta(before, after, options) {
931
1035
  formsAppeared,
932
1036
  formsRemoved,
933
1037
  listCountsChanged,
1038
+ ...(navigation ? { navigation } : {}),
1039
+ ...(viewport ? { viewport } : {}),
1040
+ ...(focus ? { focus } : {}),
934
1041
  };
935
1042
  }
936
1043
  export function hasUiDelta(delta) {
@@ -941,10 +1048,24 @@ export function hasUiDelta(delta) {
941
1048
  delta.dialogsClosed.length > 0 ||
942
1049
  delta.formsAppeared.length > 0 ||
943
1050
  delta.formsRemoved.length > 0 ||
944
- delta.listCountsChanged.length > 0);
1051
+ delta.listCountsChanged.length > 0 ||
1052
+ !!delta.navigation ||
1053
+ !!delta.viewport ||
1054
+ !!delta.focus);
945
1055
  }
946
1056
  export function summarizeUiDelta(delta, maxLines = 14) {
947
1057
  const lines = [];
1058
+ if (delta.navigation) {
1059
+ lines.push(`~ navigation ${JSON.stringify(delta.navigation.beforeUrl ?? 'unknown')} -> ${JSON.stringify(delta.navigation.afterUrl ?? 'unknown')}`);
1060
+ }
1061
+ if (delta.viewport) {
1062
+ lines.push(`~ viewport scroll (${delta.viewport.beforeScrollX ?? 0},${delta.viewport.beforeScrollY ?? 0}) -> (${delta.viewport.afterScrollX ?? 0},${delta.viewport.afterScrollY ?? 0})`);
1063
+ }
1064
+ if (delta.focus) {
1065
+ const beforeLabel = delta.focus.before ? compactNodeLabel(delta.focus.before) : 'unset';
1066
+ const afterLabel = delta.focus.after ? compactNodeLabel(delta.focus.after) : 'unset';
1067
+ lines.push(`~ focus ${beforeLabel} -> ${afterLabel}`);
1068
+ }
948
1069
  for (const dialog of delta.dialogsOpened.slice(0, 2)) {
949
1070
  lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
950
1071
  }
@@ -1040,6 +1161,15 @@ function walkNode(element, layout, path) {
1040
1161
  const checked = normalizeCheckedState(semantic?.ariaChecked);
1041
1162
  if (checked !== undefined)
1042
1163
  state.checked = checked;
1164
+ if (semantic?.focused !== undefined)
1165
+ state.focused = !!semantic.focused;
1166
+ const meta = {};
1167
+ if (typeof semantic?.pageUrl === 'string')
1168
+ meta.pageUrl = semantic.pageUrl;
1169
+ if (typeof semantic?.scrollX === 'number' && Number.isFinite(semantic.scrollX))
1170
+ meta.scrollX = semantic.scrollX;
1171
+ if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
1172
+ meta.scrollY = semantic.scrollY;
1043
1173
  const children = [];
1044
1174
  const elementChildren = element.children;
1045
1175
  const layoutChildren = layout.children;
@@ -1054,6 +1184,7 @@ function walkNode(element, layout, path) {
1054
1184
  role,
1055
1185
  ...(name ? { name } : {}),
1056
1186
  ...(Object.keys(state).length > 0 ? { state } : {}),
1187
+ ...(Object.keys(meta).length > 0 ? { meta } : {}),
1057
1188
  bounds,
1058
1189
  path,
1059
1190
  children,
@@ -1158,6 +1289,10 @@ function waitForNextUpdate(session) {
1158
1289
  cleanup();
1159
1290
  resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1160
1291
  }
1292
+ else if (msg.type === 'ack') {
1293
+ cleanup();
1294
+ resolve({ status: 'acknowledged', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1295
+ }
1161
1296
  }
1162
1297
  catch { /* ignore */ }
1163
1298
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.5",
3
+ "version": "1.19.7",
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.5",
33
+ "@geometra/proxy": "^1.19.7",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"