@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 +37 -21
- package/dist/__tests__/connect-utils.test.js +44 -2
- package/dist/__tests__/server-batch-results.test.js +113 -2
- package/dist/__tests__/session-model.test.js +130 -1
- package/dist/proxy-spawn.js +80 -4
- package/dist/server.js +249 -7
- package/dist/session.d.ts +30 -0
- package/dist/session.js +195 -8
- package/package.json +2 -2
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:
|
|
279
|
-
→ {"
|
|
280
|
+
Agent: geometra_form_schema({})
|
|
281
|
+
→ {"forms":[{"formId":"fm:1.0","fields":[{"id":"ff:1.0.0","label":"Email"}, ...]}]}
|
|
280
282
|
|
|
281
|
-
Agent:
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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. **`
|
|
303
|
-
6. **`
|
|
304
|
-
7.
|
|
305
|
-
8.
|
|
306
|
-
9. After
|
|
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. `
|
|
313
|
-
2. `
|
|
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. `
|
|
316
|
-
5. `
|
|
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,
|
|
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
|
-
|
|
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:
|
|
40
|
-
connectThroughProxy:
|
|
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 }, {
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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.
|
|
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.
|
|
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"
|