@geometra/mcp 1.19.17 → 1.19.18
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.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
});
|
package/dist/session.js
CHANGED
|
@@ -128,6 +128,112 @@ function shutdownPreviousSession(opts) {
|
|
|
128
128
|
void prev.proxyRuntime.close().catch(() => { });
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
function formatUnknownError(err) {
|
|
132
|
+
return err instanceof Error ? err.message : String(err);
|
|
133
|
+
}
|
|
134
|
+
async function attachToReusableProxy(options) {
|
|
135
|
+
if (!reusableProxy) {
|
|
136
|
+
throw new Error('Failed to attach to reusable proxy session');
|
|
137
|
+
}
|
|
138
|
+
const session = ((reusableProxy.child && activeSession?.proxyChild === reusableProxy.child) ||
|
|
139
|
+
(reusableProxy.runtime && activeSession?.proxyRuntime === reusableProxy.runtime))
|
|
140
|
+
? activeSession
|
|
141
|
+
: await connect(reusableProxy.wsUrl, {
|
|
142
|
+
skipInitialResize: true,
|
|
143
|
+
closePreviousProxy: false,
|
|
144
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
145
|
+
});
|
|
146
|
+
if (!session) {
|
|
147
|
+
throw new Error('Failed to attach to reusable proxy session');
|
|
148
|
+
}
|
|
149
|
+
session.proxyChild = reusableProxy.child;
|
|
150
|
+
session.proxyRuntime = reusableProxy.runtime;
|
|
151
|
+
session.proxyReusable = true;
|
|
152
|
+
const desiredWidth = options.width ?? reusableProxy.width;
|
|
153
|
+
const desiredHeight = options.height ?? reusableProxy.height;
|
|
154
|
+
if (desiredWidth !== reusableProxy.width || desiredHeight !== reusableProxy.height) {
|
|
155
|
+
await sendAndWaitForUpdate(session, {
|
|
156
|
+
type: 'resize',
|
|
157
|
+
width: desiredWidth,
|
|
158
|
+
height: desiredHeight,
|
|
159
|
+
}, 5_000);
|
|
160
|
+
reusableProxy.width = desiredWidth;
|
|
161
|
+
reusableProxy.height = desiredHeight;
|
|
162
|
+
}
|
|
163
|
+
const currentUrl = session.cachedA11y?.meta?.pageUrl ?? reusableProxy.pageUrl;
|
|
164
|
+
if (currentUrl !== options.pageUrl) {
|
|
165
|
+
await sendNavigate(session, options.pageUrl, 15_000);
|
|
166
|
+
if ((session.proxyChild && reusableProxy?.child === session.proxyChild) ||
|
|
167
|
+
(session.proxyRuntime && reusableProxy?.runtime === session.proxyRuntime)) {
|
|
168
|
+
reusableProxy.pageUrl = options.pageUrl;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return session;
|
|
172
|
+
}
|
|
173
|
+
async function startFreshProxySession(options) {
|
|
174
|
+
closeReusableProxy();
|
|
175
|
+
try {
|
|
176
|
+
const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
|
|
177
|
+
pageUrl: options.pageUrl,
|
|
178
|
+
port: options.port ?? 0,
|
|
179
|
+
headless: options.headless,
|
|
180
|
+
width: options.width,
|
|
181
|
+
height: options.height,
|
|
182
|
+
slowMo: options.slowMo,
|
|
183
|
+
});
|
|
184
|
+
const session = await connect(wsUrl, {
|
|
185
|
+
skipInitialResize: true,
|
|
186
|
+
closePreviousProxy: false,
|
|
187
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
188
|
+
});
|
|
189
|
+
session.proxyRuntime = runtime;
|
|
190
|
+
session.proxyReusable = true;
|
|
191
|
+
setReusableProxy({ runtime }, wsUrl, {
|
|
192
|
+
headless: options.headless,
|
|
193
|
+
slowMo: options.slowMo,
|
|
194
|
+
width: options.width,
|
|
195
|
+
height: options.height,
|
|
196
|
+
pageUrl: options.pageUrl,
|
|
197
|
+
});
|
|
198
|
+
return session;
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
const { child, wsUrl } = await spawnGeometraProxy({
|
|
202
|
+
pageUrl: options.pageUrl,
|
|
203
|
+
port: options.port ?? 0,
|
|
204
|
+
headless: options.headless,
|
|
205
|
+
width: options.width,
|
|
206
|
+
height: options.height,
|
|
207
|
+
slowMo: options.slowMo,
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
const session = await connect(wsUrl, {
|
|
211
|
+
skipInitialResize: true,
|
|
212
|
+
closePreviousProxy: false,
|
|
213
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
214
|
+
});
|
|
215
|
+
session.proxyChild = child;
|
|
216
|
+
session.proxyReusable = true;
|
|
217
|
+
setReusableProxy({ child }, wsUrl, {
|
|
218
|
+
headless: options.headless,
|
|
219
|
+
slowMo: options.slowMo,
|
|
220
|
+
width: options.width,
|
|
221
|
+
height: options.height,
|
|
222
|
+
pageUrl: options.pageUrl,
|
|
223
|
+
});
|
|
224
|
+
return session;
|
|
225
|
+
}
|
|
226
|
+
catch (fallbackError) {
|
|
227
|
+
try {
|
|
228
|
+
child.kill('SIGTERM');
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* ignore */
|
|
232
|
+
}
|
|
233
|
+
throw fallbackError instanceof Error ? fallbackError : e;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
131
237
|
/**
|
|
132
238
|
* Connect to a running Geometra server. Waits for the first frame so that
|
|
133
239
|
* layout/tree state is available immediately after connection.
|
|
@@ -232,107 +338,26 @@ export async function connectThroughProxy(options) {
|
|
|
232
338
|
clearReusableProxyIfExited();
|
|
233
339
|
const desiredHeadless = options.headless === true;
|
|
234
340
|
const desiredSlowMo = options.slowMo ?? 0;
|
|
341
|
+
let reuseFailure;
|
|
235
342
|
if (reusableProxy &&
|
|
236
343
|
reusableProxy.headless === desiredHeadless &&
|
|
237
344
|
reusableProxy.slowMo === desiredSlowMo) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
? activeSession
|
|
241
|
-
: await connect(reusableProxy.wsUrl, {
|
|
242
|
-
skipInitialResize: true,
|
|
243
|
-
closePreviousProxy: false,
|
|
244
|
-
awaitInitialFrame: options.awaitInitialFrame,
|
|
245
|
-
});
|
|
246
|
-
if (!session) {
|
|
247
|
-
throw new Error('Failed to attach to reusable proxy session');
|
|
248
|
-
}
|
|
249
|
-
session.proxyChild = reusableProxy.child;
|
|
250
|
-
session.proxyRuntime = reusableProxy.runtime;
|
|
251
|
-
session.proxyReusable = true;
|
|
252
|
-
const desiredWidth = options.width ?? reusableProxy.width;
|
|
253
|
-
const desiredHeight = options.height ?? reusableProxy.height;
|
|
254
|
-
if (desiredWidth !== reusableProxy.width || desiredHeight !== reusableProxy.height) {
|
|
255
|
-
await sendAndWaitForUpdate(session, {
|
|
256
|
-
type: 'resize',
|
|
257
|
-
width: desiredWidth,
|
|
258
|
-
height: desiredHeight,
|
|
259
|
-
}, 5_000);
|
|
260
|
-
reusableProxy.width = desiredWidth;
|
|
261
|
-
reusableProxy.height = desiredHeight;
|
|
345
|
+
try {
|
|
346
|
+
return await attachToReusableProxy(options);
|
|
262
347
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
await sendNavigate(session, options.pageUrl, 15_000);
|
|
267
|
-
if ((session.proxyChild && reusableProxy?.child === session.proxyChild) ||
|
|
268
|
-
(session.proxyRuntime && reusableProxy?.runtime === session.proxyRuntime)) {
|
|
269
|
-
reusableProxy.pageUrl = options.pageUrl;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
reuseFailure = err;
|
|
350
|
+
closeReusableProxy();
|
|
272
351
|
}
|
|
273
|
-
return session;
|
|
274
352
|
}
|
|
275
|
-
closeReusableProxy();
|
|
276
353
|
try {
|
|
277
|
-
|
|
278
|
-
pageUrl: options.pageUrl,
|
|
279
|
-
port: options.port ?? 0,
|
|
280
|
-
headless: options.headless,
|
|
281
|
-
width: options.width,
|
|
282
|
-
height: options.height,
|
|
283
|
-
slowMo: options.slowMo,
|
|
284
|
-
});
|
|
285
|
-
const session = await connect(wsUrl, {
|
|
286
|
-
skipInitialResize: true,
|
|
287
|
-
closePreviousProxy: false,
|
|
288
|
-
awaitInitialFrame: options.awaitInitialFrame,
|
|
289
|
-
});
|
|
290
|
-
session.proxyRuntime = runtime;
|
|
291
|
-
session.proxyReusable = true;
|
|
292
|
-
setReusableProxy({ runtime }, wsUrl, {
|
|
293
|
-
headless: options.headless,
|
|
294
|
-
slowMo: options.slowMo,
|
|
295
|
-
width: options.width,
|
|
296
|
-
height: options.height,
|
|
297
|
-
pageUrl: options.pageUrl,
|
|
298
|
-
});
|
|
299
|
-
return session;
|
|
354
|
+
return await startFreshProxySession(options);
|
|
300
355
|
}
|
|
301
356
|
catch (e) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
port: options.port ?? 0,
|
|
305
|
-
headless: options.headless,
|
|
306
|
-
width: options.width,
|
|
307
|
-
height: options.height,
|
|
308
|
-
slowMo: options.slowMo,
|
|
309
|
-
});
|
|
310
|
-
try {
|
|
311
|
-
const session = await connect(wsUrl, {
|
|
312
|
-
skipInitialResize: true,
|
|
313
|
-
closePreviousProxy: false,
|
|
314
|
-
awaitInitialFrame: options.awaitInitialFrame,
|
|
315
|
-
});
|
|
316
|
-
session.proxyChild = child;
|
|
317
|
-
session.proxyReusable = true;
|
|
318
|
-
setReusableProxy({ child }, wsUrl, {
|
|
319
|
-
headless: options.headless,
|
|
320
|
-
slowMo: options.slowMo,
|
|
321
|
-
width: options.width,
|
|
322
|
-
height: options.height,
|
|
323
|
-
pageUrl: options.pageUrl,
|
|
324
|
-
});
|
|
325
|
-
return session;
|
|
326
|
-
}
|
|
327
|
-
catch (fallbackError) {
|
|
328
|
-
try {
|
|
329
|
-
child.kill('SIGTERM');
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
/* ignore */
|
|
333
|
-
}
|
|
334
|
-
throw fallbackError instanceof Error ? fallbackError : e;
|
|
357
|
+
if (reuseFailure) {
|
|
358
|
+
throw new Error(`Failed to recover reusable browser session after it became stale: ${formatUnknownError(reuseFailure)}\nFresh proxy start also failed: ${formatUnknownError(e)}`);
|
|
335
359
|
}
|
|
360
|
+
throw e;
|
|
336
361
|
}
|
|
337
362
|
}
|
|
338
363
|
export function getSession() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.18",
|
|
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.18",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|