@geometra/mcp 1.57.0 → 1.58.0

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/dist/index.js CHANGED
@@ -1,18 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { createServer } from './server.js';
4
- import { disconnect } from './session.js';
4
+ import { disconnect, listSessions } from './session.js';
5
+ import { shutdownSessionLifecycleRegistry } from './session-state.js';
5
6
  let cleanedUp = false;
6
7
  function cleanupActiveSession() {
7
8
  if (cleanedUp)
8
9
  return;
9
10
  cleanedUp = true;
10
11
  try {
12
+ for (const session of listSessions()) {
13
+ disconnect({ sessionId: session.id, closeProxy: false });
14
+ }
11
15
  disconnect({ closeProxy: true });
12
16
  }
13
17
  catch {
14
18
  /* ignore */
15
19
  }
20
+ try {
21
+ shutdownSessionLifecycleRegistry();
22
+ }
23
+ catch {
24
+ /* ignore */
25
+ }
16
26
  }
17
27
  async function main() {
18
28
  const server = createServer();
@@ -0,0 +1,35 @@
1
+ interface SessionLifecycleTarget {
2
+ id: string;
3
+ url: string;
4
+ isolated?: boolean;
5
+ proxyReusable?: boolean;
6
+ updateRevision: number;
7
+ layout: Record<string, unknown> | null;
8
+ tree: Record<string, unknown> | null;
9
+ ws: {
10
+ readyState: number;
11
+ };
12
+ connectTrace?: {
13
+ mode?: string;
14
+ } | null;
15
+ cachedA11y?: {
16
+ meta?: {
17
+ pageUrl?: string;
18
+ };
19
+ } | null;
20
+ lifecycleTaskId?: string;
21
+ lifecycleTaskKind?: string;
22
+ lifecycleLeaseId?: string;
23
+ lifecycleWorkerId?: string;
24
+ lifecycleFinalized?: boolean;
25
+ }
26
+ export declare function initializeSessionLifecycle(target: SessionLifecycleTarget, options?: {
27
+ pageUrl?: string;
28
+ transportMode?: string;
29
+ }): void;
30
+ export declare function heartbeatSessionLifecycle(target: SessionLifecycleTarget): void;
31
+ export declare function recordSessionSnapshot(target: SessionLifecycleTarget, label: string, extra?: Record<string, unknown>): void;
32
+ export declare function completeSessionLifecycle(target: SessionLifecycleTarget, reason: string, extra?: Record<string, unknown>): void;
33
+ export declare function failSessionLifecycle(target: SessionLifecycleTarget, error: string, extra?: Record<string, unknown>): void;
34
+ export declare function shutdownSessionLifecycleRegistry(): void;
35
+ export {};
@@ -0,0 +1,191 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { ParallelMcpOrchestrator, SqliteParallelMcpStore, } from '@razroo/parallel-mcp';
5
+ const SESSION_NAMESPACE = 'geometra-mcp-session';
6
+ const SESSION_TASK_KEY = 'session.live';
7
+ const SESSION_LEASE_MS = 60_000;
8
+ const SESSION_SWEEP_MS = 15_000;
9
+ const SESSION_WORKER_PREFIX = `geometra-mcp:${process.pid}`;
10
+ function resolveSessionStateFile() {
11
+ const raw = process.env.GEOMETRA_MCP_STATE_FILE?.trim();
12
+ if (raw) {
13
+ mkdirSync(path.dirname(raw), { recursive: true });
14
+ return raw;
15
+ }
16
+ const dir = path.join(homedir(), '.geometra-mcp');
17
+ mkdirSync(dir, { recursive: true });
18
+ return path.join(dir, `parallel-mcp-${process.pid}.sqlite`);
19
+ }
20
+ const orchestrator = new ParallelMcpOrchestrator(new SqliteParallelMcpStore({ filename: resolveSessionStateFile() }), { defaultLeaseMs: SESSION_LEASE_MS });
21
+ const leaseSweep = setInterval(() => {
22
+ try {
23
+ orchestrator.expireLeases();
24
+ }
25
+ catch {
26
+ /* ignore background lease sweep failures */
27
+ }
28
+ }, SESSION_SWEEP_MS);
29
+ leaseSweep.unref();
30
+ function extractPageUrl(target) {
31
+ const cached = target.cachedA11y?.meta?.pageUrl;
32
+ if (typeof cached === 'string' && cached.length > 0)
33
+ return cached;
34
+ const semantic = target.tree?.semantic;
35
+ if (semantic && typeof semantic.pageUrl === 'string' && semantic.pageUrl.length > 0)
36
+ return semantic.pageUrl;
37
+ return null;
38
+ }
39
+ function toJsonValue(value) {
40
+ if (value === null)
41
+ return null;
42
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
43
+ return value;
44
+ }
45
+ if (value instanceof Date) {
46
+ return value.toISOString();
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return value.map(item => toJsonValue(item));
50
+ }
51
+ if (typeof value === 'object') {
52
+ const object = {};
53
+ for (const [key, entry] of Object.entries(value)) {
54
+ if (entry === undefined)
55
+ continue;
56
+ object[key] = toJsonValue(entry);
57
+ }
58
+ return object;
59
+ }
60
+ return String(value);
61
+ }
62
+ function buildSessionContext(target, label, extra) {
63
+ return {
64
+ sessionId: target.id,
65
+ label,
66
+ transportUrl: target.url,
67
+ pageUrl: extractPageUrl(target),
68
+ isolated: target.isolated === true,
69
+ proxyReusable: target.proxyReusable === true,
70
+ wsReadyState: target.ws.readyState,
71
+ updateRevision: target.updateRevision,
72
+ hasLayout: target.layout !== null,
73
+ hasTree: target.tree !== null,
74
+ connectMode: target.connectTrace?.mode ?? null,
75
+ ...(extra ? { extra: toJsonValue(extra) } : {}),
76
+ };
77
+ }
78
+ function liveTaskIdFor(sessionId) {
79
+ return `${sessionId}:live`;
80
+ }
81
+ function liveTaskKindFor(sessionId) {
82
+ return `session.live:${sessionId}`;
83
+ }
84
+ function workerIdFor(sessionId) {
85
+ return `${SESSION_WORKER_PREFIX}:${sessionId}`;
86
+ }
87
+ export function initializeSessionLifecycle(target, options) {
88
+ const sessionId = target.id;
89
+ const taskId = liveTaskIdFor(sessionId);
90
+ const taskKind = liveTaskKindFor(sessionId);
91
+ const workerId = workerIdFor(sessionId);
92
+ orchestrator.createRun({
93
+ id: sessionId,
94
+ namespace: SESSION_NAMESPACE,
95
+ metadata: toJsonValue({
96
+ transportMode: options?.transportMode ?? 'direct-ws',
97
+ isolated: target.isolated === true,
98
+ }),
99
+ context: buildSessionContext(target, 'session.initialized', {
100
+ requestedPageUrl: options?.pageUrl ?? null,
101
+ }),
102
+ });
103
+ orchestrator.enqueueTask({
104
+ id: taskId,
105
+ runId: sessionId,
106
+ key: SESSION_TASK_KEY,
107
+ kind: taskKind,
108
+ input: toJsonValue({
109
+ transportUrl: target.url,
110
+ requestedPageUrl: options?.pageUrl ?? null,
111
+ }),
112
+ });
113
+ const claimed = orchestrator.claimNextTask({
114
+ workerId,
115
+ kinds: [taskKind],
116
+ leaseMs: SESSION_LEASE_MS,
117
+ });
118
+ if (!claimed || claimed.task.id !== taskId) {
119
+ throw new Error(`Failed to initialize durable session task for ${sessionId}`);
120
+ }
121
+ orchestrator.markTaskRunning({
122
+ taskId,
123
+ leaseId: claimed.lease.id,
124
+ workerId,
125
+ });
126
+ target.lifecycleTaskId = taskId;
127
+ target.lifecycleTaskKind = taskKind;
128
+ target.lifecycleLeaseId = claimed.lease.id;
129
+ target.lifecycleWorkerId = workerId;
130
+ target.lifecycleFinalized = false;
131
+ }
132
+ export function heartbeatSessionLifecycle(target) {
133
+ if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
134
+ return;
135
+ orchestrator.heartbeatLease({
136
+ taskId: target.lifecycleTaskId,
137
+ leaseId: target.lifecycleLeaseId,
138
+ workerId: target.lifecycleWorkerId,
139
+ leaseMs: SESSION_LEASE_MS,
140
+ });
141
+ }
142
+ export function recordSessionSnapshot(target, label, extra) {
143
+ if (!target.lifecycleTaskId)
144
+ return;
145
+ orchestrator.appendContextSnapshot({
146
+ runId: target.id,
147
+ taskId: target.lifecycleTaskId,
148
+ scope: 'run',
149
+ label,
150
+ payload: buildSessionContext(target, label, extra),
151
+ });
152
+ }
153
+ export function completeSessionLifecycle(target, reason, extra) {
154
+ if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
155
+ return;
156
+ orchestrator.completeTask({
157
+ taskId: target.lifecycleTaskId,
158
+ leaseId: target.lifecycleLeaseId,
159
+ workerId: target.lifecycleWorkerId,
160
+ output: toJsonValue({
161
+ reason,
162
+ ...(extra ? { extra } : {}),
163
+ }),
164
+ nextContext: buildSessionContext(target, 'session.completed', {
165
+ reason,
166
+ ...(extra ? { ...extra } : {}),
167
+ }),
168
+ nextContextLabel: 'session.completed',
169
+ });
170
+ target.lifecycleFinalized = true;
171
+ }
172
+ export function failSessionLifecycle(target, error, extra) {
173
+ if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
174
+ return;
175
+ recordSessionSnapshot(target, 'session.failed', {
176
+ error,
177
+ ...(extra ? { ...extra } : {}),
178
+ });
179
+ orchestrator.failTask({
180
+ taskId: target.lifecycleTaskId,
181
+ leaseId: target.lifecycleLeaseId,
182
+ workerId: target.lifecycleWorkerId,
183
+ error,
184
+ metadata: extra ? toJsonValue(extra) : undefined,
185
+ });
186
+ target.lifecycleFinalized = true;
187
+ }
188
+ export function shutdownSessionLifecycleRegistry() {
189
+ clearInterval(leaseSweep);
190
+ orchestrator.close();
191
+ }
package/dist/session.d.ts CHANGED
@@ -392,7 +392,7 @@ export interface WorkflowState {
392
392
  startedAt: number;
393
393
  }
394
394
  export interface Session {
395
- /** Short stable identifier (e.g. "s1", "s2") returned by geometra_connect. */
395
+ /** Durable unique identifier returned by geometra_connect. */
396
396
  id: string;
397
397
  ws: WebSocket;
398
398
  layout: Record<string, unknown> | null;
@@ -421,6 +421,14 @@ export interface Session {
421
421
  }>;
422
422
  workflowState?: WorkflowState;
423
423
  reconnectInFlight?: Promise<boolean>;
424
+ lifecycleTaskId?: string;
425
+ lifecycleTaskKind?: string;
426
+ lifecycleLeaseId?: string;
427
+ lifecycleWorkerId?: string;
428
+ lifecycleFinalized?: boolean;
429
+ heartbeatInterval?: ReturnType<typeof setInterval> | null;
430
+ heartbeatLastMessageAt?: number;
431
+ heartbeatPendingPongBy?: number | null;
424
432
  }
425
433
  export interface SessionConnectTrace {
426
434
  mode: 'direct-ws' | 'fresh-proxy' | 'reused-proxy';
package/dist/session.js CHANGED
@@ -1,11 +1,12 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { performance } from 'node:perf_hooks';
2
3
  import WebSocket from 'ws';
3
4
  import { spawnGeometraProxy, startEmbeddedGeometraProxy } from './proxy-spawn.js';
5
+ import { completeSessionLifecycle, failSessionLifecycle, heartbeatSessionLifecycle, initializeSessionLifecycle, recordSessionSnapshot, } from './session-state.js';
4
6
  const activeSessions = new Map();
5
7
  let defaultSessionId = null;
6
8
  const MAX_ACTIVE_SESSIONS = 5;
7
- let nextSessionId = 0;
8
- function generateSessionId() { return `s${++nextSessionId}`; }
9
+ function generateSessionId() { return `s_${randomUUID()}`; }
9
10
  let reusableProxies = [];
10
11
  const REUSABLE_PROXY_POOL_LIMIT = 6;
11
12
  /** Close idle reusable proxies after 5 minutes of inactivity. */
@@ -229,11 +230,20 @@ function shutdownSession(id, opts) {
229
230
  const prev = activeSessions.get(id);
230
231
  if (!prev)
231
232
  return;
233
+ const forceCloseProxy = prev.isolated === true;
234
+ safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
235
+ closeProxy: opts?.closeProxy ?? false,
236
+ forceCloseProxy,
237
+ });
232
238
  activeSessions.delete(id);
233
239
  if (defaultSessionId === id)
234
240
  promoteDefaultSession();
241
+ stopSessionHeartbeat(prev);
242
+ releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
243
+ }
244
+ function releaseSessionResources(session, opts) {
235
245
  try {
236
- prev.ws.close();
246
+ session.ws.close();
237
247
  }
238
248
  catch {
239
249
  /* ignore */
@@ -243,44 +253,44 @@ function shutdownSession(id, opts) {
243
253
  // the underlying browser would defeat the entire point of the
244
254
  // isolation flag (the next non-isolated connect could attach to a
245
255
  // proxy with stale storage from this session's job).
246
- const forceCloseProxy = prev.isolated === true;
247
- if (prev.proxyChild) {
248
- const shouldKeepProxy = !forceCloseProxy && prev.proxyReusable && opts?.closeProxy === false;
249
- rememberReusableProxyPageUrl(prev);
256
+ const forceCloseProxy = session.isolated === true;
257
+ if (session.proxyChild) {
258
+ const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
259
+ rememberReusableProxyPageUrl(session);
250
260
  if (shouldKeepProxy) {
251
- const entry = reusableProxyEntryForSession(prev);
261
+ const entry = reusableProxyEntryForSession(session);
252
262
  if (entry)
253
263
  touchReusableProxy(entry);
254
264
  return;
255
265
  }
256
- const entry = reusableProxyEntryForSession(prev);
266
+ const entry = reusableProxyEntryForSession(session);
257
267
  if (entry) {
258
268
  closeReusableProxy(entry);
259
269
  return;
260
270
  }
261
271
  try {
262
- prev.proxyChild.kill('SIGTERM');
272
+ session.proxyChild.kill('SIGTERM');
263
273
  }
264
274
  catch {
265
275
  /* ignore */
266
276
  }
267
277
  return;
268
278
  }
269
- if (prev.proxyRuntime) {
270
- const shouldKeepProxy = !forceCloseProxy && prev.proxyReusable && opts?.closeProxy === false;
271
- rememberReusableProxyPageUrl(prev);
279
+ if (session.proxyRuntime) {
280
+ const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
281
+ rememberReusableProxyPageUrl(session);
272
282
  if (shouldKeepProxy) {
273
- const entry = reusableProxyEntryForSession(prev);
283
+ const entry = reusableProxyEntryForSession(session);
274
284
  if (entry)
275
285
  touchReusableProxy(entry);
276
286
  return;
277
287
  }
278
- const entry = reusableProxyEntryForSession(prev);
288
+ const entry = reusableProxyEntryForSession(session);
279
289
  if (entry) {
280
290
  closeReusableProxy(entry);
281
291
  return;
282
292
  }
283
- void prev.proxyRuntime.close().catch(() => { });
293
+ void session.proxyRuntime.close().catch(() => { });
284
294
  }
285
295
  }
286
296
  /** Evict the oldest session when at capacity. */
@@ -288,11 +298,93 @@ function evictOldestSession() {
288
298
  if (activeSessions.size < MAX_ACTIVE_SESSIONS)
289
299
  return;
290
300
  const oldestId = activeSessions.keys().next().value;
291
- shutdownSession(oldestId, { closeProxy: false });
301
+ shutdownSession(oldestId, { closeProxy: false, reason: 'evicted' });
292
302
  }
293
303
  function formatUnknownError(err) {
294
304
  return err instanceof Error ? err.message : String(err);
295
305
  }
306
+ function warnSessionLifecycleError(action, session, err) {
307
+ console.warn(`geometra-mcp: failed to ${action} for session ${session.id}: ${formatUnknownError(err)}`);
308
+ }
309
+ function safeRecordSessionSnapshot(session, label, extra) {
310
+ try {
311
+ recordSessionSnapshot(session, label, extra);
312
+ }
313
+ catch (err) {
314
+ warnSessionLifecycleError(`record snapshot "${label}"`, session, err);
315
+ }
316
+ }
317
+ function safeHeartbeatSessionLifecycle(session) {
318
+ try {
319
+ heartbeatSessionLifecycle(session);
320
+ }
321
+ catch (err) {
322
+ warnSessionLifecycleError('heartbeat durable state', session, err);
323
+ }
324
+ }
325
+ function safeCompleteSessionLifecycle(session, reason, extra) {
326
+ try {
327
+ completeSessionLifecycle(session, reason, extra);
328
+ }
329
+ catch (err) {
330
+ warnSessionLifecycleError(`complete durable state as "${reason}"`, session, err);
331
+ }
332
+ }
333
+ function safeFailSessionLifecycle(session, error, extra) {
334
+ try {
335
+ failSessionLifecycle(session, error, extra);
336
+ }
337
+ catch (err) {
338
+ warnSessionLifecycleError(`fail durable state as "${error}"`, session, err);
339
+ }
340
+ }
341
+ function noteSessionSocketActivity(session, ws) {
342
+ if (session.ws !== ws)
343
+ return;
344
+ session.heartbeatLastMessageAt = Date.now();
345
+ session.heartbeatPendingPongBy = null;
346
+ }
347
+ // Keep the durable lease alive independently from a transient socket and only
348
+ // ping the WebSocket transport when a socket is actually open.
349
+ function startSessionHeartbeat(session) {
350
+ if (session.heartbeatInterval)
351
+ return;
352
+ session.heartbeatLastMessageAt ??= Date.now();
353
+ session.heartbeatPendingPongBy ??= null;
354
+ session.heartbeatInterval = setInterval(() => {
355
+ safeHeartbeatSessionLifecycle(session);
356
+ const ws = session.ws;
357
+ if (ws.readyState !== WebSocket.OPEN)
358
+ return;
359
+ const pendingPongBy = session.heartbeatPendingPongBy ?? null;
360
+ if (pendingPongBy !== null && Date.now() > pendingPongBy) {
361
+ try {
362
+ ws.close();
363
+ }
364
+ catch {
365
+ /* ignore */
366
+ }
367
+ return;
368
+ }
369
+ if (Date.now() - (session.heartbeatLastMessageAt ?? 0) > 10_000) {
370
+ try {
371
+ ws.ping();
372
+ session.heartbeatPendingPongBy = Date.now() + 30_000;
373
+ }
374
+ catch {
375
+ /* ignore */
376
+ }
377
+ }
378
+ }, 15_000);
379
+ session.heartbeatInterval.unref();
380
+ }
381
+ function stopSessionHeartbeat(session) {
382
+ if (session.heartbeatInterval) {
383
+ clearInterval(session.heartbeatInterval);
384
+ session.heartbeatInterval = null;
385
+ }
386
+ session.heartbeatPendingPongBy = null;
387
+ }
296
388
  function reusableProxyMatchesOptions(entry, options) {
297
389
  return (entry.pageUrl === options.pageUrl &&
298
390
  entry.headless === (options.headless === true) &&
@@ -477,6 +569,11 @@ async function attachToReusableProxy(proxy, options) {
477
569
  totalMs: performance.now() - startedAt,
478
570
  };
479
571
  updateReusableProxySnapshotState(proxy, session);
572
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
573
+ transportMode: 'reused-proxy',
574
+ targetPageUrl: options.pageUrl,
575
+ reusedExistingSession: reusedExistingSession !== null,
576
+ });
480
577
  return session;
481
578
  }
482
579
  async function startFreshProxySession(options) {
@@ -542,6 +639,12 @@ async function startFreshProxySession(options) {
542
639
  resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
543
640
  totalMs: performance.now() - startedAt,
544
641
  };
642
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
643
+ transportMode: 'fresh-proxy',
644
+ proxyStartMode: 'embedded',
645
+ requestedPageUrl: options.pageUrl,
646
+ isolated: options.isolated === true,
647
+ });
545
648
  return session;
546
649
  }
547
650
  catch (e) {
@@ -603,6 +706,12 @@ async function startFreshProxySession(options) {
603
706
  resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
604
707
  totalMs: performance.now() - startedAt,
605
708
  };
709
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
710
+ transportMode: 'fresh-proxy',
711
+ proxyStartMode: 'child',
712
+ requestedPageUrl: options.pageUrl,
713
+ isolated: options.isolated === true,
714
+ });
606
715
  return session;
607
716
  }
608
717
  catch (fallbackError) {
@@ -642,56 +751,40 @@ export function connect(url, opts) {
642
751
  cachedA11y: null,
643
752
  cachedA11yRevision: -1,
644
753
  cachedFormSchemas: new Map(),
754
+ heartbeatInterval: null,
755
+ heartbeatLastMessageAt: Date.now(),
756
+ heartbeatPendingPongBy: null,
645
757
  };
646
- let resolved = false;
647
- let lastMessageAt = Date.now();
648
- let heartbeatInterval = null;
649
- let pendingPongBy = null;
650
- // Heartbeat: send a real WS-level ping every 15s and only tear the socket
651
- // down if the peer fails to respond to two consecutive pings (i.e. ~45s of
652
- // true unresponsiveness). Previous versions used a dumb idle timer that
653
- // closed the socket after 30s of no inbound frames — which killed sessions
654
- // during normal form-submission flows where the DOM is legitimately idle
655
- // for 20-30+ seconds while the backend processes (Greenhouse submit →
656
- // security-code dialog is the canonical repro). A real ping/pong cycle
657
- // distinguishes a silent-but-healthy session from a dead one.
658
- function startHeartbeat() {
659
- if (heartbeatInterval)
660
- return;
661
- heartbeatInterval = setInterval(() => {
662
- // If we're waiting on a pong and it's overdue, the peer is dead.
663
- if (pendingPongBy !== null && Date.now() > pendingPongBy) {
664
- try {
665
- ws.close();
666
- }
667
- catch { /* ignore */ }
668
- return;
669
- }
670
- // Only send a new ping if we haven't heard anything for a while,
671
- // to avoid spamming a chatty session.
672
- if (Date.now() - lastMessageAt > 10_000) {
673
- try {
674
- ws.ping();
675
- // Allow 30s for the pong before declaring the peer dead.
676
- pendingPongBy = Date.now() + 30_000;
677
- }
678
- catch {
679
- /* if ping throws, the socket is already gone — let 'close' handle */
680
- }
681
- }
682
- }, 15_000);
683
- heartbeatInterval.unref();
758
+ try {
759
+ initializeSessionLifecycle(session, { transportMode: 'direct-ws' });
684
760
  }
761
+ catch (err) {
762
+ try {
763
+ ws.terminate();
764
+ }
765
+ catch {
766
+ /* ignore */
767
+ }
768
+ reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
769
+ return;
770
+ }
771
+ let resolved = false;
685
772
  ws.on('pong', () => {
686
- if (session.ws !== ws)
687
- return;
688
- lastMessageAt = Date.now();
689
- pendingPongBy = null;
773
+ noteSessionSocketActivity(session, ws);
690
774
  });
691
775
  const timeout = setTimeout(() => {
692
776
  if (!resolved) {
777
+ safeFailSessionLifecycle(session, 'connect_timeout', {
778
+ transportUrl: url,
779
+ timeoutMs: 10_000,
780
+ });
693
781
  resolved = true;
694
- ws.close();
782
+ try {
783
+ ws.close();
784
+ }
785
+ catch {
786
+ /* ignore */
787
+ }
695
788
  reject(new Error(`Connection to ${url} timed out after 10s`));
696
789
  }
697
790
  }, 10_000);
@@ -715,14 +808,17 @@ export function connect(url, opts) {
715
808
  }
716
809
  activeSessions.set(session.id, session);
717
810
  defaultSessionId = session.id;
718
- startHeartbeat();
811
+ startSessionHeartbeat(session);
812
+ safeRecordSessionSnapshot(session, 'session.open', {
813
+ awaitInitialFrame: false,
814
+ });
719
815
  resolve(session);
720
816
  }
721
817
  });
722
818
  ws.on('message', (data) => {
819
+ noteSessionSocketActivity(session, ws);
723
820
  if (session.ws !== ws)
724
821
  return;
725
- lastMessageAt = Date.now();
726
822
  try {
727
823
  const msg = JSON.parse(String(data));
728
824
  if (msg.type === 'frame') {
@@ -731,6 +827,7 @@ export function connect(url, opts) {
731
827
  session.updateRevision++;
732
828
  invalidateSessionCaches(session);
733
829
  const connectTrace = session.connectTrace;
830
+ const firstFrame = connectTrace?.firstFrameMs === undefined;
734
831
  if (connectTrace && connectTrace.firstFrameMs === undefined) {
735
832
  connectTrace.firstFrameMs = performance.now() - startedAt;
736
833
  }
@@ -742,9 +839,15 @@ export function connect(url, opts) {
742
839
  }
743
840
  activeSessions.set(session.id, session);
744
841
  defaultSessionId = session.id;
745
- startHeartbeat();
842
+ startSessionHeartbeat(session);
843
+ safeRecordSessionSnapshot(session, 'session.connected');
746
844
  resolve(session);
747
845
  }
846
+ else if (firstFrame) {
847
+ safeRecordSessionSnapshot(session, 'session.connected', {
848
+ lateInitialFrame: session.connectTrace?.resolvedWithoutInitialFrame === true,
849
+ });
850
+ }
748
851
  }
749
852
  else if (msg.type === 'patch' && session.layout) {
750
853
  applyPatches(session.layout, msg.patches);
@@ -756,6 +859,10 @@ export function connect(url, opts) {
756
859
  });
757
860
  ws.on('error', (err) => {
758
861
  if (!resolved) {
862
+ safeFailSessionLifecycle(session, 'websocket_error', {
863
+ transportUrl: url,
864
+ message: err.message,
865
+ });
759
866
  resolved = true;
760
867
  clearTimeout(timeout);
761
868
  reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
@@ -764,30 +871,19 @@ export function connect(url, opts) {
764
871
  ws.on('close', () => {
765
872
  if (session.ws !== ws)
766
873
  return;
767
- if (heartbeatInterval) {
768
- clearInterval(heartbeatInterval);
769
- heartbeatInterval = null;
770
- }
771
- if (activeSessions.get(session.id) === session) {
772
- activeSessions.delete(session.id);
773
- if (defaultSessionId === session.id)
774
- promoteDefaultSession();
775
- if (session.proxyChild && !session.proxyReusable) {
776
- try {
777
- session.proxyChild.kill('SIGTERM');
778
- }
779
- catch {
780
- /* ignore */
781
- }
782
- }
783
- if (session.proxyRuntime && !session.proxyReusable) {
784
- void session.proxyRuntime.close().catch(() => { });
785
- }
786
- }
787
874
  if (!resolved) {
875
+ safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
876
+ transportUrl: url,
877
+ });
788
878
  resolved = true;
789
879
  clearTimeout(timeout);
790
880
  reject(new Error(`Connection to ${url} closed before first frame`));
881
+ return;
882
+ }
883
+ if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
884
+ safeRecordSessionSnapshot(session, 'session.transport_closed', {
885
+ reconnectable: reconnectUrlForSession(session) !== null,
886
+ });
791
887
  }
792
888
  });
793
889
  });
@@ -858,7 +954,7 @@ export function getSession(id) {
858
954
  export function pruneDisconnectedSessions() {
859
955
  const removedIds = [];
860
956
  for (const [id, session] of activeSessions.entries()) {
861
- if (session.ws.readyState === WebSocket.OPEN)
957
+ if (session.ws.readyState === WebSocket.OPEN || session.reconnectInFlight || reconnectUrlForSession(session))
862
958
  continue;
863
959
  removedIds.push(id);
864
960
  activeSessions.delete(id);
@@ -905,10 +1001,10 @@ export function getDefaultSessionId() {
905
1001
  }
906
1002
  export function disconnect(opts) {
907
1003
  if (opts?.sessionId) {
908
- shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false });
1004
+ shutdownSession(opts.sessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
909
1005
  }
910
1006
  else if (defaultSessionId) {
911
- shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false });
1007
+ shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
912
1008
  }
913
1009
  if (opts?.closeProxy)
914
1010
  closeReusableProxies();
@@ -1026,6 +1122,7 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1026
1122
  }
1027
1123
  function bindReconnectedSocket(session, ws) {
1028
1124
  ws.on('message', data => {
1125
+ noteSessionSocketActivity(session, ws);
1029
1126
  if (session.ws !== ws)
1030
1127
  return;
1031
1128
  try {
@@ -1046,13 +1143,16 @@ function bindReconnectedSocket(session, ws) {
1046
1143
  /* ignore malformed messages */
1047
1144
  }
1048
1145
  });
1146
+ ws.on('pong', () => {
1147
+ noteSessionSocketActivity(session, ws);
1148
+ });
1049
1149
  ws.on('close', () => {
1050
1150
  if (session.ws !== ws)
1051
1151
  return;
1052
- if (activeSessions.get(session.id) === session) {
1053
- activeSessions.delete(session.id);
1054
- if (defaultSessionId === session.id)
1055
- promoteDefaultSession();
1152
+ if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
1153
+ safeRecordSessionSnapshot(session, 'session.transport_closed', {
1154
+ reconnectable: reconnectUrlForSession(session) !== null,
1155
+ });
1056
1156
  }
1057
1157
  });
1058
1158
  }
@@ -1071,20 +1171,41 @@ async function ensureSessionConnected(session) {
1071
1171
  throw new Error('Not connected');
1072
1172
  }
1073
1173
  const reconnectPromise = (async () => {
1074
- const nextWs = await openWebSocket(targetUrl);
1075
1174
  try {
1076
- session.ws.close();
1077
- }
1078
- catch {
1079
- /* ignore */
1175
+ const nextWs = await openWebSocket(targetUrl);
1176
+ try {
1177
+ session.ws.close();
1178
+ }
1179
+ catch {
1180
+ /* ignore */
1181
+ }
1182
+ session.ws = nextWs;
1183
+ noteSessionSocketActivity(session, nextWs);
1184
+ bindReconnectedSocket(session, nextWs);
1185
+ activeSessions.set(session.id, session);
1186
+ if (!session.isolated) {
1187
+ defaultSessionId = session.id;
1188
+ }
1189
+ startSessionHeartbeat(session);
1190
+ safeRecordSessionSnapshot(session, 'session.reconnected', {
1191
+ targetUrl,
1192
+ });
1193
+ return true;
1080
1194
  }
1081
- session.ws = nextWs;
1082
- bindReconnectedSocket(session, nextWs);
1083
- activeSessions.set(session.id, session);
1084
- if (!session.isolated) {
1085
- defaultSessionId = session.id;
1195
+ catch (err) {
1196
+ safeFailSessionLifecycle(session, 'reconnect_failed', {
1197
+ targetUrl,
1198
+ message: formatUnknownError(err),
1199
+ });
1200
+ if (activeSessions.get(session.id) === session) {
1201
+ activeSessions.delete(session.id);
1202
+ if (defaultSessionId === session.id)
1203
+ promoteDefaultSession();
1204
+ }
1205
+ stopSessionHeartbeat(session);
1206
+ releaseSessionResources(session, { closeProxy: true });
1207
+ throw err;
1086
1208
  }
1087
- return true;
1088
1209
  })();
1089
1210
  session.reconnectInFlight = reconnectPromise;
1090
1211
  let recovered = false;
@@ -1296,10 +1417,18 @@ export function sendPdfGenerate(session, options, timeoutMs = 30_000) {
1296
1417
  }
1297
1418
  /** Navigate the proxy page to a new URL while keeping the browser process alive. */
1298
1419
  export function sendNavigate(session, url, timeoutMs = 15_000) {
1299
- return sendAndWaitForUpdate(session, {
1300
- type: 'navigate',
1301
- url,
1302
- }, timeoutMs, { requireUpdateOnAck: true });
1420
+ return (async () => {
1421
+ const result = await sendAndWaitForUpdate(session, {
1422
+ type: 'navigate',
1423
+ url,
1424
+ }, timeoutMs, { requireUpdateOnAck: true });
1425
+ safeRecordSessionSnapshot(session, 'session.navigate', {
1426
+ requestedUrl: url,
1427
+ status: result.status,
1428
+ result: result.result ?? null,
1429
+ });
1430
+ return result;
1431
+ })();
1303
1432
  }
1304
1433
  /**
1305
1434
  * Build a flat accessibility tree from the raw UI tree + layout.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.57.0",
3
+ "version": "1.58.0",
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",
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "@geometra/proxy": "^1.19.23",
34
34
  "@modelcontextprotocol/sdk": "^1.12.1",
35
+ "@razroo/parallel-mcp": "^0.1.0",
35
36
  "ws": "^8.18.0",
36
37
  "zod": "^3.23.0"
37
38
  },