@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.
- package/dist/__tests__/session-model.test.js +59 -1
- package/dist/server.js +45 -10
- package/dist/session.d.ts +31 -0
- package/dist/session.js +151 -24
- package/package.json +2 -2
|
@@ -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
|
+
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
|
|
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:
|
|
437
|
-
y:
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
877
|
-
const
|
|
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.
|
|
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.
|
|
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"
|