@geometra/mcp 1.19.12 → 1.19.13

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,6 +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, ancestor/prompt context, current value, or semantic state such as `invalid`, `required`, or `busy` |
23
23
  | `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
24
+ | `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups |
25
+ | `geometra_fill_form` | Fill a form from `valuesById` / `valuesByLabel` in one MCP call; preferred low-token happy path for standard forms |
24
26
  | `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
25
27
  | `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
28
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
@@ -275,19 +277,15 @@ With `python3 -m http.server 8080` in `demos/proxy-mcp-sample` and `npx geometra
275
277
  Agent: geometra_connect({ url: "ws://127.0.0.1:3200" })
276
278
  → Connected. UI includes textbox "Email", button "Save", …
277
279
 
278
- Agent: geometra_page_model({})
279
- → {"viewport":{"width":1024,"height":768},"archetypes":["shell","form"],"summary":{...},"forms":[{"id":"fm:1.0","fieldCount":3,"actionCount":1}], ...}
280
+ Agent: geometra_form_schema({})
281
+ → {"forms":[{"formId":"fm:1.0","fields":[{"id":"ff:1.0.0","label":"Email"}, ...]}]}
280
282
 
281
- Agent: geometra_expand_section({ id: "fm:1.0" })
282
- {"id":"fm:1.0","kind":"form","fields":[{"id":"n:1.0.0","name":"Email"}, ...], "actions":[...]}
283
-
284
- Agent: geometra_query({ role: "textbox", name: "Email" })
285
- → bounds for the email field (viewport coordinates)
286
-
287
- Agent: geometra_click({ x: <center-x>, y: <center-y> })
288
- → Focuses the input
289
-
290
- Agent: geometra_type({ text: "hello@example.com" })
283
+ Agent: geometra_fill_form({
284
+ formId: "fm:1.0",
285
+ valuesByLabel: { "Email": "hello@example.com" },
286
+ failOnInvalid: true
287
+ })
288
+ → {"completed":true,"successCount":1,"errorCount":0,"final":{"invalidCount":0,...}}
291
289
 
292
290
  Agent: geometra_query({ role: "button", name: "Save" })
293
291
  → Click center to submit the sample form; status text updates in the DOM
@@ -299,21 +297,22 @@ Agent: geometra_query({ role: "button", name: "Save" })
299
297
  2. It receives the computed layout (`{ x, y, width, height }` for every node) and the UI tree (`kind`, `semantic`, `props`, `handlers`, `children`).
300
298
  3. It builds an accessibility tree from that data — roles, names, focusable state, bounds.
301
299
  4. **`geometra_snapshot`** defaults to a **compact** flat list of viewport-visible actionable nodes (minified JSON) to reduce LLM tokens; use `view: "full"` for the complete nested tree.
302
- 5. **`geometra_page_model`** is summary-first: page archetypes, stable section ids, counts, top-level landmarks/forms/dialogs/lists, and a few primary actions. It is designed to be cheaper than dumping full previews for every section.
303
- 6. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
304
- 7. After interactions, action tools return a **semantic delta** when possible (dialogs opened/closed, forms appeared/removed, list counts changed, named/focusable nodes added/removed/updated). If nothing meaningful changed, they fall back to a short current-UI overview.
305
- 8. Tools expose query, click, type, snapshot, page-model, and section-expansion operations over this structured data.
306
- 9. After each interaction, the peer sends updated geometry (full `frame` or `patch`) the MCP tools interpret that into compact summaries.
300
+ 5. **`geometra_form_schema`** is the compact form-specific path: stable field ids, required/invalid state, current values, and collapsed choice groups without layout-heavy section detail.
301
+ 6. **`geometra_fill_form`** turns a compact values object into semantic field operations server-side, so the model does not need to emit one tool call per field.
302
+ 7. **`geometra_page_model`** is still the right summary-first path for non-form exploration: page archetypes, stable section ids, counts, top-level landmarks/forms/dialogs/lists, and a few primary actions.
303
+ 8. **`geometra_expand_section`** fetches richer details only for the section you care about (fields, actions, headings, nested lists, list items, text preview).
304
+ 9. After interactions, action tools return a **semantic delta** when possible (dialogs opened/closed, forms appeared/removed, list counts changed, named/focusable nodes added/removed/updated). If nothing meaningful changed, they fall back to a short current-UI overview.
305
+ 10. After each interaction, the peer sends updated geometry (full `frame` or `patch`) — the MCP tools interpret that into compact summaries.
307
306
 
308
307
  ## Long Forms
309
308
 
310
309
  For long application flows, prefer one of these patterns:
311
310
 
312
- 1. `geometra_page_model`
313
- 2. `geometra_expand_section`
311
+ 1. `geometra_form_schema`
312
+ 2. `geometra_fill_form`
314
313
  3. `geometra_reveal` for far-below-fold targets such as submit buttons
315
- 4. `geometra_fill_fields` for obvious field entry
316
- 5. `geometra_run_actions` when you need mixed navigation + waits + field entry
314
+ 4. `geometra_run_actions` when you need mixed navigation + waits + field entry
315
+ 5. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
317
316
 
318
317
  Typical batch:
319
318
 
@@ -335,6 +334,23 @@ For the smallest long-form responses, prefer:
335
334
  1. `detail: "minimal"` for structured step metadata instead of narrated deltas
336
335
  2. `includeSteps: false` when you only need aggregate success/error counts plus the final validation/state payload
337
336
 
337
+ Typical low-token form fill:
338
+
339
+ ```json
340
+ {
341
+ "formId": "fm:1.0",
342
+ "valuesById": {
343
+ "ff:1.0.0": "Taylor Applicant",
344
+ "ff:1.0.1": "taylor@example.com",
345
+ "ff:1.0.2": "Germany",
346
+ "ff:1.0.3": "No"
347
+ },
348
+ "failOnInvalid": true,
349
+ "includeSteps": false,
350
+ "detail": "minimal"
351
+ }
352
+ ```
353
+
338
354
  For long single-page forms:
339
355
 
340
356
  1. Use `geometra_expand_section` with `fieldOffset` / `actionOffset` to page through large forms instead of taking a full snapshot.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
@@ -78,7 +78,22 @@ describe('proxy ready helpers', () => {
78
78
  const packageDir = path.join(scopeDir, 'proxy');
79
79
  const probePath = path.join(tempRoot, 'probe.cjs');
80
80
  mkdirSync(scopeDir, { recursive: true });
81
- symlinkSync(path.resolve(process.cwd(), 'packages/proxy'), packageDir, 'dir');
81
+ mkdirSync(path.join(packageDir, 'src'), { recursive: true });
82
+ writeFileSync(path.join(packageDir, 'package.json'), JSON.stringify({
83
+ name: '@geometra/proxy',
84
+ version: '0.0.0-test',
85
+ type: 'module',
86
+ }));
87
+ writeFileSync(path.join(packageDir, 'tsconfig.build.json'), JSON.stringify({
88
+ extends: path.resolve(process.cwd(), 'tsconfig.base.json'),
89
+ compilerOptions: {
90
+ outDir: 'dist',
91
+ rootDir: 'src',
92
+ noEmit: false,
93
+ },
94
+ include: ['src'],
95
+ }));
96
+ writeFileSync(path.join(packageDir, 'src', 'index.ts'), 'console.log("proxy");\n');
82
97
  writeFileSync(probePath, 'module.exports = {}');
83
98
  const customRequire = createRequire(probePath);
84
99
  const scriptPath = resolveProxyScriptPathWith(customRequire);
@@ -89,6 +104,33 @@ describe('proxy ready helpers', () => {
89
104
  rmSync(tempRoot, { recursive: true, force: true });
90
105
  }
91
106
  });
107
+ it('prefers the current workspace proxy dist over a bundled nested dependency in source checkouts', () => {
108
+ const tempRoot = mkdtempSync(path.join(tmpdir(), 'geometra-proxy-workspace-prefer-'));
109
+ try {
110
+ const workspaceDistDir = path.join(tempRoot, 'packages', 'proxy', 'dist');
111
+ const bundledProxyDir = path.join(tempRoot, 'mcp', 'node_modules', '@geometra', 'proxy');
112
+ const bundledDistDir = path.join(bundledProxyDir, 'dist');
113
+ const mcpDistDir = path.join(tempRoot, 'mcp', 'dist');
114
+ const probePath = path.join(mcpDistDir, 'proxy-spawn.cjs');
115
+ mkdirSync(workspaceDistDir, { recursive: true });
116
+ mkdirSync(bundledDistDir, { recursive: true });
117
+ mkdirSync(mcpDistDir, { recursive: true });
118
+ writeFileSync(path.join(workspaceDistDir, 'index.js'), 'export const source = "workspace";\n');
119
+ writeFileSync(path.join(bundledDistDir, 'index.js'), 'export const source = "bundled";\n');
120
+ writeFileSync(path.join(bundledProxyDir, 'package.json'), JSON.stringify({
121
+ name: '@geometra/proxy',
122
+ version: '0.0.0-test',
123
+ type: 'module',
124
+ }));
125
+ writeFileSync(probePath, 'module.exports = {};\n');
126
+ const customRequire = createRequire(probePath);
127
+ const scriptPath = resolveProxyScriptPathWith(customRequire, mcpDistDir);
128
+ expect(scriptPath).toBe(path.join(workspaceDistDir, 'index.js'));
129
+ }
130
+ finally {
131
+ rmSync(tempRoot, { recursive: true, force: true });
132
+ }
133
+ });
92
134
  it('falls back to the packaged sibling proxy dist when package exports are stale', () => {
93
135
  const tempRoot = mkdtempSync(path.join(tmpdir(), 'geometra-proxy-stale-exports-'));
94
136
  try {
@@ -23,6 +23,9 @@ const mockState = vi.hoisted(() => ({
23
23
  url: 'ws://127.0.0.1:3200',
24
24
  updateRevision: 1,
25
25
  },
26
+ formSchemas: [],
27
+ connect: vi.fn(),
28
+ connectThroughProxy: vi.fn(),
26
29
  sendClick: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
27
30
  sendType: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
28
31
  sendKey: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
@@ -36,8 +39,8 @@ const mockState = vi.hoisted(() => ({
36
39
  waitForUiCondition: vi.fn(async () => true),
37
40
  }));
38
41
  vi.mock('../session.js', () => ({
39
- connect: vi.fn(),
40
- connectThroughProxy: vi.fn(),
42
+ connect: mockState.connect,
43
+ connectThroughProxy: mockState.connectThroughProxy,
41
44
  disconnect: vi.fn(),
42
45
  getSession: vi.fn(() => mockState.session),
43
46
  sendClick: mockState.sendClick,
@@ -62,6 +65,7 @@ vi.mock('../session.js', () => ({
62
65
  dialogs: [],
63
66
  lists: [],
64
67
  })),
68
+ buildFormSchemas: vi.fn(() => mockState.formSchemas),
65
69
  expandPageSection: vi.fn(() => null),
66
70
  buildUiDelta: vi.fn(() => ({})),
67
71
  hasUiDelta: vi.fn(() => false),
@@ -79,6 +83,9 @@ function getToolHandler(name) {
79
83
  describe('batch MCP result shaping', () => {
80
84
  beforeEach(() => {
81
85
  vi.clearAllMocks();
86
+ mockState.connect.mockResolvedValue(mockState.session);
87
+ mockState.connectThroughProxy.mockResolvedValue(mockState.session);
88
+ mockState.formSchemas = [];
82
89
  mockState.currentA11yRoot = node('group', undefined, {
83
90
  meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 420 },
84
91
  children: [
@@ -203,6 +210,110 @@ describe('batch MCP result shaping', () => {
203
210
  expect(final.invalidFields.length).toBe(4);
204
211
  expect(final.alerts.length).toBe(1);
205
212
  });
213
+ it('returns a compact structured connect payload by default', async () => {
214
+ const handler = getToolHandler('geometra_connect');
215
+ const result = await handler({
216
+ pageUrl: 'https://jobs.example.com/application',
217
+ headless: true,
218
+ });
219
+ const payload = JSON.parse(result.content[0].text);
220
+ expect(payload).toMatchObject({
221
+ connected: true,
222
+ transport: 'proxy',
223
+ wsUrl: 'ws://127.0.0.1:3200',
224
+ pageUrl: 'https://jobs.example.com/application',
225
+ });
226
+ expect(payload).not.toHaveProperty('currentUi');
227
+ });
228
+ it('returns compact form schemas without requiring section expansion', async () => {
229
+ const handler = getToolHandler('geometra_form_schema');
230
+ mockState.formSchemas = [
231
+ {
232
+ formId: 'fm:0',
233
+ name: 'Application',
234
+ fieldCount: 4,
235
+ requiredCount: 3,
236
+ invalidCount: 0,
237
+ fields: [
238
+ { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
239
+ { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
240
+ { id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?', options: ['Yes', 'No'], optionCount: 2 },
241
+ { id: 'ff:0.3', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
242
+ ],
243
+ },
244
+ ];
245
+ const result = await handler({ maxFields: 20 });
246
+ const payload = JSON.parse(result.content[0].text);
247
+ expect(payload.forms).toEqual([
248
+ expect.objectContaining({
249
+ formId: 'fm:0',
250
+ fieldCount: 4,
251
+ requiredCount: 3,
252
+ invalidCount: 0,
253
+ }),
254
+ ]);
255
+ });
256
+ it('fills a form from ids and labels without echoing long essay content', async () => {
257
+ const longAnswer = 'B'.repeat(220);
258
+ const handler = getToolHandler('geometra_fill_form');
259
+ mockState.formSchemas = [
260
+ {
261
+ formId: 'fm:0',
262
+ name: 'Application',
263
+ fieldCount: 4,
264
+ requiredCount: 3,
265
+ invalidCount: 0,
266
+ fields: [
267
+ { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
268
+ { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', options: ['Yes', 'No'], optionCount: 2 },
269
+ { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
270
+ { id: 'ff:0.3', kind: 'text', label: 'Why Geometra?' },
271
+ ],
272
+ },
273
+ ];
274
+ mockState.currentA11yRoot = node('group', undefined, {
275
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 640 },
276
+ children: [
277
+ node('textbox', 'Full name', { value: 'Taylor Applicant', path: [0] }),
278
+ node('textbox', 'Why Geometra?', { value: longAnswer, path: [1] }),
279
+ node('checkbox', 'Share my profile for future roles', {
280
+ path: [2],
281
+ state: { checked: true },
282
+ }),
283
+ ],
284
+ });
285
+ const result = await handler({
286
+ valuesById: {
287
+ 'ff:0.0': 'Taylor Applicant',
288
+ },
289
+ valuesByLabel: {
290
+ 'Are you legally authorized to work in Germany?': true,
291
+ 'Share my profile for future roles': true,
292
+ 'Why Geometra?': longAnswer,
293
+ },
294
+ includeSteps: true,
295
+ detail: 'minimal',
296
+ });
297
+ const text = result.content[0].text;
298
+ const payload = JSON.parse(text);
299
+ const steps = payload.steps;
300
+ expect(text).not.toContain(longAnswer);
301
+ expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Are you legally authorized to work in Germany?', 'Yes', { exact: undefined, query: undefined }, undefined);
302
+ expect(payload).toMatchObject({
303
+ completed: true,
304
+ formId: 'fm:0',
305
+ requestedValueCount: 4,
306
+ fieldCount: 4,
307
+ successCount: 4,
308
+ errorCount: 0,
309
+ });
310
+ expect(steps[3]).toMatchObject({
311
+ kind: 'text',
312
+ fieldLabel: 'Why Geometra?',
313
+ valueLength: 220,
314
+ readback: { role: 'textbox', valueLength: 220 },
315
+ });
316
+ });
206
317
  });
207
318
  describe('query and reveal tools', () => {
208
319
  beforeEach(() => {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
2
+ import { buildA11yTree, buildCompactUiIndex, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
3
3
  function node(role, name, bounds, options) {
4
4
  return {
5
5
  role,
@@ -237,6 +237,135 @@ describe('buildPageModel', () => {
237
237
  expect(model.forms[0]?.name).toBeUndefined();
238
238
  });
239
239
  });
240
+ describe('buildFormSchemas', () => {
241
+ it('builds a compact fill-oriented schema and collapses repeated answer groups', () => {
242
+ const longEssay = 'Semantic browser automation should be reliable, compact, and predictable across large forms.';
243
+ const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
244
+ children: [
245
+ node('form', 'Application', { x: 32, y: 32, width: 760, height: 1500 }, {
246
+ path: [0],
247
+ children: [
248
+ node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
249
+ path: [0, 0],
250
+ state: { required: true },
251
+ }),
252
+ node('combobox', 'Preferred location', { x: 48, y: 180, width: 320, height: 36 }, {
253
+ path: [0, 1],
254
+ state: { required: true },
255
+ value: 'Berlin, Germany',
256
+ }),
257
+ node('group', undefined, { x: 40, y: 260, width: 520, height: 96 }, {
258
+ path: [0, 2],
259
+ children: [
260
+ node('text', 'Are you legally authorized to work in Germany?', { x: 48, y: 260, width: 360, height: 24 }, {
261
+ path: [0, 2, 0],
262
+ }),
263
+ node('button', 'Yes', { x: 48, y: 300, width: 88, height: 40 }, {
264
+ path: [0, 2, 1],
265
+ focusable: true,
266
+ state: { required: true },
267
+ }),
268
+ node('button', 'No', { x: 148, y: 300, width: 88, height: 40 }, {
269
+ path: [0, 2, 2],
270
+ focusable: true,
271
+ }),
272
+ ],
273
+ }),
274
+ node('checkbox', 'Share my profile for future roles', { x: 48, y: 400, width: 24, height: 24 }, {
275
+ path: [0, 3],
276
+ focusable: true,
277
+ state: { checked: true },
278
+ }),
279
+ node('textbox', 'Why Geometra?', { x: 48, y: 480, width: 520, height: 180 }, {
280
+ path: [0, 4],
281
+ state: { required: true, invalid: true },
282
+ validation: { error: 'Please enter at least 40 characters.' },
283
+ value: longEssay,
284
+ }),
285
+ ],
286
+ }),
287
+ ],
288
+ });
289
+ const schemas = buildFormSchemas(tree);
290
+ expect(schemas).toHaveLength(1);
291
+ expect(schemas[0]).toMatchObject({
292
+ formId: 'fm:0',
293
+ name: 'Application',
294
+ fieldCount: 5,
295
+ requiredCount: 4,
296
+ invalidCount: 1,
297
+ });
298
+ expect(schemas[0]?.fields).toEqual([
299
+ expect.objectContaining({
300
+ kind: 'text',
301
+ label: 'Full name',
302
+ required: true,
303
+ }),
304
+ expect.objectContaining({
305
+ kind: 'choice',
306
+ label: 'Preferred location',
307
+ required: true,
308
+ value: 'Berlin, Germany',
309
+ }),
310
+ expect.objectContaining({
311
+ kind: 'choice',
312
+ label: 'Are you legally authorized to work in Germany?',
313
+ required: true,
314
+ optionCount: 2,
315
+ options: ['Yes', 'No'],
316
+ }),
317
+ expect.objectContaining({
318
+ kind: 'toggle',
319
+ label: 'Share my profile for future roles',
320
+ checked: true,
321
+ controlType: 'checkbox',
322
+ }),
323
+ expect.objectContaining({
324
+ kind: 'text',
325
+ label: 'Why Geometra?',
326
+ required: true,
327
+ invalid: true,
328
+ valueLength: longEssay.length,
329
+ }),
330
+ ]);
331
+ });
332
+ it('prefers question prompts over nearby explanatory copy for grouped choices', () => {
333
+ const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
334
+ children: [
335
+ node('form', 'Application', { x: 20, y: 20, width: 760, height: 480 }, {
336
+ path: [0],
337
+ children: [
338
+ node('group', undefined, { x: 32, y: 80, width: 520, height: 120 }, {
339
+ path: [0, 0],
340
+ children: [
341
+ node('text', 'Will you now or in the future require sponsorship?', { x: 40, y: 80, width: 420, height: 24 }, {
342
+ path: [0, 0, 0],
343
+ }),
344
+ node('text', 'This intentionally repeats Yes / No labels to test contextual disambiguation.', { x: 40, y: 112, width: 520, height: 24 }, {
345
+ path: [0, 0, 1],
346
+ }),
347
+ node('button', 'Yes', { x: 40, y: 152, width: 88, height: 40 }, {
348
+ path: [0, 0, 2],
349
+ focusable: true,
350
+ }),
351
+ node('button', 'No', { x: 140, y: 152, width: 88, height: 40 }, {
352
+ path: [0, 0, 3],
353
+ focusable: true,
354
+ }),
355
+ ],
356
+ }),
357
+ ],
358
+ }),
359
+ ],
360
+ });
361
+ const schema = buildFormSchemas(tree)[0];
362
+ expect(schema?.fields[0]).toMatchObject({
363
+ kind: 'choice',
364
+ label: 'Will you now or in the future require sponsorship?',
365
+ options: ['Yes', 'No'],
366
+ });
367
+ });
368
+ });
240
369
  describe('buildUiDelta', () => {
241
370
  it('captures opened dialogs, state changes, and list count changes', () => {
242
371
  const before = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
@@ -1,5 +1,5 @@
1
- import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -13,9 +13,34 @@ export function resolveProxyScriptPath() {
13
13
  }
14
14
  export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
15
15
  const errors = [];
16
+ const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
17
+ const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
18
+ const packageDir = resolveProxyPackageDir(customRequire);
19
+ if (packageDir) {
20
+ if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
21
+ return workspaceDist;
22
+ }
23
+ const packagedDist = path.join(packageDir, 'dist/index.js');
24
+ if (existsSync(packagedDist))
25
+ return packagedDist;
26
+ const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
27
+ if (builtLocalDist)
28
+ return builtLocalDist;
29
+ errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/index.js was missing`);
30
+ }
31
+ else {
32
+ errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
33
+ }
16
34
  try {
17
35
  const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
18
- return path.join(path.dirname(pkgJson), 'dist/index.js');
36
+ const exportPackageDir = path.dirname(pkgJson);
37
+ const packagedDist = path.join(exportPackageDir, 'dist/index.js');
38
+ if (existsSync(packagedDist))
39
+ return packagedDist;
40
+ const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
41
+ if (builtLocalDist)
42
+ return builtLocalDist;
43
+ errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/index.js was missing`);
19
44
  }
20
45
  catch (err) {
21
46
  errors.push(err instanceof Error ? err.message : String(err));
@@ -31,13 +56,64 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
31
56
  return packagedSiblingDist;
32
57
  }
33
58
  errors.push(`Packaged sibling fallback not found at ${packagedSiblingDist}`);
34
- const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
35
59
  if (existsSync(workspaceDist)) {
36
60
  return workspaceDist;
37
61
  }
38
62
  errors.push(`Workspace fallback not found at ${workspaceDist}`);
39
63
  throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
40
64
  }
65
+ function resolveProxyPackageDir(customRequire) {
66
+ const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
67
+ for (const searchRoot of searchRoots) {
68
+ const packageDir = path.join(searchRoot, '@geometra', 'proxy');
69
+ if (existsSync(path.join(packageDir, 'package.json')))
70
+ return packageDir;
71
+ }
72
+ return undefined;
73
+ }
74
+ function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
75
+ try {
76
+ return realpathSync(packageDir) === realpathSync(bundledDependencyDir);
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ function buildLocalProxyDistIfPossible(packageDir, errors) {
83
+ const distEntry = path.join(packageDir, 'dist/index.js');
84
+ const sourceEntry = path.join(packageDir, 'src/index.ts');
85
+ const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
86
+ if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
87
+ return undefined;
88
+ }
89
+ try {
90
+ const realPackageDir = realpathSync(packageDir);
91
+ const realTsconfigPath = path.join(realPackageDir, 'tsconfig.build.json');
92
+ const realDistDir = path.join(realPackageDir, 'dist');
93
+ const tscBin = require.resolve('typescript/bin/tsc');
94
+ rmSync(realDistDir, { recursive: true, force: true });
95
+ const result = spawnSync(process.execPath, [tscBin, '-p', realTsconfigPath], {
96
+ cwd: realPackageDir,
97
+ encoding: 'utf8',
98
+ stdio: 'pipe',
99
+ });
100
+ if (result.status !== 0) {
101
+ const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
102
+ errors.push(`Failed to build local @geometra/proxy at ${realPackageDir}: ${detail || `exit ${result.status ?? 'unknown'}`}`);
103
+ return undefined;
104
+ }
105
+ if (existsSync(distEntry))
106
+ return distEntry;
107
+ const realDistEntry = path.join(realPackageDir, 'dist/index.js');
108
+ if (existsSync(realDistEntry))
109
+ return realDistEntry;
110
+ errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/index.js is still missing`);
111
+ }
112
+ catch (err) {
113
+ errors.push(err instanceof Error ? err.message : String(err));
114
+ }
115
+ return undefined;
116
+ }
41
117
  export function parseProxyReadySignalLine(line) {
42
118
  const trimmed = line.trim();
43
119
  if (!trimmed)
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
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, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
4
+ import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
5
5
  function checkedStateInput() {
6
6
  return z
7
7
  .union([z.boolean(), z.literal('mixed')])
@@ -66,6 +66,12 @@ const fillFieldSchema = z.discriminatedUnion('kind', [
66
66
  timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
67
67
  }),
68
68
  ]);
69
+ const formValueSchema = z.union([
70
+ z.string(),
71
+ z.boolean(),
72
+ z.array(z.string()).min(1),
73
+ ]);
74
+ const formValuesRecordSchema = z.record(z.string(), formValueSchema);
69
75
  const batchActionSchema = z.discriminatedUnion('type', [
70
76
  z.object({
71
77
  type: z.literal('click'),
@@ -146,7 +152,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
146
152
  }),
147
153
  ]);
148
154
  export function createServer() {
149
- const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
155
+ const server = new McpServer({ name: 'geometra', version: '1.19.13' }, { capabilities: { tools: {} } });
150
156
  // ── connect ──────────────────────────────────────────────────
151
157
  server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
152
158
 
@@ -184,6 +190,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
184
190
  .nonnegative()
185
191
  .optional()
186
192
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
193
+ detail: detailInput(),
187
194
  }, async (input) => {
188
195
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
189
196
  if (!normalized.ok)
@@ -199,13 +206,20 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
199
206
  height: input.height,
200
207
  slowMo: input.slowMo,
201
208
  });
202
- const summary = compactSessionSummary(session);
203
- const inferred = target.autoCoercedFromUrl ? ' inferred from url input' : '';
204
- return ok(`Started geometra-proxy and connected at ${session.url} (page: ${target.pageUrl}${inferred}). UI state:\n${summary}`);
209
+ return ok(JSON.stringify(connectPayload(session, {
210
+ transport: 'proxy',
211
+ requestedPageUrl: target.pageUrl,
212
+ autoCoercedFromUrl: target.autoCoercedFromUrl,
213
+ detail: input.detail,
214
+ }), null, input.detail === 'verbose' ? 2 : undefined));
205
215
  }
206
216
  const session = await connect(target.wsUrl);
207
- const summary = compactSessionSummary(session);
208
- return ok(`Connected to ${target.wsUrl}. UI state:\n${summary}`);
217
+ return ok(JSON.stringify(connectPayload(session, {
218
+ transport: 'ws',
219
+ requestedWsUrl: target.wsUrl,
220
+ autoCoercedFromUrl: false,
221
+ detail: input.detail,
222
+ }), null, input.detail === 'verbose' ? 2 : undefined));
209
223
  }
210
224
  catch (e) {
211
225
  return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
@@ -360,6 +374,85 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
360
374
  }
361
375
  return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
362
376
  });
377
+ server.tool('geometra_fill_form', `Fill a form from a compact values object instead of expanding sections first. This is the lowest-token happy path for standard application flows.
378
+
379
+ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most stable matching, or \`valuesByLabel\` when labels are unique enough. MCP resolves the form schema, executes the semantic field operations server-side, and returns one consolidated result.`, {
380
+ formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
381
+ valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
382
+ valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
383
+ stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
384
+ failOnInvalid: z
385
+ .boolean()
386
+ .optional()
387
+ .default(false)
388
+ .describe('Return an error if invalid fields remain after filling'),
389
+ includeSteps: z
390
+ .boolean()
391
+ .optional()
392
+ .default(false)
393
+ .describe('Include per-field step results in the JSON payload (default false for the smallest response)'),
394
+ detail: detailInput(),
395
+ }, async ({ formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, detail }) => {
396
+ const session = getSession();
397
+ if (!session)
398
+ return err('Not connected. Call geometra_connect first.');
399
+ const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
400
+ if (entryCount === 0) {
401
+ return err('Provide at least one value in valuesById or valuesByLabel');
402
+ }
403
+ const afterConnect = sessionA11y(session);
404
+ if (!afterConnect)
405
+ return err('No UI tree available for form filling');
406
+ const schemas = buildFormSchemas(afterConnect);
407
+ if (schemas.length === 0)
408
+ return err('No forms found in the current UI');
409
+ const resolution = resolveTargetFormSchema(schemas, { formId, valuesById, valuesByLabel });
410
+ if (!resolution.ok)
411
+ return err(resolution.error);
412
+ const schema = resolution.schema;
413
+ const planned = planFormFill(schema, { valuesById, valuesByLabel });
414
+ if (!planned.ok)
415
+ return err(planned.error);
416
+ const steps = [];
417
+ let stoppedAt;
418
+ for (let index = 0; index < planned.fields.length; index++) {
419
+ const field = planned.fields[index];
420
+ try {
421
+ const result = await executeFillField(session, field, detail);
422
+ steps.push(detail === 'verbose'
423
+ ? { index, kind: field.kind, ok: true, summary: result.summary }
424
+ : { index, kind: field.kind, ok: true, ...result.compact });
425
+ }
426
+ catch (e) {
427
+ const message = e instanceof Error ? e.message : String(e);
428
+ steps.push({ index, kind: field.kind, ok: false, error: message });
429
+ if (stopOnError) {
430
+ stoppedAt = index;
431
+ break;
432
+ }
433
+ }
434
+ }
435
+ const after = sessionA11y(session);
436
+ const signals = after ? collectSessionSignals(after) : undefined;
437
+ const invalidRemaining = signals?.invalidFields.length ?? 0;
438
+ const successCount = steps.filter(step => step.ok === true).length;
439
+ const errorCount = steps.length - successCount;
440
+ const payload = {
441
+ completed: stoppedAt === undefined && steps.length === planned.fields.length,
442
+ formId: schema.formId,
443
+ requestedValueCount: entryCount,
444
+ fieldCount: planned.fields.length,
445
+ successCount,
446
+ errorCount,
447
+ ...(includeSteps ? { steps } : {}),
448
+ ...(stoppedAt !== undefined ? { stoppedAt } : {}),
449
+ ...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
450
+ };
451
+ if (failOnInvalid && invalidRemaining > 0) {
452
+ return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
453
+ }
454
+ return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
455
+ });
363
456
  server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
364
457
 
365
458
  Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
@@ -436,6 +529,29 @@ Use this first on normal HTML pages when you want to understand the page shape w
436
529
  const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
437
530
  return ok(JSON.stringify(model));
438
531
  });
532
+ server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
533
+
534
+ Unlike geometra_expand_section, this collapses repeated radio/button groups into single logical fields, keeps output compact, and omits layout-heavy detail by default.`, {
535
+ formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
536
+ maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
537
+ onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
538
+ onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
539
+ }, async ({ formId, maxFields, onlyRequiredFields, onlyInvalidFields }) => {
540
+ const session = getSession();
541
+ if (!session?.tree || !session?.layout)
542
+ return err('Not connected. Call geometra_connect first.');
543
+ const a11y = buildA11yTree(session.tree, session.layout);
544
+ const forms = buildFormSchemas(a11y, {
545
+ formId,
546
+ maxFields,
547
+ onlyRequiredFields,
548
+ onlyInvalidFields,
549
+ });
550
+ if (forms.length === 0) {
551
+ return err(formId ? `No form schema found for id ${formId}` : 'No forms found in the current UI');
552
+ }
553
+ return ok(JSON.stringify({ forms }));
554
+ });
439
555
  server.tool('geometra_expand_section', `Expand one section from geometra_page_model by stable id. Returns richer on-demand details such as headings, fields, actions, nested lists, list items, and text preview.
440
556
 
441
557
  Use this after geometra_page_model when you know which form/dialog/list/landmark you want to inspect more closely. Per-item bounds are omitted by default to save tokens; set includeBounds=true if you need them immediately.`, {
@@ -863,6 +979,18 @@ function compactSessionSummary(session) {
863
979
  return 'No UI update received';
864
980
  return sessionOverviewFromA11y(a11y);
865
981
  }
982
+ function connectPayload(session, opts) {
983
+ const a11y = sessionA11y(session);
984
+ return {
985
+ connected: true,
986
+ transport: opts.transport,
987
+ wsUrl: session.url,
988
+ ...(a11y?.meta?.pageUrl || opts.requestedPageUrl ? { pageUrl: a11y?.meta?.pageUrl ?? opts.requestedPageUrl } : {}),
989
+ ...(opts.requestedWsUrl ? { requestedWsUrl: opts.requestedWsUrl } : {}),
990
+ ...(opts.autoCoercedFromUrl ? { autoCoercedFromUrl: true } : {}),
991
+ ...(opts.detail === 'verbose' && a11y ? { currentUi: sessionOverviewFromA11y(a11y) } : {}),
992
+ };
993
+ }
866
994
  function sessionA11y(session) {
867
995
  if (!session.tree || !session.layout)
868
996
  return null;
@@ -1072,6 +1200,120 @@ function waitStatusPayload(wait) {
1072
1200
  function compactFilterPayload(filter) {
1073
1201
  return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
1074
1202
  }
1203
+ function normalizeLookupKey(value) {
1204
+ return value.replace(/\s+/g, ' ').trim().toLowerCase();
1205
+ }
1206
+ function resolveTargetFormSchema(schemas, opts) {
1207
+ if (opts.formId) {
1208
+ const matched = schemas.find(schema => schema.formId === opts.formId);
1209
+ return matched
1210
+ ? { ok: true, schema: matched }
1211
+ : { ok: false, error: `No form schema found for id ${opts.formId}` };
1212
+ }
1213
+ if (schemas.length === 1)
1214
+ return { ok: true, schema: schemas[0] };
1215
+ const idKeys = Object.keys(opts.valuesById ?? {});
1216
+ const labelKeys = Object.keys(opts.valuesByLabel ?? {}).map(normalizeLookupKey);
1217
+ const matches = schemas.filter(schema => {
1218
+ const ids = new Set(schema.fields.map(field => field.id));
1219
+ const labels = new Set(schema.fields.map(field => normalizeLookupKey(field.label)));
1220
+ return idKeys.every(id => ids.has(id)) && labelKeys.every(label => labels.has(label));
1221
+ });
1222
+ if (matches.length === 1)
1223
+ return { ok: true, schema: matches[0] };
1224
+ if (matches.length === 0) {
1225
+ return {
1226
+ ok: false,
1227
+ error: 'Could not infer which form to fill from the provided field ids/labels. Pass formId from geometra_form_schema.',
1228
+ };
1229
+ }
1230
+ return {
1231
+ ok: false,
1232
+ error: 'Multiple forms match the provided field ids/labels. Pass formId from geometra_form_schema.',
1233
+ };
1234
+ }
1235
+ function coerceChoiceValue(field, value) {
1236
+ if (typeof value === 'string')
1237
+ return value;
1238
+ if (typeof value !== 'boolean')
1239
+ return null;
1240
+ const desired = value ? 'yes' : 'no';
1241
+ const option = field.options?.find(option => normalizeLookupKey(option) === desired);
1242
+ return option ?? (value ? 'Yes' : 'No');
1243
+ }
1244
+ function plannedFillInputsForField(field, value) {
1245
+ if (field.kind === 'text') {
1246
+ if (typeof value !== 'string')
1247
+ return { error: `Field "${field.label}" expects a string value` };
1248
+ return [{ kind: 'text', fieldLabel: field.label, value }];
1249
+ }
1250
+ if (field.kind === 'choice') {
1251
+ const coerced = coerceChoiceValue(field, value);
1252
+ if (!coerced)
1253
+ return { error: `Field "${field.label}" expects a string value` };
1254
+ return [{ kind: 'choice', fieldLabel: field.label, value: coerced }];
1255
+ }
1256
+ if (field.kind === 'toggle') {
1257
+ if (typeof value !== 'boolean')
1258
+ return { error: `Field "${field.label}" expects a boolean value` };
1259
+ return [{ kind: 'toggle', label: field.label, checked: value, controlType: field.controlType }];
1260
+ }
1261
+ const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
1262
+ if (!selected || selected.length === 0)
1263
+ return { error: `Field "${field.label}" expects a string array value` };
1264
+ if (!field.options || field.options.length === 0) {
1265
+ return { error: `Field "${field.label}" does not expose checkbox options; use geometra_fill_fields for this field` };
1266
+ }
1267
+ const selectedKeys = new Set(selected.map(normalizeLookupKey));
1268
+ return field.options.map(option => ({
1269
+ kind: 'toggle',
1270
+ label: option,
1271
+ checked: selectedKeys.has(normalizeLookupKey(option)),
1272
+ controlType: 'checkbox',
1273
+ }));
1274
+ }
1275
+ function planFormFill(schema, opts) {
1276
+ const fieldById = new Map(schema.fields.map(field => [field.id, field]));
1277
+ const fieldsByLabel = new Map();
1278
+ for (const field of schema.fields) {
1279
+ const key = normalizeLookupKey(field.label);
1280
+ const existing = fieldsByLabel.get(key);
1281
+ if (existing)
1282
+ existing.push(field);
1283
+ else
1284
+ fieldsByLabel.set(key, [field]);
1285
+ }
1286
+ const planned = [];
1287
+ const seenFieldIds = new Set();
1288
+ for (const [fieldId, value] of Object.entries(opts.valuesById ?? {})) {
1289
+ const field = fieldById.get(fieldId);
1290
+ if (!field)
1291
+ return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
1292
+ const next = plannedFillInputsForField(field, value);
1293
+ if ('error' in next)
1294
+ return { ok: false, error: next.error };
1295
+ planned.push(...next);
1296
+ seenFieldIds.add(field.id);
1297
+ }
1298
+ for (const [label, value] of Object.entries(opts.valuesByLabel ?? {})) {
1299
+ const matches = fieldsByLabel.get(normalizeLookupKey(label)) ?? [];
1300
+ if (matches.length === 0)
1301
+ return { ok: false, error: `Unknown form field label "${label}". Refresh geometra_form_schema and try again.` };
1302
+ if (matches.length > 1) {
1303
+ return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
1304
+ }
1305
+ const field = matches[0];
1306
+ if (seenFieldIds.has(field.id)) {
1307
+ return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
1308
+ }
1309
+ const next = plannedFillInputsForField(field, value);
1310
+ if ('error' in next)
1311
+ return { ok: false, error: next.error };
1312
+ planned.push(...next);
1313
+ seenFieldIds.add(field.id);
1314
+ }
1315
+ return { ok: true, fields: planned };
1316
+ }
1075
1317
  async function executeBatchAction(session, action, detail, includeSteps) {
1076
1318
  switch (action.type) {
1077
1319
  case 'click': {
package/dist/session.d.ts CHANGED
@@ -241,6 +241,30 @@ export interface PageSectionDetail {
241
241
  items: PageListItemModel[];
242
242
  textPreview: string[];
243
243
  }
244
+ export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
245
+ export interface FormSchemaField {
246
+ id: string;
247
+ kind: FormSchemaFieldKind;
248
+ label: string;
249
+ required?: boolean;
250
+ invalid?: boolean;
251
+ controlType?: 'checkbox' | 'radio';
252
+ value?: string;
253
+ valueLength?: number;
254
+ checked?: boolean;
255
+ values?: string[];
256
+ optionCount?: number;
257
+ options?: string[];
258
+ context?: NodeContextModel;
259
+ }
260
+ export interface FormSchemaModel {
261
+ formId: string;
262
+ name?: string;
263
+ fieldCount: number;
264
+ requiredCount: number;
265
+ invalidCount: number;
266
+ fields: FormSchemaField[];
267
+ }
244
268
  export interface UiNodeUpdate {
245
269
  before: CompactUiNode;
246
270
  after: CompactUiNode;
@@ -413,6 +437,12 @@ export declare function buildPageModel(root: A11yNode, options?: {
413
437
  maxPrimaryActions?: number;
414
438
  maxSectionsPerKind?: number;
415
439
  }): PageModel;
440
+ export declare function buildFormSchemas(root: A11yNode, options?: {
441
+ formId?: string;
442
+ maxFields?: number;
443
+ onlyRequiredFields?: boolean;
444
+ onlyInvalidFields?: boolean;
445
+ }): FormSchemaModel[];
416
446
  /**
417
447
  * Expand a page-model section by stable ID into richer, on-demand details.
418
448
  */
package/dist/session.js CHANGED
@@ -387,6 +387,9 @@ function decodePath(encoded) {
387
387
  export function nodeIdForPath(path) {
388
388
  return `n:${encodePath(path)}`;
389
389
  }
390
+ function formFieldIdForPath(path) {
391
+ return `ff:${encodePath(path)}`;
392
+ }
390
393
  function sectionPrefix(kind) {
391
394
  if (kind === 'landmark')
392
395
  return 'lm';
@@ -800,7 +803,9 @@ function nearestPromptText(container, target) {
800
803
  const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
801
804
  const dx = Math.abs(target.bounds.x - candidate.bounds.x);
802
805
  const headingBonus = candidate.role === 'heading' ? -32 : 0;
803
- return { text, score: dy * 4 + dx + headingBonus };
806
+ const questionBonus = /\?\s*$/.test(text) ? -160 : 0;
807
+ const lengthPenalty = text.length > 90 ? 80 : text.length > 60 ? 40 : text.length > 45 ? 20 : 0;
808
+ return { text, score: dy * 4 + dx + headingBonus + questionBonus + lengthPenalty };
804
809
  })
805
810
  .filter((candidate) => !!candidate)
806
811
  .sort((a, b) => a.score - b.score)[0];
@@ -809,13 +814,19 @@ function nearestPromptText(container, target) {
809
814
  function nodeContext(root, node) {
810
815
  const ancestors = ancestorNodes(root, node.path);
811
816
  let prompt;
812
- for (let index = ancestors.length - 1; index >= 0; index--) {
813
- const ancestor = ancestors[index];
814
- const grouped = countGroupedChoiceControls(ancestor) >= 2;
815
- if (grouped || ancestor.role === 'group' || ancestor.role === 'form' || ancestor.role === 'dialog') {
816
- prompt = nearestPromptText(ancestor, node);
817
- if (prompt)
818
- break;
817
+ const promptEligibleNode = node.role === 'radio' || node.role === 'button';
818
+ if (promptEligibleNode) {
819
+ for (let index = ancestors.length - 1; index >= 0; index--) {
820
+ const ancestor = ancestors[index];
821
+ const grouped = countGroupedChoiceControls(ancestor) >= 2;
822
+ const eligiblePromptContainer = (ancestor.role === 'group' && ancestor.path.length > 0) ||
823
+ ancestor.role === 'dialog' ||
824
+ (ancestor.role === 'form' && grouped);
825
+ if (eligiblePromptContainer) {
826
+ prompt = nearestPromptText(ancestor, node);
827
+ if (prompt)
828
+ break;
829
+ }
819
830
  }
820
831
  }
821
832
  let section;
@@ -868,6 +879,173 @@ function toActionModel(root, node, includeBounds = true) {
868
879
  ...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
869
880
  };
870
881
  }
882
+ function compactSchemaContext(context, label) {
883
+ if (!context)
884
+ return undefined;
885
+ const out = {};
886
+ if (context.prompt && normalizeUiText(context.prompt) !== normalizeUiText(label))
887
+ out.prompt = context.prompt;
888
+ if (context.section)
889
+ out.section = context.section;
890
+ return Object.keys(out).length > 0 ? out : undefined;
891
+ }
892
+ function compactSchemaValue(value, inlineLimit = 80) {
893
+ const normalized = sanitizeInlineName(value, Math.max(120, inlineLimit + 32));
894
+ if (!normalized)
895
+ return {};
896
+ return normalized.length <= inlineLimit
897
+ ? { value: normalized }
898
+ : { valueLength: normalized.length };
899
+ }
900
+ function schemaOptionLabel(node) {
901
+ return sanitizeFieldName(node.name, 80) ?? sanitizeInlineName(node.name, 80);
902
+ }
903
+ function isGroupedChoiceControl(node) {
904
+ return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
905
+ }
906
+ function groupedChoiceForNode(root, formNode, seed) {
907
+ const context = nodeContext(root, seed);
908
+ const prompt = context?.prompt;
909
+ if (!prompt)
910
+ return null;
911
+ const matchesPrompt = (candidate) => {
912
+ if (!isGroupedChoiceControl(candidate))
913
+ return false;
914
+ return nodeContext(root, candidate)?.prompt === prompt;
915
+ };
916
+ const ancestors = ancestorNodes(root, seed.path);
917
+ for (let index = ancestors.length - 1; index >= 0; index--) {
918
+ const ancestor = ancestors[index];
919
+ if (ancestor.role === 'form')
920
+ continue;
921
+ const controls = sortByBounds(collectDescendants(ancestor, matchesPrompt));
922
+ if (controls.length >= 2) {
923
+ return { container: ancestor, prompt, controls };
924
+ }
925
+ }
926
+ if (seed.role !== 'radio' && seed.role !== 'button')
927
+ return null;
928
+ const controls = sortByBounds(collectDescendants(formNode, matchesPrompt));
929
+ return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
930
+ }
931
+ function simpleSchemaField(root, node) {
932
+ const context = nodeContext(root, node);
933
+ const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
934
+ if (!label)
935
+ return null;
936
+ return {
937
+ id: formFieldIdForPath(node.path),
938
+ kind: node.role === 'combobox' ? 'choice' : 'text',
939
+ label,
940
+ ...(node.state?.required ? { required: true } : {}),
941
+ ...(node.state?.invalid ? { invalid: true } : {}),
942
+ ...compactSchemaValue(node.value, 72),
943
+ ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
944
+ };
945
+ }
946
+ function groupedSchemaField(root, grouped) {
947
+ const optionEntries = grouped.controls
948
+ .map(control => ({
949
+ label: schemaOptionLabel(control),
950
+ selected: control.state?.checked === true || control.state?.selected === true,
951
+ role: control.role,
952
+ }))
953
+ .filter((entry) => !!entry.label);
954
+ if (optionEntries.length < 2)
955
+ return null;
956
+ const options = dedupeStrings(optionEntries.map(entry => entry.label), 16);
957
+ const selectedOptions = dedupeStrings(optionEntries.filter(entry => entry.selected).map(entry => entry.label), 16);
958
+ const radioLike = optionEntries.every(entry => entry.role === 'radio' || entry.role === 'button');
959
+ const context = nodeContext(root, grouped.controls[0]);
960
+ return {
961
+ id: formFieldIdForPath(grouped.container.path),
962
+ kind: radioLike ? 'choice' : 'multi_choice',
963
+ label: grouped.prompt,
964
+ ...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
965
+ ...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
966
+ ...(radioLike
967
+ ? {
968
+ ...(selectedOptions[0] ? { value: selectedOptions[0] } : {}),
969
+ }
970
+ : {
971
+ ...(selectedOptions.length > 0 ? { values: selectedOptions } : {}),
972
+ }),
973
+ optionCount: options.length,
974
+ options,
975
+ ...(compactSchemaContext(context, grouped.prompt) ? { context: compactSchemaContext(context, grouped.prompt) } : {}),
976
+ };
977
+ }
978
+ function toggleSchemaField(root, node) {
979
+ const label = schemaOptionLabel(node);
980
+ if (!label)
981
+ return null;
982
+ const context = nodeContext(root, node);
983
+ const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
984
+ return {
985
+ id: formFieldIdForPath(node.path),
986
+ kind: 'toggle',
987
+ label,
988
+ controlType,
989
+ ...(node.state?.required ? { required: true } : {}),
990
+ ...(node.state?.invalid ? { invalid: true } : {}),
991
+ ...(node.state?.checked !== undefined ? { checked: node.state.checked === true } : {}),
992
+ ...(compactSchemaContext(context, label) ? { context: compactSchemaContext(context, label) } : {}),
993
+ };
994
+ }
995
+ function buildFormSchemaForNode(root, formNode, options) {
996
+ const candidates = sortByBounds(collectDescendants(formNode, candidate => candidate.role === 'textbox' ||
997
+ candidate.role === 'combobox' ||
998
+ candidate.role === 'checkbox' ||
999
+ candidate.role === 'radio' ||
1000
+ (candidate.role === 'button' && candidate.focusable)));
1001
+ const consumed = new Set();
1002
+ const fields = [];
1003
+ for (const candidate of candidates) {
1004
+ const candidateKey = pathKey(candidate.path);
1005
+ if (consumed.has(candidateKey))
1006
+ continue;
1007
+ if (candidate.role === 'textbox' || candidate.role === 'combobox') {
1008
+ const field = simpleSchemaField(root, candidate);
1009
+ if (field)
1010
+ fields.push(field);
1011
+ consumed.add(candidateKey);
1012
+ continue;
1013
+ }
1014
+ const grouped = groupedChoiceForNode(root, formNode, candidate);
1015
+ if (grouped && grouped.controls.some(control => pathKey(control.path) === candidateKey)) {
1016
+ const field = groupedSchemaField(root, grouped);
1017
+ for (const control of grouped.controls)
1018
+ consumed.add(pathKey(control.path));
1019
+ if (field)
1020
+ fields.push(field);
1021
+ continue;
1022
+ }
1023
+ if (candidate.role === 'checkbox' || candidate.role === 'radio') {
1024
+ const field = toggleSchemaField(root, candidate);
1025
+ if (field)
1026
+ fields.push(field);
1027
+ consumed.add(candidateKey);
1028
+ }
1029
+ }
1030
+ const filteredFields = fields.filter(field => {
1031
+ if (options?.onlyRequiredFields && !field.required)
1032
+ return false;
1033
+ if (options?.onlyInvalidFields && !field.invalid)
1034
+ return false;
1035
+ return true;
1036
+ });
1037
+ const maxFields = options?.maxFields ?? filteredFields.length;
1038
+ const pageFields = filteredFields.slice(0, maxFields);
1039
+ const name = sectionDisplayName(formNode, 'form');
1040
+ return {
1041
+ formId: sectionIdForPath('form', formNode.path),
1042
+ ...(name ? { name } : {}),
1043
+ fieldCount: fields.length,
1044
+ requiredCount: fields.filter(field => field.required).length,
1045
+ invalidCount: fields.filter(field => field.invalid).length,
1046
+ fields: pageFields,
1047
+ };
1048
+ }
871
1049
  function toLandmarkModel(node) {
872
1050
  const name = sectionDisplayName(node, 'landmark');
873
1051
  return {
@@ -991,6 +1169,15 @@ export function buildPageModel(root, options) {
991
1169
  archetypes: inferPageArchetypes(baseModel),
992
1170
  };
993
1171
  }
1172
+ export function buildFormSchemas(root, options) {
1173
+ const forms = sortByBounds([
1174
+ ...(root.role === 'form' ? [root] : []),
1175
+ ...collectDescendants(root, candidate => candidate.role === 'form'),
1176
+ ]);
1177
+ return forms
1178
+ .filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
1179
+ .map(form => buildFormSchemaForNode(root, form, options));
1180
+ }
994
1181
  function headingModels(node, maxHeadings, includeBounds) {
995
1182
  const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
996
1183
  return headings.slice(0, maxHeadings).map(heading => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.19.12",
3
+ "version": "1.19.13",
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.12",
33
+ "@geometra/proxy": "^1.19.13",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
35
  "ws": "^8.18.0",
36
36
  "zod": "^3.23.0"