@geometra/mcp 1.19.8 → 1.19.10
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 +44 -2
- package/dist/__tests__/proxy-session-actions.test.js +97 -1
- package/dist/__tests__/server-filters.test.d.ts +1 -0
- package/dist/__tests__/server-filters.test.js +57 -0
- package/dist/__tests__/session-model.test.js +103 -3
- package/dist/server.d.ts +19 -0
- package/dist/server.js +770 -53
- package/dist/session.d.ts +31 -8
- package/dist/session.js +144 -22
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,13 +19,16 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
|
|
|
19
19
|
| Tool | Description |
|
|
20
20
|
|---|---|
|
|
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
|
-
| `geometra_query` | Find elements by stable id, role, name,
|
|
22
|
+
| `geometra_query` | Find elements by stable id, role, name, text content, current value, or semantic state such as `invalid`, `required`, or `busy` |
|
|
23
|
+
| `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
|
|
24
|
+
| `geometra_fill_fields` | Fill labeled text/choice/toggle/file fields in one MCP call |
|
|
25
|
+
| `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip and get one consolidated result |
|
|
23
26
|
| `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
|
|
24
27
|
| `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand |
|
|
25
28
|
| `geometra_click` | Click an element by coordinates |
|
|
26
29
|
| `geometra_type` | Type text into the focused element |
|
|
27
30
|
| `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
|
|
28
|
-
| `geometra_upload_files` | Attach files: auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
|
|
31
|
+
| `geometra_upload_files` | Attach files: labeled field / auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
|
|
29
32
|
| `geometra_pick_listbox_option` | Pick an option from a custom dropdown/searchable combobox; can open by field label (`@geometra/proxy` only) |
|
|
30
33
|
| `geometra_select_option` | Choose an option on a native `<select>` (`@geometra/proxy` only) |
|
|
31
34
|
| `geometra_set_checked` | Set a checkbox or radio by label instead of coordinate clicks (`@geometra/proxy` only) |
|
|
@@ -173,4 +176,43 @@ Agent: geometra_query({ role: "button", name: "Save" })
|
|
|
173
176
|
8. Tools expose query, click, type, snapshot, page-model, and section-expansion operations over this structured data.
|
|
174
177
|
9. After each interaction, the peer sends updated geometry (full `frame` or `patch`) — the MCP tools interpret that into compact summaries.
|
|
175
178
|
|
|
179
|
+
## Long Forms
|
|
180
|
+
|
|
181
|
+
For long application flows, prefer one of these patterns:
|
|
182
|
+
|
|
183
|
+
1. `geometra_page_model`
|
|
184
|
+
2. `geometra_expand_section`
|
|
185
|
+
3. `geometra_fill_fields` for obvious field entry
|
|
186
|
+
4. `geometra_run_actions` when you need mixed navigation + waits + field entry
|
|
187
|
+
|
|
188
|
+
Typical batch:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"actions": [
|
|
193
|
+
{ "type": "click", "x": 412, "y": 228 },
|
|
194
|
+
{ "type": "type", "text": "Taylor Applicant" },
|
|
195
|
+
{ "type": "upload_files", "paths": ["/Users/you/resume.pdf"], "fieldLabel": "Resume" },
|
|
196
|
+
{ "type": "wait_for", "text": "Parsing your resume", "present": false, "timeoutMs": 10000 }
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Single action tools now default to terse summaries. Pass `detail: "verbose"` when you need a fuller current-UI fallback for debugging.
|
|
202
|
+
|
|
203
|
+
Typical field fill:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"fields": [
|
|
208
|
+
{ "kind": "text", "fieldLabel": "Full name", "value": "Taylor Applicant" },
|
|
209
|
+
{ "kind": "text", "fieldLabel": "Email", "value": "taylor@example.com" },
|
|
210
|
+
{ "kind": "choice", "fieldLabel": "Country", "value": "Germany" },
|
|
211
|
+
{ "kind": "choice", "fieldLabel": "Will you require sponsorship?", "value": "No" },
|
|
212
|
+
{ "kind": "file", "fieldLabel": "Resume", "paths": ["/Users/you/resume.pdf"] }
|
|
213
|
+
],
|
|
214
|
+
"failOnInvalid": true
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
176
218
|
With a **native** Geometra server, layout comes from Textura/Yoga. With **`@geometra/proxy`**, layout comes from the browser’s computed DOM geometry; the MCP layer is the same.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { connect, disconnect, sendListboxPick } from '../session.js';
|
|
3
|
+
import { connect, disconnect, sendClick, sendListboxPick } from '../session.js';
|
|
4
4
|
describe('proxy-backed MCP actions', () => {
|
|
5
5
|
afterAll(() => {
|
|
6
6
|
disconnect();
|
|
@@ -56,4 +56,100 @@ describe('proxy-backed MCP actions', () => {
|
|
|
56
56
|
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
|
+
it('falls back to the latest observed update when a legacy peer does not send request-scoped ack', async () => {
|
|
60
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
61
|
+
wss.on('connection', ws => {
|
|
62
|
+
ws.on('message', raw => {
|
|
63
|
+
const msg = JSON.parse(String(raw));
|
|
64
|
+
if (msg.type === 'resize') {
|
|
65
|
+
ws.send(JSON.stringify({
|
|
66
|
+
type: 'frame',
|
|
67
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
68
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
69
|
+
}));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === 'event') {
|
|
73
|
+
ws.send(JSON.stringify({
|
|
74
|
+
type: 'frame',
|
|
75
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
76
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group', ariaLabel: 'Updated' }, children: [] },
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
const port = await new Promise((resolve, reject) => {
|
|
82
|
+
wss.once('listening', () => {
|
|
83
|
+
const address = wss.address();
|
|
84
|
+
if (typeof address === 'object' && address)
|
|
85
|
+
resolve(address.port);
|
|
86
|
+
else
|
|
87
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
88
|
+
});
|
|
89
|
+
wss.once('error', reject);
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
93
|
+
await expect(sendClick(session, 5, 5, 60)).resolves.toMatchObject({ status: 'updated', timeoutMs: 60 });
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
disconnect();
|
|
97
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('ignores invalid patch paths instead of mutating ancestor layout nodes', async () => {
|
|
101
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
102
|
+
wss.on('connection', ws => {
|
|
103
|
+
ws.on('message', raw => {
|
|
104
|
+
const msg = JSON.parse(String(raw));
|
|
105
|
+
if (msg.type === 'resize') {
|
|
106
|
+
ws.send(JSON.stringify({
|
|
107
|
+
type: 'frame',
|
|
108
|
+
layout: {
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0,
|
|
111
|
+
width: 200,
|
|
112
|
+
height: 100,
|
|
113
|
+
children: [{ x: 10, y: 20, width: 30, height: 40, children: [] }],
|
|
114
|
+
},
|
|
115
|
+
tree: {
|
|
116
|
+
kind: 'box',
|
|
117
|
+
props: {},
|
|
118
|
+
semantic: { tag: 'body', role: 'group' },
|
|
119
|
+
children: [{ kind: 'box', props: {}, semantic: { tag: 'div', role: 'group' }, children: [] }],
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
ws.send(JSON.stringify({
|
|
124
|
+
type: 'patch',
|
|
125
|
+
patches: [{ path: [9], x: 999, y: 999 }],
|
|
126
|
+
}));
|
|
127
|
+
}, 10);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const port = await new Promise((resolve, reject) => {
|
|
132
|
+
wss.once('listening', () => {
|
|
133
|
+
const address = wss.address();
|
|
134
|
+
if (typeof address === 'object' && address)
|
|
135
|
+
resolve(address.port);
|
|
136
|
+
else
|
|
137
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
138
|
+
});
|
|
139
|
+
wss.once('error', reject);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
144
|
+
expect(session.layout).toMatchObject({
|
|
145
|
+
x: 0,
|
|
146
|
+
y: 0,
|
|
147
|
+
children: [{ x: 10, y: 20 }],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
disconnect();
|
|
152
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
59
155
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { findNodes } from '../server.js';
|
|
3
|
+
function node(role, bounds, options) {
|
|
4
|
+
return {
|
|
5
|
+
role,
|
|
6
|
+
...(options?.name ? { name: options.name } : {}),
|
|
7
|
+
...(options?.value ? { value: options.value } : {}),
|
|
8
|
+
...(options?.state ? { state: options.state } : {}),
|
|
9
|
+
...(options?.validation ? { validation: options.validation } : {}),
|
|
10
|
+
bounds,
|
|
11
|
+
path: options?.path ?? [],
|
|
12
|
+
children: options?.children ?? [],
|
|
13
|
+
focusable: options?.focusable ?? false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
describe('findNodes', () => {
|
|
17
|
+
it('matches value and checked-state filters generically', () => {
|
|
18
|
+
const tree = node('group', { x: 0, y: 0, width: 800, height: 600 }, {
|
|
19
|
+
children: [
|
|
20
|
+
node('combobox', { x: 20, y: 20, width: 260, height: 36 }, {
|
|
21
|
+
path: [0],
|
|
22
|
+
name: 'Location',
|
|
23
|
+
value: 'Austin, Texas, United States',
|
|
24
|
+
focusable: true,
|
|
25
|
+
}),
|
|
26
|
+
node('textbox', { x: 20, y: 120, width: 260, height: 36 }, {
|
|
27
|
+
path: [1],
|
|
28
|
+
name: 'Email',
|
|
29
|
+
focusable: true,
|
|
30
|
+
state: { invalid: true, required: true, busy: true },
|
|
31
|
+
validation: { error: 'Please enter a valid email address.' },
|
|
32
|
+
}),
|
|
33
|
+
node('checkbox', { x: 20, y: 72, width: 24, height: 24 }, {
|
|
34
|
+
path: [2],
|
|
35
|
+
name: 'Notion Website',
|
|
36
|
+
state: { checked: true },
|
|
37
|
+
focusable: true,
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
expect(findNodes(tree, { value: 'Austin, Texas' })).toEqual([
|
|
42
|
+
expect.objectContaining({ role: 'combobox', name: 'Location' }),
|
|
43
|
+
]);
|
|
44
|
+
expect(findNodes(tree, { text: 'United States' })).toEqual([
|
|
45
|
+
expect.objectContaining({ role: 'combobox', value: 'Austin, Texas, United States' }),
|
|
46
|
+
]);
|
|
47
|
+
expect(findNodes(tree, { invalid: true, required: true, busy: true })).toEqual([
|
|
48
|
+
expect.objectContaining({ role: 'textbox', name: 'Email' }),
|
|
49
|
+
]);
|
|
50
|
+
expect(findNodes(tree, { text: 'valid email address' })).toEqual([
|
|
51
|
+
expect.objectContaining({ role: 'textbox', name: 'Email' }),
|
|
52
|
+
]);
|
|
53
|
+
expect(findNodes(tree, { role: 'checkbox', checked: true })).toEqual([
|
|
54
|
+
expect.objectContaining({ name: 'Notion Website' }),
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
2
|
+
import { buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
3
3
|
function node(role, name, bounds, options) {
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
6
6
|
...(name ? { name } : {}),
|
|
7
|
+
...(options?.value ? { value: options.value } : {}),
|
|
7
8
|
...(options?.state ? { state: options.state } : {}),
|
|
9
|
+
...(options?.validation ? { validation: options.validation } : {}),
|
|
8
10
|
...(options?.meta ? { meta: options.meta } : {}),
|
|
9
11
|
bounds,
|
|
10
12
|
path: options?.path ?? [],
|
|
@@ -83,8 +85,17 @@ describe('buildPageModel', () => {
|
|
|
83
85
|
path: [0, 0],
|
|
84
86
|
children: [
|
|
85
87
|
node('heading', 'Application', { x: 60, y: 132, width: 200, height: 24 }, { path: [0, 0, 0] }),
|
|
86
|
-
node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
|
|
87
|
-
|
|
88
|
+
node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
|
|
89
|
+
path: [0, 0, 1],
|
|
90
|
+
value: 'Taylor Applicant',
|
|
91
|
+
state: { required: true },
|
|
92
|
+
}),
|
|
93
|
+
node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, {
|
|
94
|
+
path: [0, 0, 2],
|
|
95
|
+
value: 'taylor@example.com',
|
|
96
|
+
state: { invalid: true, required: true },
|
|
97
|
+
validation: { error: 'Please enter a valid email address.' },
|
|
98
|
+
}),
|
|
88
99
|
node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
|
|
89
100
|
path: [0, 0, 3],
|
|
90
101
|
focusable: true,
|
|
@@ -108,6 +119,10 @@ describe('buildPageModel', () => {
|
|
|
108
119
|
},
|
|
109
120
|
});
|
|
110
121
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
122
|
+
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
123
|
+
expect(detail?.fields[0]?.state).toEqual({ required: true });
|
|
124
|
+
expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
|
|
125
|
+
expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
|
|
111
126
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
112
127
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
113
128
|
});
|
|
@@ -280,4 +295,89 @@ describe('buildUiDelta', () => {
|
|
|
280
295
|
expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
|
|
281
296
|
expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
|
|
282
297
|
});
|
|
298
|
+
it('includes control values in compact indexes and semantic deltas', () => {
|
|
299
|
+
const before = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
|
|
300
|
+
children: [
|
|
301
|
+
node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
|
|
302
|
+
path: [0],
|
|
303
|
+
focusable: true,
|
|
304
|
+
value: 'Austin',
|
|
305
|
+
}),
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
const after = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
|
|
309
|
+
children: [
|
|
310
|
+
node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
|
|
311
|
+
path: [0],
|
|
312
|
+
focusable: true,
|
|
313
|
+
value: 'Austin, Texas, United States',
|
|
314
|
+
}),
|
|
315
|
+
],
|
|
316
|
+
});
|
|
317
|
+
const compact = buildCompactUiIndex(after, { maxNodes: 10 });
|
|
318
|
+
expect(compact.nodes[0]).toMatchObject({
|
|
319
|
+
role: 'textbox',
|
|
320
|
+
name: 'Location',
|
|
321
|
+
value: 'Austin, Texas, United States',
|
|
322
|
+
});
|
|
323
|
+
const delta = buildUiDelta(before, after);
|
|
324
|
+
expect(delta.updated).toEqual([
|
|
325
|
+
expect.objectContaining({
|
|
326
|
+
changes: [
|
|
327
|
+
'value "Austin" -> "Austin, Texas, United States"',
|
|
328
|
+
],
|
|
329
|
+
}),
|
|
330
|
+
]);
|
|
331
|
+
expect(summarizeUiDelta(delta)).toContain('value "Austin" -> "Austin, Texas, United States"');
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
describe('buildA11yTree', () => {
|
|
335
|
+
it('maps required, invalid, busy, and validation text from raw semantic nodes', () => {
|
|
336
|
+
const tree = {
|
|
337
|
+
kind: 'box',
|
|
338
|
+
props: {},
|
|
339
|
+
semantic: {},
|
|
340
|
+
children: [
|
|
341
|
+
{
|
|
342
|
+
kind: 'box',
|
|
343
|
+
props: { value: '' },
|
|
344
|
+
semantic: {
|
|
345
|
+
role: 'textbox',
|
|
346
|
+
ariaLabel: 'Email',
|
|
347
|
+
ariaRequired: true,
|
|
348
|
+
ariaInvalid: true,
|
|
349
|
+
ariaBusy: true,
|
|
350
|
+
validationDescription: 'We will contact you about this role.',
|
|
351
|
+
validationError: 'Please enter a valid email address.',
|
|
352
|
+
},
|
|
353
|
+
handlers: { onClick: true, onKeyDown: true },
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
const layout = {
|
|
358
|
+
x: 0,
|
|
359
|
+
y: 0,
|
|
360
|
+
width: 800,
|
|
361
|
+
height: 600,
|
|
362
|
+
children: [
|
|
363
|
+
{
|
|
364
|
+
x: 24,
|
|
365
|
+
y: 40,
|
|
366
|
+
width: 320,
|
|
367
|
+
height: 36,
|
|
368
|
+
children: [],
|
|
369
|
+
},
|
|
370
|
+
],
|
|
371
|
+
};
|
|
372
|
+
const a11y = buildA11yTree(tree, layout);
|
|
373
|
+
expect(a11y.children[0]).toMatchObject({
|
|
374
|
+
role: 'textbox',
|
|
375
|
+
name: 'Email',
|
|
376
|
+
state: { required: true, invalid: true, busy: true },
|
|
377
|
+
validation: {
|
|
378
|
+
description: 'We will contact you about this role.',
|
|
379
|
+
error: 'Please enter a valid email address.',
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
});
|
|
283
383
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,21 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { A11yNode } from './session.js';
|
|
3
|
+
type NodeStateFilterValue = boolean | 'mixed';
|
|
4
|
+
interface NodeFilter {
|
|
5
|
+
id?: string;
|
|
6
|
+
role?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
checked?: NodeStateFilterValue;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
focused?: boolean;
|
|
13
|
+
selected?: boolean;
|
|
14
|
+
expanded?: boolean;
|
|
15
|
+
invalid?: boolean;
|
|
16
|
+
required?: boolean;
|
|
17
|
+
busy?: boolean;
|
|
18
|
+
}
|
|
2
19
|
export declare function createServer(): McpServer;
|
|
20
|
+
export declare function findNodes(node: A11yNode, filter: NodeFilter): A11yNode[];
|
|
21
|
+
export {};
|