@geometra/mcp 1.19.17 → 1.19.19

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
@@ -14,11 +14,17 @@ Native Geometra: WebSocket → JSON geometry (no browser on the agent path)
14
14
  Geometra proxy: Chromium → DOM geometry → same WebSocket as native → MCP tools unchanged (often started via `pageUrl`, no manual CLI)
15
15
  ```
16
16
 
17
+ ## When to choose this
18
+
19
+ Use Geometra MCP when an LLM needs to explore, interpret, and operate a real UI with compact semantic state instead of repeatedly consuming large browser snapshots. Keep Playwright-style tooling for deterministic scripts, DOM-oriented test automation, and compatibility fallback paths while Geometra closes remaining live-site gaps.
20
+
21
+ Proxy-backed sessions stay warm by default on disconnect, and MCP now keeps a small warm pool so compatible headed and headless workflows do not immediately evict each other.
22
+
17
23
  ## Tools
18
24
 
19
25
  | Tool | Description |
20
26
  |---|---|
21
- | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; `url: "https://…"` is auto-coerced onto the proxy path |
27
+ | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; can inline `formSchema` and/or `pageModel` for lower-turn starts |
22
28
  | `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
29
  | `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.) |
24
30
  | `geometra_form_schema` | Compact, fill-oriented form schema with stable field ids and collapsed radio/button groups; can auto-connect from `pageUrl` / `url` |
@@ -28,7 +34,7 @@ Geometra proxy: Chromium → DOM geometry → same WebSocket as native →
28
34
  | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions |
29
35
  | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
30
36
  | `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas |
31
- | `geometra_click` | Click an element by coordinates |
37
+ | `geometra_click` | Click by coordinates or semantic target, optionally waiting for a post-click semantic condition |
32
38
  | `geometra_type` | Type text into the focused element |
33
39
  | `geometra_key` | Send special keys (Enter, Tab, Escape, arrows) |
34
40
  | `geometra_upload_files` | Attach files: labeled field / auto / hidden input / native chooser / synthetic drop (`@geometra/proxy` only) |
@@ -251,19 +257,17 @@ Then in Claude Code (either backend):
251
257
  Agent: geometra_connect({ url: "ws://localhost:3100" })
252
258
  → Connected. UI: button "Sign Up", textbox "Email", textbox "Password"
253
259
 
254
- Agent: geometra_query({ role: "textbox", name: "Email" })
255
- [{ role: "textbox", name: "Email", bounds: {x:100, y:200, w:300, h:40}, center: {x:250, y:220} }]
256
-
257
- Agent: geometra_click({ x: 250, y: 220 })
258
- → Clicked. Email input focused.
260
+ Agent: geometra_click({ role: "textbox", name: "Email" })
261
+ Clicked textbox "Email" (n:0) at (250, 220). Email input focused.
259
262
 
260
263
  Agent: geometra_type({ text: "test@example.com" })
261
264
  → Typed. Email field updated.
262
265
 
263
- Agent: geometra_query({ role: "button", name: "Sign Up" })
264
- → [{ role: "button", name: "Sign Up", bounds: {x:100, y:350, w:120, h:44}, center: {x:160, y:372} }]
265
-
266
- Agent: geometra_click({ x: 160, y: 372 })
266
+ Agent: geometra_click({
267
+ role: "button",
268
+ name: "Sign Up",
269
+ waitFor: { role: "dialog", name: "Success" }
270
+ })
267
271
  → Clicked. Success message visible.
268
272
  ```
269
273
 
@@ -312,18 +316,20 @@ For long application flows, prefer one of these patterns:
312
316
  2. otherwise `geometra_form_schema`
313
317
  3. then `geometra_fill_form`
314
318
  3. `geometra_reveal` for far-below-fold targets such as submit buttons
315
- 4. `geometra_run_actions` when you need mixed navigation + waits + field entry
316
- 5. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
319
+ 4. `geometra_click({ ..., waitFor: ... })` when one action should also wait for the next semantic state
320
+ 5. `geometra_run_actions` when you need mixed navigation + waits + field entry
321
+ 6. `geometra_connect({ pageUrl, returnPageModel: true })` when you want connect + summary-first exploration in one turn
322
+ 7. `geometra_page_model` + `geometra_expand_section` when you are still exploring the page rather than filling it
317
323
 
318
324
  Typical batch:
319
325
 
320
326
  ```json
321
327
  {
322
328
  "actions": [
323
- { "type": "click", "x": 412, "y": 228 },
329
+ { "type": "click", "role": "textbox", "name": "Full name" },
324
330
  { "type": "type", "text": "Taylor Applicant" },
325
331
  { "type": "upload_files", "paths": ["/Users/you/resume.pdf"], "fieldLabel": "Resume" },
326
- { "type": "wait_for", "text": "Parsing your resume", "present": false, "timeoutMs": 10000 }
332
+ { "type": "click", "role": "button", "name": "Submit", "waitFor": { "text": "Application submitted", "timeoutMs": 10000 } }
327
333
  ]
328
334
  }
329
335
  ```
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { WebSocketServer } from 'ws';
3
+ const mockState = vi.hoisted(() => ({
4
+ startEmbeddedGeometraProxy: vi.fn(),
5
+ spawnGeometraProxy: vi.fn(),
6
+ }));
7
+ vi.mock('../proxy-spawn.js', () => ({
8
+ startEmbeddedGeometraProxy: mockState.startEmbeddedGeometraProxy,
9
+ spawnGeometraProxy: mockState.spawnGeometraProxy,
10
+ }));
11
+ const { connectThroughProxy, disconnect } = await import('../session.js');
12
+ function frame(pageUrl) {
13
+ return {
14
+ type: 'frame',
15
+ layout: { x: 0, y: 0, width: 1280, height: 720, children: [] },
16
+ tree: {
17
+ kind: 'box',
18
+ props: {},
19
+ semantic: {
20
+ tag: 'body',
21
+ role: 'group',
22
+ pageUrl,
23
+ },
24
+ children: [],
25
+ },
26
+ };
27
+ }
28
+ async function createProxyPeer(options) {
29
+ const wss = new WebSocketServer({ port: 0 });
30
+ wss.on('connection', ws => {
31
+ ws.send(JSON.stringify(frame(options?.pageUrl ?? 'https://jobs.example.com/original')));
32
+ ws.on('message', raw => {
33
+ const msg = JSON.parse(String(raw));
34
+ if (msg.type === 'navigate') {
35
+ options?.onNavigate?.(ws, msg);
36
+ }
37
+ });
38
+ });
39
+ const port = await new Promise((resolve, reject) => {
40
+ wss.once('listening', () => {
41
+ const address = wss.address();
42
+ if (typeof address === 'object' && address)
43
+ resolve(address.port);
44
+ else
45
+ reject(new Error('Failed to resolve ephemeral WebSocket port'));
46
+ });
47
+ wss.once('error', reject);
48
+ });
49
+ return {
50
+ wss,
51
+ wsUrl: `ws://127.0.0.1:${port}`,
52
+ };
53
+ }
54
+ afterEach(async () => {
55
+ disconnect({ closeProxy: true });
56
+ vi.clearAllMocks();
57
+ });
58
+ async function closePeer(wss) {
59
+ for (const client of wss.clients) {
60
+ client.close();
61
+ }
62
+ await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
63
+ }
64
+ describe('connectThroughProxy recovery', () => {
65
+ it('restarts from a fresh proxy when a reused browser session was already closed', async () => {
66
+ const stalePeer = await createProxyPeer({
67
+ pageUrl: 'https://jobs.example.com/original',
68
+ onNavigate(ws, msg) {
69
+ ws.send(JSON.stringify({
70
+ type: 'error',
71
+ requestId: msg.requestId,
72
+ message: 'page.goto: Target page, context or browser has been closed',
73
+ }));
74
+ },
75
+ });
76
+ const freshPeer = await createProxyPeer({
77
+ pageUrl: 'https://jobs.example.com/recovered',
78
+ });
79
+ const staleRuntime = {
80
+ wsUrl: stalePeer.wsUrl,
81
+ closed: false,
82
+ close: vi.fn(async () => {
83
+ staleRuntime.closed = true;
84
+ }),
85
+ };
86
+ const freshRuntime = {
87
+ wsUrl: freshPeer.wsUrl,
88
+ closed: false,
89
+ close: vi.fn(async () => {
90
+ freshRuntime.closed = true;
91
+ }),
92
+ };
93
+ mockState.startEmbeddedGeometraProxy
94
+ .mockResolvedValueOnce({ runtime: staleRuntime, wsUrl: stalePeer.wsUrl })
95
+ .mockResolvedValueOnce({ runtime: freshRuntime, wsUrl: freshPeer.wsUrl });
96
+ mockState.spawnGeometraProxy.mockRejectedValue(new Error('spawn fallback should not be used'));
97
+ try {
98
+ const firstSession = await connectThroughProxy({
99
+ pageUrl: 'https://jobs.example.com/original',
100
+ headless: true,
101
+ });
102
+ expect(firstSession.proxyRuntime).toBe(staleRuntime);
103
+ const recoveredSession = await connectThroughProxy({
104
+ pageUrl: 'https://jobs.example.com/recovered',
105
+ headless: true,
106
+ });
107
+ expect(recoveredSession.proxyRuntime).toBe(freshRuntime);
108
+ expect(mockState.startEmbeddedGeometraProxy).toHaveBeenCalledTimes(2);
109
+ expect(staleRuntime.close).toHaveBeenCalledTimes(1);
110
+ expect(mockState.spawnGeometraProxy).not.toHaveBeenCalled();
111
+ }
112
+ finally {
113
+ disconnect({ closeProxy: true });
114
+ await closePeer(stalePeer.wss);
115
+ await closePeer(freshPeer.wss);
116
+ }
117
+ });
118
+ it('keeps separate warm proxies for compatible headed and headless reuse', async () => {
119
+ const headedPeer = await createProxyPeer({
120
+ pageUrl: 'https://jobs.example.com/headed',
121
+ });
122
+ const headlessPeer = await createProxyPeer({
123
+ pageUrl: 'https://jobs.example.com/headless',
124
+ });
125
+ const headedRuntime = {
126
+ wsUrl: headedPeer.wsUrl,
127
+ closed: false,
128
+ close: vi.fn(async () => {
129
+ headedRuntime.closed = true;
130
+ }),
131
+ };
132
+ const headlessRuntime = {
133
+ wsUrl: headlessPeer.wsUrl,
134
+ closed: false,
135
+ close: vi.fn(async () => {
136
+ headlessRuntime.closed = true;
137
+ }),
138
+ };
139
+ mockState.startEmbeddedGeometraProxy
140
+ .mockResolvedValueOnce({ runtime: headedRuntime, wsUrl: headedPeer.wsUrl })
141
+ .mockResolvedValueOnce({ runtime: headlessRuntime, wsUrl: headlessPeer.wsUrl });
142
+ mockState.spawnGeometraProxy.mockRejectedValue(new Error('spawn fallback should not be used'));
143
+ try {
144
+ const headedSession = await connectThroughProxy({
145
+ pageUrl: 'https://jobs.example.com/headed',
146
+ headless: false,
147
+ });
148
+ expect(headedSession.proxyRuntime).toBe(headedRuntime);
149
+ disconnect();
150
+ const headlessSession = await connectThroughProxy({
151
+ pageUrl: 'https://jobs.example.com/headless',
152
+ headless: true,
153
+ });
154
+ expect(headlessSession.proxyRuntime).toBe(headlessRuntime);
155
+ disconnect();
156
+ const reusedHeadedSession = await connectThroughProxy({
157
+ pageUrl: 'https://jobs.example.com/headed',
158
+ headless: false,
159
+ });
160
+ expect(reusedHeadedSession.proxyRuntime).toBe(headedRuntime);
161
+ expect(mockState.startEmbeddedGeometraProxy).toHaveBeenCalledTimes(2);
162
+ expect(headedRuntime.close).not.toHaveBeenCalled();
163
+ expect(headlessRuntime.close).not.toHaveBeenCalled();
164
+ }
165
+ finally {
166
+ disconnect({ closeProxy: true });
167
+ expect(headedRuntime.close).toHaveBeenCalledTimes(1);
168
+ expect(headlessRuntime.close).toHaveBeenCalledTimes(1);
169
+ await closePeer(headedPeer.wss);
170
+ await closePeer(headlessPeer.wss);
171
+ }
172
+ });
173
+ });
@@ -294,6 +294,28 @@ describe('batch MCP result shaping', () => {
294
294
  },
295
295
  });
296
296
  });
297
+ it('can inline the page model into connect for the low-turn exploration path', async () => {
298
+ const handler = getToolHandler('geometra_connect');
299
+ const result = await handler({
300
+ pageUrl: 'https://jobs.example.com/application',
301
+ headless: true,
302
+ returnPageModel: true,
303
+ maxPrimaryActions: 4,
304
+ maxSectionsPerKind: 3,
305
+ });
306
+ const payload = JSON.parse(result.content[0].text);
307
+ expect(payload).toMatchObject({
308
+ connected: true,
309
+ transport: 'proxy',
310
+ pageUrl: 'https://jobs.example.com/application',
311
+ pageModel: {
312
+ viewport: { width: 1280, height: 800 },
313
+ archetypes: ['form'],
314
+ summary: { formCount: 1 },
315
+ },
316
+ });
317
+ expect(payload).not.toHaveProperty('formSchema');
318
+ });
297
319
  it('returns compact form schemas without requiring section expansion', async () => {
298
320
  const handler = getToolHandler('geometra_form_schema');
299
321
  mockState.formSchemas = [
@@ -741,4 +763,222 @@ describe('query and reveal tools', () => {
741
763
  },
742
764
  });
743
765
  });
766
+ it('clicks an offscreen semantic target by revealing it first', async () => {
767
+ const handler = getToolHandler('geometra_click');
768
+ mockState.currentA11yRoot = node('group', undefined, {
769
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
770
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
771
+ children: [
772
+ node('form', 'Application', {
773
+ bounds: { x: 20, y: -200, width: 760, height: 1900 },
774
+ path: [0],
775
+ children: [
776
+ node('button', 'Submit application', {
777
+ bounds: { x: 60, y: 1540, width: 180, height: 40 },
778
+ path: [0, 0],
779
+ }),
780
+ ],
781
+ }),
782
+ ],
783
+ });
784
+ mockState.sendWheel.mockImplementationOnce(async () => {
785
+ mockState.currentA11yRoot = node('group', undefined, {
786
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
787
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
788
+ children: [
789
+ node('form', 'Application', {
790
+ bounds: { x: 20, y: -1420, width: 760, height: 1900 },
791
+ path: [0],
792
+ children: [
793
+ node('button', 'Submit application', {
794
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
795
+ path: [0, 0],
796
+ }),
797
+ ],
798
+ }),
799
+ ],
800
+ });
801
+ bumpMockUiRevision();
802
+ return { status: 'updated', timeoutMs: 2500 };
803
+ });
804
+ const result = await handler({
805
+ id: 'n:0.0',
806
+ maxRevealSteps: 3,
807
+ revealTimeoutMs: 2500,
808
+ detail: 'minimal',
809
+ });
810
+ expect(mockState.sendWheel).toHaveBeenCalledTimes(1);
811
+ expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 150, 340, undefined);
812
+ expect(result.content[0].text).toContain('Clicked button "Submit application" (n:0.0) at (150, 340) after 1 reveal step.');
813
+ });
814
+ it('can wait for a semantic post-click condition in the same click call', async () => {
815
+ const handler = getToolHandler('geometra_click');
816
+ mockState.currentA11yRoot = node('group', undefined, {
817
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
818
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
819
+ children: [
820
+ node('button', 'Submit application', {
821
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
822
+ path: [0],
823
+ }),
824
+ ],
825
+ });
826
+ mockState.sendClick.mockImplementationOnce(async () => {
827
+ mockState.currentA11yRoot = node('group', undefined, {
828
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
829
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
830
+ children: [
831
+ node('button', 'Submit application', {
832
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
833
+ path: [0],
834
+ }),
835
+ node('dialog', 'Application submitted', {
836
+ bounds: { x: 240, y: 140, width: 420, height: 260 },
837
+ path: [1],
838
+ }),
839
+ ],
840
+ });
841
+ bumpMockUiRevision();
842
+ return { status: 'updated', timeoutMs: 2000 };
843
+ });
844
+ const result = await handler({
845
+ role: 'button',
846
+ name: 'Submit application',
847
+ waitFor: {
848
+ role: 'dialog',
849
+ name: 'Application submitted',
850
+ timeoutMs: 5000,
851
+ },
852
+ detail: 'minimal',
853
+ });
854
+ expect(mockState.waitForUiCondition).toHaveBeenCalledWith(mockState.session, expect.any(Function), 5000);
855
+ expect(result.content[0].text).toContain('Post-click condition satisfied after');
856
+ expect(result.content[0].text).toContain('1 matching node(s).');
857
+ });
858
+ it('lets run_actions click a semantic target without manual coordinates', async () => {
859
+ const handler = getToolHandler('geometra_run_actions');
860
+ mockState.currentA11yRoot = node('group', undefined, {
861
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
862
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
863
+ children: [
864
+ node('form', 'Application', {
865
+ bounds: { x: 20, y: -200, width: 760, height: 1900 },
866
+ path: [0],
867
+ children: [
868
+ node('button', 'Submit application', {
869
+ bounds: { x: 60, y: 1540, width: 180, height: 40 },
870
+ path: [0, 0],
871
+ }),
872
+ ],
873
+ }),
874
+ ],
875
+ });
876
+ mockState.sendWheel.mockImplementationOnce(async () => {
877
+ mockState.currentA11yRoot = node('group', undefined, {
878
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
879
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 1220 },
880
+ children: [
881
+ node('form', 'Application', {
882
+ bounds: { x: 20, y: -1420, width: 760, height: 1900 },
883
+ path: [0],
884
+ children: [
885
+ node('button', 'Submit application', {
886
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
887
+ path: [0, 0],
888
+ }),
889
+ ],
890
+ }),
891
+ ],
892
+ });
893
+ bumpMockUiRevision();
894
+ return { status: 'updated', timeoutMs: 2500 };
895
+ });
896
+ const result = await handler({
897
+ actions: [
898
+ {
899
+ type: 'click',
900
+ role: 'button',
901
+ name: 'Submit application',
902
+ maxRevealSteps: 3,
903
+ revealTimeoutMs: 2500,
904
+ },
905
+ ],
906
+ stopOnError: true,
907
+ includeSteps: true,
908
+ detail: 'minimal',
909
+ });
910
+ const payload = JSON.parse(result.content[0].text);
911
+ const steps = payload.steps;
912
+ expect(mockState.sendWheel).toHaveBeenCalledTimes(1);
913
+ expect(mockState.sendClick).toHaveBeenCalledWith(mockState.session, 150, 340, undefined);
914
+ expect(steps[0]).toMatchObject({
915
+ index: 0,
916
+ type: 'click',
917
+ ok: true,
918
+ at: { x: 150, y: 340 },
919
+ revealSteps: 1,
920
+ target: { id: 'n:0.0', role: 'button', name: 'Submit application' },
921
+ wait: 'updated',
922
+ });
923
+ });
924
+ it('lets run_actions click and wait for a semantic post-condition in one step', async () => {
925
+ const handler = getToolHandler('geometra_run_actions');
926
+ mockState.currentA11yRoot = node('group', undefined, {
927
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
928
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
929
+ children: [
930
+ node('button', 'Submit application', {
931
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
932
+ path: [0],
933
+ }),
934
+ ],
935
+ });
936
+ mockState.sendClick.mockImplementationOnce(async () => {
937
+ mockState.currentA11yRoot = node('group', undefined, {
938
+ bounds: { x: 0, y: 0, width: 1280, height: 800 },
939
+ meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
940
+ children: [
941
+ node('button', 'Submit application', {
942
+ bounds: { x: 60, y: 320, width: 180, height: 40 },
943
+ path: [0],
944
+ }),
945
+ node('dialog', 'Application submitted', {
946
+ bounds: { x: 240, y: 140, width: 420, height: 260 },
947
+ path: [1],
948
+ }),
949
+ ],
950
+ });
951
+ bumpMockUiRevision();
952
+ return { status: 'updated', timeoutMs: 2000 };
953
+ });
954
+ const result = await handler({
955
+ actions: [
956
+ {
957
+ type: 'click',
958
+ role: 'button',
959
+ name: 'Submit application',
960
+ waitFor: {
961
+ role: 'dialog',
962
+ name: 'Application submitted',
963
+ timeoutMs: 5000,
964
+ },
965
+ },
966
+ ],
967
+ stopOnError: true,
968
+ includeSteps: true,
969
+ detail: 'minimal',
970
+ });
971
+ const payload = JSON.parse(result.content[0].text);
972
+ const steps = payload.steps;
973
+ expect(steps[0]).toMatchObject({
974
+ index: 0,
975
+ type: 'click',
976
+ ok: true,
977
+ postWait: {
978
+ present: true,
979
+ matchCount: 1,
980
+ filter: { role: 'dialog', name: 'Application submitted' },
981
+ },
982
+ });
983
+ });
744
984
  });