@geometra/mcp 1.62.2 → 1.63.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/README.md CHANGED
@@ -24,7 +24,7 @@ Proxy-backed sessions stay warm by default on disconnect, and MCP now keeps a sm
24
24
 
25
25
  | Tool | Description |
26
26
  |---|---|
27
- | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; pass `stealth: true` for CloakBrowser's patched Chromium; can inline `formSchema` and/or `pageModel`, or defer the page model for a faster first connect response |
27
+ | `geometra_connect` | Connect with `url` (ws://…) **or** `pageUrl` (https://…) to auto-start geometra-proxy; pass `browserMode: "stock" | "cloakbrowser"` for explicit authorized testing modes; can inline `formSchema` and/or `pageModel`, or defer the page model for a faster first connect response |
28
28
  | `geometra_prepare_browser` | Pre-launch and pre-navigate a reusable proxy/browser for `pageUrl` without creating an active session; supports the same headed/headless/stealth browser settings as `geometra_connect` |
29
29
  | `geometra_query` | Find elements by stable id, role, name, text content, prompt/section/item context, current value, or semantic state such as `invalid`, `required`, or `busy` |
30
30
  | `geometra_wait_for` | Wait for a semantic condition instead of guessing sleeps (`busy`, `disabled`, alerts, values, etc.). **Strict parameters** — use `text` plus `present: false` to wait until a substring disappears (e.g. “Parsing your resume”); there is no `textGone` field |
@@ -33,7 +33,7 @@ Proxy-backed sessions stay warm by default on disconnect, and MCP now keeps a sm
33
33
  | `geometra_fill_form` | Fill a form from `valuesById` / `valuesByLabel` in one MCP call; can auto-connect from `pageUrl` / `url` for the lowest-token known-form path |
34
34
  | `geometra_fill_fields` | Fill text/choice/toggle/file fields in one MCP call; text-only batches now use the proxy fast path when step output is omitted, and text/choice/toggle entries can use `fieldId` from `geometra_form_schema` without repeating the label |
35
35
  | `geometra_run_actions` | Execute a batch of high-level actions in one MCP round trip; can auto-connect from `pageUrl` / `url` and return a final-only payload for the smallest multi-step responses |
36
- | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, and primary actions with nearby section/item context when available |
36
+ | `geometra_page_model` | Summary-first webpage model: archetypes, stable section ids, counts, top-level sections, primary actions, and `blockedSite` metadata for CAPTCHA/challenge/access-denied states |
37
37
  | `geometra_find_action` | Resolve a repeated button/link by action label plus optional `sectionText`, `promptText`, or `itemText` before clicking |
38
38
  | `geometra_expand_section` | Expand one form/dialog/list/landmark from `geometra_page_model` on demand, with paging/filtering for long sections |
39
39
  | `geometra_reveal` | Scroll until a matching node is visible instead of guessing wheel deltas; auto-scales reveal steps for tall forms when omitted |
@@ -252,10 +252,12 @@ In another terminal (from repo root after `npm install` / `bun install` and `bun
252
252
  ```bash
253
253
  npx geometra-proxy http://localhost:8080 --port 3200
254
254
  # Requires Chromium: npx playwright install chromium
255
- # Optional stealth prefetch: npx cloakbrowser install
255
+ # Optional authorized stealth-testing prefetch: npx cloakbrowser install
256
256
  ```
257
257
 
258
- `geometra-proxy` opens a **visible Chromium window by default**. For servers or CI, pass **`--headless`** or set **`GEOMETRA_HEADLESS=1`**. Pass **`--stealth`**, **`stealth: true`** on MCP tools, or **`GEOMETRA_STEALTH=1`** to use CloakBrowser's patched Chromium through the same proxy protocol. Optional **`--slow-mo <ms>`** slows Playwright actions so they are easier to watch. Headed vs headless usually does **not** materially change token usage, since token usage is driven by MCP tool output rather than whether Chromium is visible.
258
+ `geometra-proxy` runs **headless by default**. Pass **`--headed`** or **`headless: false`** on MCP tools when you need a visible Chromium window. Pass **`--stealth`**, **`stealth: true`** on MCP tools, or **`GEOMETRA_STEALTH=1`** to use CloakBrowser's Chromium through the same proxy protocol for authorized testing. Optional **`--slow-mo <ms>`** slows Playwright actions so they are easier to watch. Headed vs headless usually does **not** materially change token usage, since token usage is driven by MCP tool output rather than whether Chromium is visible.
259
+
260
+ For blocked/challenge pages, keep `blockDetection: true` (default). Set `blockedSitePolicy` to `"continue"` (default), `"manual-handoff"` to return retry guidance for a visible user handoff, or `"error"` to stop immediately with structured `blockedSite` details.
259
261
 
260
262
  Point MCP at `ws://127.0.0.1:3200` instead of a native Geometra server. The proxy translates clicks and keyboard messages into Playwright actions and streams updated geometry.
261
263
 
@@ -12,11 +12,9 @@ export declare function resolveProxyScriptPathWith(customRequire: NodeRequire, m
12
12
  export declare function resolveProxyRuntimePath(): string;
13
13
  export declare function resolveProxyRuntimePathWith(customRequire: NodeRequire, moduleDir?: string): string;
14
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.
15
+ * BYO outbound proxy for the spawned Chromium. Geometra only passes the
16
+ * caller-provided network route through to Playwright; callers are responsible
17
+ * for using proxies with authorization and in line with the target site's rules.
20
18
  */
21
19
  export interface SpawnProxyConfig {
22
20
  server: string;
@@ -127,7 +127,26 @@ function buildLocalProxyDistIfPossible(packageDir, entryFile, errors) {
127
127
  return undefined;
128
128
  }
129
129
  export function resolveStealthMode(stealth) {
130
- return stealth ?? true;
130
+ if (stealth !== undefined)
131
+ return stealth;
132
+ const explicit = process.env.GEOMETRA_STEALTH;
133
+ if (truthyEnv(explicit))
134
+ return true;
135
+ if (falseyEnv(explicit))
136
+ return false;
137
+ const browser = process.env.GEOMETRA_BROWSER?.trim().toLowerCase();
138
+ if (browser === 'stealth' || browser === 'cloakbrowser' || browser === 'cloak')
139
+ return true;
140
+ if (browser === 'chromium' || browser === 'chrome' || browser === 'stock' || browser === 'playwright') {
141
+ return false;
142
+ }
143
+ return false;
144
+ }
145
+ function truthyEnv(value) {
146
+ return value != null && /^(1|true|yes|on)$/i.test(value);
147
+ }
148
+ function falseyEnv(value) {
149
+ return value != null && /^(0|false|no|off)$/i.test(value);
131
150
  }
132
151
  export async function startEmbeddedGeometraProxy(opts) {
133
152
  const runtimePath = resolveProxyRuntimePath();
@@ -176,7 +195,7 @@ export function formatProxyStartupFailure(message, opts) {
176
195
  hints.push(PLAYWRIGHT_INSTALL_HINT);
177
196
  }
178
197
  if (/cloakbrowser|CLOAKBROWSER|ERR_MODULE_NOT_FOUND|Cannot find package/i.test(message)) {
179
- hints.push('Stealth mode uses CloakBrowser. Install dependencies with npm install, or disable stealth with stealth=false / GEOMETRA_STEALTH=0. To prefetch the patched Chromium binary, run: npx cloakbrowser install');
198
+ hints.push('Stealth mode uses CloakBrowser. Install dependencies with npm install, or disable stealth with stealth=false / GEOMETRA_STEALTH=0. To prefetch the browser binary for authorized testing, run: npx cloakbrowser install');
180
199
  }
181
200
  if (opts.port > 0 && /EADDRINUSE|address already in use/i.test(message)) {
182
201
  hints.push(`Requested port ${opts.port} is unavailable. Omit the port to use an ephemeral OS-assigned port, or choose another local port.`);
package/dist/server.js CHANGED
@@ -21,7 +21,34 @@ function stealthInput() {
21
21
  return z
22
22
  .boolean()
23
23
  .optional()
24
- .describe('Launch CloakBrowser stealth Chromium for proxy-backed pageUrl sessions. Default false unless GEOMETRA_STEALTH=1 or GEOMETRA_BROWSER=stealth is set.');
24
+ .describe('Launch CloakBrowser Chromium for authorized proxy-backed pageUrl testing. Default false unless GEOMETRA_STEALTH=1 or GEOMETRA_BROWSER=stealth is set.');
25
+ }
26
+ function browserModeInput() {
27
+ return z
28
+ .enum(['stock', 'cloakbrowser'])
29
+ .optional()
30
+ .describe('Explicit browser engine for proxy-backed sessions. `stock` forces Playwright Chromium; `cloakbrowser` opts into CloakBrowser for authorized testing. Conflicts with a contradictory stealth value.');
31
+ }
32
+ function blockDetectionInput() {
33
+ return z
34
+ .boolean()
35
+ .optional()
36
+ .default(true)
37
+ .describe('Detect CAPTCHA/challenge/blocked/unsupported-browser pages and surface `blockedSite` metadata (default true).');
38
+ }
39
+ function blockedSitePolicyInput() {
40
+ return z
41
+ .enum(['continue', 'manual-handoff', 'error'])
42
+ .optional()
43
+ .default('continue')
44
+ .describe('How to respond when blockDetection finds a blocked/challenge page: continue (default), manual-handoff (return handoff metadata), or error.');
45
+ }
46
+ function manualHandoffInput() {
47
+ return z
48
+ .boolean()
49
+ .optional()
50
+ .default(false)
51
+ .describe('Shortcut for blockedSitePolicy="manual-handoff" when a blocked/challenge page is detected.');
25
52
  }
26
53
  function formSchemaFormatInput() {
27
54
  return z
@@ -37,6 +64,76 @@ function pageModelModeInput() {
37
64
  .default('inline')
38
65
  .describe('When returnPageModel=true, `inline` includes the full page model in the connect response. `deferred` returns connect as soon as the transport is ready and lets the caller fetch geometra_page_model separately.');
39
66
  }
67
+ function resolveBrowserStealth(input) {
68
+ const modeStealth = input.browserMode === 'cloakbrowser'
69
+ ? true
70
+ : input.browserMode === 'stock'
71
+ ? false
72
+ : undefined;
73
+ if (modeStealth !== undefined && input.stealth !== undefined && input.stealth !== modeStealth) {
74
+ return {
75
+ ok: false,
76
+ error: `Conflicting browser settings: browserMode="${input.browserMode}" implies stealth=${modeStealth}, but stealth=${input.stealth} was also provided.`,
77
+ };
78
+ }
79
+ const stealth = input.stealth ?? modeStealth;
80
+ return stealth === undefined ? { ok: true } : { ok: true, stealth };
81
+ }
82
+ function effectiveBlockedSitePolicy(policy, manualHandoff) {
83
+ return manualHandoff ? 'manual-handoff' : policy;
84
+ }
85
+ function blockedSiteManualHandoffPayload(blockedSite, options) {
86
+ return {
87
+ required: true,
88
+ reason: blockedSite.hint ?? 'Blocked or challenge page detected',
89
+ recommendedAction: blockedSite.recommendedAction ?? 'manual-handoff',
90
+ message: 'Pause automation and let a user complete the challenge or review access in a normal visible browser session.',
91
+ retry: {
92
+ ...(options?.pageUrl ? { pageUrl: options.pageUrl } : {}),
93
+ headless: false,
94
+ blockDetection: true,
95
+ blockedSitePolicy: 'continue',
96
+ },
97
+ };
98
+ }
99
+ function blockedSitePolicyErrorPayload(blockedSite, policy) {
100
+ return {
101
+ blocked: true,
102
+ blockedSite,
103
+ blockedSitePolicy: policy,
104
+ message: blockedSite.hint ?? 'Blocked or challenge page detected',
105
+ };
106
+ }
107
+ function applyBlockedSitePolicyPayload(payload, blockedSite, options) {
108
+ if (!blockedSite?.detected)
109
+ return payload;
110
+ const policy = effectiveBlockedSitePolicy(options.policy, options.manualHandoff);
111
+ const next = {
112
+ ...payload,
113
+ blockedSite,
114
+ blockedSitePolicy: policy,
115
+ };
116
+ if (policy === 'manual-handoff') {
117
+ next.manualHandoff = blockedSiteManualHandoffPayload(blockedSite, options);
118
+ }
119
+ return next;
120
+ }
121
+ function blockedSiteFromPayload(payload) {
122
+ const value = payload.blockedSite;
123
+ if (!value || typeof value !== 'object')
124
+ return undefined;
125
+ const candidate = value;
126
+ return candidate.detected ? candidate : undefined;
127
+ }
128
+ function blockedSiteErrorIfNeeded(payload, policy, manualHandoff) {
129
+ const effectivePolicy = effectiveBlockedSitePolicy(policy, manualHandoff);
130
+ if (effectivePolicy !== 'error')
131
+ return undefined;
132
+ const blockedSite = blockedSiteFromPayload(payload);
133
+ if (!blockedSite)
134
+ return undefined;
135
+ return err(JSON.stringify(blockedSitePolicyErrorPayload(blockedSite, effectivePolicy)));
136
+ }
40
137
  function formSchemaContextInput() {
41
138
  return z
42
139
  .enum(['auto', 'always', 'none'])
@@ -407,7 +504,7 @@ export function createServer() {
407
504
 
408
505
  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.
409
506
 
410
- Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth: true\` (or set \`GEOMETRA_STEALTH=1\`) to launch CloakBrowser's patched Chromium instead of stock Playwright Chromium. 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.
507
+ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth: true\` (or set \`GEOMETRA_STEALTH=1\`) to opt into CloakBrowser's Chromium for authorized testing instead of stock Playwright Chromium. 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.
411
508
 
412
509
  **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.`, {
413
510
  url: z
@@ -430,7 +527,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
430
527
  headless: z
431
528
  .boolean()
432
529
  .optional()
433
- .describe('Run Chromium headless (default false = visible window).'),
530
+ .describe('Run Chromium headless (default true). Set false for a visible window.'),
434
531
  width: z.number().int().positive().optional().describe('Viewport width for spawned proxy.'),
435
532
  height: z.number().int().positive().optional().describe('Viewport height for spawned proxy.'),
436
533
  slowMo: z
@@ -440,6 +537,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
440
537
  .optional()
441
538
  .describe('Playwright slowMo (ms) on spawned proxy for easier visual following.'),
442
539
  stealth: stealthInput(),
540
+ browserMode: browserModeInput(),
443
541
  isolated: z
444
542
  .boolean()
445
543
  .optional()
@@ -458,7 +556,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
458
556
  .describe('Comma-separated host patterns to bypass (e.g. "*.internal,localhost").'),
459
557
  })
460
558
  .optional()
461
- .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.'),
559
+ .describe('BYO outbound proxy for the spawned Chromium. Routes browser traffic through the supplied HTTP/SOCKS proxy. Use only proxies you are authorized to use and in line with the target site rules. The reusable proxy pool is partitioned by proxy identity so callers with different proxy configs never share a Chromium instance.'),
462
560
  returnForms: z
463
561
  .boolean()
464
562
  .optional()
@@ -470,6 +568,9 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
470
568
  .default(false)
471
569
  .describe('Include geometra_page_model output in the connect response so exploration can start in one turn.'),
472
570
  pageModelMode: pageModelModeInput(),
571
+ blockDetection: blockDetectionInput(),
572
+ blockedSitePolicy: blockedSitePolicyInput(),
573
+ manualHandoff: manualHandoffInput(),
473
574
  formId: z.string().optional().describe('Optional form id filter when returnForms=true'),
474
575
  maxFields: z.number().int().min(1).max(120).optional().default(80).describe('Cap returned fields per form when returnForms=true'),
475
576
  onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
@@ -485,6 +586,9 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
485
586
  const normalized = normalizeConnectTarget({ url: input.url, pageUrl: input.pageUrl });
486
587
  if (!normalized.ok)
487
588
  return err(normalized.error);
589
+ const browser = resolveBrowserStealth({ stealth: input.stealth, browserMode: input.browserMode });
590
+ if (!browser.ok)
591
+ return err(browser.error);
488
592
  const target = normalized.value;
489
593
  const formSchema = {
490
594
  formId: input.formId,
@@ -513,7 +617,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
513
617
  width: input.width,
514
618
  height: input.height,
515
619
  slowMo: input.slowMo,
516
- ...(input.stealth !== undefined && { stealth: input.stealth }),
620
+ ...(browser.stealth !== undefined && { stealth: browser.stealth }),
517
621
  isolated: input.isolated,
518
622
  proxy: input.proxy,
519
623
  awaitInitialFrame: deferInlinePageModel ? false : undefined,
@@ -522,7 +626,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
522
626
  if (input.returnForms) {
523
627
  await stabilizeInlineFormSchemas(session, formSchema);
524
628
  }
525
- return ok(JSON.stringify(connectResponsePayload(session, {
629
+ const payload = connectResponsePayload(session, {
526
630
  transport: 'proxy',
527
631
  requestedPageUrl: target.pageUrl,
528
632
  autoCoercedFromUrl: target.autoCoercedFromUrl,
@@ -530,9 +634,17 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
530
634
  returnForms: input.returnForms,
531
635
  returnPageModel: input.returnPageModel,
532
636
  pageModelMode: input.pageModelMode,
637
+ blockDetection: input.blockDetection,
638
+ blockedSitePolicy: input.blockedSitePolicy,
639
+ manualHandoff: input.manualHandoff,
533
640
  formSchema,
534
641
  pageModelOptions,
535
- }), null, input.detail === 'verbose' ? 2 : undefined));
642
+ headless: input.headless,
643
+ });
644
+ const blockedError = blockedSiteErrorIfNeeded(payload, input.blockedSitePolicy, input.manualHandoff);
645
+ if (blockedError)
646
+ return blockedError;
647
+ return ok(JSON.stringify(payload, null, input.detail === 'verbose' ? 2 : undefined));
536
648
  }
537
649
  const session = await connect(target.wsUrl, {
538
650
  width: input.width,
@@ -542,7 +654,7 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
542
654
  if (input.returnForms) {
543
655
  await stabilizeInlineFormSchemas(session, formSchema);
544
656
  }
545
- return ok(JSON.stringify(connectResponsePayload(session, {
657
+ const payload = connectResponsePayload(session, {
546
658
  transport: 'ws',
547
659
  requestedWsUrl: target.wsUrl,
548
660
  autoCoercedFromUrl: false,
@@ -550,9 +662,17 @@ Chromium opens **visible** by default unless \`headless: true\`. Pass \`stealth:
550
662
  returnForms: input.returnForms,
551
663
  returnPageModel: input.returnPageModel,
552
664
  pageModelMode: input.pageModelMode,
665
+ blockDetection: input.blockDetection,
666
+ blockedSitePolicy: input.blockedSitePolicy,
667
+ manualHandoff: input.manualHandoff,
553
668
  formSchema,
554
669
  pageModelOptions,
555
- }), null, input.detail === 'verbose' ? 2 : undefined));
670
+ headless: input.headless,
671
+ });
672
+ const blockedError = blockedSiteErrorIfNeeded(payload, input.blockedSitePolicy, input.manualHandoff);
673
+ if (blockedError)
674
+ return blockedError;
675
+ return ok(JSON.stringify(payload, null, input.detail === 'verbose' ? 2 : undefined));
556
676
  }
557
677
  catch (e) {
558
678
  return err(`Failed to connect: ${formatConnectFailureMessage(e, target)}`);
@@ -577,7 +697,7 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
577
697
  headless: z
578
698
  .boolean()
579
699
  .optional()
580
- .describe('Run Chromium headless (default false = visible window).'),
700
+ .describe('Run Chromium headless (default true). Set false for a visible window.'),
581
701
  width: z.number().int().positive().optional().describe('Viewport width for the warmed browser.'),
582
702
  height: z.number().int().positive().optional().describe('Viewport height for the warmed browser.'),
583
703
  slowMo: z
@@ -587,6 +707,7 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
587
707
  .optional()
588
708
  .describe('Playwright slowMo (ms) for the warmed browser.'),
589
709
  stealth: stealthInput(),
710
+ browserMode: browserModeInput(),
590
711
  proxy: z
591
712
  .object({
592
713
  server: z
@@ -601,8 +722,11 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
601
722
  })
602
723
  .optional()
603
724
  .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.'),
604
- }, async ({ pageUrl, port, headless, width, height, slowMo, stealth, proxy }) => {
725
+ }, async ({ pageUrl, port, headless, width, height, slowMo, stealth, browserMode, proxy }) => {
605
726
  try {
727
+ const browser = resolveBrowserStealth({ stealth, browserMode });
728
+ if (!browser.ok)
729
+ return err(browser.error);
606
730
  const prepared = await prewarmProxy({
607
731
  pageUrl,
608
732
  port,
@@ -610,7 +734,7 @@ Use this when you can prepare ahead of the user-facing task so the next \`geomet
610
734
  width,
611
735
  height,
612
736
  slowMo,
613
- ...(stealth !== undefined && { stealth }),
737
+ ...(browser.stealth !== undefined && { stealth: browser.stealth }),
614
738
  proxy,
615
739
  });
616
740
  return ok(JSON.stringify(prepared));
@@ -975,11 +1099,12 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
975
1099
  url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before filling.'),
976
1100
  pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before filling. Prefer this over url for browser pages.'),
977
1101
  port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
978
- headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1102
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default true). Set false for a visible window.'),
979
1103
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
980
1104
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
981
1105
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
982
1106
  stealth: stealthInput(),
1107
+ browserMode: browserModeInput(),
983
1108
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
984
1109
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
985
1110
  valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
@@ -1017,7 +1142,10 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1017
1142
  .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.'),
1018
1143
  detail: detailInput(),
1019
1144
  sessionId: sessionIdInput,
1020
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, isolated, detail, sessionId }) => {
1145
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, formId, valuesById, valuesByLabel, stopOnError, failOnInvalid, includeSteps, resumeFromIndex, verifyFills, skipPreFilled, isolated, detail, sessionId }) => {
1146
+ const browser = resolveBrowserStealth({ stealth, browserMode });
1147
+ if (!browser.ok)
1148
+ return err(browser.error);
1021
1149
  const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
1022
1150
  ? directLabelBatchFields(valuesByLabel)
1023
1151
  : null;
@@ -1030,7 +1158,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
1030
1158
  width,
1031
1159
  height,
1032
1160
  slowMo,
1033
- stealth,
1161
+ stealth: browser.stealth,
1034
1162
  isolated,
1035
1163
  awaitInitialFrame: directFields ? false : undefined,
1036
1164
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
@@ -1286,11 +1414,12 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1286
1414
  url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before submitting.'),
1287
1415
  pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before submitting. Prefer this over url for browser pages.'),
1288
1416
  port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
1289
- headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1417
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default true). Set false for a visible window.'),
1290
1418
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1291
1419
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1292
1420
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1293
1421
  stealth: stealthInput(),
1422
+ browserMode: browserModeInput(),
1294
1423
  isolated: z.boolean().optional().default(false).describe('When auto-connecting via pageUrl/url, request an isolated proxy. Required for safe parallel form submission.'),
1295
1424
  formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
1296
1425
  valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
@@ -1304,11 +1433,14 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
1304
1433
  failOnInvalid: z.boolean().optional().default(false).describe('Return an error if invalid fields remain after the submit wait resolves.'),
1305
1434
  detail: detailInput(),
1306
1435
  sessionId: sessionIdInput,
1307
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
1436
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
1437
+ const browser = resolveBrowserStealth({ stealth, browserMode });
1438
+ if (!browser.ok)
1439
+ return err(browser.error);
1308
1440
  const toolStartedAt = performance.now();
1309
1441
  const effectiveSoftTimeoutMs = softTimeoutMs ?? HOST_SAFE_TOOL_TIMEOUT_MS;
1310
1442
  const deadlineAt = toolStartedAt + effectiveSoftTimeoutMs;
1311
- const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, stealth, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_submit_form.');
1443
+ const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, stealth: browser.stealth, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_submit_form.');
1312
1444
  if (!resolved.ok)
1313
1445
  return err(resolved.error);
1314
1446
  const session = resolved.session;
@@ -1589,11 +1721,12 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1589
1721
  url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before running actions.'),
1590
1722
  pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before running actions. Prefer this over url for browser pages.'),
1591
1723
  port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
1592
- headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1724
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default true). Set false for a visible window.'),
1593
1725
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1594
1726
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1595
1727
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1596
1728
  stealth: stealthInput(),
1729
+ browserMode: browserModeInput(),
1597
1730
  isolated: z
1598
1731
  .boolean()
1599
1732
  .optional()
@@ -1617,7 +1750,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1617
1750
  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.'),
1618
1751
  detail: detailInput(),
1619
1752
  sessionId: sessionIdInput,
1620
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, isolated, actions, resumeFromIndex, softTimeoutMs, stopOnError, includeSteps, output, detail, sessionId }) => {
1753
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, actions, resumeFromIndex, softTimeoutMs, stopOnError, includeSteps, output, detail, sessionId }) => {
1754
+ const browser = resolveBrowserStealth({ stealth, browserMode });
1755
+ if (!browser.ok)
1756
+ return err(browser.error);
1621
1757
  const toolStartedAt = performance.now();
1622
1758
  const effectiveSoftTimeoutMs = softTimeoutMs ?? HOST_SAFE_TOOL_TIMEOUT_MS;
1623
1759
  const deadlineAt = toolStartedAt + effectiveSoftTimeoutMs;
@@ -1630,7 +1766,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
1630
1766
  width,
1631
1767
  height,
1632
1768
  slowMo,
1633
- stealth,
1769
+ stealth: browser.stealth,
1634
1770
  isolated,
1635
1771
  awaitInitialFrame: canDeferInitialFrameForRunActions(actions) ? false : undefined,
1636
1772
  }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_run_actions.');
@@ -1800,8 +1936,11 @@ Use this first on normal HTML pages when you want to understand the page shape w
1800
1936
  .optional()
1801
1937
  .default(false)
1802
1938
  .describe('Attach a base64 PNG viewport screenshot. Requires @geometra/proxy. Use when geometry alone is ambiguous (icon-only buttons, visual styling cues).'),
1939
+ blockDetection: blockDetectionInput(),
1940
+ blockedSitePolicy: blockedSitePolicyInput(),
1941
+ manualHandoff: manualHandoffInput(),
1803
1942
  sessionId: sessionIdInput,
1804
- }, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot, sessionId }) => {
1943
+ }, async ({ maxPrimaryActions, maxSectionsPerKind, includeScreenshot, blockDetection, blockedSitePolicy, manualHandoff, sessionId }) => {
1805
1944
  const sessionResult = resolveToolSession(sessionId);
1806
1945
  if ('error' in sessionResult)
1807
1946
  return sessionResult.error;
@@ -1809,9 +1948,14 @@ Use this first on normal HTML pages when you want to understand the page shape w
1809
1948
  const a11y = await sessionA11yWhenReady(session);
1810
1949
  if (!a11y)
1811
1950
  return err('No UI tree available');
1812
- const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind });
1951
+ const model = buildPageModel(a11y, { maxPrimaryActions, maxSectionsPerKind, blockDetection });
1952
+ const policy = effectiveBlockedSitePolicy(blockedSitePolicy, manualHandoff);
1953
+ if (policy === 'error' && model.blockedSite?.detected) {
1954
+ return err(JSON.stringify(blockedSitePolicyErrorPayload(model.blockedSite, policy)));
1955
+ }
1956
+ const payload = applyBlockedSitePolicyPayload(model, model.blockedSite, { policy: blockedSitePolicy, manualHandoff, pageUrl: a11y.meta?.pageUrl });
1813
1957
  const screenshot = includeScreenshot ? await captureScreenshotBase64(session) : undefined;
1814
- return ok(JSON.stringify(model), screenshot);
1958
+ return ok(JSON.stringify(payload), screenshot);
1815
1959
  });
1816
1960
  server.tool('geometra_form_schema', `Get a compact, fill-oriented schema for forms on the page. This is the preferred discovery step before geometra_fill_form.
1817
1961
 
@@ -1819,11 +1963,12 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1819
1963
  url: z.string().optional().describe('Optional target URL. Use a ws:// Geometra server URL or an http(s) page URL to auto-connect before discovery.'),
1820
1964
  pageUrl: z.string().optional().describe('Optional http(s) page URL to auto-connect before discovery. Prefer this over url for browser pages.'),
1821
1965
  port: z.number().int().min(0).max(65535).optional().describe('Preferred local port for an auto-spawned proxy (default: ephemeral OS-assigned port).'),
1822
- headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default false = visible window).'),
1966
+ headless: z.boolean().optional().describe('Run Chromium headless when auto-spawning a proxy (default true). Set false for a visible window.'),
1823
1967
  width: z.number().int().positive().optional().describe('Viewport width for auto-connected sessions.'),
1824
1968
  height: z.number().int().positive().optional().describe('Viewport height for auto-connected sessions.'),
1825
1969
  slowMo: z.number().int().nonnegative().optional().describe('Playwright slowMo (ms) when auto-spawning a proxy.'),
1826
1970
  stealth: stealthInput(),
1971
+ browserMode: browserModeInput(),
1827
1972
  isolated: z
1828
1973
  .boolean()
1829
1974
  .optional()
@@ -1838,8 +1983,11 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
1838
1983
  sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
1839
1984
  format: formSchemaFormatInput(),
1840
1985
  sessionId: sessionIdInput,
1841
- }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
1842
- const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, stealth, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
1986
+ }, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
1987
+ const browser = resolveBrowserStealth({ stealth, browserMode });
1988
+ if (!browser.ok)
1989
+ return err(browser.error);
1990
+ const resolved = await ensureToolSession({ sessionId, url, pageUrl, port, headless, width, height, slowMo, stealth: browser.stealth, isolated }, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_form_schema.');
1843
1991
  if (!resolved.ok)
1844
1992
  return err(resolved.error);
1845
1993
  const session = resolved.session;
@@ -2811,16 +2959,27 @@ async function stabilizeInlineFormSchemas(session, options, opts) {
2811
2959
  }
2812
2960
  function connectResponsePayload(session, opts) {
2813
2961
  const payload = connectPayload(session, opts);
2962
+ const policy = opts.blockedSitePolicy ?? 'continue';
2963
+ const pageModelOptions = {
2964
+ ...opts.pageModelOptions,
2965
+ blockDetection: opts.blockDetection,
2966
+ };
2967
+ const model = opts.blockDetection === false ? undefined : pageModelFromSession(session, pageModelOptions);
2968
+ const nextPayload = applyBlockedSitePolicyPayload({ ...payload }, model?.blockedSite, {
2969
+ policy,
2970
+ manualHandoff: opts.manualHandoff,
2971
+ pageUrl: opts.requestedPageUrl,
2972
+ headless: opts.headless,
2973
+ });
2814
2974
  if (!opts.returnForms && !opts.returnPageModel)
2815
- return payload;
2816
- const nextPayload = { ...payload };
2975
+ return nextPayload;
2817
2976
  if (opts.returnForms) {
2818
2977
  nextPayload.formSchema = formSchemaResponsePayload(session, opts.formSchema ?? {});
2819
2978
  }
2820
2979
  if (opts.returnPageModel) {
2821
2980
  nextPayload.pageModel = opts.pageModelMode === 'deferred'
2822
- ? deferredPageModelConnectPayload(session, opts.pageModelOptions)
2823
- : pageModelResponsePayload(session, opts.pageModelOptions);
2981
+ ? deferredPageModelConnectPayload(session, pageModelOptions)
2982
+ : (model ?? pageModelResponsePayload(session, pageModelOptions));
2824
2983
  }
2825
2984
  return nextPayload;
2826
2985
  }
@@ -2832,14 +2991,17 @@ function deferredPageModelConnectPayload(session, options) {
2832
2991
  options: {
2833
2992
  maxPrimaryActions: options?.maxPrimaryActions ?? 6,
2834
2993
  maxSectionsPerKind: options?.maxSectionsPerKind ?? 8,
2994
+ blockDetection: options?.blockDetection !== false,
2835
2995
  },
2836
2996
  };
2837
2997
  }
2838
2998
  function pageModelResponsePayload(session, options) {
2999
+ return pageModelFromSession(session, options) ?? { available: false };
3000
+ }
3001
+ function pageModelFromSession(session, options) {
2839
3002
  const a11y = sessionA11y(session);
2840
- if (!a11y) {
2841
- return { available: false };
2842
- }
3003
+ if (!a11y)
3004
+ return undefined;
2843
3005
  return buildPageModel(a11y, options);
2844
3006
  }
2845
3007
  async function ensureToolSession(target, missingConnectionMessage = 'Not connected. Call geometra_connect first.') {
package/dist/session.d.ts CHANGED
@@ -142,6 +142,13 @@ export interface CaptchaDetection {
142
142
  type?: 'recaptcha' | 'hcaptcha' | 'turnstile' | 'cloudflare-challenge' | 'unknown';
143
143
  hint?: string;
144
144
  }
145
+ export interface BlockedSiteDetection {
146
+ detected: boolean;
147
+ type?: 'captcha' | 'cloudflare-challenge' | 'automation-detected' | 'access-denied' | 'unsupported-browser' | 'rate-limited' | 'unknown';
148
+ hint?: string;
149
+ evidence?: string[];
150
+ recommendedAction?: 'manual-handoff' | 'retry-later' | 'review-site-rules';
151
+ }
145
152
  export interface VerificationDetection {
146
153
  detected: boolean;
147
154
  type?: 'email_code' | 'sms_code' | 'security_question' | 'unknown';
@@ -160,6 +167,7 @@ export interface PageModel {
160
167
  listCount: number;
161
168
  focusableCount: number;
162
169
  };
170
+ blockedSite?: BlockedSiteDetection;
163
171
  captcha?: CaptchaDetection;
164
172
  verification?: VerificationDetection;
165
173
  primaryActions: PagePrimaryAction[];
@@ -539,10 +547,9 @@ export declare function connectThroughProxy(options: {
539
547
  */
540
548
  isolated?: boolean;
541
549
  /**
542
- * BYO outbound proxy for the Chromium. Routes all browser traffic through
543
- * the supplied residential / mobile / SOCKS proxy. The reusable pool is
544
- * partitioned by proxy identity so two callers with different proxy
545
- * configs never share a Chromium instance.
550
+ * BYO outbound proxy for Chromium. The reusable pool is partitioned by proxy
551
+ * identity so two callers with different proxy configs never share a
552
+ * Chromium instance.
546
553
  */
547
554
  proxy?: SpawnProxyConfig;
548
555
  }): Promise<Session>;
@@ -722,6 +729,7 @@ export declare function nodeContextForNode(root: A11yNode, node: A11yNode): Node
722
729
  export declare function buildPageModel(root: A11yNode, options?: {
723
730
  maxPrimaryActions?: number;
724
731
  maxSectionsPerKind?: number;
732
+ blockDetection?: boolean;
725
733
  }): PageModel;
726
734
  export declare function buildFormSchemas(root: A11yNode, options?: FormSchemaBuildOptions): FormSchemaModel[];
727
735
  /**
package/dist/session.js CHANGED
@@ -441,8 +441,8 @@ function findReusableProxy(options) {
441
441
  .filter(entry => entry.headless === desiredHeadless
442
442
  && entry.stealth === desiredStealth
443
443
  && entry.slowMo === desiredSlowMo
444
- // Proxy partition is hard a session with residential proxy MUST NOT
445
- // attach to a pooled direct-connection Chromium (and vice versa).
444
+ // Proxy partition is hard: a session with a caller-provided proxy MUST
445
+ // NOT attach to a pooled direct-connection Chromium (and vice versa).
446
446
  // Different proxy credentials also get separate pool entries.
447
447
  && entry.proxyKey === desiredProxyKey)
448
448
  .sort((a, b) => {
@@ -2529,6 +2529,90 @@ function detectCaptcha(root) {
2529
2529
  }
2530
2530
  return found ?? { detected: false };
2531
2531
  }
2532
+ const BLOCKED_SITE_PATTERNS = [
2533
+ {
2534
+ pattern: /cloudflare.*challenge|challenge-platform|just a moment|checking your browser|cdn-cgi\/challenge/i,
2535
+ type: 'cloudflare-challenge',
2536
+ hint: 'Cloudflare challenge page detected',
2537
+ recommendedAction: 'manual-handoff',
2538
+ },
2539
+ {
2540
+ pattern: /verify (you are|that you are|you're|you.re) human|are you human|human verification|i.m not a robot|not a robot/i,
2541
+ type: 'captcha',
2542
+ hint: 'Human verification challenge detected',
2543
+ recommendedAction: 'manual-handoff',
2544
+ },
2545
+ {
2546
+ pattern: /automated access|automation detected|bot detected|bot activity|unusual traffic|suspicious traffic|browser automation/i,
2547
+ type: 'automation-detected',
2548
+ hint: 'Automation block detected',
2549
+ recommendedAction: 'manual-handoff',
2550
+ },
2551
+ {
2552
+ pattern: /access denied|forbidden|blocked from accessing|temporarily blocked|request blocked|not authorized/i,
2553
+ type: 'access-denied',
2554
+ hint: 'Access denied or request blocked page detected',
2555
+ recommendedAction: 'review-site-rules',
2556
+ },
2557
+ {
2558
+ pattern: /unsupported browser|browser is not supported|please update your browser|enable javascript/i,
2559
+ type: 'unsupported-browser',
2560
+ hint: 'Unsupported browser or JavaScript requirement detected',
2561
+ recommendedAction: 'manual-handoff',
2562
+ },
2563
+ {
2564
+ pattern: /too many requests|rate limit|temporarily unavailable|try again later/i,
2565
+ type: 'rate-limited',
2566
+ hint: 'Rate limit or temporary block detected',
2567
+ recommendedAction: 'retry-later',
2568
+ },
2569
+ ];
2570
+ function detectBlockedSite(root, captcha) {
2571
+ if (captcha.detected) {
2572
+ return {
2573
+ detected: true,
2574
+ type: captcha.type === 'cloudflare-challenge' ? 'cloudflare-challenge' : 'captcha',
2575
+ ...(captcha.hint ? { hint: captcha.hint } : {}),
2576
+ recommendedAction: 'manual-handoff',
2577
+ };
2578
+ }
2579
+ let found;
2580
+ const evidence = [];
2581
+ const checkText = (raw) => {
2582
+ if (!raw || found)
2583
+ return;
2584
+ const text = raw.replace(/\s+/g, ' ').trim();
2585
+ if (!text)
2586
+ return;
2587
+ for (const candidate of BLOCKED_SITE_PATTERNS) {
2588
+ if (!candidate.pattern.test(text))
2589
+ continue;
2590
+ found = {
2591
+ detected: true,
2592
+ type: candidate.type,
2593
+ hint: candidate.hint,
2594
+ recommendedAction: candidate.recommendedAction,
2595
+ };
2596
+ evidence.push(truncateUiText(text, 140));
2597
+ return;
2598
+ }
2599
+ };
2600
+ checkText(root.meta?.pageUrl);
2601
+ function walk(node) {
2602
+ if (found)
2603
+ return;
2604
+ checkText([node.name, node.value, node.validation?.error, node.validation?.description].filter(Boolean).join(' '));
2605
+ for (const child of node.children)
2606
+ walk(child);
2607
+ }
2608
+ walk(root);
2609
+ if (!found)
2610
+ return { detected: false };
2611
+ return {
2612
+ ...found,
2613
+ ...(evidence.length > 0 ? { evidence: evidence.slice(0, 3) } : {}),
2614
+ };
2615
+ }
2532
2616
  const VERIFICATION_FIELD_PATTERN = /verif|security.?code|confirm.*(code|email)|one.?time|otp|2fa|mfa|passcode/i;
2533
2617
  const VERIFICATION_CONTEXT_PATTERN = /sent.*(code|email|sms|text)|enter.*code|check.your.(email|phone|inbox)|we.sent|verification/i;
2534
2618
  function detectVerification(root) {
@@ -2643,9 +2727,11 @@ export function buildPageModel(root, options) {
2643
2727
  lists: sortByBounds(lists).slice(0, maxSectionsPerKind),
2644
2728
  };
2645
2729
  const captcha = detectCaptcha(root);
2730
+ const blockedSite = options?.blockDetection === false ? { detected: false } : detectBlockedSite(root, captcha);
2646
2731
  const verification = detectVerification(root);
2647
2732
  return {
2648
2733
  ...baseModel,
2734
+ ...(blockedSite.detected ? { blockedSite } : {}),
2649
2735
  ...(captcha.detected ? { captcha } : {}),
2650
2736
  ...(verification.detected ? { verification } : {}),
2651
2737
  archetypes: inferPageArchetypes(baseModel),
@@ -2844,6 +2930,9 @@ export function summarizePageModel(model, maxLines = 10) {
2844
2930
  if (model.archetypes.length > 0) {
2845
2931
  lines.push(`archetypes: ${model.archetypes.join(', ')}`);
2846
2932
  }
2933
+ if (model.blockedSite?.detected) {
2934
+ lines.push(`blocked: ${model.blockedSite.type ?? 'unknown'}${model.blockedSite.hint ? ` - ${model.blockedSite.hint}` : ''}`);
2935
+ }
2847
2936
  lines.push(`summary: ${model.summary.landmarkCount} landmarks, ${model.summary.formCount} forms, ${model.summary.dialogCount} dialogs, ${model.summary.listCount} lists, ${model.summary.focusableCount} focusable`);
2848
2937
  for (const landmark of model.landmarks.slice(0, 3)) {
2849
2938
  const name = landmark.name ? ` "${truncateUiText(landmark.name, 32)}"` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geometra/mcp",
3
- "version": "1.62.2",
3
+ "version": "1.63.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,7 +32,7 @@
32
32
  "ui-testing"
33
33
  ],
34
34
  "dependencies": {
35
- "@geometra/proxy": "^1.62.2",
35
+ "@geometra/proxy": "^1.63.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.1",
37
37
  "@razroo/parallel-mcp": "^0.1.0",
38
38
  "ws": "^8.18.0",