@geometra/mcp 1.36.0 → 1.37.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Integration test for `connectThroughProxy({ isolated: true })`.
3
+ *
4
+ * Setup: a tiny HTTP server serves an HTML page that, on first visit, writes
5
+ * a path-tagged marker to `localStorage` if one isn't already set, then
6
+ * renders the marker text into an `<h1>`. The marker is therefore set ONCE
7
+ * per browser instance, regardless of how many navigations happen.
8
+ *
9
+ * Two scenarios verify the isolated flag's behavior:
10
+ *
11
+ * 1. **Pooled (default)**: connect to `/page-a`, disconnect (the proxy
12
+ * enters the reusable pool), then connect to `/page-b`. The second
13
+ * connect attaches to the same pooled proxy, which navigates the
14
+ * existing Chromium from /page-a to /page-b — but the browser's
15
+ * localStorage still has `marker-from-/page-a`, so the second session
16
+ * SEES THE FIRST SESSION'S MARKER. This documents the contamination
17
+ * that breaks parallel form submission against real apply flows.
18
+ *
19
+ * 2. **Isolated**: same connect/disconnect/connect sequence, but with
20
+ * `isolated: true`. Each connect spawns a brand-new Chromium with
21
+ * empty storage, so the second session sees its own marker, not the
22
+ * first's. This is the fix.
23
+ *
24
+ * The point of having both cases in one test file is so that future edits
25
+ * to the pool code can't quietly break the isolation guarantee — both
26
+ * paths run end-to-end against real Chromium and assert on the actual
27
+ * post-navigation a11y tree the MCP would expose to a tool call.
28
+ */
29
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
30
+ import http from 'node:http';
31
+ import { buildA11yTree, connectThroughProxy, disconnect } from '../session.js';
32
+ const PAGE_HTML = `<!doctype html>
33
+ <html>
34
+ <head><title>isolation-fixture</title></head>
35
+ <body>
36
+ <h1 id="marker"></h1>
37
+ <script>
38
+ const stored = localStorage.getItem('isolation-marker')
39
+ if (!stored) {
40
+ // First visit in this browser instance — set a path-tagged marker.
41
+ localStorage.setItem('isolation-marker', 'marker-from-' + location.pathname)
42
+ }
43
+ document.getElementById('marker').textContent =
44
+ localStorage.getItem('isolation-marker') || 'marker-missing'
45
+ </script>
46
+ </body>
47
+ </html>`;
48
+ let baseUrl;
49
+ let server;
50
+ function findHeadingText(node) {
51
+ if (!node)
52
+ return undefined;
53
+ if (node.role === 'heading') {
54
+ const name = (node.name ?? '').trim();
55
+ if (name)
56
+ return name;
57
+ }
58
+ for (const child of node.children ?? []) {
59
+ const found = findHeadingText(child);
60
+ if (found)
61
+ return found;
62
+ }
63
+ return undefined;
64
+ }
65
+ function currentA11y(session) {
66
+ if (!session.tree || !session.layout)
67
+ return null;
68
+ return buildA11yTree(session.tree, session.layout);
69
+ }
70
+ async function waitForMarkerText(session, expectedPrefix, timeoutMs = 6_000) {
71
+ const deadline = Date.now() + timeoutMs;
72
+ while (Date.now() < deadline) {
73
+ const text = findHeadingText(currentA11y(session));
74
+ if (text && text.startsWith(expectedPrefix))
75
+ return text;
76
+ await new Promise(r => setTimeout(r, 100));
77
+ }
78
+ throw new Error(`Timed out waiting for heading text starting with "${expectedPrefix}". ` +
79
+ `Last seen: ${JSON.stringify(findHeadingText(currentA11y(session)) ?? null)}`);
80
+ }
81
+ describe('connectThroughProxy({ isolated: true })', () => {
82
+ beforeAll(async () => {
83
+ server = http.createServer((req, res) => {
84
+ const url = req.url ?? '/';
85
+ if (url === '/page-a' || url === '/page-b') {
86
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
87
+ res.end(PAGE_HTML);
88
+ return;
89
+ }
90
+ res.writeHead(404);
91
+ res.end();
92
+ });
93
+ await new Promise((resolve, reject) => {
94
+ server.once('error', reject);
95
+ server.listen(0, '127.0.0.1', () => resolve());
96
+ });
97
+ const address = server.address();
98
+ baseUrl = `http://127.0.0.1:${address.port}`;
99
+ });
100
+ afterEach(() => {
101
+ // Force-close everything so the next test starts with no pooled proxies.
102
+ disconnect({ closeProxy: true });
103
+ });
104
+ it('isolated sessions get independent localStorage between connects', async () => {
105
+ // First isolated session against /page-a.
106
+ const sessionA = await connectThroughProxy({
107
+ pageUrl: `${baseUrl}/page-a`,
108
+ headless: true,
109
+ isolated: true,
110
+ });
111
+ expect(sessionA.isolated).toBe(true);
112
+ const markerA = await waitForMarkerText(sessionA, 'marker-from-');
113
+ expect(markerA).toBe('marker-from-/page-a');
114
+ // Disconnect — because the session is isolated, this MUST destroy the
115
+ // underlying Chromium. The next connect cannot attach to it.
116
+ disconnect({ sessionId: sessionA.id });
117
+ // Second isolated session against /page-b. Because each isolated
118
+ // session gets its own brand-new Chromium, /page-b's first-visit
119
+ // script runs against an empty localStorage and writes its own marker.
120
+ const sessionB = await connectThroughProxy({
121
+ pageUrl: `${baseUrl}/page-b`,
122
+ headless: true,
123
+ isolated: true,
124
+ });
125
+ expect(sessionB.isolated).toBe(true);
126
+ const markerB = await waitForMarkerText(sessionB, 'marker-from-');
127
+ // Critical assertion: sessionB does NOT see sessionA's marker.
128
+ expect(markerB).toBe('marker-from-/page-b');
129
+ expect(markerB).not.toBe(markerA);
130
+ disconnect({ sessionId: sessionB.id });
131
+ }, 30_000);
132
+ it('pooled (default) sessions DO leak localStorage — documents the bug isolated fixes', async () => {
133
+ // First pooled session against /page-a. The proxy will be eligible
134
+ // for reuse after disconnect.
135
+ const sessionA = await connectThroughProxy({
136
+ pageUrl: `${baseUrl}/page-a`,
137
+ headless: true,
138
+ // isolated: false (default)
139
+ });
140
+ expect(sessionA.isolated).toBeFalsy();
141
+ const markerA = await waitForMarkerText(sessionA, 'marker-from-');
142
+ expect(markerA).toBe('marker-from-/page-a');
143
+ // Disconnect WITHOUT closing the proxy — leaves it in the reusable pool.
144
+ disconnect({ sessionId: sessionA.id, closeProxy: false });
145
+ // Second pooled session against a *different* URL. The pool will
146
+ // attach the existing Chromium and navigate it to /page-b, but the
147
+ // browser still has /page-a's localStorage, so /page-b's first-visit
148
+ // script sees the existing marker and doesn't overwrite it.
149
+ const sessionB = await connectThroughProxy({
150
+ pageUrl: `${baseUrl}/page-b`,
151
+ headless: true,
152
+ });
153
+ const markerB = await waitForMarkerText(sessionB, 'marker-from-');
154
+ // Documents the contamination: the second session sees the first
155
+ // session's marker because they share a browser via the pool.
156
+ expect(markerB).toBe('marker-from-/page-a');
157
+ disconnect({ sessionId: sessionB.id, closeProxy: true });
158
+ }, 30_000);
159
+ });
package/dist/server.js CHANGED
@@ -296,7 +296,9 @@ export function createServer() {
296
296
 
297
297
  Use \`url\` (ws://…) only when a Geometra/native server or an already-running proxy is listening. If you accidentally pass \`https://…\` in \`url\`, MCP treats it like \`pageUrl\` and starts the proxy for you.
298
298
 
299
- Chromium opens **visible** by default unless \`headless: true\`. File upload / wheel / native \`<select>\` need the proxy path (\`pageUrl\` or ws to proxy). Set \`returnForms: true\` and/or \`returnPageModel: true\` when you want a lower-turn startup response. When connect first-response latency matters more than inlining the page model, pair \`returnPageModel: true\` with \`pageModelMode: "deferred"\` and call \`geometra_page_model\` next.`, {
299
+ Chromium opens **visible** by default unless \`headless: true\`. File upload / wheel / native \`<select>\` need the proxy path (\`pageUrl\` or ws to proxy). Set \`returnForms: true\` and/or \`returnPageModel: true\` when you want a lower-turn startup response. When connect first-response latency matters more than inlining the page model, pair \`returnPageModel: true\` with \`pageModelMode: "deferred"\` and call \`geometra_page_model\` next.
300
+
301
+ **Parallelism:** by default, geometra MCP pools and reuses Chromium instances across sessions for speed. That pooling is safe for read-only exploration, but it shares localStorage / cookies / page state across whichever sessions land on the same proxy — which means **two parallel form-submission flows can contaminate each other** (one job's email/autocomplete state leaks into another, or worse, two agents end up driving the same browser tab). For parallel apply / form submission, pass \`isolated: true\`. Each isolated session gets its own brand-new Chromium that is destroyed on disconnect, never enters the pool, and is guaranteed independent of every other session. The cost is ~1–2s of extra startup vs the ~50ms reusable-proxy attach.`, {
300
302
  url: z
301
303
  .string()
302
304
  .optional()
@@ -326,6 +328,11 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
326
328
  .nonnegative()
327
329
  .optional()
328
330
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
331
+ isolated: z
332
+ .boolean()
333
+ .optional()
334
+ .default(false)
335
+ .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).'),
329
336
  returnForms: z
330
337
  .boolean()
331
338
  .optional()
@@ -380,6 +387,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
380
387
  width: input.width,
381
388
  height: input.height,
382
389
  slowMo: input.slowMo,
390
+ isolated: input.isolated,
383
391
  awaitInitialFrame: deferInlinePageModel ? false : undefined,
384
392
  eagerInitialExtract: deferInlinePageModel ? true : undefined,
385
393
  });
@@ -837,9 +845,14 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
837
845
  .optional()
838
846
  .default(false)
839
847
  .describe('Skip fields that already contain a matching value. Avoids overwriting good data from resume parsing or previous fills.'),
848
+ isolated: z
849
+ .boolean()
850
+ .optional()
851
+ .default(false)
852
+ .describe('When auto-connecting via pageUrl/url, request an isolated proxy (own brand-new Chromium, destroyed on disconnect). Required for safe parallel form submission. See geometra_connect for details. Ignored when reusing an existing sessionId — set isolated on the original geometra_connect for that case.'),
840
853
  detail: detailInput(),
841
854
  sessionId: sessionIdInput,
842
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, detail, sessionId }) => {
855
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, isolated, detail, sessionId }) => {
843
856
  const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
844
857
  ? directLabelBatchFields(valuesByLabel)
845
858
  : null;
@@ -852,6 +865,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
852
865
  width,
853
866
  height,
854
867
  slowMo,
868
+ isolated,
855
869
  awaitInitialFrame: directFields ? false : undefined,
856
870
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
857
871
  if (!resolved.ok)
@@ -1103,6 +1117,11 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1103
1117
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1104
1118
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1105
1119
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1120
+ isolated: z
1121
+ .boolean()
1122
+ .optional()
1123
+ .default(false)
1124
+ .describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
1106
1125
  actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
1107
1126
  stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing step (default true)'),
1108
1127
  includeSteps: z
@@ -1113,7 +1132,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1113
1132
  output: z.enum(['full', 'final']).optional().default('full').describe('`full` (default) returns counts and optional step listings. `final` keeps only completion state plus final semantic signals.'),
1114
1133
  detail: detailInput(),
1115
1134
  sessionId: sessionIdInput,
1116
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, actions, stopOnError, includeSteps, output, detail, sessionId }) => {
1135
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, isolated, actions, stopOnError, includeSteps, output, detail, sessionId }) => {
1117
1136
  const resolved = await ensureToolSession({
1118
1137
  sessionId,
1119
1138
  url,
@@ -1123,6 +1142,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1123
1142
  width,
1124
1143
  height,
1125
1144
  slowMo,
1145
+ isolated,
1126
1146
  awaitInitialFrame: canDeferInitialFrameForRunActions(actions) ? false : undefined,
1127
1147
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_run_actions.');
1128
1148
  if (!resolved.ok)
@@ -1262,6 +1282,11 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1262
1282
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1263
1283
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1264
1284
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1285
+ isolated: z
1286
+ .boolean()
1287
+ .optional()
1288
+ .default(false)
1289
+ .describe('When auto-connecting via pageUrl/url, request an isolated proxy. See geometra_connect for details.'),
1265
1290
  formId: z.string().optional().describe('Optional form id from geometra_page_model. If omitted, returns every form schema on the page.'),
1266
1291
  maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form'),
1267
1292
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
@@ -1271,8 +1296,8 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1271
1296
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
1272
1297
  format: formSchemaFormatInput(),
1273
1298
  sessionId: sessionIdInput,
1274
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
1275
- const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
1299
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
1300
+ const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
1276
1301
  if (!resolved.ok)
1277
1302
  return err(resolved.error);
1278
1303
  const session = resolved.session;
@@ -2235,6 +2260,7 @@ async function ensureToolSession(target, missingConnectionMessage = 'Not connect
2235
2260
  height: target.height,
2236
2261
  slowMo: target.slowMo,
2237
2262
  awaitInitialFrame: target.awaitInitialFrame,
2263
+ isolated: target.isolated,
2238
2264
  });
2239
2265
  return {
2240
2266
  ok: true,
package/dist/session.d.ts CHANGED
@@ -390,6 +390,15 @@ export interface Session {
390
390
  proxyChild?: ChildProcess;
391
391
  proxyRuntime?: EmbeddedProxyRuntime;
392
392
  proxyReusable?: boolean;
393
+ /**
394
+ * True when this session was started with `isolated: true`. Isolated sessions
395
+ * never enter the reusable proxy pool — they always spawn a fresh Chromium
396
+ * and the proxy is destroyed on disconnect rather than pooled. This gives
397
+ * each parallel agent its own independent localStorage / cookies / page
398
+ * state, which is the only safe configuration for parallel form submission
399
+ * (see the v1.37.0 release notes for the JobForge bug-report context).
400
+ */
401
+ isolated?: boolean;
393
402
  connectTrace?: SessionConnectTrace;
394
403
  cachedA11y?: A11yNode | null;
395
404
  cachedA11yRevision?: number;
@@ -493,6 +502,15 @@ export declare function connectThroughProxy(options: {
493
502
  slowMo?: number;
494
503
  awaitInitialFrame?: boolean;
495
504
  eagerInitialExtract?: boolean;
505
+ /**
506
+ * When true, bypass the reusable proxy pool entirely and always spawn a
507
+ * fresh Chromium for this session. The session is tagged isolated, never
508
+ * entered into the pool on disconnect, and its underlying browser is
509
+ * destroyed when the session disconnects. Use for parallel form
510
+ * submission so localStorage / cookies / page state from one job cannot
511
+ * leak into another. Default false preserves the existing pool behavior.
512
+ */
513
+ isolated?: boolean;
496
514
  }): Promise<Session>;
497
515
  export declare function getSession(id?: string): Session | null;
498
516
  export declare function listSessions(): Array<{
package/dist/session.js CHANGED
@@ -212,8 +212,14 @@ function shutdownSession(id, opts) {
212
212
  catch {
213
213
  /* ignore */
214
214
  }
215
+ // Isolated sessions always destroy their proxy on disconnect — they
216
+ // never went into the reusable pool in the first place, and leaking
217
+ // the underlying browser would defeat the entire point of the
218
+ // isolation flag (the next non-isolated connect could attach to a
219
+ // proxy with stale storage from this session's job).
220
+ const forceCloseProxy = prev.isolated === true;
215
221
  if (prev.proxyChild) {
216
- const shouldKeepProxy = prev.proxyReusable && opts?.closeProxy === false;
222
+ const shouldKeepProxy = !forceCloseProxy && prev.proxyReusable && opts?.closeProxy === false;
217
223
  rememberReusableProxyPageUrl(prev);
218
224
  if (shouldKeepProxy) {
219
225
  const entry = reusableProxyEntryForSession(prev);
@@ -235,7 +241,7 @@ function shutdownSession(id, opts) {
235
241
  return;
236
242
  }
237
243
  if (prev.proxyRuntime) {
238
- const shouldKeepProxy = prev.proxyReusable && opts?.closeProxy === false;
244
+ const shouldKeepProxy = !forceCloseProxy && prev.proxyReusable && opts?.closeProxy === false;
239
245
  rememberReusableProxyPageUrl(prev);
240
246
  if (shouldKeepProxy) {
241
247
  const entry = reusableProxyEntryForSession(prev);
@@ -472,15 +478,20 @@ async function startFreshProxySession(options) {
472
478
  awaitInitialFrame: options.awaitInitialFrame,
473
479
  });
474
480
  session.proxyRuntime = runtime;
475
- session.proxyReusable = true;
476
- setReusableProxy({ runtime }, wsUrl, {
477
- headless: options.headless,
478
- slowMo: options.slowMo,
479
- width: options.width,
480
- height: options.height,
481
- pageUrl: options.pageUrl,
482
- snapshotReady: Boolean(session.tree && session.layout),
483
- });
481
+ session.proxyReusable = !options.isolated;
482
+ if (options.isolated) {
483
+ session.isolated = true;
484
+ }
485
+ else {
486
+ setReusableProxy({ runtime }, wsUrl, {
487
+ headless: options.headless,
488
+ slowMo: options.slowMo,
489
+ width: options.width,
490
+ height: options.height,
491
+ pageUrl: options.pageUrl,
492
+ snapshotReady: Boolean(session.tree && session.layout),
493
+ });
494
+ }
484
495
  const baseConnectTrace = session.connectTrace;
485
496
  session.connectTrace = {
486
497
  mode: 'fresh-proxy',
@@ -515,15 +526,20 @@ async function startFreshProxySession(options) {
515
526
  awaitInitialFrame: options.awaitInitialFrame,
516
527
  });
517
528
  session.proxyChild = child;
518
- session.proxyReusable = true;
519
- setReusableProxy({ child }, wsUrl, {
520
- headless: options.headless,
521
- slowMo: options.slowMo,
522
- width: options.width,
523
- height: options.height,
524
- pageUrl: options.pageUrl,
525
- snapshotReady: Boolean(session.tree && session.layout),
526
- });
529
+ session.proxyReusable = !options.isolated;
530
+ if (options.isolated) {
531
+ session.isolated = true;
532
+ }
533
+ else {
534
+ setReusableProxy({ child }, wsUrl, {
535
+ headless: options.headless,
536
+ slowMo: options.slowMo,
537
+ width: options.width,
538
+ height: options.height,
539
+ pageUrl: options.pageUrl,
540
+ snapshotReady: Boolean(session.tree && session.layout),
541
+ });
542
+ }
527
543
  const baseConnectTrace = session.connectTrace;
528
544
  session.connectTrace = {
529
545
  mode: 'fresh-proxy',
@@ -697,6 +713,13 @@ export function connect(url, opts) {
697
713
  */
698
714
  export async function connectThroughProxy(options) {
699
715
  clearReusableProxiesIfExited();
716
+ // Isolated sessions skip the pool entirely. They always get their own
717
+ // brand-new Chromium and never reuse a proxy from a prior call. The
718
+ // tag flows down so startFreshProxySession knows to keep this proxy out
719
+ // of the pool on success and so shutdownSession knows to force-close it.
720
+ if (options.isolated) {
721
+ return await startFreshProxySession({ ...options, isolated: true });
722
+ }
700
723
  let reuseFailure;
701
724
  const reusableProxy = findReusableProxy(options);
702
725
  if (reusableProxy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.36.0",
3
+ "version": "1.37.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",