@geometra/mcp 1.19.10 → 1.19.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,8 +21,8 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
21
21
  | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
22
22
  | `geometra_query` | Find elements by stable id, role, name, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
23
23
  | `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
24
- | `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call |
25
- | `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result |
24
+ | `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
25
+ | `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result, with optional final-only output |
26
26
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
27
27
  | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
28
28
  | `geometra_click` | Click an element by coordinates |
@@ -39,27 +39,155 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
39
39
 
40
40
  ## Setup
41
41
 
42
- ### Claude Code
42
+ <details>
43
+ <summary>Claude Code</summary>
43
44
 
45
+ **One-line install:**
44
46
  ```bash
45
- claude mcp add geometra -- npx @geometra/mcp
47
+ claude mcp add geometra -- npx -y @geometra/mcp
46
48
  ```
47
49
 
48
- ### Claude Desktop
50
+ **Uninstall:**
51
+ ```bash
52
+ claude mcp remove geometra
53
+ ```
54
+
55
+ Or manually add to `.mcp.json` (project-level) or `~/.claude/settings.json` (global):
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "geometra": {
60
+ "command": "npx",
61
+ "args": ["-y", "@geometra/mcp"]
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ To uninstall manually, remove the `geometra` entry from the config file.
68
+
69
+ </details>
70
+
71
+ <details>
72
+ <summary>Claude Desktop</summary>
73
+
74
+ Add to your Claude Desktop MCP config:
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "geometra": {
80
+ "command": "npx",
81
+ "args": ["-y", "@geometra/mcp"]
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ To uninstall, remove the `geometra` entry from the config file.
88
+
89
+ </details>
90
+
91
+ <details>
92
+ <summary>OpenAI Codex</summary>
93
+
94
+ Add to your Codex MCP configuration:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "geometra": {
100
+ "command": "npx",
101
+ "args": ["-y", "@geometra/mcp"]
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ To uninstall, remove the `geometra` entry from the config file.
108
+
109
+ </details>
110
+
111
+ <details>
112
+ <summary>Cursor</summary>
49
113
 
50
- Add to `claude_desktop_config.json`:
114
+ Open Settings → MCP → Add new MCP server, or add to `.cursor/mcp.json`:
51
115
 
52
116
  ```json
53
117
  {
54
118
  "mcpServers": {
55
119
  "geometra": {
56
120
  "command": "npx",
57
- "args": ["@geometra/mcp"]
121
+ "args": ["-y", "@geometra/mcp"]
58
122
  }
59
123
  }
60
124
  }
61
125
  ```
62
126
 
127
+ To uninstall, remove the entry from MCP settings.
128
+
129
+ </details>
130
+
131
+ <details>
132
+ <summary>Windsurf</summary>
133
+
134
+ Add to `~/.codeium/windsurf/mcp_config.json`:
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "geometra": {
140
+ "command": "npx",
141
+ "args": ["-y", "@geometra/mcp"]
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ To uninstall, remove the entry from the config file.
148
+
149
+ </details>
150
+
151
+ <details>
152
+ <summary>VS Code / Copilot</summary>
153
+
154
+ **One-line install:**
155
+ ```bash
156
+ code --add-mcp '{"name":"geometra","command":"npx","args":["-y","@geometra/mcp"]}'
157
+ ```
158
+
159
+ Or add to `.vscode/mcp.json`:
160
+ ```json
161
+ {
162
+ "servers": {
163
+ "geometra": {
164
+ "command": "npx",
165
+ "args": ["-y", "@geometra/mcp"]
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ To uninstall, remove the entry from MCP settings or delete the server from the MCP panel.
172
+
173
+ </details>
174
+
175
+ <details>
176
+ <summary>Other MCP clients</summary>
177
+
178
+ Any MCP client that supports stdio transport can use Geometra. The server config is:
179
+
180
+ ```json
181
+ {
182
+ "command": "npx",
183
+ "args": ["-y", "@geometra/mcp"]
184
+ }
185
+ ```
186
+
187
+ To uninstall, remove the server entry from your client's MCP configuration.
188
+
189
+ </details>
190
+
63
191
  ### From source (this repo)
64
192
 
65
193
  ```bash
@@ -200,6 +328,11 @@ Typical batch:
200
328
 
201
329
  Single action tools now default to terse summaries. Pass `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
202
330
 
331
+ For the smallest long-form responses, prefer:
332
+
333
+ 1. `detail: "minimal"` for structured step metadata instead of narrated deltas
334
+ 2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
335
+
203
336
  Typical field fill:
204
337
 
205
338
  ```json
@@ -211,7 +344,9 @@ Typical field fill:
211
344
  { "kind": "choice", "fieldLabel": "Will you require sponsorship?", "value": "No" },
212
345
  { "kind": "file", "fieldLabel": "Resume", "paths": ["/Users/you/resume.pdf"] }
213
346
  ],
214
- "failOnInvalid": true
347
+ "failOnInvalid": true,
348
+ "detail": "minimal",
349
+ "includeSteps": false
215
350
  }
216
351
  ```
217
352
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,206 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ function node(role, name, options) {
3
+ return {
4
+ role,
5
+ ...(name ? { name } : {}),
6
+ ...(options?.value ? { value: options.value } : {}),
7
+ ...(options?.state ? { state: options.state } : {}),
8
+ ...(options?.validation ? { validation: options.validation } : {}),
9
+ ...(options?.meta ? { meta: options.meta } : {}),
10
+ bounds: { x: 0, y: 0, width: 120, height: 40 },
11
+ path: options?.path ?? [],
12
+ children: options?.children ?? [],
13
+ focusable: role !== 'group',
14
+ };
15
+ }
16
+ const mockState = vi.hoisted(() => ({
17
+ currentA11yRoot: node('group', undefined, {
18
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
19
+ }),
20
+ session: {
21
+ tree: { kind: 'box' },
22
+ layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
23
+ url: 'ws://127.0.0.1:3200',
24
+ updateRevision: 1,
25
+ },
26
+ sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
27
+ sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
28
+ sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
29
+ sendFileUpload: vi.fn(async () => ({ status: 'updated', timeoutMs: 8000 })),
30
+ sendFieldText: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
31
+ sendFieldChoice: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
32
+ sendListboxPick: vi.fn(async () => ({ status: 'updated', timeoutMs: 4500 })),
33
+ sendSelectOption: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
34
+ sendSetChecked: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
35
+ sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
36
+ waitForUiCondition: vi.fn(async () => true),
37
+ }));
38
+ vi.mock('../session.js', () => ({
39
+ connect: vi.fn(),
40
+ connectThroughProxy: vi.fn(),
41
+ disconnect: vi.fn(),
42
+ getSession: vi.fn(() => mockState.session),
43
+ sendClick: mockState.sendClick,
44
+ sendType: mockState.sendType,
45
+ sendKey: mockState.sendKey,
46
+ sendFileUpload: mockState.sendFileUpload,
47
+ sendFieldText: mockState.sendFieldText,
48
+ sendFieldChoice: mockState.sendFieldChoice,
49
+ sendListboxPick: mockState.sendListboxPick,
50
+ sendSelectOption: mockState.sendSelectOption,
51
+ sendSetChecked: mockState.sendSetChecked,
52
+ sendWheel: mockState.sendWheel,
53
+ buildA11yTree: vi.fn(() => mockState.currentA11yRoot),
54
+ buildCompactUiIndex: vi.fn(() => ({ nodes: [], context: {} })),
55
+ buildPageModel: vi.fn(() => ({
56
+ viewport: { width: 1280, height: 800 },
57
+ archetypes: ['form'],
58
+ summary: { landmarkCount: 0, formCount: 1, dialogCount: 0, listCount: 0, focusableCount: 2 },
59
+ primaryActions: [],
60
+ landmarks: [],
61
+ forms: [],
62
+ dialogs: [],
63
+ lists: [],
64
+ })),
65
+ expandPageSection: vi.fn(() => null),
66
+ buildUiDelta: vi.fn(() => ({})),
67
+ hasUiDelta: vi.fn(() => false),
68
+ nodeIdForPath: vi.fn((path) => `n:${path.length > 0 ? path.join('.') : 'root'}`),
69
+ summarizeCompactIndex: vi.fn(() => ''),
70
+ summarizePageModel: vi.fn(() => ''),
71
+ summarizeUiDelta: vi.fn(() => ''),
72
+ waitForUiCondition: mockState.waitForUiCondition,
73
+ }));
74
+ const { createServer } = await import('../server.js');
75
+ function getToolHandler(name) {
76
+ const server = createServer();
77
+ return server._registeredTools[name].handler;
78
+ }
79
+ describe('batch MCP result shaping', () => {
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+ mockState.currentA11yRoot = node('group', undefined, {
83
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
84
+ children: [
85
+ node('textbox', 'Mission', {
86
+ value: 'Ship calm developer tools across browsers and platforms.',
87
+ path: [0],
88
+ }),
89
+ node('textbox', 'Email', {
90
+ value: 'taylor@example.com',
91
+ path: [1],
92
+ }),
93
+ ],
94
+ });
95
+ });
96
+ it('keeps fill_fields minimal output structured and does not echo long essay text', async () => {
97
+ const longAnswer = 'A'.repeat(180);
98
+ const handler = getToolHandler('geometra_fill_fields');
99
+ mockState.currentA11yRoot = node('group', undefined, {
100
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
101
+ children: [
102
+ node('textbox', 'Mission', { value: longAnswer, path: [0] }),
103
+ node('textbox', 'Email', { value: 'taylor@example.com', path: [1] }),
104
+ ],
105
+ });
106
+ const result = await handler({
107
+ fields: [
108
+ { kind: 'text', fieldLabel: 'Mission', value: longAnswer },
109
+ { kind: 'text', fieldLabel: 'Email', value: 'taylor@example.com' },
110
+ ],
111
+ stopOnError: true,
112
+ failOnInvalid: false,
113
+ includeSteps: true,
114
+ detail: 'minimal',
115
+ });
116
+ const text = result.content[0].text;
117
+ const payload = JSON.parse(text);
118
+ const steps = payload.steps;
119
+ expect(text).not.toContain(longAnswer);
120
+ expect(payload).toMatchObject({
121
+ completed: true,
122
+ fieldCount: 2,
123
+ successCount: 2,
124
+ errorCount: 0,
125
+ });
126
+ expect(steps[0]).toMatchObject({
127
+ index: 0,
128
+ kind: 'text',
129
+ ok: true,
130
+ fieldLabel: 'Mission',
131
+ valueLength: 180,
132
+ wait: 'updated',
133
+ readback: { role: 'textbox', valueLength: 180 },
134
+ });
135
+ expect(steps[1]).toMatchObject({
136
+ index: 1,
137
+ kind: 'text',
138
+ ok: true,
139
+ fieldLabel: 'Email',
140
+ value: 'taylor@example.com',
141
+ wait: 'updated',
142
+ readback: { role: 'textbox', value: 'taylor@example.com' },
143
+ });
144
+ });
145
+ it('lets run_actions omit step listings while keeping capped final validation state', async () => {
146
+ const handler = getToolHandler('geometra_run_actions');
147
+ mockState.currentA11yRoot = node('group', undefined, {
148
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 2400 },
149
+ children: [
150
+ node('textbox', 'Full name', {
151
+ value: '',
152
+ path: [0],
153
+ state: { invalid: true, required: true },
154
+ validation: { error: 'Enter your full name.' },
155
+ }),
156
+ node('textbox', 'Email', {
157
+ value: '',
158
+ path: [1],
159
+ state: { invalid: true, required: true },
160
+ validation: { error: 'Enter your email.' },
161
+ }),
162
+ node('textbox', 'Phone', {
163
+ value: '',
164
+ path: [2],
165
+ state: { invalid: true, required: true },
166
+ validation: { error: 'Enter your phone number.' },
167
+ }),
168
+ node('textbox', 'Location', {
169
+ value: '',
170
+ path: [3],
171
+ state: { invalid: true, required: true },
172
+ validation: { error: 'Choose a location.' },
173
+ }),
174
+ node('textbox', 'LinkedIn', {
175
+ value: '',
176
+ path: [4],
177
+ state: { invalid: true },
178
+ validation: { error: 'Enter a valid URL.' },
179
+ }),
180
+ node('alert', 'Your form needs corrections', { path: [5] }),
181
+ ],
182
+ });
183
+ const result = await handler({
184
+ actions: [{ type: 'click', x: 320, y: 540 }],
185
+ stopOnError: true,
186
+ includeSteps: false,
187
+ detail: 'minimal',
188
+ });
189
+ const payload = JSON.parse(result.content[0].text);
190
+ const final = payload.final;
191
+ expect(payload).toMatchObject({
192
+ completed: true,
193
+ stepCount: 1,
194
+ successCount: 1,
195
+ errorCount: 0,
196
+ });
197
+ expect(payload).not.toHaveProperty('steps');
198
+ expect(final).toMatchObject({
199
+ pageUrl: 'https://jobs.example.com/application',
200
+ alertCount: 1,
201
+ invalidCount: 5,
202
+ });
203
+ expect(final.invalidFields.length).toBe(4);
204
+ expect(final.alerts.length).toBe(1);
205
+ });
206
+ });
package/dist/server.js CHANGED
@@ -145,7 +145,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
145
145
  }),
146
146
  ]);
147
147
  export function createServer() {
148
- const server = new McpServer({ name: 'geometra', version: '1.19.10' }, { capabilities: { tools: {} } });
148
+ const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
149
149
  // ── connect ──────────────────────────────────────────────────
150
150
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
151
151
 
@@ -309,8 +309,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
309
309
  .optional()
310
310
  .default(false)
311
311
  .describe('Return an error if invalid fields remain after filling'),
312
+ includeSteps: z
313
+ .boolean()
314
+ .optional()
315
+ .default(true)
316
+ .describe('Include per-field step results in the JSON payload (default true). Set false for the smallest batch response.'),
312
317
  detail: detailInput(),
313
- }, async ({ fields, stopOnError, failOnInvalid, detail }) => {
318
+ }, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail }) => {
314
319
  const session = getSession();
315
320
  if (!session)
316
321
  return err('Not connected. Call geometra_connect first.');
@@ -319,8 +324,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
319
324
  for (let index = 0; index < fields.length; index++) {
320
325
  const field = fields[index];
321
326
  try {
322
- const summary = await executeFillField(session, field, detail);
323
- steps.push({ index, kind: field.kind, ok: true, summary });
327
+ const result = await executeFillField(session, field, detail);
328
+ steps.push(detail === 'verbose'
329
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
330
+ : { index, kind: field.kind, ok: true, ...result.compact });
324
331
  }
325
332
  catch (e) {
326
333
  const message = e instanceof Error ? e.message : String(e);
@@ -334,12 +341,16 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
334
341
  const after = sessionA11y(session);
335
342
  const signals = after ? collectSessionSignals(after) : undefined;
336
343
  const invalidRemaining = signals?.invalidFields.length ?? 0;
344
+ const successCount = steps.filter(step => step.ok === true).length;
345
+ const errorCount = steps.length - successCount;
337
346
  const payload = {
338
347
  completed: stoppedAt === undefined && steps.length === fields.length,
339
348
  fieldCount: fields.length,
340
- steps,
349
+ successCount,
350
+ errorCount,
351
+ ...(includeSteps ? { steps } : {}),
341
352
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
342
- ...(signals ? { final: sessionSignalsPayload(signals) } : {}),
353
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
343
354
  };
344
355
  if (failOnInvalid && invalidRemaining > 0) {
345
356
  return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
@@ -351,8 +362,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
351
362
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
352
363
  actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
353
364
  stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing step (default true)'),
365
+ includeSteps: z
366
+ .boolean()
367
+ .optional()
368
+ .default(true)
369
+ .describe('Include per-action step results in the JSON payload (default true). Set false for the smallest batch response.'),
354
370
  detail: detailInput(),
355
- }, async ({ actions, stopOnError, detail }) => {
371
+ }, async ({ actions, stopOnError, includeSteps, detail }) => {
356
372
  const session = getSession();
357
373
  if (!session)
358
374
  return err('Not connected. Call geometra_connect first.');
@@ -361,8 +377,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
361
377
  for (let index = 0; index < actions.length; index++) {
362
378
  const action = actions[index];
363
379
  try {
364
- const summary = await executeBatchAction(session, action, detail);
365
- steps.push({ index, type: action.type, ok: true, summary });
380
+ const result = await executeBatchAction(session, action, detail, includeSteps);
381
+ steps.push(detail === 'verbose'
382
+ ? { index, type: action.type, ok: true, summary: result.summary }
383
+ : { index, type: action.type, ok: true, ...result.compact });
366
384
  }
367
385
  catch (e) {
368
386
  const message = e instanceof Error ? e.message : String(e);
@@ -374,12 +392,16 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
374
392
  }
375
393
  }
376
394
  const after = sessionA11y(session);
395
+ const successCount = steps.filter(step => step.ok === true).length;
396
+ const errorCount = steps.length - successCount;
377
397
  const payload = {
378
398
  completed: stoppedAt === undefined && steps.length === actions.length,
379
399
  stepCount: actions.length,
380
- steps,
400
+ successCount,
401
+ errorCount,
402
+ ...(includeSteps ? { steps } : {}),
381
403
  ...(stoppedAt !== undefined ? { stoppedAt } : {}),
382
- ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after)) } : {}),
404
+ ...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
383
405
  };
384
406
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
385
407
  });
@@ -897,7 +919,7 @@ function truncateInlineText(text, max) {
897
919
  return undefined;
898
920
  return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
899
921
  }
900
- function sessionSignalsPayload(signals) {
922
+ function sessionSignalsPayload(signals, detail = 'minimal') {
901
923
  return {
902
924
  ...(signals.pageUrl ? { pageUrl: signals.pageUrl } : {}),
903
925
  ...(signals.scrollX !== undefined || signals.scrollY !== undefined
@@ -906,26 +928,85 @@ function sessionSignalsPayload(signals) {
906
928
  ...(signals.focus ? { focus: signals.focus } : {}),
907
929
  dialogCount: signals.dialogCount,
908
930
  busyCount: signals.busyCount,
909
- alerts: signals.alerts,
910
- invalidFields: signals.invalidFields,
931
+ alertCount: signals.alerts.length,
932
+ invalidCount: signals.invalidFields.length,
933
+ alerts: detail === 'verbose' ? signals.alerts : signals.alerts.slice(0, 2),
934
+ invalidFields: detail === 'verbose' ? signals.invalidFields : signals.invalidFields.slice(0, 4),
911
935
  };
912
936
  }
913
- async function executeBatchAction(session, action, detail) {
937
+ function compactTextValue(value, inlineLimit = 48) {
938
+ const normalized = value.replace(/\s+/g, ' ').trim();
939
+ if (!normalized)
940
+ return { valueLength: value.length };
941
+ return normalized.length <= inlineLimit
942
+ ? { value: normalized }
943
+ : { valueLength: value.length };
944
+ }
945
+ function fieldStatePayload(session, fieldLabel) {
946
+ const a11y = sessionA11y(session);
947
+ if (!a11y)
948
+ return undefined;
949
+ const matches = findNodes(a11y, {
950
+ name: fieldLabel,
951
+ role: 'combobox',
952
+ });
953
+ if (matches.length === 0) {
954
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
955
+ }
956
+ if (matches.length === 0) {
957
+ matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
958
+ }
959
+ const match = matches[0];
960
+ if (!match)
961
+ return undefined;
962
+ const valuePayload = match.value ? compactTextValue(match.value, 64) : {};
963
+ return {
964
+ role: match.role,
965
+ ...valuePayload,
966
+ ...(match.state && Object.keys(match.state).length > 0 ? { state: match.state } : {}),
967
+ ...(match.validation?.error ? { error: truncateInlineText(match.validation.error, 120) } : {}),
968
+ };
969
+ }
970
+ function waitStatusPayload(wait) {
971
+ return wait ? { wait: wait.status } : {};
972
+ }
973
+ function compactFilterPayload(filter) {
974
+ return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
975
+ }
976
+ async function executeBatchAction(session, action, detail, includeSteps) {
914
977
  switch (action.type) {
915
978
  case 'click': {
916
979
  const before = sessionA11y(session);
917
980
  const wait = await sendClick(session, action.x, action.y, action.timeoutMs);
918
- return `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`;
981
+ return {
982
+ summary: `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`,
983
+ compact: {
984
+ at: { x: action.x, y: action.y },
985
+ ...waitStatusPayload(wait),
986
+ },
987
+ };
919
988
  }
920
989
  case 'type': {
921
990
  const before = sessionA11y(session);
922
991
  const wait = await sendType(session, action.text, action.timeoutMs);
923
- return `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`;
992
+ return {
993
+ summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
994
+ compact: {
995
+ ...compactTextValue(action.text),
996
+ ...waitStatusPayload(wait),
997
+ },
998
+ };
924
999
  }
925
1000
  case 'key': {
926
1001
  const before = sessionA11y(session);
927
1002
  const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
928
- return `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`;
1003
+ return {
1004
+ summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
1005
+ compact: {
1006
+ key: formatKeyCombo(action.key, action),
1007
+ ...waitStatusPayload(wait),
1008
+ },
1009
+ };
929
1010
  }
930
1011
  case 'upload_files': {
931
1012
  const before = sessionA11y(session);
@@ -936,7 +1017,16 @@ async function executeBatchAction(session, action, detail) {
936
1017
  strategy: action.strategy,
937
1018
  drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
938
1019
  }, action.timeoutMs ?? 8_000);
939
- return `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`;
1020
+ return {
1021
+ summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
1022
+ compact: {
1023
+ fileCount: action.paths.length,
1024
+ ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
1025
+ ...(action.strategy ? { strategy: action.strategy } : {}),
1026
+ ...waitStatusPayload(wait),
1027
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
1028
+ },
1029
+ };
940
1030
  }
941
1031
  case 'pick_listbox_option': {
942
1032
  const before = sessionA11y(session);
@@ -948,7 +1038,15 @@ async function executeBatchAction(session, action, detail) {
948
1038
  }, action.timeoutMs);
949
1039
  const summary = postActionSummary(session, before, wait, detail);
950
1040
  const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
951
- return [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n');
1041
+ return {
1042
+ summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
1043
+ compact: {
1044
+ label: action.label,
1045
+ ...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
1046
+ ...waitStatusPayload(wait),
1047
+ ...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
1048
+ },
1049
+ };
952
1050
  }
953
1051
  case 'select_option': {
954
1052
  if (action.value === undefined && action.label === undefined && action.index === undefined) {
@@ -960,7 +1058,16 @@ async function executeBatchAction(session, action, detail) {
960
1058
  label: action.label,
961
1059
  index: action.index,
962
1060
  }, action.timeoutMs);
963
- return `Selected option.\n${postActionSummary(session, before, wait, detail)}`;
1061
+ return {
1062
+ summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
1063
+ compact: {
1064
+ at: { x: action.x, y: action.y },
1065
+ ...(action.value !== undefined ? { value: action.value } : {}),
1066
+ ...(action.label !== undefined ? { label: action.label } : {}),
1067
+ ...(action.index !== undefined ? { index: action.index } : {}),
1068
+ ...waitStatusPayload(wait),
1069
+ },
1070
+ };
964
1071
  }
965
1072
  case 'set_checked': {
966
1073
  const before = sessionA11y(session);
@@ -969,7 +1076,15 @@ async function executeBatchAction(session, action, detail) {
969
1076
  exact: action.exact,
970
1077
  controlType: action.controlType,
971
1078
  }, action.timeoutMs);
972
- return `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
1079
+ return {
1080
+ summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
1081
+ compact: {
1082
+ label: action.label,
1083
+ checked: action.checked ?? true,
1084
+ ...(action.controlType ? { controlType: action.controlType } : {}),
1085
+ ...waitStatusPayload(wait),
1086
+ },
1087
+ };
973
1088
  }
974
1089
  case 'wheel': {
975
1090
  const before = sessionA11y(session);
@@ -978,7 +1093,15 @@ async function executeBatchAction(session, action, detail) {
978
1093
  x: action.x,
979
1094
  y: action.y,
980
1095
  }, action.timeoutMs);
981
- return `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`;
1096
+ return {
1097
+ summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
1098
+ compact: {
1099
+ deltaY: action.deltaY,
1100
+ ...(action.deltaX !== undefined ? { deltaX: action.deltaX } : {}),
1101
+ ...(action.x !== undefined && action.y !== undefined ? { at: { x: action.x, y: action.y } } : {}),
1102
+ ...waitStatusPayload(wait),
1103
+ },
1104
+ };
982
1105
  }
983
1106
  case 'wait_for': {
984
1107
  if (!session.tree || !session.layout)
@@ -1016,24 +1139,64 @@ async function executeBatchAction(session, action, detail) {
1016
1139
  throw new Error(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}`);
1017
1140
  }
1018
1141
  if (!present) {
1019
- return `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`;
1142
+ return {
1143
+ summary: `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`,
1144
+ compact: {
1145
+ present,
1146
+ elapsedMs,
1147
+ filter: compactFilterPayload(filter),
1148
+ },
1149
+ };
1020
1150
  }
1021
1151
  const after = sessionA11y(session);
1022
1152
  if (!after) {
1023
- return `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`;
1153
+ return {
1154
+ summary: `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`,
1155
+ compact: {
1156
+ present,
1157
+ elapsedMs,
1158
+ filter: compactFilterPayload(filter),
1159
+ },
1160
+ };
1024
1161
  }
1025
1162
  const matches = findNodes(after, filter);
1026
1163
  if (detail === 'verbose') {
1027
- return JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2);
1164
+ return {
1165
+ summary: JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2),
1166
+ compact: {
1167
+ present,
1168
+ elapsedMs,
1169
+ matchCount: matches.length,
1170
+ filter: compactFilterPayload(filter),
1171
+ },
1172
+ };
1028
1173
  }
1029
- return `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`;
1174
+ return {
1175
+ summary: `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`,
1176
+ compact: {
1177
+ present,
1178
+ elapsedMs,
1179
+ matchCount: matches.length,
1180
+ filter: compactFilterPayload(filter),
1181
+ },
1182
+ };
1030
1183
  }
1031
1184
  case 'fill_fields': {
1032
- const lines = [];
1033
- for (const field of action.fields) {
1034
- lines.push(await executeFillField(session, field, detail));
1185
+ const steps = [];
1186
+ for (let index = 0; index < action.fields.length; index++) {
1187
+ const field = action.fields[index];
1188
+ const result = await executeFillField(session, field, detail);
1189
+ steps.push(detail === 'verbose'
1190
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
1191
+ : { index, kind: field.kind, ok: true, ...result.compact });
1035
1192
  }
1036
- return lines.join('\n');
1193
+ return {
1194
+ summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
1195
+ compact: {
1196
+ fieldCount: action.fields.length,
1197
+ ...(includeSteps ? { steps } : {}),
1198
+ },
1199
+ };
1037
1200
  }
1038
1201
  }
1039
1202
  }
@@ -1043,36 +1206,68 @@ async function executeFillField(session, field, detail) {
1043
1206
  const before = sessionA11y(session);
1044
1207
  const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
1045
1208
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1046
- return [
1047
- `Filled text field "${field.fieldLabel}".`,
1048
- fieldSummary,
1049
- postActionSummary(session, before, wait, detail),
1050
- ].filter(Boolean).join('\n');
1209
+ return {
1210
+ summary: [
1211
+ `Filled text field "${field.fieldLabel}".`,
1212
+ fieldSummary,
1213
+ postActionSummary(session, before, wait, detail),
1214
+ ].filter(Boolean).join('\n'),
1215
+ compact: {
1216
+ fieldLabel: field.fieldLabel,
1217
+ ...compactTextValue(field.value),
1218
+ ...waitStatusPayload(wait),
1219
+ readback: fieldStatePayload(session, field.fieldLabel),
1220
+ },
1221
+ };
1051
1222
  }
1052
1223
  case 'choice': {
1053
1224
  const before = sessionA11y(session);
1054
1225
  const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
1055
1226
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1056
- return [
1057
- `Set choice field "${field.fieldLabel}" to "${field.value}".`,
1058
- fieldSummary,
1059
- postActionSummary(session, before, wait, detail),
1060
- ].filter(Boolean).join('\n');
1227
+ return {
1228
+ summary: [
1229
+ `Set choice field "${field.fieldLabel}" to "${field.value}".`,
1230
+ fieldSummary,
1231
+ postActionSummary(session, before, wait, detail),
1232
+ ].filter(Boolean).join('\n'),
1233
+ compact: {
1234
+ fieldLabel: field.fieldLabel,
1235
+ value: field.value,
1236
+ ...waitStatusPayload(wait),
1237
+ readback: fieldStatePayload(session, field.fieldLabel),
1238
+ },
1239
+ };
1061
1240
  }
1062
1241
  case 'toggle': {
1063
1242
  const before = sessionA11y(session);
1064
1243
  const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
1065
- return `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
1244
+ return {
1245
+ summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
1246
+ compact: {
1247
+ label: field.label,
1248
+ checked: field.checked ?? true,
1249
+ ...(field.controlType ? { controlType: field.controlType } : {}),
1250
+ ...waitStatusPayload(wait),
1251
+ },
1252
+ };
1066
1253
  }
1067
1254
  case 'file': {
1068
1255
  const before = sessionA11y(session);
1069
1256
  const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
1070
1257
  const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
1071
- return [
1072
- `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
1073
- fieldSummary,
1074
- postActionSummary(session, before, wait, detail),
1075
- ].filter(Boolean).join('\n');
1258
+ return {
1259
+ summary: [
1260
+ `Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
1261
+ fieldSummary,
1262
+ postActionSummary(session, before, wait, detail),
1263
+ ].filter(Boolean).join('\n'),
1264
+ compact: {
1265
+ fieldLabel: field.fieldLabel,
1266
+ fileCount: field.paths.length,
1267
+ ...waitStatusPayload(wait),
1268
+ readback: fieldStatePayload(session, field.fieldLabel),
1269
+ },
1270
+ };
1076
1271
  }
1077
1272
  }
1078
1273
  }
@@ -1134,29 +1329,20 @@ export function findNodes(node, filter) {
1134
1329
  return matches;
1135
1330
  }
1136
1331
  function summarizeFieldLabelState(session, fieldLabel) {
1137
- const a11y = sessionA11y(session);
1138
- if (!a11y)
1139
- return undefined;
1140
- const matches = findNodes(a11y, {
1141
- name: fieldLabel,
1142
- role: 'combobox',
1143
- });
1144
- if (matches.length === 0) {
1145
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
1146
- }
1147
- if (matches.length === 0) {
1148
- matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
1149
- }
1150
- const match = matches[0];
1151
- if (!match)
1332
+ const payload = fieldStatePayload(session, fieldLabel);
1333
+ if (!payload)
1152
1334
  return undefined;
1153
1335
  const parts = [`Field "${fieldLabel}"`];
1154
- if (match.value)
1155
- parts.push(`value=${JSON.stringify(match.value)}`);
1156
- if (match.state && Object.keys(match.state).length > 0)
1157
- parts.push(`state=${JSON.stringify(match.state)}`);
1158
- if (match.validation?.error)
1159
- parts.push(`error=${JSON.stringify(match.validation.error)}`);
1336
+ if (payload.role)
1337
+ parts.push(`role=${String(payload.role)}`);
1338
+ if (payload.value)
1339
+ parts.push(`value=${JSON.stringify(payload.value)}`);
1340
+ if (payload.valueLength)
1341
+ parts.push(`valueLength=${String(payload.valueLength)}`);
1342
+ if (payload.state)
1343
+ parts.push(`state=${JSON.stringify(payload.state)}`);
1344
+ if (payload.error)
1345
+ parts.push(`error=${JSON.stringify(payload.error)}`);
1160
1346
  return parts.join(' ');
1161
1347
  }
1162
1348
  function formatNode(node, viewport) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.10",
3
+ "version": "1.19.11",
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.10",
33
+ "@geometra/proxy": "^1.19.11",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"