@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
- const session = ((reusableProxy.child && activeSession?.proxyChild === reusableProxy.child) ||
239
- (reusableProxy.runtime && activeSession?.proxyRuntime === reusableProxy.runtime))
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
- if (options.pageUrl) {
264
- const currentUrl = session.cachedA11y?.meta?.pageUrl ?? reusableProxy.pageUrl;
265
- if (currentUrl !== options.pageUrl) {
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
- const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
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
- const { child, wsUrl } = await spawnGeometraProxy({
303
- pageUrl: options.pageUrl,
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.17",
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.17",
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"