@geometra/mcp 1.19.7 → 1.19.9

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,155 @@
1
+ import { afterAll, describe, expect, it } from 'vitest';
2
+ import { WebSocketServer } from 'ws';
3
+ import { connect, disconnect, sendClick, sendListboxPick } from '../session.js';
4
+ describe('proxy-backed MCP actions', () => {
5
+ afterAll(() => {
6
+ disconnect();
7
+ });
8
+ it('waits for final listbox outcome instead of resolving on intermediate updates', async () => {
9
+ const wss = new WebSocketServer({ port: 0 });
10
+ wss.on('connection', ws => {
11
+ ws.on('message', raw => {
12
+ const msg = JSON.parse(String(raw));
13
+ if (msg.type === 'resize') {
14
+ ws.send(JSON.stringify({
15
+ type: 'frame',
16
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
17
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
18
+ }));
19
+ return;
20
+ }
21
+ if (msg.type === 'listboxPick') {
22
+ ws.send(JSON.stringify({
23
+ type: 'frame',
24
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
25
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
26
+ }));
27
+ setTimeout(() => {
28
+ ws.send(JSON.stringify({
29
+ type: 'error',
30
+ requestId: msg.requestId,
31
+ message: 'listboxPick: no visible option matching \"Japan\"',
32
+ }));
33
+ }, 20);
34
+ }
35
+ });
36
+ });
37
+ const port = await new Promise((resolve, reject) => {
38
+ wss.once('listening', () => {
39
+ const address = wss.address();
40
+ if (typeof address === 'object' && address)
41
+ resolve(address.port);
42
+ else
43
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
44
+ });
45
+ wss.once('error', reject);
46
+ });
47
+ try {
48
+ const session = await connect(`ws://127.0.0.1:${port}`);
49
+ await expect(sendListboxPick(session, 'Japan', {
50
+ fieldLabel: 'Country',
51
+ exact: true,
52
+ })).rejects.toThrow('listboxPick: no visible option matching "Japan"');
53
+ }
54
+ finally {
55
+ disconnect();
56
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
57
+ }
58
+ });
59
+ it('falls back to the latest observed update when a legacy peer does not send request-scoped ack', async () => {
60
+ const wss = new WebSocketServer({ port: 0 });
61
+ wss.on('connection', ws => {
62
+ ws.on('message', raw => {
63
+ const msg = JSON.parse(String(raw));
64
+ if (msg.type === 'resize') {
65
+ ws.send(JSON.stringify({
66
+ type: 'frame',
67
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
68
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
69
+ }));
70
+ return;
71
+ }
72
+ if (msg.type === 'event') {
73
+ ws.send(JSON.stringify({
74
+ type: 'frame',
75
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
76
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group', ariaLabel: 'Updated' }, children: [] },
77
+ }));
78
+ }
79
+ });
80
+ });
81
+ const port = await new Promise((resolve, reject) => {
82
+ wss.once('listening', () => {
83
+ const address = wss.address();
84
+ if (typeof address === 'object' && address)
85
+ resolve(address.port);
86
+ else
87
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
88
+ });
89
+ wss.once('error', reject);
90
+ });
91
+ try {
92
+ const session = await connect(`ws://127.0.0.1:${port}`);
93
+ await expect(sendClick(session, 5, 5, 60)).resolves.toMatchObject({ status: 'updated', timeoutMs: 60 });
94
+ }
95
+ finally {
96
+ disconnect();
97
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
98
+ }
99
+ });
100
+ it('ignores invalid patch paths instead of mutating ancestor layout nodes', async () => {
101
+ const wss = new WebSocketServer({ port: 0 });
102
+ wss.on('connection', ws => {
103
+ ws.on('message', raw => {
104
+ const msg = JSON.parse(String(raw));
105
+ if (msg.type === 'resize') {
106
+ ws.send(JSON.stringify({
107
+ type: 'frame',
108
+ layout: {
109
+ x: 0,
110
+ y: 0,
111
+ width: 200,
112
+ height: 100,
113
+ children: [{ x: 10, y: 20, width: 30, height: 40, children: [] }],
114
+ },
115
+ tree: {
116
+ kind: 'box',
117
+ props: {},
118
+ semantic: { tag: 'body', role: 'group' },
119
+ children: [{ kind: 'box', props: {}, semantic: { tag: 'div', role: 'group' }, children: [] }],
120
+ },
121
+ }));
122
+ setTimeout(() => {
123
+ ws.send(JSON.stringify({
124
+ type: 'patch',
125
+ patches: [{ path: [9], x: 999, y: 999 }],
126
+ }));
127
+ }, 10);
128
+ }
129
+ });
130
+ });
131
+ const port = await new Promise((resolve, reject) => {
132
+ wss.once('listening', () => {
133
+ const address = wss.address();
134
+ if (typeof address === 'object' && address)
135
+ resolve(address.port);
136
+ else
137
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
138
+ });
139
+ wss.once('error', reject);
140
+ });
141
+ try {
142
+ const session = await connect(`ws://127.0.0.1:${port}`);
143
+ await new Promise(resolve => setTimeout(resolve, 30));
144
+ expect(session.layout).toMatchObject({
145
+ x: 0,
146
+ y: 0,
147
+ children: [{ x: 10, y: 20 }],
148
+ });
149
+ }
150
+ finally {
151
+ disconnect();
152
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
153
+ }
154
+ });
155
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { findNodes } from '../server.js';
3
+ function node(role, bounds, options) {
4
+ return {
5
+ role,
6
+ ...(options?.name ? { name: options.name } : {}),
7
+ ...(options?.value ? { value: options.value } : {}),
8
+ ...(options?.state ? { state: options.state } : {}),
9
+ bounds,
10
+ path: options?.path ?? [],
11
+ children: options?.children ?? [],
12
+ focusable: options?.focusable ?? false,
13
+ };
14
+ }
15
+ describe('findNodes', () => {
16
+ it('matches value and checked-state filters generically', () => {
17
+ const tree = node('group', { x: 0, y: 0, width: 800, height: 600 }, {
18
+ children: [
19
+ node('combobox', { x: 20, y: 20, width: 260, height: 36 }, {
20
+ path: [0],
21
+ name: 'Location',
22
+ value: 'Austin, Texas, United States',
23
+ focusable: true,
24
+ }),
25
+ node('checkbox', { x: 20, y: 72, width: 24, height: 24 }, {
26
+ path: [1],
27
+ name: 'Notion Website',
28
+ state: { checked: true },
29
+ focusable: true,
30
+ }),
31
+ ],
32
+ });
33
+ expect(findNodes(tree, { value: 'Austin, Texas' })).toEqual([
34
+ expect.objectContaining({ role: 'combobox', name: 'Location' }),
35
+ ]);
36
+ expect(findNodes(tree, { text: 'United States' })).toEqual([
37
+ expect.objectContaining({ role: 'combobox', value: 'Austin, Texas, United States' }),
38
+ ]);
39
+ expect(findNodes(tree, { role: 'checkbox', checked: true })).toEqual([
40
+ expect.objectContaining({ name: 'Notion Website' }),
41
+ ]);
42
+ });
43
+ });
@@ -4,6 +4,7 @@ function node(role, name, bounds, options) {
4
4
  return {
5
5
  role,
6
6
  ...(name ? { name } : {}),
7
+ ...(options?.value ? { value: options.value } : {}),
7
8
  ...(options?.state ? { state: options.state } : {}),
8
9
  ...(options?.meta ? { meta: options.meta } : {}),
9
10
  bounds,
@@ -83,8 +84,14 @@ describe('buildPageModel', () => {
83
84
  path: [0, 0],
84
85
  children: [
85
86
  node('heading', 'Application', { x: 60, y: 132, width: 200, height: 24 }, { path: [0, 0, 0] }),
86
- node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, { path: [0, 0, 1] }),
87
- node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, { path: [0, 0, 2] }),
87
+ node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
88
+ path: [0, 0, 1],
89
+ value: 'Taylor Applicant',
90
+ }),
91
+ node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, {
92
+ path: [0, 0, 2],
93
+ value: 'taylor@example.com',
94
+ }),
88
95
  node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
89
96
  path: [0, 0, 3],
90
97
  focusable: true,
@@ -108,6 +115,7 @@ describe('buildPageModel', () => {
108
115
  },
109
116
  });
110
117
  expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
118
+ expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
111
119
  expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
112
120
  expect(detail?.fields[0]).not.toHaveProperty('bounds');
113
121
  });
@@ -280,4 +288,39 @@ describe('buildUiDelta', () => {
280
288
  expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
281
289
  expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
282
290
  });
291
+ it('includes control values in compact indexes and semantic deltas', () => {
292
+ const before = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
293
+ children: [
294
+ node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
295
+ path: [0],
296
+ focusable: true,
297
+ value: 'Austin',
298
+ }),
299
+ ],
300
+ });
301
+ const after = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
302
+ children: [
303
+ node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
304
+ path: [0],
305
+ focusable: true,
306
+ value: 'Austin, Texas, United States',
307
+ }),
308
+ ],
309
+ });
310
+ const compact = buildCompactUiIndex(after, { maxNodes: 10 });
311
+ expect(compact.nodes[0]).toMatchObject({
312
+ role: 'textbox',
313
+ name: 'Location',
314
+ value: 'Austin, Texas, United States',
315
+ });
316
+ const delta = buildUiDelta(before, after);
317
+ expect(delta.updated).toEqual([
318
+ expect.objectContaining({
319
+ changes: [
320
+ 'value "Austin" -> "Austin, Texas, United States"',
321
+ ],
322
+ }),
323
+ ]);
324
+ expect(summarizeUiDelta(delta)).toContain('value "Austin" -> "Austin, Texas, United States"');
325
+ });
283
326
  });
package/dist/server.d.ts CHANGED
@@ -1,2 +1,18 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { A11yNode } from './session.js';
3
+ type NodeStateFilterValue = boolean | 'mixed';
4
+ interface NodeFilter {
5
+ id?: string;
6
+ role?: string;
7
+ name?: string;
8
+ text?: string;
9
+ value?: string;
10
+ checked?: NodeStateFilterValue;
11
+ disabled?: boolean;
12
+ focused?: boolean;
13
+ selected?: boolean;
14
+ expanded?: boolean;
15
+ }
2
16
  export declare function createServer(): McpServer;
17
+ export declare function findNodes(node: A11yNode, filter: NodeFilter): A11yNode[];
18
+ export {};
package/dist/server.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
3
  import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
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';
4
+ import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
5
+ function checkedStateInput() {
6
+ return z
7
+ .union([z.boolean(), z.literal('mixed')])
8
+ .optional()
9
+ .describe('Match checked state (`true`, `false`, or `mixed`)');
10
+ }
5
11
  export function createServer() {
6
- const server = new McpServer({ name: 'geometra', version: '1.19.7' }, { capabilities: { tools: {} } });
12
+ const server = new McpServer({ name: 'geometra', version: '1.19.9' }, { capabilities: { tools: {} } });
7
13
  // ── connect ──────────────────────────────────────────────────
8
14
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
9
15
 
@@ -69,25 +75,86 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
69
75
  }
70
76
  });
71
77
  // ── 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}, visible in-viewport bounds, an on-screen center point, role, name, and tree path.
78
+ 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.
73
79
 
74
80
  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
81
  id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
76
82
  role: z.string().optional().describe('ARIA role to match (e.g. "button", "textbox", "text", "heading", "listitem")'),
77
83
  name: z.string().optional().describe('Accessible name to match (exact or substring)'),
78
84
  text: z.string().optional().describe('Text content to search for (substring match)'),
79
- }, async ({ id, role, name, text }) => {
85
+ value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
86
+ checked: checkedStateInput(),
87
+ disabled: z.boolean().optional().describe('Match disabled state'),
88
+ focused: z.boolean().optional().describe('Match focused state'),
89
+ selected: z.boolean().optional().describe('Match selected state'),
90
+ expanded: z.boolean().optional().describe('Match expanded state'),
91
+ }, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded }) => {
80
92
  const session = getSession();
81
93
  if (!session?.tree || !session?.layout)
82
94
  return err('Not connected. Call geometra_connect first.');
83
95
  const a11y = buildA11yTree(session.tree, session.layout);
84
- const matches = findNodes(a11y, { id, role, name, text });
96
+ const filter = { id, role, name, text, value, checked, disabled, focused, selected, expanded };
97
+ if (!hasNodeFilter(filter))
98
+ return err('Provide at least one query filter (id, role, name, text, value, or state)');
99
+ const matches = findNodes(a11y, filter);
85
100
  if (matches.length === 0) {
86
- return ok(`No elements found matching ${JSON.stringify({ id, role, name, text })}`);
101
+ return ok(`No elements found matching ${JSON.stringify(filter)}`);
87
102
  }
88
103
  const result = matches.map(node => formatNode(node, a11y.bounds));
89
104
  return ok(JSON.stringify(result, null, 2));
90
105
  });
106
+ 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.
107
+
108
+ The filter matches the same fields as geometra_query. Set \`present: false\` to wait for something to disappear (for example an alert or a "Parsing…" status).`, {
109
+ id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
110
+ role: z.string().optional().describe('ARIA role to match'),
111
+ name: z.string().optional().describe('Accessible name to match (exact or substring)'),
112
+ text: z.string().optional().describe('Text content to search for (substring match)'),
113
+ value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
114
+ checked: checkedStateInput(),
115
+ disabled: z.boolean().optional().describe('Match disabled state'),
116
+ focused: z.boolean().optional().describe('Match focused state'),
117
+ selected: z.boolean().optional().describe('Match selected state'),
118
+ expanded: z.boolean().optional().describe('Match expanded state'),
119
+ present: z.boolean().optional().default(true).describe('Wait for a matching node to exist (default true) or disappear'),
120
+ timeoutMs: z
121
+ .number()
122
+ .int()
123
+ .min(50)
124
+ .max(60_000)
125
+ .optional()
126
+ .default(10_000)
127
+ .describe('Maximum time to wait before returning an error (default 10000ms)'),
128
+ }, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, present, timeoutMs }) => {
129
+ const session = getSession();
130
+ if (!session?.tree || !session?.layout)
131
+ return err('Not connected. Call geometra_connect first.');
132
+ const filter = { id, role, name, text, value, checked, disabled, focused, selected, expanded };
133
+ if (!hasNodeFilter(filter))
134
+ return err('Provide at least one wait filter (id, role, name, text, value, or state)');
135
+ const matchesCondition = () => {
136
+ if (!session.tree || !session.layout)
137
+ return false;
138
+ const a11y = buildA11yTree(session.tree, session.layout);
139
+ const matches = findNodes(a11y, filter);
140
+ return present ? matches.length > 0 : matches.length === 0;
141
+ };
142
+ const startedAt = Date.now();
143
+ const matched = await waitForUiCondition(session, matchesCondition, timeoutMs);
144
+ const elapsedMs = Date.now() - startedAt;
145
+ if (!matched) {
146
+ return err(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}.\nCurrent UI:\n${compactSessionSummary(session)}`);
147
+ }
148
+ if (!present) {
149
+ return ok(`Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`);
150
+ }
151
+ const after = sessionA11y(session);
152
+ if (!after)
153
+ return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
154
+ const matches = findNodes(after, filter);
155
+ const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
156
+ return ok(JSON.stringify(result, null, 2));
157
+ });
91
158
  // ── page model ────────────────────────────────────────────────
92
159
  server.tool('geometra_page_model', `Get a higher-level webpage summary instead of a raw node dump. Returns stable section ids, page archetypes, summary counts, top-level landmarks/forms/dialogs/lists, and a few primary actions.
93
160
 
@@ -151,12 +218,19 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
151
218
  After clicking, returns a compact semantic delta when possible (dialogs/forms/lists/nodes changed). If nothing meaningful changed, returns a short current-UI overview.`, {
152
219
  x: z.number().describe('X coordinate to click (use center of element bounds from geometra_query)'),
153
220
  y: z.number().describe('Y coordinate to click'),
154
- }, async ({ x, y }) => {
221
+ timeoutMs: z
222
+ .number()
223
+ .int()
224
+ .min(50)
225
+ .max(60_000)
226
+ .optional()
227
+ .describe('Optional action wait timeout (use a longer value for slow submits or route transitions)'),
228
+ }, async ({ x, y, timeoutMs }) => {
155
229
  const session = getSession();
156
230
  if (!session)
157
231
  return err('Not connected. Call geometra_connect first.');
158
232
  const before = sessionA11y(session);
159
- const wait = await sendClick(session, x, y);
233
+ const wait = await sendClick(session, x, y, timeoutMs);
160
234
  const summary = postActionSummary(session, before, wait);
161
235
  return ok(`Clicked at (${x}, ${y}).\n${summary}`);
162
236
  });
@@ -165,12 +239,19 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
165
239
 
166
240
  Each character is sent as a key event through the geometry protocol. Returns a compact semantic delta when possible, otherwise a short current-UI overview.`, {
167
241
  text: z.string().describe('Text to type into the focused element'),
168
- }, async ({ text }) => {
242
+ timeoutMs: z
243
+ .number()
244
+ .int()
245
+ .min(50)
246
+ .max(60_000)
247
+ .optional()
248
+ .describe('Optional action wait timeout'),
249
+ }, async ({ text, timeoutMs }) => {
169
250
  const session = getSession();
170
251
  if (!session)
171
252
  return err('Not connected. Call geometra_connect first.');
172
253
  const before = sessionA11y(session);
173
- const wait = await sendType(session, text);
254
+ const wait = await sendType(session, text, timeoutMs);
174
255
  const summary = postActionSummary(session, before, wait);
175
256
  return ok(`Typed "${text}".\n${summary}`);
176
257
  });
@@ -181,12 +262,19 @@ Each character is sent as a key event through the geometry protocol. Returns a c
181
262
  ctrl: z.boolean().optional().describe('Hold Ctrl'),
182
263
  meta: z.boolean().optional().describe('Hold Meta/Cmd'),
183
264
  alt: z.boolean().optional().describe('Hold Alt'),
184
- }, async ({ key, shift, ctrl, meta, alt }) => {
265
+ timeoutMs: z
266
+ .number()
267
+ .int()
268
+ .min(50)
269
+ .max(60_000)
270
+ .optional()
271
+ .describe('Optional action wait timeout'),
272
+ }, async ({ key, shift, ctrl, meta, alt, timeoutMs }) => {
185
273
  const session = getSession();
186
274
  if (!session)
187
275
  return err('Not connected. Call geometra_connect first.');
188
276
  const before = sessionA11y(session);
189
- const wait = await sendKey(session, key, { shift, ctrl, meta, alt });
277
+ const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
190
278
  const summary = postActionSummary(session, before, wait);
191
279
  return ok(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`);
192
280
  });
@@ -203,7 +291,14 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
203
291
  .describe('Upload strategy (default auto)'),
204
292
  dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
205
293
  dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
206
- }, async ({ paths, x, y, strategy, dropX, dropY }) => {
294
+ timeoutMs: z
295
+ .number()
296
+ .int()
297
+ .min(50)
298
+ .max(60_000)
299
+ .optional()
300
+ .describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
301
+ }, async ({ paths, x, y, strategy, dropX, dropY, timeoutMs }) => {
207
302
  const session = getSession();
208
303
  if (!session)
209
304
  return err('Not connected. Call geometra_connect first.');
@@ -213,7 +308,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
213
308
  click: x !== undefined && y !== undefined ? { x, y } : undefined,
214
309
  strategy,
215
310
  drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
216
- });
311
+ }, timeoutMs ?? 8_000);
217
312
  const summary = postActionSummary(session, before, wait);
218
313
  return ok(`Uploaded ${paths.length} file(s).\n${summary}`);
219
314
  }
@@ -223,14 +318,21 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
223
318
  });
224
319
  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
320
 
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.`, {
321
+ 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, prefers the popup nearest the opened field, and handles a few short affirmative/negative aliases such as \`Yes\` /\`No\` for consent-style copy.`, {
227
322
  label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
228
323
  exact: z.boolean().optional().describe('Exact name match'),
229
324
  openX: z.number().optional().describe('Click to open dropdown'),
230
325
  openY: z.number().optional().describe('Click to open dropdown'),
231
326
  fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
232
327
  query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
233
- }, async ({ label, exact, openX, openY, fieldLabel, query }) => {
328
+ timeoutMs: z
329
+ .number()
330
+ .int()
331
+ .min(50)
332
+ .max(60_000)
333
+ .optional()
334
+ .describe('Optional action wait timeout for slow dropdowns / remote search results'),
335
+ }, async ({ label, exact, openX, openY, fieldLabel, query, timeoutMs }) => {
234
336
  const session = getSession();
235
337
  if (!session)
236
338
  return err('Not connected. Call geometra_connect first.');
@@ -241,9 +343,14 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
241
343
  open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
242
344
  fieldLabel,
243
345
  query,
244
- });
346
+ }, timeoutMs);
245
347
  const summary = postActionSummary(session, before, wait);
246
- return ok(`Picked listbox option "${label}".\n${summary}`);
348
+ const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
349
+ return ok([
350
+ `Picked listbox option "${label}".`,
351
+ fieldSummary,
352
+ summary,
353
+ ].filter(Boolean).join('\n'));
247
354
  }
248
355
  catch (e) {
249
356
  return err(e.message);
@@ -258,7 +365,14 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
258
365
  value: z.string().optional().describe('Option value= attribute'),
259
366
  label: z.string().optional().describe('Visible option label (substring match)'),
260
367
  index: z.number().int().min(0).optional().describe('Zero-based option index'),
261
- }, async ({ x, y, value, label, index }) => {
368
+ timeoutMs: z
369
+ .number()
370
+ .int()
371
+ .min(50)
372
+ .max(60_000)
373
+ .optional()
374
+ .describe('Optional action wait timeout'),
375
+ }, async ({ x, y, value, label, index, timeoutMs }) => {
262
376
  const session = getSession();
263
377
  if (!session)
264
378
  return err('Not connected. Call geometra_connect first.');
@@ -267,7 +381,7 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
267
381
  }
268
382
  const before = sessionA11y(session);
269
383
  try {
270
- const wait = await sendSelectOption(session, x, y, { value, label, index });
384
+ const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
271
385
  const summary = postActionSummary(session, before, wait);
272
386
  return ok(`Selected option.\n${summary}`);
273
387
  }
@@ -282,13 +396,20 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
282
396
  checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
283
397
  exact: z.boolean().optional().describe('Exact label match'),
284
398
  controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
285
- }, async ({ label, checked, exact, controlType }) => {
399
+ timeoutMs: z
400
+ .number()
401
+ .int()
402
+ .min(50)
403
+ .max(60_000)
404
+ .optional()
405
+ .describe('Optional action wait timeout'),
406
+ }, async ({ label, checked, exact, controlType, timeoutMs }) => {
286
407
  const session = getSession();
287
408
  if (!session)
288
409
  return err('Not connected. Call geometra_connect first.');
289
410
  const before = sessionA11y(session);
290
411
  try {
291
- const wait = await sendSetChecked(session, label, { checked, exact, controlType });
412
+ const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
292
413
  const summary = postActionSummary(session, before, wait);
293
414
  return ok(`Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`);
294
415
  }
@@ -302,13 +423,20 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
302
423
  deltaX: z.number().optional().describe('Horizontal scroll delta'),
303
424
  x: z.number().optional().describe('Move pointer to X before scrolling'),
304
425
  y: z.number().optional().describe('Move pointer to Y before scrolling'),
305
- }, async ({ deltaY, deltaX, x, y }) => {
426
+ timeoutMs: z
427
+ .number()
428
+ .int()
429
+ .min(50)
430
+ .max(60_000)
431
+ .optional()
432
+ .describe('Optional action wait timeout'),
433
+ }, async ({ deltaY, deltaX, x, y, timeoutMs }) => {
306
434
  const session = getSession();
307
435
  if (!session)
308
436
  return err('Not connected. Call geometra_connect first.');
309
437
  const before = sessionA11y(session);
310
438
  try {
311
- const wait = await sendWheel(session, deltaY, { deltaX, x, y });
439
+ const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
312
440
  const summary = postActionSummary(session, before, wait);
313
441
  return ok(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`);
314
442
  }
@@ -390,7 +518,7 @@ function postActionSummary(session, before, wait) {
390
518
  const after = sessionA11y(session);
391
519
  const notes = [];
392
520
  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.');
521
+ notes.push('The peer acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.');
394
522
  }
395
523
  if (wait?.status === 'timed_out') {
396
524
  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.`);
@@ -424,19 +552,43 @@ function ok(text) {
424
552
  function err(text) {
425
553
  return { content: [{ type: 'text', text }], isError: true };
426
554
  }
427
- function findNodes(node, filter) {
555
+ function hasNodeFilter(filter) {
556
+ return Object.values(filter).some(value => value !== undefined);
557
+ }
558
+ function textMatches(haystack, needle) {
559
+ if (!needle)
560
+ return true;
561
+ if (!haystack)
562
+ return false;
563
+ return haystack.toLowerCase().includes(needle.toLowerCase());
564
+ }
565
+ function nodeMatchesFilter(node, filter) {
566
+ if (filter.id && nodeIdForPath(node.path) !== filter.id)
567
+ return false;
568
+ if (filter.role && node.role !== filter.role)
569
+ return false;
570
+ if (!textMatches(node.name, filter.name))
571
+ return false;
572
+ if (!textMatches(node.value, filter.value))
573
+ return false;
574
+ if (filter.text && !textMatches(`${node.name ?? ''} ${node.value ?? ''}`.trim(), filter.text))
575
+ return false;
576
+ if (filter.checked !== undefined && node.state?.checked !== filter.checked)
577
+ return false;
578
+ if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
579
+ return false;
580
+ if (filter.focused !== undefined && (node.state?.focused ?? false) !== filter.focused)
581
+ return false;
582
+ if (filter.selected !== undefined && (node.state?.selected ?? false) !== filter.selected)
583
+ return false;
584
+ if (filter.expanded !== undefined && (node.state?.expanded ?? false) !== filter.expanded)
585
+ return false;
586
+ return true;
587
+ }
588
+ export function findNodes(node, filter) {
428
589
  const matches = [];
429
590
  function walk(n) {
430
- let match = true;
431
- if (filter.id && nodeIdForPath(n.path) !== filter.id)
432
- match = false;
433
- if (filter.role && n.role !== filter.role)
434
- match = false;
435
- if (filter.name && (!n.name || !n.name.includes(filter.name)))
436
- match = false;
437
- if (filter.text && (!n.name || !n.name.includes(filter.text)))
438
- match = false;
439
- if (match && (filter.id || filter.role || filter.name || filter.text))
591
+ if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
440
592
  matches.push(n);
441
593
  for (const child of n.children)
442
594
  walk(child);
@@ -444,22 +596,53 @@ function findNodes(node, filter) {
444
596
  walk(node);
445
597
  return matches;
446
598
  }
599
+ function summarizeFieldLabelState(session, fieldLabel) {
600
+ const a11y = sessionA11y(session);
601
+ if (!a11y)
602
+ return undefined;
603
+ const matches = findNodes(a11y, {
604
+ name: fieldLabel,
605
+ role: 'combobox',
606
+ });
607
+ if (matches.length === 0) {
608
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
609
+ }
610
+ if (matches.length === 0) {
611
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
612
+ }
613
+ const match = matches[0];
614
+ if (!match)
615
+ return undefined;
616
+ const parts = [`Field "${fieldLabel}"`];
617
+ if (match.value)
618
+ parts.push(`value=${JSON.stringify(match.value)}`);
619
+ if (match.state && Object.keys(match.state).length > 0)
620
+ parts.push(`state=${JSON.stringify(match.state)}`);
621
+ return parts.join(' ');
622
+ }
447
623
  function formatNode(node, viewport) {
448
624
  const visibleLeft = Math.max(0, node.bounds.x);
449
625
  const visibleTop = Math.max(0, node.bounds.y);
450
626
  const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
451
627
  const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
452
628
  const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
629
+ const fullyVisible = node.bounds.x >= 0 &&
630
+ node.bounds.y >= 0 &&
631
+ node.bounds.x + node.bounds.width <= viewport.width &&
632
+ node.bounds.y + node.bounds.height <= viewport.height;
453
633
  const centerX = hasVisibleIntersection
454
634
  ? Math.round((visibleLeft + visibleRight) / 2)
455
635
  : Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
456
636
  const centerY = hasVisibleIntersection
457
637
  ? Math.round((visibleTop + visibleBottom) / 2)
458
638
  : Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
639
+ const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
640
+ const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
459
641
  return {
460
642
  id: nodeIdForPath(node.path),
461
643
  role: node.role,
462
644
  name: node.name,
645
+ ...(node.value ? { value: node.value } : {}),
463
646
  bounds: node.bounds,
464
647
  visibleBounds: {
465
648
  x: visibleLeft,
@@ -471,6 +654,19 @@ function formatNode(node, viewport) {
471
654
  x: centerX,
472
655
  y: centerY,
473
656
  },
657
+ visibility: {
658
+ intersectsViewport: hasVisibleIntersection,
659
+ fullyVisible,
660
+ offscreenAbove: node.bounds.y + node.bounds.height <= 0,
661
+ offscreenBelow: node.bounds.y >= viewport.height,
662
+ offscreenLeft: node.bounds.x + node.bounds.width <= 0,
663
+ offscreenRight: node.bounds.x >= viewport.width,
664
+ },
665
+ scrollHint: {
666
+ status: fullyVisible ? 'visible' : hasVisibleIntersection ? 'partial' : 'offscreen',
667
+ revealDeltaX,
668
+ revealDeltaY,
669
+ },
474
670
  focusable: node.focusable,
475
671
  ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
476
672
  path: node.path,
package/dist/session.d.ts CHANGED
@@ -8,6 +8,7 @@ import WebSocket from 'ws';
8
8
  export interface A11yNode {
9
9
  role: string;
10
10
  name?: string;
11
+ value?: string;
11
12
  state?: {
12
13
  disabled?: boolean;
13
14
  expanded?: boolean;
@@ -35,6 +36,7 @@ export interface CompactUiNode {
35
36
  id: string;
36
37
  role: string;
37
38
  name?: string;
39
+ value?: string;
38
40
  state?: A11yNode['state'];
39
41
  pinned?: boolean;
40
42
  bounds: {
@@ -124,6 +126,7 @@ export interface PageFieldModel {
124
126
  id: string;
125
127
  role: string;
126
128
  name?: string;
129
+ value?: string;
127
130
  state?: A11yNode['state'];
128
131
  bounds?: {
129
132
  x: number;
@@ -223,6 +226,7 @@ export interface Session {
223
226
  layout: Record<string, unknown> | null;
224
227
  tree: Record<string, unknown> | null;
225
228
  url: string;
229
+ updateRevision: number;
226
230
  /** Present when this session owns a child geometra-proxy process (pageUrl connect). */
227
231
  proxyChild?: ChildProcess;
228
232
  }
@@ -249,14 +253,15 @@ export declare function connectThroughProxy(options: {
249
253
  }): Promise<Session>;
250
254
  export declare function getSession(): Session | null;
251
255
  export declare function disconnect(): void;
256
+ export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
252
257
  /**
253
258
  * Send a click event at (x, y) and wait for the next frame/patch response.
254
259
  */
255
- export declare function sendClick(session: Session, x: number, y: number): Promise<UpdateWaitResult>;
260
+ export declare function sendClick(session: Session, x: number, y: number, timeoutMs?: number): Promise<UpdateWaitResult>;
256
261
  /**
257
262
  * Send a sequence of key events to type text into the focused element.
258
263
  */
259
- export declare function sendType(session: Session, text: string): Promise<UpdateWaitResult>;
264
+ export declare function sendType(session: Session, text: string, timeoutMs?: number): Promise<UpdateWaitResult>;
260
265
  /**
261
266
  * Send a special key (Enter, Tab, Escape, etc.)
262
267
  */
@@ -265,7 +270,7 @@ export declare function sendKey(session: Session, key: string, modifiers?: {
265
270
  ctrl?: boolean;
266
271
  meta?: boolean;
267
272
  alt?: boolean;
268
- }): Promise<UpdateWaitResult>;
273
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
269
274
  /**
270
275
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
271
276
  * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
@@ -280,7 +285,7 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
280
285
  x: number;
281
286
  y: number;
282
287
  };
283
- }): Promise<UpdateWaitResult>;
288
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
284
289
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
285
290
  export declare function sendListboxPick(session: Session, label: string, opts?: {
286
291
  exact?: boolean;
@@ -290,25 +295,25 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
290
295
  };
291
296
  fieldLabel?: string;
292
297
  query?: string;
293
- }): Promise<UpdateWaitResult>;
298
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
294
299
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
295
300
  export declare function sendSelectOption(session: Session, x: number, y: number, option: {
296
301
  value?: string;
297
302
  label?: string;
298
303
  index?: number;
299
- }): Promise<UpdateWaitResult>;
304
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
300
305
  /** Set a checkbox/radio by label instead of relying on coordinate clicks. */
301
306
  export declare function sendSetChecked(session: Session, label: string, opts?: {
302
307
  checked?: boolean;
303
308
  exact?: boolean;
304
309
  controlType?: 'checkbox' | 'radio';
305
- }): Promise<UpdateWaitResult>;
310
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
306
311
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
307
312
  export declare function sendWheel(session: Session, deltaY: number, opts?: {
308
313
  deltaX?: number;
309
314
  x?: number;
310
315
  y?: number;
311
- }): Promise<UpdateWaitResult>;
316
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
312
317
  /**
313
318
  * Build a flat accessibility tree from the raw UI tree + layout.
314
319
  * This is a standalone reimplementation that works with raw JSON —
package/dist/session.js CHANGED
@@ -2,6 +2,8 @@ import WebSocket from 'ws';
2
2
  import { spawnGeometraProxy } from './proxy-spawn.js';
3
3
  let activeSession = null;
4
4
  const ACTION_UPDATE_TIMEOUT_MS = 2000;
5
+ const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
6
+ let nextRequestSequence = 0;
5
7
  function shutdownPreviousSession() {
6
8
  const prev = activeSession;
7
9
  if (!prev)
@@ -30,7 +32,7 @@ export function connect(url) {
30
32
  return new Promise((resolve, reject) => {
31
33
  shutdownPreviousSession();
32
34
  const ws = new WebSocket(url);
33
- const session = { ws, layout: null, tree: null, url };
35
+ const session = { ws, layout: null, tree: null, url, updateRevision: 0 };
34
36
  let resolved = false;
35
37
  const timeout = setTimeout(() => {
36
38
  if (!resolved) {
@@ -49,6 +51,7 @@ export function connect(url) {
49
51
  if (msg.type === 'frame') {
50
52
  session.layout = msg.layout;
51
53
  session.tree = msg.tree;
54
+ session.updateRevision++;
52
55
  if (!resolved) {
53
56
  resolved = true;
54
57
  clearTimeout(timeout);
@@ -58,6 +61,7 @@ export function connect(url) {
58
61
  }
59
62
  else if (msg.type === 'patch' && session.layout) {
60
63
  applyPatches(session.layout, msg.patches);
64
+ session.updateRevision++;
61
65
  }
62
66
  }
63
67
  catch { /* ignore malformed messages */ }
@@ -123,21 +127,57 @@ export function getSession() {
123
127
  export function disconnect() {
124
128
  shutdownPreviousSession();
125
129
  }
130
+ export function waitForUiCondition(session, predicate, timeoutMs) {
131
+ return new Promise((resolve) => {
132
+ const check = () => {
133
+ let matched = false;
134
+ try {
135
+ matched = predicate();
136
+ }
137
+ catch {
138
+ matched = false;
139
+ }
140
+ if (matched) {
141
+ cleanup();
142
+ resolve(true);
143
+ }
144
+ };
145
+ const timeout = setTimeout(() => {
146
+ cleanup();
147
+ resolve(false);
148
+ }, timeoutMs);
149
+ const onMessage = () => {
150
+ check();
151
+ };
152
+ const onClose = () => {
153
+ cleanup();
154
+ resolve(false);
155
+ };
156
+ function cleanup() {
157
+ clearTimeout(timeout);
158
+ session.ws.off('message', onMessage);
159
+ session.ws.off('close', onClose);
160
+ }
161
+ session.ws.on('message', onMessage);
162
+ session.ws.on('close', onClose);
163
+ check();
164
+ });
165
+ }
126
166
  /**
127
167
  * Send a click event at (x, y) and wait for the next frame/patch response.
128
168
  */
129
- export function sendClick(session, x, y) {
169
+ export function sendClick(session, x, y, timeoutMs) {
130
170
  return sendAndWaitForUpdate(session, {
131
171
  type: 'event',
132
172
  eventType: 'onClick',
133
173
  x,
134
174
  y,
135
- });
175
+ }, timeoutMs);
136
176
  }
137
177
  /**
138
178
  * Send a sequence of key events to type text into the focused element.
139
179
  */
140
- export function sendType(session, text) {
180
+ export function sendType(session, text, timeoutMs) {
141
181
  return new Promise((resolve, reject) => {
142
182
  if (session.ws.readyState !== WebSocket.OPEN) {
143
183
  reject(new Error('Not connected'));
@@ -159,13 +199,13 @@ export function sendType(session, text) {
159
199
  session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
160
200
  }
161
201
  // Wait briefly for server to process and send update
162
- waitForNextUpdate(session).then(resolve).catch(reject);
202
+ waitForNextUpdate(session, timeoutMs).then(resolve).catch(reject);
163
203
  });
164
204
  }
165
205
  /**
166
206
  * Send a special key (Enter, Tab, Escape, etc.)
167
207
  */
168
- export function sendKey(session, key, modifiers) {
208
+ export function sendKey(session, key, modifiers, timeoutMs) {
169
209
  return sendAndWaitForUpdate(session, {
170
210
  type: 'key',
171
211
  eventType: 'onKeyDown',
@@ -175,13 +215,13 @@ export function sendKey(session, key, modifiers) {
175
215
  ctrlKey: modifiers?.ctrl ?? false,
176
216
  metaKey: modifiers?.meta ?? false,
177
217
  altKey: modifiers?.alt ?? false,
178
- });
218
+ }, timeoutMs);
179
219
  }
180
220
  /**
181
221
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
182
222
  * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
183
223
  */
184
- export function sendFileUpload(session, paths, opts) {
224
+ export function sendFileUpload(session, paths, opts, timeoutMs) {
185
225
  const payload = { type: 'file', paths };
186
226
  if (opts?.click) {
187
227
  payload.x = opts.click.x;
@@ -193,10 +233,10 @@ export function sendFileUpload(session, paths, opts) {
193
233
  payload.dropX = opts.drop.x;
194
234
  payload.dropY = opts.drop.y;
195
235
  }
196
- return sendAndWaitForUpdate(session, payload);
236
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
197
237
  }
198
238
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
199
- export function sendListboxPick(session, label, opts) {
239
+ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
200
240
  const payload = { type: 'listboxPick', label };
201
241
  if (opts?.exact !== undefined)
202
242
  payload.exact = opts.exact;
@@ -208,19 +248,19 @@ export function sendListboxPick(session, label, opts) {
208
248
  payload.fieldLabel = opts.fieldLabel;
209
249
  if (opts?.query)
210
250
  payload.query = opts.query;
211
- return sendAndWaitForUpdate(session, payload);
251
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
212
252
  }
213
253
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
214
- export function sendSelectOption(session, x, y, option) {
254
+ export function sendSelectOption(session, x, y, option, timeoutMs) {
215
255
  return sendAndWaitForUpdate(session, {
216
256
  type: 'selectOption',
217
257
  x,
218
258
  y,
219
259
  ...option,
220
- });
260
+ }, timeoutMs);
221
261
  }
222
262
  /** Set a checkbox/radio by label instead of relying on coordinate clicks. */
223
- export function sendSetChecked(session, label, opts) {
263
+ export function sendSetChecked(session, label, opts, timeoutMs) {
224
264
  const payload = { type: 'setChecked', label };
225
265
  if (opts?.checked !== undefined)
226
266
  payload.checked = opts.checked;
@@ -228,17 +268,17 @@ export function sendSetChecked(session, label, opts) {
228
268
  payload.exact = opts.exact;
229
269
  if (opts?.controlType)
230
270
  payload.controlType = opts.controlType;
231
- return sendAndWaitForUpdate(session, payload);
271
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
232
272
  }
233
273
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
234
- export function sendWheel(session, deltaY, opts) {
274
+ export function sendWheel(session, deltaY, opts, timeoutMs) {
235
275
  return sendAndWaitForUpdate(session, {
236
276
  type: 'wheel',
237
277
  deltaY,
238
278
  deltaX: opts?.deltaX ?? 0,
239
279
  ...(opts?.x !== undefined ? { x: opts.x } : {}),
240
280
  ...(opts?.y !== undefined ? { y: opts.y } : {}),
241
- });
281
+ }, timeoutMs);
242
282
  }
243
283
  /**
244
284
  * Build a flat accessibility tree from the raw UI tree + layout.
@@ -411,10 +451,12 @@ function intersectsViewportWithMargin(b, vw, vh, marginY) {
411
451
  }
412
452
  function compactNodeFromA11y(node, pinned = false) {
413
453
  const name = sanitizeInlineName(node.name, 240);
454
+ const value = sanitizeInlineName(node.value, 180);
414
455
  return {
415
456
  id: nodeIdForPath(node.path),
416
457
  role: node.role,
417
458
  ...(name ? { name } : {}),
459
+ ...(value ? { value } : {}),
418
460
  ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
419
461
  ...(pinned ? { pinned: true } : {}),
420
462
  bounds: { ...node.bounds },
@@ -517,11 +559,12 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
517
559
  const slice = nodes.slice(0, maxLines);
518
560
  for (const n of slice) {
519
561
  const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
562
+ const val = n.value ? ` value=${JSON.stringify(truncateUiText(n.value, 40))}` : '';
520
563
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
521
564
  const foc = n.focusable ? ' *' : '';
522
565
  const pin = n.pinned ? ' [pinned]' : '';
523
566
  const b = n.bounds;
524
- lines.push(`${n.id} ${n.role}${nm}${pin} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
567
+ lines.push(`${n.id} ${n.role}${nm}${pin}${val} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
525
568
  }
526
569
  if (nodes.length > maxLines) {
527
570
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -657,10 +700,12 @@ function primaryAction(node) {
657
700
  };
658
701
  }
659
702
  function toFieldModel(node, includeBounds = true) {
703
+ const value = sanitizeInlineName(node.value, 120);
660
704
  return {
661
705
  id: nodeIdForPath(node.path),
662
706
  role: node.role,
663
707
  ...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
708
+ ...(value ? { value } : {}),
664
709
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
665
710
  ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
666
711
  };
@@ -914,8 +959,12 @@ function pathKey(path) {
914
959
  return path.join('.');
915
960
  }
916
961
  function compactNodeLabel(node) {
917
- if (node.name)
918
- return `${node.id} ${node.role} "${truncateUiText(node.name, 40)}"`;
962
+ if (node.name) {
963
+ const value = node.value ? ` value=${JSON.stringify(truncateUiText(node.value, 28))}` : '';
964
+ return `${node.id} ${node.role} "${truncateUiText(node.name, 40)}"${value}`;
965
+ }
966
+ if (node.value)
967
+ return `${node.id} ${node.role} value=${JSON.stringify(truncateUiText(node.value, 28))}`;
919
968
  return `${node.id} ${node.role}`;
920
969
  }
921
970
  function formatStateValue(value) {
@@ -928,6 +977,9 @@ function diffCompactNodes(before, after) {
928
977
  if ((before.name ?? '') !== (after.name ?? '')) {
929
978
  changes.push(`name ${JSON.stringify(truncateUiText(before.name ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.name ?? 'unset', 32))}`);
930
979
  }
980
+ if ((before.value ?? '') !== (after.value ?? '')) {
981
+ changes.push(`value ${JSON.stringify(truncateUiText(before.value ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.value ?? 'unset', 32))}`);
982
+ }
931
983
  const beforeState = before.state ?? {};
932
984
  const afterState = after.state ?? {};
933
985
  for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
@@ -1143,6 +1195,7 @@ function walkNode(element, layout, path) {
1143
1195
  const handlers = element.handlers;
1144
1196
  const role = inferRole(kind, semantic, handlers);
1145
1197
  const name = inferName(kind, semantic, props);
1198
+ const value = inferValue(semantic, props);
1146
1199
  const focusable = !!(handlers?.onClick || handlers?.onKeyDown || handlers?.onKeyUp ||
1147
1200
  handlers?.onCompositionStart || handlers?.onCompositionUpdate || handlers?.onCompositionEnd);
1148
1201
  const bounds = {
@@ -1183,6 +1236,7 @@ function walkNode(element, layout, path) {
1183
1236
  return {
1184
1237
  role,
1185
1238
  ...(name ? { name } : {}),
1239
+ ...(value ? { value } : {}),
1186
1240
  ...(Object.keys(state).length > 0 ? { state } : {}),
1187
1241
  ...(Object.keys(meta).length > 0 ? { meta } : {}),
1188
1242
  bounds,
@@ -1239,15 +1293,24 @@ function inferName(kind, semantic, props) {
1239
1293
  return (semantic?.alt ?? props?.alt);
1240
1294
  return semantic?.alt;
1241
1295
  }
1296
+ function inferValue(semantic, props) {
1297
+ const direct = semantic?.valueText ?? props?.value;
1298
+ return typeof direct === 'string' && direct.trim().length > 0 ? direct : undefined;
1299
+ }
1242
1300
  function applyPatches(layout, patches) {
1243
1301
  for (const patch of patches) {
1244
1302
  let node = layout;
1303
+ let validPath = true;
1245
1304
  for (const idx of patch.path) {
1246
1305
  const children = node.children;
1247
- if (!children?.[idx])
1306
+ if (!children?.[idx]) {
1307
+ validPath = false;
1248
1308
  break;
1309
+ }
1249
1310
  node = children[idx];
1250
1311
  }
1312
+ if (!validPath)
1313
+ continue;
1251
1314
  if (patch.x !== undefined)
1252
1315
  node.x = patch.x;
1253
1316
  if (patch.y !== undefined)
@@ -1258,40 +1321,55 @@ function applyPatches(layout, patches) {
1258
1321
  node.height = patch.height;
1259
1322
  }
1260
1323
  }
1261
- function sendAndWaitForUpdate(session, message) {
1324
+ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
1262
1325
  return new Promise((resolve, reject) => {
1263
1326
  if (session.ws.readyState !== WebSocket.OPEN) {
1264
1327
  reject(new Error('Not connected'));
1265
1328
  return;
1266
1329
  }
1267
- session.ws.send(JSON.stringify(message));
1268
- waitForNextUpdate(session).then(resolve).catch(reject);
1330
+ const requestId = `req-${++nextRequestSequence}`;
1331
+ const startRevision = session.updateRevision;
1332
+ session.ws.send(JSON.stringify({ ...message, requestId }));
1333
+ waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
1269
1334
  });
1270
1335
  }
1271
- function waitForNextUpdate(session) {
1336
+ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
1272
1337
  return new Promise((resolve, reject) => {
1273
1338
  const onMessage = (data) => {
1274
1339
  try {
1275
1340
  const msg = JSON.parse(String(data));
1341
+ const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1342
+ if (requestId) {
1343
+ if (msg.type === 'error' && (messageRequestId === requestId || messageRequestId === undefined)) {
1344
+ cleanup();
1345
+ reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1346
+ return;
1347
+ }
1348
+ if (msg.type === 'ack' && messageRequestId === requestId) {
1349
+ cleanup();
1350
+ resolve({
1351
+ status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
1352
+ timeoutMs,
1353
+ });
1354
+ }
1355
+ return;
1356
+ }
1276
1357
  if (msg.type === 'error') {
1277
1358
  cleanup();
1278
1359
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1279
1360
  return;
1280
1361
  }
1281
1362
  if (msg.type === 'frame') {
1282
- session.layout = msg.layout;
1283
- session.tree = msg.tree;
1284
1363
  cleanup();
1285
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1364
+ resolve({ status: 'updated', timeoutMs });
1286
1365
  }
1287
1366
  else if (msg.type === 'patch' && session.layout) {
1288
- applyPatches(session.layout, msg.patches);
1289
1367
  cleanup();
1290
- resolve({ status: 'updated', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1368
+ resolve({ status: 'updated', timeoutMs });
1291
1369
  }
1292
1370
  else if (msg.type === 'ack') {
1293
1371
  cleanup();
1294
- resolve({ status: 'acknowledged', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1372
+ resolve({ status: 'acknowledged', timeoutMs });
1295
1373
  }
1296
1374
  }
1297
1375
  catch { /* ignore */ }
@@ -1299,8 +1377,12 @@ function waitForNextUpdate(session) {
1299
1377
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1300
1378
  const timeout = setTimeout(() => {
1301
1379
  cleanup();
1302
- resolve({ status: 'timed_out', timeoutMs: ACTION_UPDATE_TIMEOUT_MS });
1303
- }, ACTION_UPDATE_TIMEOUT_MS);
1380
+ if (requestId && session.updateRevision > startRevision) {
1381
+ resolve({ status: 'updated', timeoutMs });
1382
+ return;
1383
+ }
1384
+ resolve({ status: 'timed_out', timeoutMs });
1385
+ }, timeoutMs);
1304
1386
  function cleanup() {
1305
1387
  clearTimeout(timeout);
1306
1388
  session.ws.off('message', onMessage);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.7",
3
+ "version": "1.19.9",
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.7",
33
+ "@geometra/proxy": "^1.19.9",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"