@geometra/mcp 1.19.6 → 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.
@@ -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.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 ────────────────────────────────────────────────
@@ -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,32 @@ 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 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));
430
459
  return {
431
460
  id: nodeIdForPath(node.path),
432
461
  role: node.role,
433
462
  name: node.name,
434
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
+ },
435
470
  center: {
436
- x: Math.round(node.bounds.x + node.bounds.width / 2),
437
- y: Math.round(node.bounds.y + node.bounds.height / 2),
471
+ x: centerX,
472
+ y: centerY,
438
473
  },
439
474
  focusable: node.focusable,
440
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;
@@ -297,6 +327,7 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
297
327
  }): {
298
328
  nodes: CompactUiNode[];
299
329
  truncated: boolean;
330
+ context: CompactUiContext;
300
331
  };
301
332
  export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
302
333
  /**
package/dist/session.js CHANGED
@@ -262,8 +262,18 @@ const COMPACT_INDEX_ROLES = new Set([
262
262
  'main',
263
263
  'form',
264
264
  'article',
265
+ 'tablist',
266
+ 'tab',
265
267
  'listitem',
266
268
  ]);
269
+ const PINNED_CONTEXT_ROLES = new Set([
270
+ 'navigation',
271
+ 'main',
272
+ 'form',
273
+ 'dialog',
274
+ 'tablist',
275
+ 'tab',
276
+ ]);
267
277
  const LANDMARK_ROLES = new Set([
268
278
  'banner',
269
279
  'navigation',
@@ -391,6 +401,45 @@ function intersectsViewport(b, vw, vh) {
391
401
  b.x < vw &&
392
402
  b.y < vh);
393
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
+ }
394
443
  function includeInCompactIndex(n) {
395
444
  if (n.focusable)
396
445
  return true;
@@ -408,34 +457,60 @@ export function buildCompactUiIndex(root, options) {
408
457
  const vw = options?.viewportWidth ?? root.bounds.width;
409
458
  const vh = options?.viewportHeight ?? root.bounds.height;
410
459
  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
- });
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);
424
478
  }
425
479
  for (const c of n.children)
426
- walk(c);
480
+ walk(c, [...ancestors, n]);
427
481
  }
428
- walk(root);
429
- 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
+ }
430
498
  if (a.focusable !== b.focusable)
431
499
  return a.focusable ? -1 : 1;
432
500
  if (a.bounds.y !== b.bounds.y)
433
501
  return a.bounds.y - b.bounds.y;
434
502
  return a.bounds.x - b.bounds.x;
435
503
  });
436
- if (acc.length > maxNodes)
437
- return { nodes: acc.slice(0, maxNodes), truncated: true };
438
- 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 };
439
514
  }
440
515
  export function summarizeCompactIndex(nodes, maxLines = 80) {
441
516
  const lines = [];
@@ -444,8 +519,9 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
444
519
  const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
445
520
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
446
521
  const foc = n.focusable ? ' *' : '';
522
+ const pin = n.pinned ? ' [pinned]' : '';
447
523
  const b = n.bounds;
448
- 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}`);
449
525
  }
450
526
  if (nodes.length > maxLines) {
451
527
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -467,6 +543,8 @@ function cloneState(state) {
467
543
  next.selected = state.selected;
468
544
  if (state.checked !== undefined)
469
545
  next.checked = state.checked;
546
+ if (state.focused !== undefined)
547
+ next.focused = state.focused;
470
548
  return Object.keys(next).length > 0 ? next : undefined;
471
549
  }
472
550
  function clonePath(path) {
@@ -852,7 +930,7 @@ function diffCompactNodes(before, after) {
852
930
  }
853
931
  const beforeState = before.state ?? {};
854
932
  const afterState = after.state ?? {};
855
- for (const key of ['disabled', 'expanded', 'selected', 'checked']) {
933
+ for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
856
934
  if (beforeState[key] !== afterState[key]) {
857
935
  changes.push(`${key} ${formatStateValue(beforeState[key])} -> ${formatStateValue(afterState[key])}`);
858
936
  }
@@ -873,8 +951,10 @@ function pageContainerKey(value) {
873
951
  */
874
952
  export function buildUiDelta(before, after, options) {
875
953
  const maxNodes = options?.maxNodes ?? 250;
876
- const beforeCompact = buildCompactUiIndex(before, { maxNodes }).nodes;
877
- 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;
878
958
  const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
879
959
  const afterMap = new Map(afterCompact.map(node => [node.id, node]));
880
960
  const added = [];
@@ -926,6 +1006,26 @@ export function buildUiDelta(before, after, options) {
926
1006
  });
927
1007
  }
928
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;
929
1029
  return {
930
1030
  added,
931
1031
  removed,
@@ -935,6 +1035,9 @@ export function buildUiDelta(before, after, options) {
935
1035
  formsAppeared,
936
1036
  formsRemoved,
937
1037
  listCountsChanged,
1038
+ ...(navigation ? { navigation } : {}),
1039
+ ...(viewport ? { viewport } : {}),
1040
+ ...(focus ? { focus } : {}),
938
1041
  };
939
1042
  }
940
1043
  export function hasUiDelta(delta) {
@@ -945,10 +1048,24 @@ export function hasUiDelta(delta) {
945
1048
  delta.dialogsClosed.length > 0 ||
946
1049
  delta.formsAppeared.length > 0 ||
947
1050
  delta.formsRemoved.length > 0 ||
948
- delta.listCountsChanged.length > 0);
1051
+ delta.listCountsChanged.length > 0 ||
1052
+ !!delta.navigation ||
1053
+ !!delta.viewport ||
1054
+ !!delta.focus);
949
1055
  }
950
1056
  export function summarizeUiDelta(delta, maxLines = 14) {
951
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
+ }
952
1069
  for (const dialog of delta.dialogsOpened.slice(0, 2)) {
953
1070
  lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
954
1071
  }
@@ -1044,6 +1161,15 @@ function walkNode(element, layout, path) {
1044
1161
  const checked = normalizeCheckedState(semantic?.ariaChecked);
1045
1162
  if (checked !== undefined)
1046
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;
1047
1173
  const children = [];
1048
1174
  const elementChildren = element.children;
1049
1175
  const layoutChildren = layout.children;
@@ -1058,6 +1184,7 @@ function walkNode(element, layout, path) {
1058
1184
  role,
1059
1185
  ...(name ? { name } : {}),
1060
1186
  ...(Object.keys(state).length > 0 ? { state } : {}),
1187
+ ...(Object.keys(meta).length > 0 ? { meta } : {}),
1061
1188
  bounds,
1062
1189
  path,
1063
1190
  children,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.6",
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.6",
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"