@geometra/mcp 1.19.11 → 1.19.12

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
@@ -19,12 +19,13 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
19
19
  | Tool | Description |
20
20
  |---|---|
21
21
  | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
22
- | `geometra_query` | Find elements by stable id, role, name, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
22
+ | `geometra_query` | Find elements by stable id, role, name, text content, ancestor/prompt context, current value, or semantic state such as `invalid`, `required`, or `busy` |
23
23
  | `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
24
24
  | `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
25
25
  | `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result, with optional final-only output |
26
26
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
27
- | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
27
+ | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
28
+ | `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas |
28
29
  | `geometra_click` | Click an element by coordinates |
29
30
  | `geometra_type` | Type text into the focused element |
30
31
  | `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
@@ -310,8 +311,9 @@ For long application flows, prefer one of these patterns:
310
311
 
311
312
  1. `geometra_page_model`
312
313
  2. `geometra_expand_section`
313
- 3. `geometra_fill_fields` for obvious field entry
314
- 4. `geometra_run_actions` when you need mixed navigation + waits + field entry
314
+ 3. `geometra_reveal` for far-below-fold targets such as submit buttons
315
+ 4. `geometra_fill_fields` for obvious field entry
316
+ 5. `geometra_run_actions` when you need mixed navigation + waits + field entry
315
317
 
316
318
  Typical batch:
317
319
 
@@ -333,6 +335,13 @@ For the smallest long-form responses, prefer:
333
335
  1. `detail: "minimal"` for structured step metadata instead of narrated deltas
334
336
  2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
335
337
 
338
+ For long single-page forms:
339
+
340
+ 1. Use `geometra_expand_section` with `fieldOffset` / `actionOffset` to page through large forms instead of taking a full snapshot.
341
+ 2. Add `onlyRequiredFields: true` or `onlyInvalidFields: true` when you want the actionable subset.
342
+ 3. Use `contextText` in `geometra_query` / `geometra_wait_for` to disambiguate repeated `Yes` / `No` controls by question text.
343
+ 4. Use `geometra_reveal` instead of manual wheel loops when the next target is offscreen.
344
+
336
345
  Typical field fill:
337
346
 
338
347
  ```json
@@ -7,7 +7,7 @@ function node(role, name, options) {
7
7
  ...(options?.state ? { state: options.state } : {}),
8
8
  ...(options?.validation ? { validation: options.validation } : {}),
9
9
  ...(options?.meta ? { meta: options.meta } : {}),
10
- bounds: { x: 0, y: 0, width: 120, height: 40 },
10
+ bounds: options?.bounds ?? { x: 0, y: 0, width: 120, height: 40 },
11
11
  path: options?.path ?? [],
12
12
  children: options?.children ?? [],
13
13
  focusable: role !== 'group',
@@ -204,3 +204,108 @@ describe('batch MCP result shaping', () => {
204
204
  expect(final.alerts.length).toBe(1);
205
205
  });
206
206
  });
207
+ describe('query and reveal tools', () => {
208
+ beforeEach(() => {
209
+ vi.clearAllMocks();
210
+ });
211
+ it('lets query disambiguate repeated controls by context text', async () => {
212
+ const handler = getToolHandler('geometra_query');
213
+ mockState.currentA11yRoot = node('group', undefined, {
214
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 900 },
215
+ children: [
216
+ node('form', 'Application', {
217
+ path: [0],
218
+ children: [
219
+ node('group', undefined, {
220
+ path: [0, 0],
221
+ children: [
222
+ node('text', 'Are you legally authorized to work here?', { path: [0, 0, 0] }),
223
+ node('button', 'Yes', { path: [0, 0, 1] }),
224
+ node('button', 'No', { path: [0, 0, 2] }),
225
+ ],
226
+ }),
227
+ node('group', undefined, {
228
+ path: [0, 1],
229
+ children: [
230
+ node('text', 'Will you require sponsorship?', { path: [0, 1, 0] }),
231
+ node('button', 'Yes', { path: [0, 1, 1] }),
232
+ node('button', 'No', { path: [0, 1, 2] }),
233
+ ],
234
+ }),
235
+ ],
236
+ }),
237
+ ],
238
+ });
239
+ const result = await handler({
240
+ role: 'button',
241
+ name: 'Yes',
242
+ contextText: 'sponsorship',
243
+ });
244
+ const payload = JSON.parse(result.content[0].text);
245
+ expect(payload).toHaveLength(1);
246
+ expect(payload[0]).toMatchObject({
247
+ role: 'button',
248
+ name: 'Yes',
249
+ context: {
250
+ prompt: 'Will you require sponsorship?',
251
+ section: 'Application',
252
+ },
253
+ });
254
+ });
255
+ it('reveals an offscreen target with semantic scrolling instead of requiring manual wheels', async () => {
256
+ const handler = getToolHandler('geometra_reveal');
257
+ mockState.currentA11yRoot = node('group', undefined, {
258
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
259
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
260
+ children: [
261
+ node('form', 'Application', {
262
+ bounds: { x: 20, y: -200, width: 760, height: 1900 },
263
+ path: [0],
264
+ children: [
265
+ node('button', 'Submit application', {
266
+ bounds: { x: 60, y: 1540, width: 180, height: 40 },
267
+ path: [0, 0],
268
+ }),
269
+ ],
270
+ }),
271
+ ],
272
+ });
273
+ mockState.sendWheel.mockImplementationOnce(async () => {
274
+ mockState.currentA11yRoot = node('group', undefined, {
275
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
276
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
277
+ children: [
278
+ node('form', 'Application', {
279
+ bounds: { x: 20, y: -1420, width: 760, height: 1900 },
280
+ path: [0],
281
+ children: [
282
+ node('button', 'Submit application', {
283
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
284
+ path: [0, 0],
285
+ }),
286
+ ],
287
+ }),
288
+ ],
289
+ });
290
+ return { status: 'updated', timeoutMs: 2500 };
291
+ });
292
+ const result = await handler({
293
+ role: 'button',
294
+ name: 'Submit application',
295
+ maxSteps: 3,
296
+ fullyVisible: true,
297
+ timeoutMs: 2500,
298
+ });
299
+ const payload = JSON.parse(result.content[0].text);
300
+ expect(mockState.sendWheel).toHaveBeenCalledWith(mockState.session, expect.any(Number), expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }), 2500);
301
+ expect(payload).toMatchObject({
302
+ revealed: true,
303
+ attempts: 1,
304
+ target: {
305
+ role: 'button',
306
+ name: 'Submit application',
307
+ visibility: { fullyVisible: true },
308
+ },
309
+ });
310
+ });
311
+ });
@@ -115,17 +115,117 @@ describe('buildPageModel', () => {
115
115
  summary: {
116
116
  headingCount: 1,
117
117
  fieldCount: 2,
118
+ requiredFieldCount: 2,
119
+ invalidFieldCount: 1,
118
120
  actionCount: 1,
119
121
  },
122
+ page: {
123
+ fields: { offset: 0, returned: 2, total: 2, hasMore: false },
124
+ actions: { offset: 0, returned: 1, total: 1, hasMore: false },
125
+ },
120
126
  });
121
127
  expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
122
128
  expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
123
129
  expect(detail?.fields[0]?.state).toEqual({ required: true });
124
130
  expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
125
131
  expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
132
+ expect(detail?.fields[1]?.visibility).toMatchObject({ intersectsViewport: true, fullyVisible: true });
133
+ expect(detail?.actions[0]?.scrollHint).toMatchObject({ status: 'visible' });
126
134
  expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
127
135
  expect(detail?.fields[0]).not.toHaveProperty('bounds');
128
136
  });
137
+ it('paginates long sections and carries context on repeated answers', () => {
138
+ const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
139
+ children: [
140
+ node('form', 'Application', { x: 20, y: -120, width: 760, height: 1800 }, {
141
+ path: [0],
142
+ children: [
143
+ node('heading', 'Application', { x: 40, y: 40, width: 240, height: 28 }, { path: [0, 0] }),
144
+ node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
145
+ path: [0, 1],
146
+ state: { required: true },
147
+ }),
148
+ node('textbox', 'Email', { x: 48, y: 176, width: 320, height: 36 }, {
149
+ path: [0, 2],
150
+ state: { required: true, invalid: true },
151
+ validation: { error: 'Enter a valid email.' },
152
+ }),
153
+ node('textbox', 'Phone', { x: 48, y: 232, width: 320, height: 36 }, {
154
+ path: [0, 3],
155
+ state: { required: true },
156
+ }),
157
+ node('group', undefined, { x: 40, y: 980, width: 520, height: 96 }, {
158
+ path: [0, 4],
159
+ children: [
160
+ node('text', 'Are you legally authorized to work here?', { x: 48, y: 980, width: 340, height: 24 }, {
161
+ path: [0, 4, 0],
162
+ }),
163
+ node('button', 'Yes', { x: 48, y: 1020, width: 88, height: 40 }, {
164
+ path: [0, 4, 1],
165
+ focusable: true,
166
+ }),
167
+ node('button', 'No', { x: 148, y: 1020, width: 88, height: 40 }, {
168
+ path: [0, 4, 2],
169
+ focusable: true,
170
+ }),
171
+ ],
172
+ }),
173
+ node('group', undefined, { x: 40, y: 1120, width: 520, height: 96 }, {
174
+ path: [0, 5],
175
+ children: [
176
+ node('text', 'Will you require sponsorship?', { x: 48, y: 1120, width: 260, height: 24 }, {
177
+ path: [0, 5, 0],
178
+ }),
179
+ node('button', 'Yes', { x: 48, y: 1160, width: 88, height: 40 }, {
180
+ path: [0, 5, 1],
181
+ focusable: true,
182
+ }),
183
+ node('button', 'No', { x: 148, y: 1160, width: 88, height: 40 }, {
184
+ path: [0, 5, 2],
185
+ focusable: true,
186
+ }),
187
+ ],
188
+ }),
189
+ node('button', 'Submit application', { x: 48, y: 1540, width: 180, height: 40 }, {
190
+ path: [0, 6],
191
+ focusable: true,
192
+ }),
193
+ ],
194
+ }),
195
+ ],
196
+ });
197
+ const detail = expandPageSection(tree, 'fm:0', {
198
+ maxFields: 2,
199
+ fieldOffset: 1,
200
+ onlyRequiredFields: true,
201
+ });
202
+ expect(detail).toMatchObject({
203
+ summary: {
204
+ fieldCount: 3,
205
+ requiredFieldCount: 3,
206
+ invalidFieldCount: 1,
207
+ actionCount: 5,
208
+ },
209
+ page: {
210
+ fields: { offset: 1, returned: 2, total: 3, hasMore: false },
211
+ actions: { offset: 0, returned: 5, total: 5, hasMore: false },
212
+ },
213
+ });
214
+ expect(detail?.fields.map(field => field.name)).toEqual(['Email', 'Phone']);
215
+ expect(detail?.fields[0]?.scrollHint).toMatchObject({ status: 'visible' });
216
+ const authorizedYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Are you legally authorized to work here?');
217
+ const sponsorshipYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Will you require sponsorship?');
218
+ expect(authorizedYes).toMatchObject({
219
+ name: 'Yes',
220
+ context: { prompt: 'Are you legally authorized to work here?', section: 'Application' },
221
+ visibility: { fullyVisible: false, offscreenBelow: true },
222
+ });
223
+ expect(sponsorshipYes).toMatchObject({
224
+ name: 'Yes',
225
+ context: { prompt: 'Will you require sponsorship?', section: 'Application' },
226
+ visibility: { fullyVisible: false, offscreenBelow: true },
227
+ });
228
+ });
129
229
  it('drops noisy container names and falls back to unnamed summaries', () => {
130
230
  const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
131
231
  children: [
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@ interface NodeFilter {
6
6
  role?: string;
7
7
  name?: string;
8
8
  text?: string;
9
+ contextText?: string;
9
10
  value?: string;
10
11
  checked?: NodeStateFilterValue;
11
12
  disabled?: boolean;
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ function nodeFilterShape() {
21
21
  role: z.string().optional().describe('ARIA role to match'),
22
22
  name: z.string().optional().describe('Accessible name to match (exact or substring)'),
23
23
  text: z.string().optional().describe('Text content to search for (substring match)'),
24
+ contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated controls with the same visible name'),
24
25
  value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
25
26
  checked: checkedStateInput(),
26
27
  disabled: z.boolean().optional().describe('Match disabled state'),
@@ -213,7 +214,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
213
214
  // ── query ────────────────────────────────────────────────────
214
215
  server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, text content, current value, or semantic state. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, value, state, and tree path.
215
216
 
216
- 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.`, nodeFilterShape(), async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
217
+ 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.`, nodeFilterShape(), async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
217
218
  const session = getSession();
218
219
  if (!session?.tree || !session?.layout)
219
220
  return err('Not connected. Call geometra_connect first.');
@@ -223,6 +224,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
223
224
  role,
224
225
  name,
225
226
  text,
227
+ contextText,
226
228
  value,
227
229
  checked,
228
230
  disabled,
@@ -234,12 +236,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
234
236
  busy,
235
237
  };
236
238
  if (!hasNodeFilter(filter))
237
- return err('Provide at least one query filter (id, role, name, text, value, or state)');
239
+ return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
238
240
  const matches = findNodes(a11y, filter);
239
241
  if (matches.length === 0) {
240
242
  return ok(`No elements found matching ${JSON.stringify(filter)}`);
241
243
  }
242
- const result = matches.map(node => formatNode(node, a11y.bounds));
244
+ const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
243
245
  return ok(JSON.stringify(result, null, 2));
244
246
  });
245
247
  server.tool('geometra_wait_for', `Wait for a semantic UI condition without guessing sleep durations. Use this for slow SPA transitions, resume parsing, custom validation alerts, disabled submit buttons, and value/state confirmation before submit.
@@ -255,7 +257,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
255
257
  .optional()
256
258
  .default(10_000)
257
259
  .describe('Maximum time to wait before returning an error (default 10000ms)'),
258
- }, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
260
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
259
261
  const session = getSession();
260
262
  if (!session?.tree || !session?.layout)
261
263
  return err('Not connected. Call geometra_connect first.');
@@ -264,6 +266,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
264
266
  role,
265
267
  name,
266
268
  text,
269
+ contextText,
267
270
  value,
268
271
  checked,
269
272
  disabled,
@@ -275,7 +278,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
275
278
  busy,
276
279
  };
277
280
  if (!hasNodeFilter(filter))
278
- return err('Provide at least one wait filter (id, role, name, text, value, or state)');
281
+ return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
279
282
  const matchesCondition = () => {
280
283
  if (!session.tree || !session.layout)
281
284
  return false;
@@ -296,7 +299,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
296
299
  if (!after)
297
300
  return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
298
301
  const matches = findNodes(after, filter);
299
- const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
302
+ const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
300
303
  return ok(JSON.stringify(result, null, 2));
301
304
  });
302
305
  server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
@@ -439,12 +442,18 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
439
442
  id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
440
443
  maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
441
444
  maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
445
+ fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
446
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
447
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
442
448
  maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
449
+ actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
443
450
  maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
451
+ listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
444
452
  maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
453
+ itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
445
454
  maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
446
455
  includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
447
- }, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
456
+ }, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
448
457
  const session = getSession();
449
458
  if (!session?.tree || !session?.layout)
450
459
  return err('Not connected. Call geometra_connect first.');
@@ -452,9 +461,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
452
461
  const detail = expandPageSection(a11y, id, {
453
462
  maxHeadings,
454
463
  maxFields,
464
+ fieldOffset,
465
+ onlyRequiredFields,
466
+ onlyInvalidFields,
455
467
  maxActions,
468
+ actionOffset,
456
469
  maxLists,
470
+ listOffset,
457
471
  maxItems,
472
+ itemOffset,
458
473
  maxTextPreview,
459
474
  includeBounds,
460
475
  });
@@ -462,6 +477,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
462
477
  return err(`No expandable section found for id ${id}`);
463
478
  return ok(JSON.stringify(detail));
464
479
  });
480
+ server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
481
+
482
+ Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
483
+ ...nodeFilterShape(),
484
+ index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
485
+ fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
486
+ maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
487
+ timeoutMs: z
488
+ .number()
489
+ .int()
490
+ .min(50)
491
+ .max(60_000)
492
+ .optional()
493
+ .default(2_500)
494
+ .describe('Per-scroll wait timeout (default 2500ms)'),
495
+ }, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
496
+ const session = getSession();
497
+ if (!session)
498
+ return err('Not connected. Call geometra_connect first.');
499
+ const matchIndex = index ?? 0;
500
+ const requireFullyVisible = fullyVisible ?? true;
501
+ const revealSteps = maxSteps ?? 6;
502
+ const waitTimeout = timeoutMs ?? 2_500;
503
+ const filter = {
504
+ id,
505
+ role,
506
+ name,
507
+ text,
508
+ contextText,
509
+ value,
510
+ checked,
511
+ disabled,
512
+ focused,
513
+ selected,
514
+ expanded,
515
+ invalid,
516
+ required,
517
+ busy,
518
+ };
519
+ if (!hasNodeFilter(filter))
520
+ return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
521
+ let attempts = 0;
522
+ while (attempts <= revealSteps) {
523
+ const a11y = sessionA11y(session);
524
+ if (!a11y)
525
+ return err('No UI tree available to reveal from');
526
+ const matches = sortA11yNodes(findNodes(a11y, filter));
527
+ if (matches.length === 0) {
528
+ return err(`No elements found matching ${JSON.stringify(filter)}`);
529
+ }
530
+ if (matchIndex >= matches.length) {
531
+ return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
532
+ }
533
+ const target = matches[matchIndex];
534
+ const formatted = formatNode(target, a11y, a11y.bounds);
535
+ const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
536
+ if (visible) {
537
+ return ok(JSON.stringify({
538
+ revealed: true,
539
+ attempts,
540
+ target: formatted,
541
+ }, null, 2));
542
+ }
543
+ if (attempts === revealSteps) {
544
+ return err(JSON.stringify({
545
+ revealed: false,
546
+ attempts,
547
+ target: formatted,
548
+ }, null, 2));
549
+ }
550
+ const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
551
+ let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
552
+ if (deltaY === 0 && !formatted.visibility.fullyVisible) {
553
+ deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
554
+ }
555
+ await sendWheel(session, deltaY, {
556
+ deltaX,
557
+ x: formatted.center.x,
558
+ y: formatted.center.y,
559
+ }, waitTimeout);
560
+ attempts++;
561
+ }
562
+ return err(`Failed to reveal ${JSON.stringify(filter)}`);
563
+ });
465
564
  // ── click ────────────────────────────────────────────────────
466
565
  server.tool('geometra_click', `Click an element in the Geometra UI. Provide either the element's bounds (from geometra_query) or raw x,y coordinates. The click is dispatched server-side via the geometry protocol — no browser, no simulated DOM events.
467
566
 
@@ -1111,6 +1210,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1111
1210
  role: action.role,
1112
1211
  name: action.name,
1113
1212
  text: action.text,
1213
+ contextText: action.contextText,
1114
1214
  value: action.value,
1115
1215
  checked: action.checked,
1116
1216
  disabled: action.disabled,
@@ -1162,7 +1262,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
1162
1262
  const matches = findNodes(after, filter);
1163
1263
  if (detail === 'verbose') {
1164
1264
  return {
1165
- summary: JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2),
1265
+ summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
1166
1266
  compact: {
1167
1267
  present,
1168
1268
  elapsedMs,
@@ -1287,7 +1387,96 @@ function textMatches(haystack, needle) {
1287
1387
  return false;
1288
1388
  return haystack.toLowerCase().includes(needle.toLowerCase());
1289
1389
  }
1290
- function nodeMatchesFilter(node, filter) {
1390
+ function sortA11yNodes(nodes) {
1391
+ return [...nodes].sort((a, b) => {
1392
+ if (a.bounds.y !== b.bounds.y)
1393
+ return a.bounds.y - b.bounds.y;
1394
+ if (a.bounds.x !== b.bounds.x)
1395
+ return a.bounds.x - b.bounds.x;
1396
+ return a.path.length - b.path.length;
1397
+ });
1398
+ }
1399
+ function clamp(value, min, max) {
1400
+ return Math.min(Math.max(value, min), max);
1401
+ }
1402
+ function pathStartsWith(path, prefix) {
1403
+ if (prefix.length > path.length)
1404
+ return false;
1405
+ for (let index = 0; index < prefix.length; index++) {
1406
+ if (path[index] !== prefix[index])
1407
+ return false;
1408
+ }
1409
+ return true;
1410
+ }
1411
+ function namedAncestors(root, path) {
1412
+ const out = [];
1413
+ let current = root;
1414
+ for (const index of path) {
1415
+ out.push(current);
1416
+ if (!current.children[index])
1417
+ break;
1418
+ current = current.children[index];
1419
+ }
1420
+ return out;
1421
+ }
1422
+ function collectDescendants(node, predicate) {
1423
+ const out = [];
1424
+ function walk(current) {
1425
+ for (const child of current.children) {
1426
+ if (predicate(child))
1427
+ out.push(child);
1428
+ walk(child);
1429
+ }
1430
+ }
1431
+ walk(node);
1432
+ return out;
1433
+ }
1434
+ function promptContext(root, node) {
1435
+ const ancestors = namedAncestors(root, node.path);
1436
+ const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
1437
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1438
+ const ancestor = ancestors[index];
1439
+ const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
1440
+ if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
1441
+ continue;
1442
+ const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
1443
+ !!truncateInlineText(candidate.name, 120) &&
1444
+ !pathStartsWith(candidate.path, node.path))
1445
+ .filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
1446
+ .map(candidate => {
1447
+ const text = truncateInlineText(candidate.name, 120);
1448
+ if (!text)
1449
+ return null;
1450
+ if (text.toLowerCase() === normalizedName)
1451
+ return null;
1452
+ const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
1453
+ const dx = Math.abs(node.bounds.x - candidate.bounds.x);
1454
+ const headingBonus = candidate.role === 'heading' ? -32 : 0;
1455
+ return { text, score: dy * 4 + dx + headingBonus };
1456
+ })
1457
+ .filter((candidate) => !!candidate)
1458
+ .sort((a, b) => a.score - b.score)[0];
1459
+ if (best?.text)
1460
+ return best.text;
1461
+ }
1462
+ return undefined;
1463
+ }
1464
+ function sectionContext(root, node) {
1465
+ const ancestors = namedAncestors(root, node.path);
1466
+ for (let index = ancestors.length - 1; index >= 0; index--) {
1467
+ const ancestor = ancestors[index];
1468
+ if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
1469
+ const name = truncateInlineText(ancestor.name, 80);
1470
+ if (name)
1471
+ return name;
1472
+ }
1473
+ }
1474
+ return undefined;
1475
+ }
1476
+ function nodeContextText(root, node) {
1477
+ return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
1478
+ }
1479
+ function nodeMatchesFilter(node, filter, contextText) {
1291
1480
  if (filter.id && nodeIdForPath(node.path) !== filter.id)
1292
1481
  return false;
1293
1482
  if (filter.role && node.role !== filter.role)
@@ -1299,6 +1488,8 @@ function nodeMatchesFilter(node, filter) {
1299
1488
  if (filter.text &&
1300
1489
  !textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
1301
1490
  return false;
1491
+ if (!textMatches(contextText, filter.contextText))
1492
+ return false;
1302
1493
  if (filter.checked !== undefined && node.state?.checked !== filter.checked)
1303
1494
  return false;
1304
1495
  if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
@@ -1320,7 +1511,8 @@ function nodeMatchesFilter(node, filter) {
1320
1511
  export function findNodes(node, filter) {
1321
1512
  const matches = [];
1322
1513
  function walk(n) {
1323
- if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
1514
+ const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
1515
+ if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
1324
1516
  matches.push(n);
1325
1517
  for (const child of n.children)
1326
1518
  walk(child);
@@ -1345,7 +1537,7 @@ function summarizeFieldLabelState(session, fieldLabel) {
1345
1537
  parts.push(`error=${JSON.stringify(payload.error)}`);
1346
1538
  return parts.join(' ');
1347
1539
  }
1348
- function formatNode(node, viewport) {
1540
+ function formatNode(node, root, viewport) {
1349
1541
  const visibleLeft = Math.max(0, node.bounds.x);
1350
1542
  const visibleTop = Math.max(0, node.bounds.y);
1351
1543
  const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
@@ -1363,11 +1555,14 @@ function formatNode(node, viewport) {
1363
1555
  : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
1364
1556
  const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
1365
1557
  const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
1558
+ const prompt = promptContext(root, node);
1559
+ const section = sectionContext(root, node);
1366
1560
  return {
1367
1561
  id: nodeIdForPath(node.path),
1368
1562
  role: node.role,
1369
1563
  name: node.name,
1370
1564
  ...(node.value ? { value: node.value } : {}),
1565
+ ...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
1371
1566
  bounds: node.bounds,
1372
1567
  visibleBounds: {
1373
1568
  x: visibleLeft,
package/dist/session.d.ts CHANGED
@@ -61,6 +61,23 @@ export interface CompactUiContext {
61
61
  scrollY?: number;
62
62
  focusedNode?: CompactUiNode;
63
63
  }
64
+ export interface NodeContextModel {
65
+ prompt?: string;
66
+ section?: string;
67
+ }
68
+ export interface NodeVisibilityModel {
69
+ intersectsViewport: boolean;
70
+ fullyVisible: boolean;
71
+ offscreenAbove: boolean;
72
+ offscreenBelow: boolean;
73
+ offscreenLeft: boolean;
74
+ offscreenRight: boolean;
75
+ }
76
+ export interface NodeScrollHintModel {
77
+ status: 'visible' | 'partial' | 'offscreen';
78
+ revealDeltaX: number;
79
+ revealDeltaY: number;
80
+ }
64
81
  export type PageSectionKind = 'landmark' | 'form' | 'dialog' | 'list';
65
82
  export type PageArchetype = 'shell' | 'form' | 'dialog' | 'results' | 'content' | 'dashboard';
66
83
  interface PageSectionSummaryBase {
@@ -136,6 +153,9 @@ export interface PageFieldModel {
136
153
  value?: string;
137
154
  state?: A11yNode['state'];
138
155
  validation?: A11yNode['validation'];
156
+ context?: NodeContextModel;
157
+ visibility?: NodeVisibilityModel;
158
+ scrollHint?: NodeScrollHintModel;
139
159
  bounds?: {
140
160
  x: number;
141
161
  y: number;
@@ -148,6 +168,9 @@ export interface PageActionModel {
148
168
  role: string;
149
169
  name?: string;
150
170
  state?: A11yNode['state'];
171
+ context?: NodeContextModel;
172
+ visibility?: NodeVisibilityModel;
173
+ scrollHint?: NodeScrollHintModel;
151
174
  bounds?: {
152
175
  x: number;
153
176
  y: number;
@@ -179,10 +202,38 @@ export interface PageSectionDetail {
179
202
  summary: {
180
203
  headingCount: number;
181
204
  fieldCount: number;
205
+ requiredFieldCount: number;
206
+ invalidFieldCount: number;
182
207
  actionCount: number;
183
208
  listCount: number;
184
209
  itemCount: number;
185
210
  };
211
+ page: {
212
+ fields: {
213
+ offset: number;
214
+ returned: number;
215
+ total: number;
216
+ hasMore: boolean;
217
+ };
218
+ actions: {
219
+ offset: number;
220
+ returned: number;
221
+ total: number;
222
+ hasMore: boolean;
223
+ };
224
+ lists: {
225
+ offset: number;
226
+ returned: number;
227
+ total: number;
228
+ hasMore: boolean;
229
+ };
230
+ items: {
231
+ offset: number;
232
+ returned: number;
233
+ total: number;
234
+ hasMore: boolean;
235
+ };
236
+ };
186
237
  headings: PageHeadingModel[];
187
238
  fields: PageFieldModel[];
188
239
  actions: PageActionModel[];
@@ -368,9 +419,15 @@ export declare function buildPageModel(root: A11yNode, options?: {
368
419
  export declare function expandPageSection(root: A11yNode, id: string, options?: {
369
420
  maxHeadings?: number;
370
421
  maxFields?: number;
422
+ fieldOffset?: number;
423
+ onlyRequiredFields?: boolean;
424
+ onlyInvalidFields?: boolean;
371
425
  maxActions?: number;
426
+ actionOffset?: number;
372
427
  maxLists?: number;
428
+ listOffset?: number;
373
429
  maxItems?: number;
430
+ itemOffset?: number;
374
431
  maxTextPreview?: number;
375
432
  includeBounds?: boolean;
376
433
  }): PageSectionDetail | null;
package/dist/session.js CHANGED
@@ -743,8 +743,103 @@ function primaryAction(node) {
743
743
  bounds: cloneBounds(node.bounds),
744
744
  };
745
745
  }
746
- function toFieldModel(node, includeBounds = true) {
746
+ function buildVisibility(bounds, viewport) {
747
+ const visibleLeft = Math.max(0, bounds.x);
748
+ const visibleTop = Math.max(0, bounds.y);
749
+ const visibleRight = Math.min(viewport.width, bounds.x + bounds.width);
750
+ const visibleBottom = Math.min(viewport.height, bounds.y + bounds.height);
751
+ const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
752
+ const fullyVisible = bounds.x >= 0 &&
753
+ bounds.y >= 0 &&
754
+ bounds.x + bounds.width <= viewport.width &&
755
+ bounds.y + bounds.height <= viewport.height;
756
+ return {
757
+ intersectsViewport: hasVisibleIntersection,
758
+ fullyVisible,
759
+ offscreenAbove: bounds.y + bounds.height <= 0,
760
+ offscreenBelow: bounds.y >= viewport.height,
761
+ offscreenLeft: bounds.x + bounds.width <= 0,
762
+ offscreenRight: bounds.x >= viewport.width,
763
+ };
764
+ }
765
+ function buildScrollHint(bounds, viewport) {
766
+ const visibility = buildVisibility(bounds, viewport);
767
+ return {
768
+ status: visibility.fullyVisible ? 'visible' : visibility.intersectsViewport ? 'partial' : 'offscreen',
769
+ revealDeltaX: Math.round(bounds.x + bounds.width / 2 - viewport.width / 2),
770
+ revealDeltaY: Math.round(bounds.y + bounds.height / 2 - viewport.height / 2),
771
+ };
772
+ }
773
+ function ancestorNodes(root, path) {
774
+ const out = [];
775
+ let current = root;
776
+ for (const index of path) {
777
+ out.push(current);
778
+ if (!current.children[index])
779
+ break;
780
+ current = current.children[index];
781
+ }
782
+ return out;
783
+ }
784
+ function countGroupedChoiceControls(node) {
785
+ return collectDescendants(node, candidate => candidate.role === 'radio' || candidate.role === 'checkbox' || candidate.role === 'button').length;
786
+ }
787
+ function nearestPromptText(container, target) {
788
+ const candidates = collectDescendants(container, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
789
+ !!sanitizeInlineName(candidate.name, 120) &&
790
+ pathKey(candidate.path) !== pathKey(target.path));
791
+ const normalizedTarget = normalizeUiText(target.name ?? '');
792
+ const best = candidates
793
+ .filter(candidate => candidate.bounds.y <= target.bounds.y + 8)
794
+ .map(candidate => {
795
+ const text = sanitizeInlineName(candidate.name, 120);
796
+ if (!text)
797
+ return null;
798
+ if (normalizeUiText(text) === normalizedTarget)
799
+ return null;
800
+ const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
801
+ const dx = Math.abs(target.bounds.x - candidate.bounds.x);
802
+ const headingBonus = candidate.role === 'heading' ? -32 : 0;
803
+ return { text, score: dy * 4 + dx + headingBonus };
804
+ })
805
+ .filter((candidate) => !!candidate)
806
+ .sort((a, b) => a.score - b.score)[0];
807
+ return best?.text;
808
+ }
809
+ function nodeContext(root, node) {
810
+ const ancestors = ancestorNodes(root, node.path);
811
+ let prompt;
812
+ for (let index = ancestors.length - 1; index >= 0; index--) {
813
+ const ancestor = ancestors[index];
814
+ const grouped = countGroupedChoiceControls(ancestor) >= 2;
815
+ if (grouped || ancestor.role === 'group' || ancestor.role === 'form' || ancestor.role === 'dialog') {
816
+ prompt = nearestPromptText(ancestor, node);
817
+ if (prompt)
818
+ break;
819
+ }
820
+ }
821
+ let section;
822
+ for (let index = ancestors.length - 1; index >= 0; index--) {
823
+ const ancestor = ancestors[index];
824
+ const kind = sectionKindForNode(ancestor);
825
+ if (!kind)
826
+ continue;
827
+ section = sectionDisplayName(ancestor, kind);
828
+ if (section)
829
+ break;
830
+ }
831
+ if (!prompt && !section)
832
+ return undefined;
833
+ return {
834
+ ...(prompt ? { prompt } : {}),
835
+ ...(section ? { section } : {}),
836
+ };
837
+ }
838
+ function toFieldModel(root, node, includeBounds = true) {
747
839
  const value = sanitizeInlineName(node.value, 120);
840
+ const context = nodeContext(root, node);
841
+ const visibility = buildVisibility(node.bounds, root.bounds);
842
+ const scrollHint = buildScrollHint(node.bounds, root.bounds);
748
843
  return {
749
844
  id: nodeIdForPath(node.path),
750
845
  role: node.role,
@@ -752,15 +847,24 @@ function toFieldModel(node, includeBounds = true) {
752
847
  ...(value ? { value } : {}),
753
848
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
754
849
  ...(cloneValidation(node.validation) ? { validation: cloneValidation(node.validation) } : {}),
850
+ ...(context ? { context } : {}),
851
+ visibility,
852
+ scrollHint,
755
853
  ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
756
854
  };
757
855
  }
758
- function toActionModel(node, includeBounds = true) {
856
+ function toActionModel(root, node, includeBounds = true) {
857
+ const context = nodeContext(root, node);
858
+ const visibility = buildVisibility(node.bounds, root.bounds);
859
+ const scrollHint = buildScrollHint(node.bounds, root.bounds);
759
860
  return {
760
861
  id: nodeIdForPath(node.path),
761
862
  role: node.role,
762
863
  ...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
763
864
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
865
+ ...(context ? { context } : {}),
866
+ visibility,
867
+ scrollHint,
764
868
  ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
765
869
  };
766
870
  }
@@ -931,9 +1035,15 @@ export function expandPageSection(root, id, options) {
931
1035
  return null;
932
1036
  const maxHeadings = options?.maxHeadings ?? 6;
933
1037
  const maxFields = options?.maxFields ?? 18;
1038
+ const fieldOffset = Math.max(0, options?.fieldOffset ?? 0);
1039
+ const onlyRequiredFields = options?.onlyRequiredFields ?? false;
1040
+ const onlyInvalidFields = options?.onlyInvalidFields ?? false;
934
1041
  const maxActions = options?.maxActions ?? 12;
1042
+ const actionOffset = Math.max(0, options?.actionOffset ?? 0);
935
1043
  const maxLists = options?.maxLists ?? 8;
1044
+ const listOffset = Math.max(0, options?.listOffset ?? 0);
936
1045
  const maxItems = options?.maxItems ?? 20;
1046
+ const itemOffset = Math.max(0, options?.itemOffset ?? 0);
937
1047
  const maxTextPreview = options?.maxTextPreview ?? 6;
938
1048
  const includeBounds = options?.includeBounds ?? false;
939
1049
  const headingsAll = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
@@ -943,6 +1053,19 @@ export function expandPageSection(root, id, options) {
943
1053
  const itemsAll = actualKind === 'list'
944
1054
  ? sortByBounds(collectDescendants(node, candidate => candidate.role === 'listitem'))
945
1055
  : [];
1056
+ const requiredFieldCount = fieldsAll.filter(field => field.state?.required).length;
1057
+ const invalidFieldCount = fieldsAll.filter(field => field.state?.invalid).length;
1058
+ const filteredFields = fieldsAll.filter(field => {
1059
+ if (onlyRequiredFields && !field.state?.required)
1060
+ return false;
1061
+ if (onlyInvalidFields && !field.state?.invalid)
1062
+ return false;
1063
+ return true;
1064
+ });
1065
+ const pageFields = filteredFields.slice(fieldOffset, fieldOffset + maxFields);
1066
+ const pageActions = actionsAll.slice(actionOffset, actionOffset + maxActions);
1067
+ const pageLists = nestedListsAll.slice(listOffset, listOffset + maxLists);
1068
+ const pageItems = itemsAll.slice(itemOffset, itemOffset + maxItems);
946
1069
  const name = sectionDisplayName(node, actualKind);
947
1070
  return {
948
1071
  id: sectionIdForPath(actualKind, node.path),
@@ -953,15 +1076,49 @@ export function expandPageSection(root, id, options) {
953
1076
  summary: {
954
1077
  headingCount: headingsAll.length,
955
1078
  fieldCount: fieldsAll.length,
1079
+ requiredFieldCount,
1080
+ invalidFieldCount,
956
1081
  actionCount: actionsAll.length,
957
1082
  listCount: nestedListsAll.length,
958
1083
  itemCount: itemsAll.length,
959
1084
  },
1085
+ page: {
1086
+ fields: {
1087
+ offset: fieldOffset,
1088
+ returned: pageFields.length,
1089
+ total: filteredFields.length,
1090
+ hasMore: fieldOffset + pageFields.length < filteredFields.length,
1091
+ },
1092
+ actions: {
1093
+ offset: actionOffset,
1094
+ returned: pageActions.length,
1095
+ total: actionsAll.length,
1096
+ hasMore: actionOffset + pageActions.length < actionsAll.length,
1097
+ },
1098
+ lists: {
1099
+ offset: listOffset,
1100
+ returned: pageLists.length,
1101
+ total: nestedListsAll.length,
1102
+ hasMore: listOffset + pageLists.length < nestedListsAll.length,
1103
+ },
1104
+ items: {
1105
+ offset: itemOffset,
1106
+ returned: pageItems.length,
1107
+ total: itemsAll.length,
1108
+ hasMore: itemOffset + pageItems.length < itemsAll.length,
1109
+ },
1110
+ },
960
1111
  headings: headingModels(node, maxHeadings, includeBounds),
961
- fields: fieldsAll.slice(0, maxFields).map(field => toFieldModel(field, includeBounds)),
962
- actions: actionsAll.slice(0, maxActions).map(action => toActionModel(action, includeBounds)),
963
- lists: nestedListSummaries(node, maxLists, node.path),
964
- items: itemsAll.slice(0, maxItems).map(item => ({
1112
+ fields: pageFields.map(field => toFieldModel(root, field, includeBounds)),
1113
+ actions: pageActions.map(action => toActionModel(root, action, includeBounds)),
1114
+ lists: pageLists.map(list => ({
1115
+ id: sectionIdForPath('list', list.path),
1116
+ role: list.role,
1117
+ ...(sectionDisplayName(list, 'list') ? { name: sectionDisplayName(list, 'list') } : {}),
1118
+ bounds: cloneBounds(list.bounds),
1119
+ itemCount: collectDescendants(list, candidate => candidate.role === 'listitem').length,
1120
+ })),
1121
+ items: pageItems.map(item => ({
965
1122
  id: nodeIdForPath(item.path),
966
1123
  ...(listItemName(item) ? { name: listItemName(item) } : {}),
967
1124
  ...(includeBounds ? { bounds: cloneBounds(item.bounds) } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.11",
3
+ "version": "1.19.12",
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.11",
33
+ "@geometra/proxy": "^1.19.12",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"