@geometra/mcp 1.57.0 → 1.59.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();
@@ -11,6 +11,19 @@ export declare function resolveProxyScriptPath(): string;
11
11
  export declare function resolveProxyScriptPathWith(customRequire: NodeRequire, moduleDir?: string): string;
12
12
  export declare function resolveProxyRuntimePath(): string;
13
13
  export declare function resolveProxyRuntimePathWith(customRequire: NodeRequire, moduleDir?: string): string;
14
+ /**
15
+ * BYO outbound proxy for the spawned Chromium. JobForge sets this when the
16
+ * user configures a residential / mobile / SOCKS proxy in `profile.yml` to
17
+ * bypass datacenter-IP fingerprinting on apply portals (Ashby class B,
18
+ * Lever Mapbox geocoder, Cloudflare Bot Management, etc.). Geometra is the
19
+ * wire — the user supplies the proxy.
20
+ */
21
+ export interface SpawnProxyConfig {
22
+ server: string;
23
+ username?: string;
24
+ password?: string;
25
+ bypass?: string;
26
+ }
14
27
  export interface SpawnProxyParams {
15
28
  pageUrl: string;
16
29
  port: number;
@@ -19,6 +32,7 @@ export interface SpawnProxyParams {
19
32
  height?: number;
20
33
  slowMo?: number;
21
34
  eagerInitialExtract?: boolean;
35
+ proxy?: SpawnProxyConfig;
22
36
  }
23
37
  export declare function startEmbeddedGeometraProxy(opts: SpawnProxyParams): Promise<{
24
38
  runtime: EmbeddedProxyRuntime;
@@ -139,6 +139,7 @@ export async function startEmbeddedGeometraProxy(opts) {
139
139
  headed: opts.headless !== true,
140
140
  slowMo: opts.slowMo,
141
141
  eagerInitialExtract: opts.eagerInitialExtract,
142
+ ...(opts.proxy && { proxy: opts.proxy }),
142
143
  });
143
144
  return { runtime, wsUrl: runtime.wsUrl };
144
145
  }
@@ -192,6 +193,15 @@ export function spawnGeometraProxy(opts) {
192
193
  args.push('--headed');
193
194
  if (opts.eagerInitialExtract === false)
194
195
  args.push('--lazy-initial-extract');
196
+ if (opts.proxy?.server) {
197
+ args.push('--proxy-server', opts.proxy.server);
198
+ if (opts.proxy.username !== undefined)
199
+ args.push('--proxy-username', opts.proxy.username);
200
+ if (opts.proxy.password !== undefined)
201
+ args.push('--proxy-password', opts.proxy.password);
202
+ if (opts.proxy.bypass !== undefined)
203
+ args.push('--proxy-bypass', opts.proxy.bypass);
204
+ }
195
205
  return new Promise((resolve, reject) => {
196
206
  const child = spawn(process.execPath, args, {
197
207
  stdio: ['ignore', 'pipe', 'pipe'],
package/dist/server.js CHANGED
@@ -376,6 +376,20 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
376
376
  .optional()
377
377
  .default(false)
378
378
  .describe('When true, bypass the reusable proxy pool and spawn a brand-new Chromium for this session that is destroyed on disconnect. Required for safe parallel form submission — without this, two parallel sessions can land on the same pooled proxy and contaminate each other. Default false (use the pool for speed).'),
379
+ proxy: z
380
+ .object({
381
+ server: z
382
+ .string()
383
+ .describe('Proxy URL (http://host:port, https://host:port, or socks5://host:port).'),
384
+ username: z.string().optional().describe('Proxy auth username.'),
385
+ password: z.string().optional().describe('Proxy auth password.'),
386
+ bypass: z
387
+ .string()
388
+ .optional()
389
+ .describe('Comma-separated host patterns to bypass (e.g. "*.internal,localhost").'),
390
+ })
391
+ .optional()
392
+ .describe('BYO outbound proxy for the spawned Chromium. Routes all browser traffic through the supplied residential / mobile / SOCKS proxy — useful for apply portals (Ashby, Lever, Cloudflare-fronted ATSes) that fingerprint datacenter IPs and flag headless sessions as bots. The reusable proxy pool is partitioned by proxy identity so callers with different proxy configs never share a Chromium instance.'),
379
393
  returnForms: z
380
394
  .boolean()
381
395
  .optional()
@@ -431,6 +445,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
431
445
  height: input.height,
432
446
  slowMo: input.slowMo,
433
447
  isolated: input.isolated,
448
+ proxy: input.proxy,
434
449
  awaitInitialFrame: deferInlinePageModel ? false : undefined,
435
450
  eagerInitialExtract: deferInlinePageModel ? true : undefined,
436
451
  });
@@ -501,9 +516,23 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
501
516
  .nonnegative()
502
517
  .optional()
503
518
  .describe('Playwright slowMo (ms) for the warmed browser.'),
504
- }, async ({ pageUrl, port, headless, width, height, slowMo }) => {
519
+ proxy: z
520
+ .object({
521
+ server: z
522
+ .string()
523
+ .describe('Proxy URL (http://host:port, https://host:port, or socks5://host:port).'),
524
+ username: z.string().optional().describe('Proxy auth username.'),
525
+ password: z.string().optional().describe('Proxy auth password.'),
526
+ bypass: z
527
+ .string()
528
+ .optional()
529
+ .describe('Comma-separated host patterns to bypass.'),
530
+ })
531
+ .optional()
532
+ .describe('BYO outbound proxy for the warmed Chromium. The pool entry is partitioned by proxy identity, so a later geometra_connect with the same proxy config will reuse this warmed browser; a different proxy config (or no proxy) will not.'),
533
+ }, async ({ pageUrl, port, headless, width, height, slowMo, proxy }) => {
505
534
  try {
506
- const prepared = await prewarmProxy({ pageUrl, port, headless, width, height, slowMo });
535
+ const prepared = await prewarmProxy({ pageUrl, port, headless, width, height, slowMo, proxy });
507
536
  return ok(JSON.stringify(prepared));
508
537
  }
509
538
  catch (e) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  import type { ChildProcess } from 'node:child_process';
2
2
  import WebSocket from 'ws';
3
- import { type EmbeddedProxyRuntime } from './proxy-spawn.js';
3
+ import { type EmbeddedProxyRuntime, type SpawnProxyConfig } from './proxy-spawn.js';
4
4
  /**
5
5
  * Parsed accessibility node from the UI tree + computed layout.
6
6
  * Mirrors the shape of @geometra/core's AccessibilityNode without importing it
@@ -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';
@@ -484,6 +492,7 @@ export declare function prewarmProxy(options: {
484
492
  width?: number;
485
493
  height?: number;
486
494
  slowMo?: number;
495
+ proxy?: SpawnProxyConfig;
487
496
  }): Promise<{
488
497
  prepared: true;
489
498
  reused: boolean;
@@ -527,6 +536,13 @@ export declare function connectThroughProxy(options: {
527
536
  * leak into another. Default false preserves the existing pool behavior.
528
537
  */
529
538
  isolated?: boolean;
539
+ /**
540
+ * BYO outbound proxy for the Chromium. Routes all browser traffic through
541
+ * the supplied residential / mobile / SOCKS proxy. The reusable pool is
542
+ * partitioned by proxy identity so two callers with different proxy
543
+ * configs never share a Chromium instance.
544
+ */
545
+ proxy?: SpawnProxyConfig;
530
546
  }): Promise<Session>;
531
547
  export declare function getSession(id?: string): Session | null;
532
548
  export declare function pruneDisconnectedSessions(): string[];
package/dist/session.js CHANGED
@@ -1,11 +1,25 @@
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';
6
+ /**
7
+ * Stable identity for an outbound proxy config, used as the reusable-pool
8
+ * partition key. Two sessions with different proxy configs MUST NOT share a
9
+ * pooled Chromium — otherwise the first apply's IP leaks into subsequent
10
+ * applies even when the caller opted into a fresh proxy. Password is
11
+ * excluded from the key to keep logs safe; `server + username + bypass` is
12
+ * enough to distinguish every realistic multi-tenant config.
13
+ */
14
+ function proxyKeyFor(proxy) {
15
+ if (!proxy?.server)
16
+ return '';
17
+ return `${proxy.server}|${proxy.username ?? ''}|${proxy.bypass ?? ''}`;
18
+ }
4
19
  const activeSessions = new Map();
5
20
  let defaultSessionId = null;
6
21
  const MAX_ACTIVE_SESSIONS = 5;
7
- let nextSessionId = 0;
8
- function generateSessionId() { return `s${++nextSessionId}`; }
22
+ function generateSessionId() { return `s_${randomUUID()}`; }
9
23
  let reusableProxies = [];
10
24
  const REUSABLE_PROXY_POOL_LIMIT = 6;
11
25
  /** Close idle reusable proxies after 5 minutes of inactivity. */
@@ -128,6 +142,7 @@ function enforceReusableProxyPoolLimit() {
128
142
  function setReusableProxy(proxy, wsUrl, opts) {
129
143
  clearReusableProxiesIfExited();
130
144
  const now = Date.now();
145
+ const proxyKey = proxyKeyFor(opts.proxy);
131
146
  const existing = reusableProxies.find(entry => sameReusableProxyEntry(entry, proxy));
132
147
  if (existing) {
133
148
  existing.wsUrl = wsUrl;
@@ -136,6 +151,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
136
151
  existing.width = opts.width ?? 1280;
137
152
  existing.height = opts.height ?? 720;
138
153
  existing.pageUrl = opts.pageUrl;
154
+ existing.proxyKey = proxyKey;
139
155
  existing.snapshotReady = opts.snapshotReady ?? existing.snapshotReady;
140
156
  existing.lastUsedAt = now;
141
157
  return;
@@ -150,6 +166,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
150
166
  width: opts.width ?? 1280,
151
167
  height: opts.height ?? 720,
152
168
  pageUrl: opts.pageUrl,
169
+ proxyKey,
153
170
  snapshotReady: opts.snapshotReady === true,
154
171
  lastUsedAt: now,
155
172
  };
@@ -175,6 +192,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
175
192
  width: opts.width ?? 1280,
176
193
  height: opts.height ?? 720,
177
194
  pageUrl: opts.pageUrl,
195
+ proxyKey,
178
196
  snapshotReady: opts.snapshotReady === true,
179
197
  lastUsedAt: now,
180
198
  });
@@ -229,11 +247,20 @@ function shutdownSession(id, opts) {
229
247
  const prev = activeSessions.get(id);
230
248
  if (!prev)
231
249
  return;
250
+ const forceCloseProxy = prev.isolated === true;
251
+ safeCompleteSessionLifecycle(prev, opts?.reason ?? 'disconnect', {
252
+ closeProxy: opts?.closeProxy ?? false,
253
+ forceCloseProxy,
254
+ });
232
255
  activeSessions.delete(id);
233
256
  if (defaultSessionId === id)
234
257
  promoteDefaultSession();
258
+ stopSessionHeartbeat(prev);
259
+ releaseSessionResources(prev, { closeProxy: opts?.closeProxy ?? false });
260
+ }
261
+ function releaseSessionResources(session, opts) {
235
262
  try {
236
- prev.ws.close();
263
+ session.ws.close();
237
264
  }
238
265
  catch {
239
266
  /* ignore */
@@ -243,44 +270,44 @@ function shutdownSession(id, opts) {
243
270
  // the underlying browser would defeat the entire point of the
244
271
  // isolation flag (the next non-isolated connect could attach to a
245
272
  // 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);
273
+ const forceCloseProxy = session.isolated === true;
274
+ if (session.proxyChild) {
275
+ const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
276
+ rememberReusableProxyPageUrl(session);
250
277
  if (shouldKeepProxy) {
251
- const entry = reusableProxyEntryForSession(prev);
278
+ const entry = reusableProxyEntryForSession(session);
252
279
  if (entry)
253
280
  touchReusableProxy(entry);
254
281
  return;
255
282
  }
256
- const entry = reusableProxyEntryForSession(prev);
283
+ const entry = reusableProxyEntryForSession(session);
257
284
  if (entry) {
258
285
  closeReusableProxy(entry);
259
286
  return;
260
287
  }
261
288
  try {
262
- prev.proxyChild.kill('SIGTERM');
289
+ session.proxyChild.kill('SIGTERM');
263
290
  }
264
291
  catch {
265
292
  /* ignore */
266
293
  }
267
294
  return;
268
295
  }
269
- if (prev.proxyRuntime) {
270
- const shouldKeepProxy = !forceCloseProxy && prev.proxyReusable && opts?.closeProxy === false;
271
- rememberReusableProxyPageUrl(prev);
296
+ if (session.proxyRuntime) {
297
+ const shouldKeepProxy = !forceCloseProxy && session.proxyReusable && opts?.closeProxy === false;
298
+ rememberReusableProxyPageUrl(session);
272
299
  if (shouldKeepProxy) {
273
- const entry = reusableProxyEntryForSession(prev);
300
+ const entry = reusableProxyEntryForSession(session);
274
301
  if (entry)
275
302
  touchReusableProxy(entry);
276
303
  return;
277
304
  }
278
- const entry = reusableProxyEntryForSession(prev);
305
+ const entry = reusableProxyEntryForSession(session);
279
306
  if (entry) {
280
307
  closeReusableProxy(entry);
281
308
  return;
282
309
  }
283
- void prev.proxyRuntime.close().catch(() => { });
310
+ void session.proxyRuntime.close().catch(() => { });
284
311
  }
285
312
  }
286
313
  /** Evict the oldest session when at capacity. */
@@ -288,17 +315,100 @@ function evictOldestSession() {
288
315
  if (activeSessions.size < MAX_ACTIVE_SESSIONS)
289
316
  return;
290
317
  const oldestId = activeSessions.keys().next().value;
291
- shutdownSession(oldestId, { closeProxy: false });
318
+ shutdownSession(oldestId, { closeProxy: false, reason: 'evicted' });
292
319
  }
293
320
  function formatUnknownError(err) {
294
321
  return err instanceof Error ? err.message : String(err);
295
322
  }
323
+ function warnSessionLifecycleError(action, session, err) {
324
+ console.warn(`geometra-mcp: failed to ${action} for session ${session.id}: ${formatUnknownError(err)}`);
325
+ }
326
+ function safeRecordSessionSnapshot(session, label, extra) {
327
+ try {
328
+ recordSessionSnapshot(session, label, extra);
329
+ }
330
+ catch (err) {
331
+ warnSessionLifecycleError(`record snapshot "${label}"`, session, err);
332
+ }
333
+ }
334
+ function safeHeartbeatSessionLifecycle(session) {
335
+ try {
336
+ heartbeatSessionLifecycle(session);
337
+ }
338
+ catch (err) {
339
+ warnSessionLifecycleError('heartbeat durable state', session, err);
340
+ }
341
+ }
342
+ function safeCompleteSessionLifecycle(session, reason, extra) {
343
+ try {
344
+ completeSessionLifecycle(session, reason, extra);
345
+ }
346
+ catch (err) {
347
+ warnSessionLifecycleError(`complete durable state as "${reason}"`, session, err);
348
+ }
349
+ }
350
+ function safeFailSessionLifecycle(session, error, extra) {
351
+ try {
352
+ failSessionLifecycle(session, error, extra);
353
+ }
354
+ catch (err) {
355
+ warnSessionLifecycleError(`fail durable state as "${error}"`, session, err);
356
+ }
357
+ }
358
+ function noteSessionSocketActivity(session, ws) {
359
+ if (session.ws !== ws)
360
+ return;
361
+ session.heartbeatLastMessageAt = Date.now();
362
+ session.heartbeatPendingPongBy = null;
363
+ }
364
+ // Keep the durable lease alive independently from a transient socket and only
365
+ // ping the WebSocket transport when a socket is actually open.
366
+ function startSessionHeartbeat(session) {
367
+ if (session.heartbeatInterval)
368
+ return;
369
+ session.heartbeatLastMessageAt ??= Date.now();
370
+ session.heartbeatPendingPongBy ??= null;
371
+ session.heartbeatInterval = setInterval(() => {
372
+ safeHeartbeatSessionLifecycle(session);
373
+ const ws = session.ws;
374
+ if (ws.readyState !== WebSocket.OPEN)
375
+ return;
376
+ const pendingPongBy = session.heartbeatPendingPongBy ?? null;
377
+ if (pendingPongBy !== null && Date.now() > pendingPongBy) {
378
+ try {
379
+ ws.close();
380
+ }
381
+ catch {
382
+ /* ignore */
383
+ }
384
+ return;
385
+ }
386
+ if (Date.now() - (session.heartbeatLastMessageAt ?? 0) > 10_000) {
387
+ try {
388
+ ws.ping();
389
+ session.heartbeatPendingPongBy = Date.now() + 30_000;
390
+ }
391
+ catch {
392
+ /* ignore */
393
+ }
394
+ }
395
+ }, 15_000);
396
+ session.heartbeatInterval.unref();
397
+ }
398
+ function stopSessionHeartbeat(session) {
399
+ if (session.heartbeatInterval) {
400
+ clearInterval(session.heartbeatInterval);
401
+ session.heartbeatInterval = null;
402
+ }
403
+ session.heartbeatPendingPongBy = null;
404
+ }
296
405
  function reusableProxyMatchesOptions(entry, options) {
297
406
  return (entry.pageUrl === options.pageUrl &&
298
407
  entry.headless === (options.headless === true) &&
299
408
  entry.slowMo === (options.slowMo ?? 0) &&
300
409
  entry.width === (options.width ?? 1280) &&
301
- entry.height === (options.height ?? 720));
410
+ entry.height === (options.height ?? 720) &&
411
+ entry.proxyKey === proxyKeyFor(options.proxy));
302
412
  }
303
413
  function findExactReusableProxy(options) {
304
414
  clearReusableProxiesIfExited();
@@ -315,8 +425,14 @@ function findReusableProxy(options) {
315
425
  const desiredSlowMo = options.slowMo ?? 0;
316
426
  const desiredWidth = options.width ?? 1280;
317
427
  const desiredHeight = options.height ?? 720;
428
+ const desiredProxyKey = proxyKeyFor(options.proxy);
318
429
  return reusableProxies
319
- .filter(entry => entry.headless === desiredHeadless && entry.slowMo === desiredSlowMo)
430
+ .filter(entry => entry.headless === desiredHeadless
431
+ && entry.slowMo === desiredSlowMo
432
+ // Proxy partition is hard — a session with residential proxy MUST NOT
433
+ // attach to a pooled direct-connection Chromium (and vice versa).
434
+ // Different proxy credentials also get separate pool entries.
435
+ && entry.proxyKey === desiredProxyKey)
320
436
  .sort((a, b) => {
321
437
  const score = (entry) => {
322
438
  let value = 0;
@@ -356,6 +472,7 @@ export async function prewarmProxy(options) {
356
472
  width: options.width,
357
473
  height: options.height,
358
474
  slowMo: options.slowMo,
475
+ proxy: options.proxy,
359
476
  });
360
477
  try {
361
478
  await runtime.ready;
@@ -371,6 +488,7 @@ export async function prewarmProxy(options) {
371
488
  height: options.height,
372
489
  pageUrl: options.pageUrl,
373
490
  snapshotReady: true,
491
+ proxy: options.proxy,
374
492
  });
375
493
  return {
376
494
  prepared: true,
@@ -394,6 +512,7 @@ export async function prewarmProxy(options) {
394
512
  width: options.width,
395
513
  height: options.height,
396
514
  slowMo: options.slowMo,
515
+ proxy: options.proxy,
397
516
  });
398
517
  setReusableProxy({ child }, wsUrl, {
399
518
  headless: options.headless,
@@ -401,6 +520,7 @@ export async function prewarmProxy(options) {
401
520
  width: options.width,
402
521
  height: options.height,
403
522
  pageUrl: options.pageUrl,
523
+ proxy: options.proxy,
404
524
  });
405
525
  return {
406
526
  prepared: true,
@@ -477,6 +597,11 @@ async function attachToReusableProxy(proxy, options) {
477
597
  totalMs: performance.now() - startedAt,
478
598
  };
479
599
  updateReusableProxySnapshotState(proxy, session);
600
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
601
+ transportMode: 'reused-proxy',
602
+ targetPageUrl: options.pageUrl,
603
+ reusedExistingSession: reusedExistingSession !== null,
604
+ });
480
605
  return session;
481
606
  }
482
607
  async function startFreshProxySession(options) {
@@ -497,6 +622,7 @@ async function startFreshProxySession(options) {
497
622
  height: options.height,
498
623
  slowMo: options.slowMo,
499
624
  eagerInitialExtract,
625
+ proxy: options.proxy,
500
626
  });
501
627
  pendingEmbeddedRuntime = runtime;
502
628
  const proxyStartMs = performance.now() - proxyStartStartedAt;
@@ -527,6 +653,7 @@ async function startFreshProxySession(options) {
527
653
  height: options.height,
528
654
  pageUrl: options.pageUrl,
529
655
  snapshotReady: Boolean(session.tree && session.layout),
656
+ proxy: options.proxy,
530
657
  });
531
658
  }
532
659
  const baseConnectTrace = session.connectTrace;
@@ -542,6 +669,12 @@ async function startFreshProxySession(options) {
542
669
  resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
543
670
  totalMs: performance.now() - startedAt,
544
671
  };
672
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
673
+ transportMode: 'fresh-proxy',
674
+ proxyStartMode: 'embedded',
675
+ requestedPageUrl: options.pageUrl,
676
+ isolated: options.isolated === true,
677
+ });
545
678
  return session;
546
679
  }
547
680
  catch (e) {
@@ -564,6 +697,7 @@ async function startFreshProxySession(options) {
564
697
  height: options.height,
565
698
  slowMo: options.slowMo,
566
699
  eagerInitialExtract,
700
+ proxy: options.proxy,
567
701
  });
568
702
  const proxyStartMs = performance.now() - proxyStartStartedAt;
569
703
  try {
@@ -588,6 +722,7 @@ async function startFreshProxySession(options) {
588
722
  height: options.height,
589
723
  pageUrl: options.pageUrl,
590
724
  snapshotReady: Boolean(session.tree && session.layout),
725
+ proxy: options.proxy,
591
726
  });
592
727
  }
593
728
  const baseConnectTrace = session.connectTrace;
@@ -603,6 +738,12 @@ async function startFreshProxySession(options) {
603
738
  resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
604
739
  totalMs: performance.now() - startedAt,
605
740
  };
741
+ safeRecordSessionSnapshot(session, 'session.proxy_attached', {
742
+ transportMode: 'fresh-proxy',
743
+ proxyStartMode: 'child',
744
+ requestedPageUrl: options.pageUrl,
745
+ isolated: options.isolated === true,
746
+ });
606
747
  return session;
607
748
  }
608
749
  catch (fallbackError) {
@@ -642,56 +783,40 @@ export function connect(url, opts) {
642
783
  cachedA11y: null,
643
784
  cachedA11yRevision: -1,
644
785
  cachedFormSchemas: new Map(),
786
+ heartbeatInterval: null,
787
+ heartbeatLastMessageAt: Date.now(),
788
+ heartbeatPendingPongBy: null,
645
789
  };
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();
790
+ try {
791
+ initializeSessionLifecycle(session, { transportMode: 'direct-ws' });
792
+ }
793
+ catch (err) {
794
+ try {
795
+ ws.terminate();
796
+ }
797
+ catch {
798
+ /* ignore */
799
+ }
800
+ reject(new Error(`Failed to initialize durable session state for ${url}: ${formatUnknownError(err)}`));
801
+ return;
684
802
  }
803
+ let resolved = false;
685
804
  ws.on('pong', () => {
686
- if (session.ws !== ws)
687
- return;
688
- lastMessageAt = Date.now();
689
- pendingPongBy = null;
805
+ noteSessionSocketActivity(session, ws);
690
806
  });
691
807
  const timeout = setTimeout(() => {
692
808
  if (!resolved) {
809
+ safeFailSessionLifecycle(session, 'connect_timeout', {
810
+ transportUrl: url,
811
+ timeoutMs: 10_000,
812
+ });
693
813
  resolved = true;
694
- ws.close();
814
+ try {
815
+ ws.close();
816
+ }
817
+ catch {
818
+ /* ignore */
819
+ }
695
820
  reject(new Error(`Connection to ${url} timed out after 10s`));
696
821
  }
697
822
  }, 10_000);
@@ -715,14 +840,17 @@ export function connect(url, opts) {
715
840
  }
716
841
  activeSessions.set(session.id, session);
717
842
  defaultSessionId = session.id;
718
- startHeartbeat();
843
+ startSessionHeartbeat(session);
844
+ safeRecordSessionSnapshot(session, 'session.open', {
845
+ awaitInitialFrame: false,
846
+ });
719
847
  resolve(session);
720
848
  }
721
849
  });
722
850
  ws.on('message', (data) => {
851
+ noteSessionSocketActivity(session, ws);
723
852
  if (session.ws !== ws)
724
853
  return;
725
- lastMessageAt = Date.now();
726
854
  try {
727
855
  const msg = JSON.parse(String(data));
728
856
  if (msg.type === 'frame') {
@@ -731,6 +859,7 @@ export function connect(url, opts) {
731
859
  session.updateRevision++;
732
860
  invalidateSessionCaches(session);
733
861
  const connectTrace = session.connectTrace;
862
+ const firstFrame = connectTrace?.firstFrameMs === undefined;
734
863
  if (connectTrace && connectTrace.firstFrameMs === undefined) {
735
864
  connectTrace.firstFrameMs = performance.now() - startedAt;
736
865
  }
@@ -742,9 +871,15 @@ export function connect(url, opts) {
742
871
  }
743
872
  activeSessions.set(session.id, session);
744
873
  defaultSessionId = session.id;
745
- startHeartbeat();
874
+ startSessionHeartbeat(session);
875
+ safeRecordSessionSnapshot(session, 'session.connected');
746
876
  resolve(session);
747
877
  }
878
+ else if (firstFrame) {
879
+ safeRecordSessionSnapshot(session, 'session.connected', {
880
+ lateInitialFrame: session.connectTrace?.resolvedWithoutInitialFrame === true,
881
+ });
882
+ }
748
883
  }
749
884
  else if (msg.type === 'patch' && session.layout) {
750
885
  applyPatches(session.layout, msg.patches);
@@ -756,6 +891,10 @@ export function connect(url, opts) {
756
891
  });
757
892
  ws.on('error', (err) => {
758
893
  if (!resolved) {
894
+ safeFailSessionLifecycle(session, 'websocket_error', {
895
+ transportUrl: url,
896
+ message: err.message,
897
+ });
759
898
  resolved = true;
760
899
  clearTimeout(timeout);
761
900
  reject(new Error(`WebSocket error connecting to ${url}: ${err.message}`));
@@ -764,30 +903,19 @@ export function connect(url, opts) {
764
903
  ws.on('close', () => {
765
904
  if (session.ws !== ws)
766
905
  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
906
  if (!resolved) {
907
+ safeFailSessionLifecycle(session, 'websocket_closed_before_ready', {
908
+ transportUrl: url,
909
+ });
788
910
  resolved = true;
789
911
  clearTimeout(timeout);
790
912
  reject(new Error(`Connection to ${url} closed before first frame`));
913
+ return;
914
+ }
915
+ if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
916
+ safeRecordSessionSnapshot(session, 'session.transport_closed', {
917
+ reconnectable: reconnectUrlForSession(session) !== null,
918
+ });
791
919
  }
792
920
  });
793
921
  });
@@ -858,7 +986,7 @@ export function getSession(id) {
858
986
  export function pruneDisconnectedSessions() {
859
987
  const removedIds = [];
860
988
  for (const [id, session] of activeSessions.entries()) {
861
- if (session.ws.readyState === WebSocket.OPEN)
989
+ if (session.ws.readyState === WebSocket.OPEN || session.reconnectInFlight || reconnectUrlForSession(session))
862
990
  continue;
863
991
  removedIds.push(id);
864
992
  activeSessions.delete(id);
@@ -905,10 +1033,10 @@ export function getDefaultSessionId() {
905
1033
  }
906
1034
  export function disconnect(opts) {
907
1035
  if (opts?.sessionId) {
908
- shutdownSession(opts.sessionId, { closeProxy: opts.closeProxy ?? false });
1036
+ shutdownSession(opts.sessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
909
1037
  }
910
1038
  else if (defaultSessionId) {
911
- shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false });
1039
+ shutdownSession(defaultSessionId, { closeProxy: opts?.closeProxy ?? false, reason: 'disconnect' });
912
1040
  }
913
1041
  if (opts?.closeProxy)
914
1042
  closeReusableProxies();
@@ -1026,6 +1154,7 @@ async function openWebSocket(url, timeoutMs = SESSION_RECONNECT_TIMEOUT_MS) {
1026
1154
  }
1027
1155
  function bindReconnectedSocket(session, ws) {
1028
1156
  ws.on('message', data => {
1157
+ noteSessionSocketActivity(session, ws);
1029
1158
  if (session.ws !== ws)
1030
1159
  return;
1031
1160
  try {
@@ -1046,13 +1175,16 @@ function bindReconnectedSocket(session, ws) {
1046
1175
  /* ignore malformed messages */
1047
1176
  }
1048
1177
  });
1178
+ ws.on('pong', () => {
1179
+ noteSessionSocketActivity(session, ws);
1180
+ });
1049
1181
  ws.on('close', () => {
1050
1182
  if (session.ws !== ws)
1051
1183
  return;
1052
- if (activeSessions.get(session.id) === session) {
1053
- activeSessions.delete(session.id);
1054
- if (defaultSessionId === session.id)
1055
- promoteDefaultSession();
1184
+ if (activeSessions.get(session.id) === session && !session.lifecycleFinalized) {
1185
+ safeRecordSessionSnapshot(session, 'session.transport_closed', {
1186
+ reconnectable: reconnectUrlForSession(session) !== null,
1187
+ });
1056
1188
  }
1057
1189
  });
1058
1190
  }
@@ -1071,20 +1203,41 @@ async function ensureSessionConnected(session) {
1071
1203
  throw new Error('Not connected');
1072
1204
  }
1073
1205
  const reconnectPromise = (async () => {
1074
- const nextWs = await openWebSocket(targetUrl);
1075
1206
  try {
1076
- session.ws.close();
1077
- }
1078
- catch {
1079
- /* ignore */
1207
+ const nextWs = await openWebSocket(targetUrl);
1208
+ try {
1209
+ session.ws.close();
1210
+ }
1211
+ catch {
1212
+ /* ignore */
1213
+ }
1214
+ session.ws = nextWs;
1215
+ noteSessionSocketActivity(session, nextWs);
1216
+ bindReconnectedSocket(session, nextWs);
1217
+ activeSessions.set(session.id, session);
1218
+ if (!session.isolated) {
1219
+ defaultSessionId = session.id;
1220
+ }
1221
+ startSessionHeartbeat(session);
1222
+ safeRecordSessionSnapshot(session, 'session.reconnected', {
1223
+ targetUrl,
1224
+ });
1225
+ return true;
1080
1226
  }
1081
- session.ws = nextWs;
1082
- bindReconnectedSocket(session, nextWs);
1083
- activeSessions.set(session.id, session);
1084
- if (!session.isolated) {
1085
- defaultSessionId = session.id;
1227
+ catch (err) {
1228
+ safeFailSessionLifecycle(session, 'reconnect_failed', {
1229
+ targetUrl,
1230
+ message: formatUnknownError(err),
1231
+ });
1232
+ if (activeSessions.get(session.id) === session) {
1233
+ activeSessions.delete(session.id);
1234
+ if (defaultSessionId === session.id)
1235
+ promoteDefaultSession();
1236
+ }
1237
+ stopSessionHeartbeat(session);
1238
+ releaseSessionResources(session, { closeProxy: true });
1239
+ throw err;
1086
1240
  }
1087
- return true;
1088
1241
  })();
1089
1242
  session.reconnectInFlight = reconnectPromise;
1090
1243
  let recovered = false;
@@ -1296,10 +1449,18 @@ export function sendPdfGenerate(session, options, timeoutMs = 30_000) {
1296
1449
  }
1297
1450
  /** Navigate the proxy page to a new URL while keeping the browser process alive. */
1298
1451
  export function sendNavigate(session, url, timeoutMs = 15_000) {
1299
- return sendAndWaitForUpdate(session, {
1300
- type: 'navigate',
1301
- url,
1302
- }, timeoutMs, { requireUpdateOnAck: true });
1452
+ return (async () => {
1453
+ const result = await sendAndWaitForUpdate(session, {
1454
+ type: 'navigate',
1455
+ url,
1456
+ }, timeoutMs, { requireUpdateOnAck: true });
1457
+ safeRecordSessionSnapshot(session, 'session.navigate', {
1458
+ requestedUrl: url,
1459
+ status: result.status,
1460
+ result: result.result ?? null,
1461
+ });
1462
+ return result;
1463
+ })();
1303
1464
  }
1304
1465
  /**
1305
1466
  * 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.59.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
  },