@geometra/mcp 1.19.15 → 1.19.17

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, 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
+ | `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups; can auto-connect from `pageUrl` / `url` |
25
+ | `geometra_fill_form` | Fill a form from `valuesById` / `valuesByLabel` in one MCP call; can auto-connect from `pageUrl` / `url` for the lowest-token known-form path |
26
26
  | `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call; can return final-only status for the smallest responses |
27
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 |
28
28
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
@@ -297,8 +297,8 @@ Agent: geometra_query({ role: "button", name: "Save" })
297
297
  2. It receives the computed layout (`{ x, y, width, height }` for every node) and the UI tree (`kind`, `semantic`, `props`, `handlers`, `children`).
298
298
  3. It builds an accessibility tree from that data — roles, names, focusable state, bounds.
299
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.
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.
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. It can auto-connect when you pass `pageUrl` / `url`.
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. It can also auto-connect, which collapses “open page + fill known values” into a single tool call.
302
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
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
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.
@@ -308,8 +308,9 @@ Agent: geometra_query({ role: "button", name: "Save" })
308
308
 
309
309
  For long application flows, prefer one of these patterns:
310
310
 
311
- 1. `geometra_form_schema`
312
- 2. `geometra_fill_form`
311
+ 1. `geometra_fill_form({ pageUrl, valuesByLabel })` when you already know the fields you want to set
312
+ 2. otherwise `geometra_form_schema`
313
+ 3. then `geometra_fill_form`
313
314
  3. `geometra_reveal` for far-below-fold targets such as submit buttons
314
315
  4. `geometra_run_actions` when you need mixed navigation + waits + field entry
315
316
  5. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
@@ -1,6 +1,6 @@
1
1
  import { afterAll, describe, expect, it } from 'vitest';
2
2
  import { WebSocketServer } from 'ws';
3
- import { connect, disconnect, sendClick, sendFillFields, sendListboxPick } from '../session.js';
3
+ import { connect, disconnect, sendClick, sendFillFields, sendListboxPick, sendNavigate } from '../session.js';
4
4
  describe('proxy-backed MCP actions', () => {
5
5
  afterAll(() => {
6
6
  disconnect();
@@ -150,8 +150,8 @@ describe('proxy-backed MCP actions', () => {
150
150
  try {
151
151
  const session = await connect(`ws://127.0.0.1:${port}`);
152
152
  await expect(sendFillFields(session, [
153
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
154
- { kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
153
+ { kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
154
+ { kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Country', value: 'Germany' },
155
155
  ], 80)).resolves.toMatchObject({
156
156
  status: 'updated',
157
157
  timeoutMs: 80,
@@ -164,8 +164,8 @@ describe('proxy-backed MCP actions', () => {
164
164
  expect(seenMessage).toMatchObject({
165
165
  type: 'fillFields',
166
166
  fields: [
167
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
168
- { kind: 'choice', fieldLabel: 'Country', value: 'Germany' },
167
+ { kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
168
+ { kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Country', value: 'Germany' },
169
169
  ],
170
170
  });
171
171
  }
@@ -229,4 +229,62 @@ describe('proxy-backed MCP actions', () => {
229
229
  await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
230
230
  }
231
231
  });
232
+ it('supports in-session navigation and waits for the resulting frame', async () => {
233
+ const wss = new WebSocketServer({ port: 0 });
234
+ const received = [];
235
+ wss.on('connection', ws => {
236
+ ws.on('message', raw => {
237
+ const msg = JSON.parse(String(raw));
238
+ received.push(msg);
239
+ if (msg.type === 'resize') {
240
+ ws.send(JSON.stringify({
241
+ type: 'frame',
242
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
243
+ tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
244
+ }));
245
+ return;
246
+ }
247
+ if (msg.type === 'navigate') {
248
+ ws.send(JSON.stringify({
249
+ type: 'frame',
250
+ layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
251
+ tree: {
252
+ kind: 'box',
253
+ props: {},
254
+ semantic: { tag: 'body', role: 'group' },
255
+ children: [],
256
+ },
257
+ }));
258
+ ws.send(JSON.stringify({
259
+ type: 'ack',
260
+ requestId: msg.requestId,
261
+ result: { pageUrl: msg.url },
262
+ }));
263
+ }
264
+ });
265
+ });
266
+ const port = await new Promise((resolve, reject) => {
267
+ wss.once('listening', () => {
268
+ const address = wss.address();
269
+ if (typeof address === 'object' && address)
270
+ resolve(address.port);
271
+ else
272
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
273
+ });
274
+ wss.once('error', reject);
275
+ });
276
+ try {
277
+ const session = await connect(`ws://127.0.0.1:${port}`);
278
+ await expect(sendNavigate(session, 'https://jobs.example.com/application', 80)).resolves.toMatchObject({
279
+ status: 'updated',
280
+ timeoutMs: 80,
281
+ result: { pageUrl: 'https://jobs.example.com/application' },
282
+ });
283
+ expect(received.some(message => message.type === 'navigate' && message.url === 'https://jobs.example.com/application')).toBe(true);
284
+ }
285
+ finally {
286
+ disconnect();
287
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
288
+ }
289
+ });
232
290
  });
@@ -22,6 +22,9 @@ const mockState = vi.hoisted(() => ({
22
22
  layout: { x: 0, y: 0, width: 1280, height: 800, children: [] },
23
23
  url: 'ws://127.0.0.1:3200',
24
24
  updateRevision: 1,
25
+ cachedA11y: undefined,
26
+ cachedA11yRevision: undefined,
27
+ cachedFormSchemas: undefined,
25
28
  },
26
29
  formSchemas: [],
27
30
  connect: vi.fn(),
@@ -43,6 +46,18 @@ const mockState = vi.hoisted(() => ({
43
46
  sendWheel: vi.fn(async () => ({ status: 'updated', timeoutMs: 2000 })),
44
47
  waitForUiCondition: vi.fn(async () => true),
45
48
  }));
49
+ function resetMockSessionCaches() {
50
+ mockState.session.updateRevision = 1;
51
+ mockState.session.cachedA11y = undefined;
52
+ mockState.session.cachedA11yRevision = undefined;
53
+ mockState.session.cachedFormSchemas = undefined;
54
+ }
55
+ function bumpMockUiRevision() {
56
+ mockState.session.updateRevision += 1;
57
+ mockState.session.cachedA11y = undefined;
58
+ mockState.session.cachedA11yRevision = undefined;
59
+ mockState.session.cachedFormSchemas = undefined;
60
+ }
46
61
  vi.mock('../session.js', () => ({
47
62
  connect: mockState.connect,
48
63
  connectThroughProxy: mockState.connectThroughProxy,
@@ -89,6 +104,7 @@ function getToolHandler(name) {
89
104
  describe('batch MCP result shaping', () => {
90
105
  beforeEach(() => {
91
106
  vi.clearAllMocks();
107
+ resetMockSessionCaches();
92
108
  mockState.connect.mockResolvedValue(mockState.session);
93
109
  mockState.connectThroughProxy.mockResolvedValue(mockState.session);
94
110
  mockState.formSchemas = [];
@@ -231,6 +247,53 @@ describe('batch MCP result shaping', () => {
231
247
  });
232
248
  expect(payload).not.toHaveProperty('currentUi');
233
249
  });
250
+ it('can inline a packed form schema into connect for the low-turn form path', async () => {
251
+ const handler = getToolHandler('geometra_connect');
252
+ mockState.formSchemas = [
253
+ {
254
+ formId: 'fm:0',
255
+ name: 'Application',
256
+ fieldCount: 2,
257
+ requiredCount: 1,
258
+ invalidCount: 0,
259
+ fields: [
260
+ { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
261
+ { id: 'ff:0.1', kind: 'choice', label: 'Work authorization', choiceType: 'group', booleanChoice: true, optionCount: 2 },
262
+ ],
263
+ },
264
+ ];
265
+ const result = await handler({
266
+ pageUrl: 'https://jobs.example.com/application',
267
+ headless: true,
268
+ returnForms: true,
269
+ includeContext: 'none',
270
+ schemaFormat: 'packed',
271
+ });
272
+ const payload = JSON.parse(result.content[0].text);
273
+ expect(payload).toMatchObject({
274
+ connected: true,
275
+ transport: 'proxy',
276
+ pageUrl: 'https://jobs.example.com/application',
277
+ formSchema: {
278
+ changed: true,
279
+ formCount: 1,
280
+ format: 'packed',
281
+ schemaId: expect.any(String),
282
+ forms: [
283
+ {
284
+ i: 'fm:0',
285
+ fc: 2,
286
+ rc: 1,
287
+ ic: 0,
288
+ f: [
289
+ { i: 'ff:0.0', k: 'text', l: 'Full name', r: 1 },
290
+ { i: 'ff:0.1', k: 'choice', l: 'Work authorization', ch: 'group', b: 1, oc: 2 },
291
+ ],
292
+ },
293
+ ],
294
+ },
295
+ });
296
+ });
234
297
  it('returns compact form schemas without requiring section expansion', async () => {
235
298
  const handler = getToolHandler('geometra_form_schema');
236
299
  mockState.formSchemas = [
@@ -242,22 +305,73 @@ describe('batch MCP result shaping', () => {
242
305
  invalidCount: 0,
243
306
  fields: [
244
307
  { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
245
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
246
- { id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?', options: ['Yes', 'No'], optionCount: 2 },
308
+ { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
309
+ { id: 'ff:0.2', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
247
310
  { id: 'ff:0.3', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
248
311
  ],
249
312
  },
250
313
  ];
251
314
  const result = await handler({ maxFields: 20 });
252
315
  const payload = JSON.parse(result.content[0].text);
253
- expect(payload.forms).toEqual([
254
- expect.objectContaining({
316
+ expect(payload).toMatchObject({
317
+ changed: true,
318
+ formCount: 1,
319
+ format: 'compact',
320
+ schemaId: expect.any(String),
321
+ forms: [
322
+ expect.objectContaining({
323
+ formId: 'fm:0',
324
+ fieldCount: 4,
325
+ requiredCount: 3,
326
+ invalidCount: 0,
327
+ }),
328
+ ],
329
+ });
330
+ const forms = payload.forms;
331
+ const fields = forms[0]?.fields;
332
+ expect(fields[2]).toMatchObject({
333
+ id: 'ff:0.2',
334
+ kind: 'choice',
335
+ label: 'Are you legally authorized to work in Germany?',
336
+ choiceType: 'group',
337
+ booleanChoice: true,
338
+ optionCount: 2,
339
+ });
340
+ expect(fields[2]).not.toHaveProperty('options');
341
+ });
342
+ it('can auto-connect inside form_schema when given a pageUrl', async () => {
343
+ const handler = getToolHandler('geometra_form_schema');
344
+ mockState.formSchemas = [
345
+ {
255
346
  formId: 'fm:0',
256
- fieldCount: 4,
257
- requiredCount: 3,
347
+ name: 'Application',
348
+ fieldCount: 1,
349
+ requiredCount: 1,
258
350
  invalidCount: 0,
259
- }),
260
- ]);
351
+ fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name', required: true }],
352
+ },
353
+ ];
354
+ const result = await handler({
355
+ pageUrl: 'https://jobs.example.com/application',
356
+ headless: true,
357
+ });
358
+ const payload = JSON.parse(result.content[0].text);
359
+ expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
360
+ pageUrl: 'https://jobs.example.com/application',
361
+ port: undefined,
362
+ headless: true,
363
+ width: undefined,
364
+ height: undefined,
365
+ slowMo: undefined,
366
+ awaitInitialFrame: undefined,
367
+ });
368
+ expect(payload).toMatchObject({
369
+ autoConnected: true,
370
+ transport: 'proxy',
371
+ pageUrl: 'https://jobs.example.com/application',
372
+ changed: true,
373
+ formCount: 1,
374
+ });
261
375
  });
262
376
  it('fills a form from ids and labels without echoing long essay content', async () => {
263
377
  const longAnswer = 'B'.repeat(220);
@@ -271,7 +385,7 @@ describe('batch MCP result shaping', () => {
271
385
  invalidCount: 0,
272
386
  fields: [
273
387
  { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
274
- { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', options: ['Yes', 'No'], optionCount: 2 },
388
+ { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
275
389
  { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
276
390
  { id: 'ff:0.3', kind: 'text', label: 'Why Geometra?' },
277
391
  ],
@@ -304,7 +418,7 @@ describe('batch MCP result shaping', () => {
304
418
  const payload = JSON.parse(text);
305
419
  const steps = payload.steps;
306
420
  expect(text).not.toContain(longAnswer);
307
- expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Are you legally authorized to work in Germany?', 'Yes', { exact: undefined, query: undefined }, undefined);
421
+ expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Are you legally authorized to work in Germany?', 'Yes', { exact: undefined, query: undefined, choiceType: 'group', fieldId: 'ff:0.1' }, undefined);
308
422
  expect(payload).toMatchObject({
309
423
  completed: true,
310
424
  formId: 'fm:0',
@@ -342,7 +456,7 @@ describe('batch MCP result shaping', () => {
342
456
  invalidCount: 0,
343
457
  fields: [
344
458
  { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
345
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true },
459
+ { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, choiceType: 'select' },
346
460
  { id: 'ff:0.2', kind: 'toggle', label: 'Share my profile for future roles', controlType: 'checkbox' },
347
461
  ],
348
462
  },
@@ -369,10 +483,11 @@ describe('batch MCP result shaping', () => {
369
483
  });
370
484
  const payload = JSON.parse(result.content[0].text);
371
485
  expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
372
- { kind: 'text', fieldLabel: 'Full name', value: 'Taylor Applicant' },
373
- { kind: 'choice', fieldLabel: 'Preferred location', value: 'Berlin, Germany' },
486
+ { kind: 'text', fieldId: 'ff:0.0', fieldLabel: 'Full name', value: 'Taylor Applicant' },
487
+ { kind: 'choice', fieldId: 'ff:0.1', fieldLabel: 'Preferred location', value: 'Berlin, Germany', choiceType: 'select' },
374
488
  {
375
489
  kind: 'toggle',
490
+ fieldId: 'ff:0.2',
376
491
  label: 'Share my profile for future roles',
377
492
  checked: true,
378
493
  controlType: 'checkbox',
@@ -396,10 +511,77 @@ describe('batch MCP result shaping', () => {
396
511
  });
397
512
  expect(payload).not.toHaveProperty('steps');
398
513
  });
514
+ it('can auto-connect inside fill_form for known-label one-turn flows', async () => {
515
+ const handler = getToolHandler('geometra_fill_form');
516
+ mockState.sendFillFields.mockResolvedValueOnce({
517
+ status: 'acknowledged',
518
+ timeoutMs: 6000,
519
+ result: {
520
+ pageUrl: 'https://jobs.example.com/application',
521
+ invalidCount: 0,
522
+ alertCount: 0,
523
+ dialogCount: 0,
524
+ busyCount: 0,
525
+ },
526
+ });
527
+ mockState.formSchemas = [
528
+ {
529
+ formId: 'fm:0',
530
+ name: 'Application',
531
+ fieldCount: 2,
532
+ requiredCount: 2,
533
+ invalidCount: 0,
534
+ fields: [
535
+ { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true },
536
+ { id: 'ff:0.1', kind: 'choice', label: 'Are you legally authorized to work in Germany?', choiceType: 'group', booleanChoice: true, optionCount: 2 },
537
+ ],
538
+ },
539
+ ];
540
+ const result = await handler({
541
+ pageUrl: 'https://jobs.example.com/application',
542
+ headless: true,
543
+ valuesByLabel: {
544
+ 'Full name': 'Taylor Applicant',
545
+ 'Are you legally authorized to work in Germany?': true,
546
+ },
547
+ includeSteps: false,
548
+ detail: 'minimal',
549
+ });
550
+ const payload = JSON.parse(result.content[0].text);
551
+ expect(mockState.connectThroughProxy).toHaveBeenCalledWith({
552
+ pageUrl: 'https://jobs.example.com/application',
553
+ port: undefined,
554
+ headless: true,
555
+ width: undefined,
556
+ height: undefined,
557
+ slowMo: undefined,
558
+ awaitInitialFrame: false,
559
+ });
560
+ expect(mockState.sendFillFields).toHaveBeenCalledWith(mockState.session, [
561
+ { kind: 'auto', fieldLabel: 'Full name', value: 'Taylor Applicant' },
562
+ { kind: 'auto', fieldLabel: 'Are you legally authorized to work in Germany?', value: true },
563
+ ]);
564
+ expect(payload).toMatchObject({
565
+ autoConnected: true,
566
+ transport: 'proxy',
567
+ pageUrl: 'https://jobs.example.com/application',
568
+ completed: true,
569
+ execution: 'batched-direct',
570
+ finalSource: 'proxy',
571
+ fieldCount: 2,
572
+ successCount: 2,
573
+ errorCount: 0,
574
+ final: {
575
+ invalidCount: 0,
576
+ alertCount: 0,
577
+ },
578
+ });
579
+ });
399
580
  });
400
581
  describe('query and reveal tools', () => {
401
582
  beforeEach(() => {
402
583
  vi.clearAllMocks();
584
+ resetMockSessionCaches();
403
585
  });
404
586
  it('lets query disambiguate repeated controls by context text', async () => {
405
587
  const handler = getToolHandler('geometra_query');
@@ -461,7 +643,7 @@ describe('query and reveal tools', () => {
461
643
  invalidCount: 2,
462
644
  fields: [
463
645
  { id: 'ff:0.0', kind: 'text', label: 'Full name', required: true, invalid: true },
464
- { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true },
646
+ { id: 'ff:0.1', kind: 'choice', label: 'Preferred location', required: true, invalid: true, choiceType: 'select' },
465
647
  ],
466
648
  },
467
649
  ];
@@ -490,8 +672,8 @@ describe('query and reveal tools', () => {
490
672
  });
491
673
  const payload = JSON.parse(result.content[0].text);
492
674
  expect(mockState.sendFillFields).toHaveBeenCalledTimes(1);
493
- expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined }, undefined);
494
- expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined }, undefined);
675
+ expect(mockState.sendFieldText).toHaveBeenCalledWith(mockState.session, 'Full name', 'Taylor Applicant', { exact: undefined, fieldId: 'ff:0.0' }, undefined);
676
+ expect(mockState.sendFieldChoice).toHaveBeenCalledWith(mockState.session, 'Preferred location', 'Berlin, Germany', { exact: undefined, query: undefined, choiceType: 'select', fieldId: 'ff:0.1' }, undefined);
495
677
  expect(payload).toMatchObject({
496
678
  completed: true,
497
679
  execution: 'sequential',
@@ -537,6 +719,7 @@ describe('query and reveal tools', () => {
537
719
  }),
538
720
  ],
539
721
  });
722
+ bumpMockUiRevision();
540
723
  return { status: 'updated', timeoutMs: 2500 };
541
724
  });
542
725
  const result = await handler({
@@ -253,6 +253,7 @@ describe('buildFormSchemas', () => {
253
253
  path: [0, 1],
254
254
  state: { required: true },
255
255
  value: 'Berlin, Germany',
256
+ meta: { controlTag: 'select' },
256
257
  }),
257
258
  node('group', undefined, { x: 40, y: 260, width: 520, height: 96 }, {
258
259
  path: [0, 2],
@@ -304,15 +305,17 @@ describe('buildFormSchemas', () => {
304
305
  expect.objectContaining({
305
306
  kind: 'choice',
306
307
  label: 'Preferred location',
308
+ choiceType: 'select',
307
309
  required: true,
308
310
  value: 'Berlin, Germany',
309
311
  }),
310
312
  expect.objectContaining({
311
313
  kind: 'choice',
312
314
  label: 'Are you legally authorized to work in Germany?',
315
+ choiceType: 'group',
313
316
  required: true,
314
317
  optionCount: 2,
315
- options: ['Yes', 'No'],
318
+ booleanChoice: true,
316
319
  }),
317
320
  expect.objectContaining({
318
321
  kind: 'toggle',
@@ -328,8 +331,9 @@ describe('buildFormSchemas', () => {
328
331
  valueLength: longEssay.length,
329
332
  }),
330
333
  ]);
334
+ expect(schemas[0]?.fields[2]).not.toHaveProperty('options');
331
335
  });
332
- it('prefers question prompts over nearby explanatory copy for grouped choices', () => {
336
+ it('includes explicit options when requested and prefers question prompts over nearby explanatory copy', () => {
333
337
  const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
334
338
  children: [
335
339
  node('form', 'Application', { x: 20, y: 20, width: 760, height: 480 }, {
@@ -358,11 +362,16 @@ describe('buildFormSchemas', () => {
358
362
  }),
359
363
  ],
360
364
  });
361
- const schema = buildFormSchemas(tree)[0];
365
+ const schema = buildFormSchemas(tree, { includeOptions: true, includeContext: 'always' })[0];
362
366
  expect(schema?.fields[0]).toMatchObject({
363
367
  kind: 'choice',
368
+ choiceType: 'group',
364
369
  label: 'Will you now or in the future require sponsorship?',
365
370
  options: ['Yes', 'No'],
371
+ booleanChoice: true,
372
+ context: {
373
+ section: 'Application',
374
+ },
366
375
  });
367
376
  });
368
377
  });
@@ -1,7 +1,14 @@
1
1
  import { type ChildProcess } from 'node:child_process';
2
+ export interface EmbeddedProxyRuntime {
3
+ wsUrl: string;
4
+ closed: boolean;
5
+ close: () => Promise<void>;
6
+ }
2
7
  /** Resolve bundled @geometra/proxy CLI entry (dist/index.js). */
3
8
  export declare function resolveProxyScriptPath(): string;
4
9
  export declare function resolveProxyScriptPathWith(customRequire: NodeRequire, moduleDir?: string): string;
10
+ export declare function resolveProxyRuntimePath(): string;
11
+ export declare function resolveProxyRuntimePathWith(customRequire: NodeRequire, moduleDir?: string): string;
5
12
  export interface SpawnProxyParams {
6
13
  pageUrl: string;
7
14
  port: number;
@@ -10,6 +17,10 @@ export interface SpawnProxyParams {
10
17
  height?: number;
11
18
  slowMo?: number;
12
19
  }
20
+ export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
21
+ runtime: EmbeddedProxyRuntime;
22
+ wsUrl: string;
23
+ }>;
13
24
  export declare function parseProxyReadySignalLine(line: string): string | undefined;
14
25
  export declare function formatProxyStartupFailure(message: string, opts: SpawnProxyParams): string;
15
26
  /**
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from 'node:child_process';
2
2
  import { existsSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  const require = createRequire(import.meta.url);
7
7
  const READY_SIGNAL_TYPE = 'geometra-proxy-ready';
8
8
  const READY_TIMEOUT_MS = 45_000;
@@ -12,21 +12,30 @@ export function resolveProxyScriptPath() {
12
12
  return resolveProxyScriptPathWith(require);
13
13
  }
14
14
  export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
15
+ return resolveProxyDistPathWith(customRequire, moduleDir, 'index.js');
16
+ }
17
+ export function resolveProxyRuntimePath() {
18
+ return resolveProxyRuntimePathWith(require);
19
+ }
20
+ export function resolveProxyRuntimePathWith(customRequire, moduleDir = MODULE_DIR) {
21
+ return resolveProxyDistPathWith(customRequire, moduleDir, 'runtime.js');
22
+ }
23
+ function resolveProxyDistPathWith(customRequire, moduleDir, entryFile) {
15
24
  const errors = [];
16
- const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
25
+ const workspaceDist = path.resolve(moduleDir, `../../packages/proxy/dist/${entryFile}`);
17
26
  const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
18
27
  const packageDir = resolveProxyPackageDir(customRequire);
19
28
  if (packageDir) {
20
29
  if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
21
30
  return workspaceDist;
22
31
  }
23
- const packagedDist = path.join(packageDir, 'dist/index.js');
32
+ const packagedDist = path.join(packageDir, 'dist', entryFile);
24
33
  if (existsSync(packagedDist))
25
34
  return packagedDist;
26
- const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
35
+ const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, entryFile, errors);
27
36
  if (builtLocalDist)
28
37
  return builtLocalDist;
29
- errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/index.js was missing`);
38
+ errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/${entryFile} was missing`);
30
39
  }
31
40
  else {
32
41
  errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
@@ -34,24 +43,26 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
34
43
  try {
35
44
  const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
36
45
  const exportPackageDir = path.dirname(pkgJson);
37
- const packagedDist = path.join(exportPackageDir, 'dist/index.js');
46
+ const packagedDist = path.join(exportPackageDir, 'dist', entryFile);
38
47
  if (existsSync(packagedDist))
39
48
  return packagedDist;
40
- const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
49
+ const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, entryFile, errors);
41
50
  if (builtLocalDist)
42
51
  return builtLocalDist;
43
- errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/index.js was missing`);
52
+ errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/${entryFile} was missing`);
44
53
  }
45
54
  catch (err) {
46
55
  errors.push(err instanceof Error ? err.message : String(err));
47
56
  }
48
- try {
49
- return customRequire.resolve('@geometra/proxy');
50
- }
51
- catch (err) {
52
- errors.push(err instanceof Error ? err.message : String(err));
57
+ if (entryFile === 'index.js') {
58
+ try {
59
+ return customRequire.resolve('@geometra/proxy');
60
+ }
61
+ catch (err) {
62
+ errors.push(err instanceof Error ? err.message : String(err));
63
+ }
53
64
  }
54
- const packagedSiblingDist = path.resolve(moduleDir, '../../proxy/dist/index.js');
65
+ const packagedSiblingDist = path.resolve(moduleDir, `../../proxy/dist/${entryFile}`);
55
66
  if (existsSync(packagedSiblingDist)) {
56
67
  return packagedSiblingDist;
57
68
  }
@@ -60,7 +71,7 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
60
71
  return workspaceDist;
61
72
  }
62
73
  errors.push(`Workspace fallback not found at ${workspaceDist}`);
63
- throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
74
+ throw new Error(`Could not resolve @geometra/proxy dist/${entryFile}. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
64
75
  }
65
76
  function resolveProxyPackageDir(customRequire) {
66
77
  const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
@@ -79,8 +90,8 @@ function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
79
90
  return false;
80
91
  }
81
92
  }
82
- function buildLocalProxyDistIfPossible(packageDir, errors) {
83
- const distEntry = path.join(packageDir, 'dist/index.js');
93
+ function buildLocalProxyDistIfPossible(packageDir, entryFile, errors) {
94
+ const distEntry = path.join(packageDir, 'dist', entryFile);
84
95
  const sourceEntry = path.join(packageDir, 'src/index.ts');
85
96
  const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
86
97
  if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
@@ -104,16 +115,32 @@ function buildLocalProxyDistIfPossible(packageDir, errors) {
104
115
  }
105
116
  if (existsSync(distEntry))
106
117
  return distEntry;
107
- const realDistEntry = path.join(realPackageDir, 'dist/index.js');
118
+ const realDistEntry = path.join(realPackageDir, 'dist', entryFile);
108
119
  if (existsSync(realDistEntry))
109
120
  return realDistEntry;
110
- errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/index.js is still missing`);
121
+ errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/${entryFile} is still missing`);
111
122
  }
112
123
  catch (err) {
113
124
  errors.push(err instanceof Error ? err.message : String(err));
114
125
  }
115
126
  return undefined;
116
127
  }
128
+ export async function startEmbeddedGeometraProxy(opts) {
129
+ const runtimePath = resolveProxyRuntimePath();
130
+ const runtimeModule = await import(pathToFileURL(runtimePath).href);
131
+ if (typeof runtimeModule.launchProxyRuntime !== 'function') {
132
+ throw new Error(`Resolved ${runtimePath}, but it did not export launchProxyRuntime()`);
133
+ }
134
+ const runtime = await runtimeModule.launchProxyRuntime({
135
+ url: opts.pageUrl,
136
+ port: opts.port,
137
+ width: opts.width,
138
+ height: opts.height,
139
+ headed: opts.headless !== true,
140
+ slowMo: opts.slowMo,
141
+ });
142
+ return { runtime, wsUrl: runtime.wsUrl };
143
+ }
117
144
  export function parseProxyReadySignalLine(line) {
118
145
  const trimmed = line.trim();
119
146
  if (!trimmed)