@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 +1 -1
- package/dist/__tests__/session-model.test.js +59 -1
- package/dist/server.js +52 -13
- package/dist/session.d.ts +34 -1
- package/dist/session.js +159 -24
- package/package.json +2 -2
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
433
|
-
y:
|
|
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
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
873
|
-
const
|
|
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.
|
|
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"
|