@geometra/mcp 1.19.8 → 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.
@@ -1,6 +1,6 @@
1
1
  import { afterAll, describe, expect, it } from 'vitest';
2
2
  import { WebSocketServer } from 'ws';
3
- import { connect, disconnect, sendListboxPick } from '../session.js';
3
+ import { connect, disconnect, sendClick, sendListboxPick } from '../session.js';
4
4
  describe('proxy-backed MCP actions', () => {
5
5
  afterAll(() => {
6
6
  disconnect();
@@ -56,4 +56,100 @@ describe('proxy-backed MCP actions', () => {
56
56
  await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
57
57
  }
58
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
+ });
59
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.8' }, { 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, visibility / scroll-reveal hints, 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
  }
@@ -230,7 +325,14 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
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,6 +596,30 @@ 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);
@@ -466,6 +642,7 @@ function formatNode(node, viewport) {
466
642
  id: nodeIdForPath(node.path),
467
643
  role: node.role,
468
644
  name: node.name,
645
+ ...(node.value ? { value: node.value } : {}),
469
646
  bounds: node.bounds,
470
647
  visibleBounds: {
471
648
  x: visibleLeft,
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;
@@ -250,14 +253,15 @@ export declare function connectThroughProxy(options: {
250
253
  }): Promise<Session>;
251
254
  export declare function getSession(): Session | null;
252
255
  export declare function disconnect(): void;
256
+ export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
253
257
  /**
254
258
  * Send a click event at (x, y) and wait for the next frame/patch response.
255
259
  */
256
- 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>;
257
261
  /**
258
262
  * Send a sequence of key events to type text into the focused element.
259
263
  */
260
- export declare function sendType(session: Session, text: string): Promise<UpdateWaitResult>;
264
+ export declare function sendType(session: Session, text: string, timeoutMs?: number): Promise<UpdateWaitResult>;
261
265
  /**
262
266
  * Send a special key (Enter, Tab, Escape, etc.)
263
267
  */
@@ -266,7 +270,7 @@ export declare function sendKey(session: Session, key: string, modifiers?: {
266
270
  ctrl?: boolean;
267
271
  meta?: boolean;
268
272
  alt?: boolean;
269
- }): Promise<UpdateWaitResult>;
273
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
270
274
  /**
271
275
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
272
276
  * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
@@ -281,7 +285,7 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
281
285
  x: number;
282
286
  y: number;
283
287
  };
284
- }): Promise<UpdateWaitResult>;
288
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
285
289
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
286
290
  export declare function sendListboxPick(session: Session, label: string, opts?: {
287
291
  exact?: boolean;
@@ -291,25 +295,25 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
291
295
  };
292
296
  fieldLabel?: string;
293
297
  query?: string;
294
- }): Promise<UpdateWaitResult>;
298
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
295
299
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
296
300
  export declare function sendSelectOption(session: Session, x: number, y: number, option: {
297
301
  value?: string;
298
302
  label?: string;
299
303
  index?: number;
300
- }): Promise<UpdateWaitResult>;
304
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
301
305
  /** Set a checkbox/radio by label instead of relying on coordinate clicks. */
302
306
  export declare function sendSetChecked(session: Session, label: string, opts?: {
303
307
  checked?: boolean;
304
308
  exact?: boolean;
305
309
  controlType?: 'checkbox' | 'radio';
306
- }): Promise<UpdateWaitResult>;
310
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
307
311
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
308
312
  export declare function sendWheel(session: Session, deltaY: number, opts?: {
309
313
  deltaX?: number;
310
314
  x?: number;
311
315
  y?: number;
312
- }): Promise<UpdateWaitResult>;
316
+ }, timeoutMs?: number): Promise<UpdateWaitResult>;
313
317
  /**
314
318
  * Build a flat accessibility tree from the raw UI tree + layout.
315
319
  * This is a standalone reimplementation that works with raw JSON —
package/dist/session.js CHANGED
@@ -127,21 +127,57 @@ export function getSession() {
127
127
  export function disconnect() {
128
128
  shutdownPreviousSession();
129
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
+ }
130
166
  /**
131
167
  * Send a click event at (x, y) and wait for the next frame/patch response.
132
168
  */
133
- export function sendClick(session, x, y) {
169
+ export function sendClick(session, x, y, timeoutMs) {
134
170
  return sendAndWaitForUpdate(session, {
135
171
  type: 'event',
136
172
  eventType: 'onClick',
137
173
  x,
138
174
  y,
139
- });
175
+ }, timeoutMs);
140
176
  }
141
177
  /**
142
178
  * Send a sequence of key events to type text into the focused element.
143
179
  */
144
- export function sendType(session, text) {
180
+ export function sendType(session, text, timeoutMs) {
145
181
  return new Promise((resolve, reject) => {
146
182
  if (session.ws.readyState !== WebSocket.OPEN) {
147
183
  reject(new Error('Not connected'));
@@ -163,13 +199,13 @@ export function sendType(session, text) {
163
199
  session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
164
200
  }
165
201
  // Wait briefly for server to process and send update
166
- waitForNextUpdate(session).then(resolve).catch(reject);
202
+ waitForNextUpdate(session, timeoutMs).then(resolve).catch(reject);
167
203
  });
168
204
  }
169
205
  /**
170
206
  * Send a special key (Enter, Tab, Escape, etc.)
171
207
  */
172
- export function sendKey(session, key, modifiers) {
208
+ export function sendKey(session, key, modifiers, timeoutMs) {
173
209
  return sendAndWaitForUpdate(session, {
174
210
  type: 'key',
175
211
  eventType: 'onKeyDown',
@@ -179,13 +215,13 @@ export function sendKey(session, key, modifiers) {
179
215
  ctrlKey: modifiers?.ctrl ?? false,
180
216
  metaKey: modifiers?.meta ?? false,
181
217
  altKey: modifiers?.alt ?? false,
182
- });
218
+ }, timeoutMs);
183
219
  }
184
220
  /**
185
221
  * Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
186
222
  * Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
187
223
  */
188
- export function sendFileUpload(session, paths, opts) {
224
+ export function sendFileUpload(session, paths, opts, timeoutMs) {
189
225
  const payload = { type: 'file', paths };
190
226
  if (opts?.click) {
191
227
  payload.x = opts.click.x;
@@ -197,10 +233,10 @@ export function sendFileUpload(session, paths, opts) {
197
233
  payload.dropX = opts.drop.x;
198
234
  payload.dropY = opts.drop.y;
199
235
  }
200
- return sendAndWaitForUpdate(session, payload);
236
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
201
237
  }
202
238
  /** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
203
- export function sendListboxPick(session, label, opts) {
239
+ export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
204
240
  const payload = { type: 'listboxPick', label };
205
241
  if (opts?.exact !== undefined)
206
242
  payload.exact = opts.exact;
@@ -212,19 +248,19 @@ export function sendListboxPick(session, label, opts) {
212
248
  payload.fieldLabel = opts.fieldLabel;
213
249
  if (opts?.query)
214
250
  payload.query = opts.query;
215
- return sendAndWaitForUpdate(session, payload, LISTBOX_UPDATE_TIMEOUT_MS);
251
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
216
252
  }
217
253
  /** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
218
- export function sendSelectOption(session, x, y, option) {
254
+ export function sendSelectOption(session, x, y, option, timeoutMs) {
219
255
  return sendAndWaitForUpdate(session, {
220
256
  type: 'selectOption',
221
257
  x,
222
258
  y,
223
259
  ...option,
224
- });
260
+ }, timeoutMs);
225
261
  }
226
262
  /** Set a checkbox/radio by label instead of relying on coordinate clicks. */
227
- export function sendSetChecked(session, label, opts) {
263
+ export function sendSetChecked(session, label, opts, timeoutMs) {
228
264
  const payload = { type: 'setChecked', label };
229
265
  if (opts?.checked !== undefined)
230
266
  payload.checked = opts.checked;
@@ -232,17 +268,17 @@ export function sendSetChecked(session, label, opts) {
232
268
  payload.exact = opts.exact;
233
269
  if (opts?.controlType)
234
270
  payload.controlType = opts.controlType;
235
- return sendAndWaitForUpdate(session, payload);
271
+ return sendAndWaitForUpdate(session, payload, timeoutMs);
236
272
  }
237
273
  /** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
238
- export function sendWheel(session, deltaY, opts) {
274
+ export function sendWheel(session, deltaY, opts, timeoutMs) {
239
275
  return sendAndWaitForUpdate(session, {
240
276
  type: 'wheel',
241
277
  deltaY,
242
278
  deltaX: opts?.deltaX ?? 0,
243
279
  ...(opts?.x !== undefined ? { x: opts.x } : {}),
244
280
  ...(opts?.y !== undefined ? { y: opts.y } : {}),
245
- });
281
+ }, timeoutMs);
246
282
  }
247
283
  /**
248
284
  * Build a flat accessibility tree from the raw UI tree + layout.
@@ -415,10 +451,12 @@ function intersectsViewportWithMargin(b, vw, vh, marginY) {
415
451
  }
416
452
  function compactNodeFromA11y(node, pinned = false) {
417
453
  const name = sanitizeInlineName(node.name, 240);
454
+ const value = sanitizeInlineName(node.value, 180);
418
455
  return {
419
456
  id: nodeIdForPath(node.path),
420
457
  role: node.role,
421
458
  ...(name ? { name } : {}),
459
+ ...(value ? { value } : {}),
422
460
  ...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
423
461
  ...(pinned ? { pinned: true } : {}),
424
462
  bounds: { ...node.bounds },
@@ -521,11 +559,12 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
521
559
  const slice = nodes.slice(0, maxLines);
522
560
  for (const n of slice) {
523
561
  const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
562
+ const val = n.value ? ` value=${JSON.stringify(truncateUiText(n.value, 40))}` : '';
524
563
  const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
525
564
  const foc = n.focusable ? ' *' : '';
526
565
  const pin = n.pinned ? ' [pinned]' : '';
527
566
  const b = n.bounds;
528
- 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}`);
529
568
  }
530
569
  if (nodes.length > maxLines) {
531
570
  lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
@@ -661,10 +700,12 @@ function primaryAction(node) {
661
700
  };
662
701
  }
663
702
  function toFieldModel(node, includeBounds = true) {
703
+ const value = sanitizeInlineName(node.value, 120);
664
704
  return {
665
705
  id: nodeIdForPath(node.path),
666
706
  role: node.role,
667
707
  ...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
708
+ ...(value ? { value } : {}),
668
709
  ...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
669
710
  ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
670
711
  };
@@ -918,8 +959,12 @@ function pathKey(path) {
918
959
  return path.join('.');
919
960
  }
920
961
  function compactNodeLabel(node) {
921
- if (node.name)
922
- 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))}`;
923
968
  return `${node.id} ${node.role}`;
924
969
  }
925
970
  function formatStateValue(value) {
@@ -932,6 +977,9 @@ function diffCompactNodes(before, after) {
932
977
  if ((before.name ?? '') !== (after.name ?? '')) {
933
978
  changes.push(`name ${JSON.stringify(truncateUiText(before.name ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.name ?? 'unset', 32))}`);
934
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
+ }
935
983
  const beforeState = before.state ?? {};
936
984
  const afterState = after.state ?? {};
937
985
  for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
@@ -1147,6 +1195,7 @@ function walkNode(element, layout, path) {
1147
1195
  const handlers = element.handlers;
1148
1196
  const role = inferRole(kind, semantic, handlers);
1149
1197
  const name = inferName(kind, semantic, props);
1198
+ const value = inferValue(semantic, props);
1150
1199
  const focusable = !!(handlers?.onClick || handlers?.onKeyDown || handlers?.onKeyUp ||
1151
1200
  handlers?.onCompositionStart || handlers?.onCompositionUpdate || handlers?.onCompositionEnd);
1152
1201
  const bounds = {
@@ -1187,6 +1236,7 @@ function walkNode(element, layout, path) {
1187
1236
  return {
1188
1237
  role,
1189
1238
  ...(name ? { name } : {}),
1239
+ ...(value ? { value } : {}),
1190
1240
  ...(Object.keys(state).length > 0 ? { state } : {}),
1191
1241
  ...(Object.keys(meta).length > 0 ? { meta } : {}),
1192
1242
  bounds,
@@ -1243,15 +1293,24 @@ function inferName(kind, semantic, props) {
1243
1293
  return (semantic?.alt ?? props?.alt);
1244
1294
  return semantic?.alt;
1245
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
+ }
1246
1300
  function applyPatches(layout, patches) {
1247
1301
  for (const patch of patches) {
1248
1302
  let node = layout;
1303
+ let validPath = true;
1249
1304
  for (const idx of patch.path) {
1250
1305
  const children = node.children;
1251
- if (!children?.[idx])
1306
+ if (!children?.[idx]) {
1307
+ validPath = false;
1252
1308
  break;
1309
+ }
1253
1310
  node = children[idx];
1254
1311
  }
1312
+ if (!validPath)
1313
+ continue;
1255
1314
  if (patch.x !== undefined)
1256
1315
  node.x = patch.x;
1257
1316
  if (patch.y !== undefined)
@@ -1281,7 +1340,7 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1281
1340
  const msg = JSON.parse(String(data));
1282
1341
  const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
1283
1342
  if (requestId) {
1284
- if (msg.type === 'error' && messageRequestId === requestId) {
1343
+ if (msg.type === 'error' && (messageRequestId === requestId || messageRequestId === undefined)) {
1285
1344
  cleanup();
1286
1345
  reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
1287
1346
  return;
@@ -1318,6 +1377,10 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
1318
1377
  // Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
1319
1378
  const timeout = setTimeout(() => {
1320
1379
  cleanup();
1380
+ if (requestId && session.updateRevision > startRevision) {
1381
+ resolve({ status: 'updated', timeoutMs });
1382
+ return;
1383
+ }
1321
1384
  resolve({ status: 'timed_out', timeoutMs });
1322
1385
  }, timeoutMs);
1323
1386
  function cleanup() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.8",
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.8",
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"