crawlforge-mcp-server 6.6.1 → 6.7.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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.6.1",
3
+ "version": "6.7.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
- "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
5
+ "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
7
7
  "bin": {
8
8
  "crawlforge": "src/cli/index.js",
package/server.js CHANGED
@@ -61,7 +61,7 @@ import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
61
61
  import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
62
62
  import { markPreflightRefusal } from "./src/server/requestContext.js";
63
63
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
64
- import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
64
+ import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
65
65
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
66
66
  import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
67
67
  // Phase 6: MCP-spec adoption — structured output, tool filtering, spec hygiene
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
108
108
  // Create the server
109
109
  const server = new McpServer({
110
110
  name: "crawlforge",
111
- version: "6.6.1",
111
+ version: "6.7.0",
112
112
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
113
113
  homepage: "https://www.crawlforge.dev",
114
114
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1063,9 +1063,24 @@ registerToolIfEnabled("browser_session", {
1063
1063
  // is megabytes beside a few lines of JSON (R21, 2026-09-09).
1064
1064
  const publish = (shot) => {
1065
1065
  if (!shot?.actionId || !shot?.data) return shot;
1066
- resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1066
+ const { bytes, withinInlineBudget } = resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1067
1067
  const { data, ...rest } = shot;
1068
- return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
1068
+ return {
1069
+ ...rest,
1070
+ resourceUri: `crawlforge://screenshot/${shot.actionId}`,
1071
+ bytes,
1072
+ // full_page is a knob this tool hands the caller, and on a long page it
1073
+ // produces an image no MCP message can carry. Saying so here costs one
1074
+ // field; learning it from the read costs a wasted call (R23).
1075
+ ...(withinInlineBudget
1076
+ ? {}
1077
+ : {
1078
+ warning: `This image is ${bytes} bytes, too large to read back over MCP ` +
1079
+ `(limit ${MAX_RESOURCE_BLOB_BYTES} bytes) — reading the resource will be refused. ` +
1080
+ `Take it again without full_page, or as format:"jpeg" with a lower quality, or ` +
1081
+ `scoped to one element with selector.`
1082
+ })
1083
+ };
1069
1084
  };
1070
1085
  if (result.screenshot) result.screenshot = publish(result.screenshot);
1071
1086
  if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
@@ -1348,7 +1363,7 @@ registerToolIfEnabled("stealth_mode", {
1348
1363
  enabled: z.boolean().default(false),
1349
1364
  proxies: z.array(z.string()).optional(),
1350
1365
  rotationInterval: z.number().default(300000)
1351
- }).optional(),
1366
+ }).optional().describe("Route the browser through your own proxies. Each entry is a proxy URL — \"http://user:pass@host:port\" (percent-encode a password containing @ : or /), or a bare \"host:port\" for an unauthenticated HTTP proxy; http, https, socks4 and socks5 are accepted. rotationInterval is the minimum ms on one proxy before the list advances. Cloudflare scores the IP before it serves a challenge, so a residential proxy is what gets past a block that no fingerprint fixes. CrawlForge supplies no proxies."),
1352
1367
  antiDetection: z.object({
1353
1368
  cloudflareBypass: z.boolean().default(true),
1354
1369
  recaptchaHandling: z.boolean().default(true),
@@ -1184,12 +1184,13 @@ export class ActionExecutor extends EventEmitter {
1184
1184
  const timeout = this.actionTimeout(action);
1185
1185
 
1186
1186
  await assertUrlAllowed(action.url, { resolveDns: true });
1187
- await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1187
+ const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1188
1188
 
1189
1189
  await this.navigateToUrl(page, action.url, {
1190
1190
  waitUntil: action.waitUntil,
1191
1191
  timeout
1192
1192
  });
1193
+ page.__crawlforgeGateWarnings = gateWarnings;
1193
1194
 
1194
1195
  return {
1195
1196
  url: action.url,
@@ -1380,9 +1381,13 @@ export class ActionExecutor extends EventEmitter {
1380
1381
  * @throws {BlockedHostError|RobotsDisallowedError}
1381
1382
  */
1382
1383
  async assertRobotsAllowed(url, browserOptions = {}) {
1383
- await browserPreflight(url, {
1384
+ return await browserPreflight(url, {
1384
1385
  respectRobots: browserOptions?.respectRobots,
1385
- tool: 'scrape_with_actions'
1386
+ // The audit row is the record of the CUSTOMER's decision (G5), so it has
1387
+ // to name the tool that actually made it. browser_session borrows this
1388
+ // executor, and until R23 every session's override was filed against
1389
+ // scrape_with_actions.
1390
+ tool: browserOptions?.tool || 'scrape_with_actions'
1386
1391
  });
1387
1392
  }
1388
1393
 
@@ -1402,13 +1407,17 @@ export class ActionExecutor extends EventEmitter {
1402
1407
  // or a disallowed path never costs a Chromium process. preflightFetch is
1403
1408
  // deliberately not used here: its identity/signature headers belong on an
1404
1409
  // HTTP fetch, not on a browser context.
1405
- await this.assertRobotsAllowed(url, browserOptions);
1410
+ const gateWarnings = await this.assertRobotsAllowed(url, browserOptions);
1406
1411
 
1407
1412
  const isStealth = !!browserOptions.stealthMode?.enabled;
1408
1413
 
1409
1414
  // Use the enhanced BrowserProcessor initialization that supports stealth mode
1410
1415
  const page = await this.browserProcessor.initializePage(browserOptions);
1411
1416
 
1417
+ // Stamped on the page for the same reason __crawlforgeNavigation is: the
1418
+ // caller's result is assembled a layer up, and the gate ran a layer down.
1419
+ page.__crawlforgeGateWarnings = gateWarnings;
1420
+
1412
1421
  try {
1413
1422
  // Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
1414
1423
  if (isStealth && this.browserProcessor.stealthManager) {
@@ -10,6 +10,7 @@ import { Logger } from '../utils/Logger.js';
10
10
  import { LLMManager } from './llm/LLMManager.js';
11
11
  import { safeFetch, safeGoto } from '../utils/ssrfGuard.js';
12
12
  import { preflightFetch, browserPreflight } from '../utils/robotsGate.js';
13
+ import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
13
14
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
14
15
  import {
15
16
  isAdmissibleClaim,
@@ -956,6 +957,9 @@ export class ResearchOrchestrator extends EventEmitter {
956
957
  const require = createRequire(import.meta.url);
957
958
  const camoufox = require('camoufox'); // CJS build — ESM build is broken
958
959
  await this._ensureCamoufoxLayout(camoufox);
960
+ // A page whose own JavaScript throws must not take the process with
961
+ // it — see src/utils/firefoxPageErrorGuard.js.
962
+ guardFirefoxPageErrors();
959
963
  this._stealthBrowser = await camoufox.Camoufox({ headless: true });
960
964
  this._stealthEngineActive = 'camoufox';
961
965
  this.logger.info('Stealth fallback using Camoufox (Firefox) engine');
@@ -15,10 +15,15 @@ import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
15
15
  import { BrowserContextPool } from './BrowserContextPool.js';
16
16
  import { safeGoto } from '../utils/ssrfGuard.js';
17
17
  import { detectChallengePage } from '../utils/challengeDetection.js';
18
+ import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
18
19
 
19
20
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
20
21
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
21
22
 
23
+ // The proxy a camoufox browser was launched with, kept on the browser itself so
24
+ // it survives being parked and restored by an engine switch.
25
+ const CAMOUFOX_PROXY = Symbol('crawlforge.camoufoxProxy');
26
+
22
27
  const StealthConfigSchema = z.object({
23
28
  level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
24
29
  randomizeFingerprint: z.boolean().default(true),
@@ -342,10 +347,28 @@ export class StealthBrowserManager {
342
347
  'camoufox is not installed. Run: npm install camoufox to use the Firefox-based stealth engine.'
343
348
  );
344
349
  }
345
- this.browser = await adapter.launch({
350
+ // camoufox fixes its fingerprint — and, with geoip, its geolocation,
351
+ // timezone and locale — at launch, from the proxy it is launched with.
352
+ // So the proxy is resolved once here and reused for every context this
353
+ // browser serves (see createStealthContext): rotating underneath it would
354
+ // leave camoufox reporting the first proxy's city behind the second
355
+ // proxy's exit IP, which is a worse signal than not rotating at all.
356
+ // A rotation takes effect on the next launch, after cleanup().
357
+ const proxy = this.resolveProxy(validatedConfig);
358
+ const browser = await adapter.launch({
346
359
  headless: true,
360
+ proxy,
361
+ // Only ask for geoip when there is a proxy to derive it from. Without
362
+ // one it would look up this machine's own public IP — a network call,
363
+ // and an external service learning our address, to confirm a location
364
+ // the browser is already in.
365
+ geoip: !!proxy,
366
+ blockWebRTC: validatedConfig.blockWebRTC,
367
+ humanize: validatedConfig.simulateHumanBehavior,
347
368
  launchOptions: {}
348
369
  });
370
+ browser[CAMOUFOX_PROXY] = proxy;
371
+ this.browser = browser;
349
372
  this._launchedEngine = 'camoufox';
350
373
  return this.browser;
351
374
  }
@@ -424,11 +447,11 @@ export class StealthBrowserManager {
424
447
  );
425
448
  }
426
449
 
427
- // Handle proxy configuration
428
- const currentProxy = await this.rotateProxy(validatedConfig);
429
- if (currentProxy) {
430
- stealthArgs.push(`--proxy-server=${currentProxy}`);
431
- }
450
+ // No proxy argument here. Chromium's --proxy-server= has no field for the
451
+ // user:pass every residential proxy requires, and a proxy fixed at launch
452
+ // could never rotate: the browser it was baked into is cached for the life
453
+ // of the process. Both engines take the proxy per context instead — see
454
+ // createStealthContext.
432
455
 
433
456
  const browser = await chromium.launch({
434
457
  headless: true,
@@ -507,19 +530,69 @@ export class StealthBrowserManager {
507
530
  serviceWorkers: 'block'
508
531
  };
509
532
 
510
- // camoufox's Firefox build predates the Browser.setDefaultViewport fields
511
- // playwright-core 1.62 sends (screenSize, isMobile, ...) and rejects unknown
512
- // properties, so any fixed viewport fails. viewport:null skips that protocol
513
- // call entirely (deviceScaleFactor/isMobile/hasTouch/screen are invalid or
514
- // meaningless without a viewport). window.screen is still spoofed via
515
- // addInitScript in applyAdvancedStealthConfigurations.
533
+ // The proxy rides on the context, credentials included: Chromium takes it
534
+ // on Target.createBrowserContext and camoufox's Juggler on
535
+ // Browser.setContextProxy. Both were verified against a local authenticating
536
+ // proxy. camoufox additionally gets one at launch, because its geoip lookup
537
+ // runs there — but a launch-time proxy routes traffic and drops the
538
+ // credentials, so the context is what actually authenticates, on both
539
+ // engines. camoufox stays on the proxy it was launched with: its geolocation,
540
+ // timezone and locale were derived from that exit IP, and rotating
541
+ // underneath it would leave the first proxy's city behind the second
542
+ // proxy's address.
543
+ const proxy = this._launchedEngine === 'camoufox'
544
+ ? (this.browser[CAMOUFOX_PROXY] || null)
545
+ : this.resolveProxy(validatedConfig);
546
+ if (proxy) {
547
+ contextOptions.proxy = proxy;
548
+ }
549
+
516
550
  if (this._launchedEngine === 'camoufox') {
551
+ // camoufox's Firefox build predates the Browser.setDefaultViewport fields
552
+ // playwright-core 1.62 sends (screenSize, isMobile, ...) and rejects
553
+ // unknown properties, so any fixed viewport fails. viewport:null skips
554
+ // that protocol call entirely (deviceScaleFactor/isMobile/hasTouch/screen
555
+ // are invalid or meaningless without a viewport). camoufox generates its
556
+ // own screen and window and spoofs them below the JS layer.
517
557
  contextOptions.viewport = null;
518
558
  delete contextOptions.deviceScaleFactor;
519
559
  delete contextOptions.isMobile;
520
560
  delete contextOptions.hasTouch;
521
561
  delete contextOptions.screen;
522
562
  delete contextOptions.serviceWorkers;
563
+
564
+ // camoufox arrives with a complete Firefox identity of its own. Ours was
565
+ // overwriting it, and not with a Firefox one: for this engine the browser
566
+ // distribution is left open, so about two thirds of camoufox contexts were
567
+ // handed a Chrome User-Agent on a Gecko engine. Every one of them also
568
+ // sent sec-ch-ua, sec-ch-ua-mobile and sec-ch-ua-platform — client hints
569
+ // Firefox has never implemented and never sends. That is a decision a
570
+ // detector can make from the request headers alone, before a line of
571
+ // script runs. Playwright still derives a correctly shaped Accept-Language
572
+ // from `locale`, so dropping these loses nothing real.
573
+ delete contextOptions.userAgent;
574
+ delete contextOptions.extraHTTPHeaders;
575
+
576
+ // Behind a proxy, camoufox has already derived locale, timezone and
577
+ // geolocation from the exit IP. Those three agree with the address the
578
+ // site sees; a persona drawn here does not, so it must not override them.
579
+ if (proxy) {
580
+ delete contextOptions.locale;
581
+ delete contextOptions.timezoneId;
582
+ delete contextOptions.geolocation;
583
+ }
584
+
585
+ // Keep what we hand back honest. create_context returns this fingerprint,
586
+ // and reporting a Chrome user agent and a persona the browser never uses
587
+ // is worse than reporting nothing: null reads as "the engine owns this".
588
+ fingerprint.userAgent = null;
589
+ fingerprint.headers = {};
590
+ fingerprint.viewport = null;
591
+ fingerprint.hardware = { ...fingerprint.hardware, platform: null };
592
+ if (proxy) {
593
+ fingerprint.locale = null;
594
+ fingerprint.timezone = null;
595
+ }
523
596
  }
524
597
 
525
598
  const context = await this.browser.newContext(contextOptions);
@@ -590,8 +663,11 @@ export class StealthBrowserManager {
590
663
  platform: fingerprint.hardware.platform,
591
664
  locale: fingerprint.locale,
592
665
  timezone: fingerprint.timezone,
593
- // width/height only — the pool's selection weight is an internal.
594
- viewport: { width: fingerprint.viewport.width, height: fingerprint.viewport.height }
666
+ // width/height only — the pool's selection weight is an internal. null on
667
+ // camoufox, which sizes its own window.
668
+ viewport: fingerprint.viewport
669
+ ? { width: fingerprint.viewport.width, height: fingerprint.viewport.height }
670
+ : null
595
671
  };
596
672
  }
597
673
 
@@ -1249,6 +1325,24 @@ export class StealthBrowserManager {
1249
1325
  * Apply advanced stealth configurations to browser context
1250
1326
  */
1251
1327
  async applyAdvancedStealthConfigurations(context, config, fingerprint) {
1328
+ // Nothing is injected into camoufox.
1329
+ //
1330
+ // Every script below is Chromium-shaped — it deletes navigator.webdriver,
1331
+ // installs a window.chrome, and patches getContext, AudioContext and font
1332
+ // metrics from the main world. camoufox does all of that in its own
1333
+ // C++/Juggler layer, where there is no JS seam to find, and a page that
1334
+ // compares property descriptors, Function.prototype.toString output or the
1335
+ // main thread against a Worker sees ours and not camoufox's. Injecting on
1336
+ // top of an engine built to need no injection only adds back the tells the
1337
+ // engine was chosen to avoid.
1338
+ //
1339
+ // This does not clear deviceandbrowserinfo.com's hasInconsistentWorkerValues
1340
+ // on camoufox: that flag stayed set with every one of these disabled, so it
1341
+ // is camoufox's own worker leak, not ours. What it removes is our share.
1342
+ if (this._launchedEngine === 'camoufox') {
1343
+ return;
1344
+ }
1345
+
1252
1346
  // Enhanced initialization script with comprehensive stealth measures
1253
1347
  await context.addInitScript((locale) => {
1254
1348
  // Remove webdriver property completely
@@ -1636,8 +1730,33 @@ export class StealthBrowserManager {
1636
1730
  // script; relative importScripts/fetch inside such a worker resolve
1637
1731
  // against the original script URL. Module workers, data:/blob: worker
1638
1732
  // URLs and a CSP that refuses blob workers fall through to the native
1639
- // constructor untouched. camoufox spoofs workers itself.
1640
- if (config.level === 'advanced' && this._launchedEngine !== 'camoufox') {
1733
+ // constructor untouched. (camoufox never reaches here — this whole method
1734
+ // returns early for it.)
1735
+ //
1736
+ // MEASURED COVERAGE, 2026-09-16, chromium, comparing a worker's navigator
1737
+ // with the main thread's:
1738
+ //
1739
+ // level new Worker('/w.js') new Worker(blob:)
1740
+ // medium leaks all four leaks all four
1741
+ // advanced matches leaks all four
1742
+ //
1743
+ // The four are platform, hardwareConcurrency, deviceMemory and languages;
1744
+ // WebGL's unmasked vendor/renderer leaks the same way, because the
1745
+ // prototype patch above lives in the window, not in a worker scope. So a
1746
+ // detector that builds its worker from a Blob — which needs no second
1747
+ // request and is the usual shape — reads straight past this at every
1748
+ // level, and deviceandbrowserinfo.com's hasInconsistentWorkerValues
1749
+ // stays set. Every one of those mismatches is ours: a worker reports the
1750
+ // truth, and only the main thread was spoofed.
1751
+ //
1752
+ // Closing it properly means one of two things, and both are trades:
1753
+ // rewrite blob/data worker sources so the patch reaches them (more
1754
+ // surface, and it still misses module workers and SharedWorker), or stop
1755
+ // spoofing in the window what cannot be spoofed in a worker (consistent,
1756
+ // but then the real values show — 32 cores and a SwiftShader GPU, which
1757
+ // is its own datacenter tell). Left as it is pending that call; do not
1758
+ // read the `advanced` gate as coverage.
1759
+ if (config.level === 'advanced') {
1641
1760
  await context.addInitScript(({ hardware, locale }) => {
1642
1761
  const NativeWorker = window.Worker;
1643
1762
  if (typeof NativeWorker !== 'function') return;
@@ -1960,25 +2079,93 @@ export class StealthBrowserManager {
1960
2079
  }
1961
2080
 
1962
2081
  /**
1963
- * Proxy rotation management
2082
+ * Parse one proxyRotation entry into the { server, username, password } shape
2083
+ * Playwright takes.
2084
+ *
2085
+ * Residential proxies — the only kind that helps against Cloudflare's IP
2086
+ * reputation check — are issued as `http://user:pass@host:port`. Those
2087
+ * credentials are the whole point: the previous code pushed the entry into
2088
+ * `--proxy-server=`, a Chromium flag with nowhere to put them, so every
2089
+ * authenticating proxy answered 407 and the request failed. Splitting them out
2090
+ * here is what lets both engines authenticate.
2091
+ *
2092
+ * A malformed entry throws rather than returning null. A proxy that silently
2093
+ * does not apply is the failure mode this whole path is being fixed for: the
2094
+ * caller believes their traffic is proxied and it is not.
1964
2095
  */
1965
- async rotateProxy(config) {
1966
- if (!config.proxyRotation?.enabled || !config.proxyRotation?.proxies?.length) {
2096
+ parseProxyEntry(entry) {
2097
+ if (typeof entry !== 'string' || !entry.trim()) {
2098
+ throw new Error('proxyRotation.proxies entries must be non-empty strings');
2099
+ }
2100
+ const raw = entry.trim();
2101
+ // `host:port` with no scheme parses as protocol "host:" and an empty host,
2102
+ // so give the bare form the http:// every proxy list assumes.
2103
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`;
2104
+
2105
+ let url;
2106
+ try {
2107
+ url = new URL(withScheme);
2108
+ } catch {
2109
+ throw new Error(`Invalid proxy "${this.redactProxy(raw)}": expected host:port or scheme://user:pass@host:port`);
2110
+ }
2111
+
2112
+ const scheme = url.protocol.replace(':', '').toLowerCase();
2113
+ if (!['http', 'https', 'socks4', 'socks5'].includes(scheme)) {
2114
+ throw new Error(`Invalid proxy scheme "${scheme}": expected http, https, socks4 or socks5`);
2115
+ }
2116
+ if (!url.hostname) {
2117
+ throw new Error(`Invalid proxy "${this.redactProxy(raw)}": no host`);
2118
+ }
2119
+
2120
+ // url.origin is the string "null" for socks4/socks5 — they are not special
2121
+ // schemes — so the server is rebuilt from protocol and host.
2122
+ const proxy = { server: `${url.protocol}//${url.host}` };
2123
+ // A password with a "@" or ":" in it must arrive percent-encoded to parse at
2124
+ // all; the proxy expects the decoded value.
2125
+ if (url.username) proxy.username = decodeURIComponent(url.username);
2126
+ if (url.password) proxy.password = decodeURIComponent(url.password);
2127
+ return proxy;
2128
+ }
2129
+
2130
+ /** A proxy entry with its credentials removed, for logs and get_stats. */
2131
+ redactProxy(entry) {
2132
+ return String(entry).replace(/\/\/[^/@]*@/, '//');
2133
+ }
2134
+
2135
+ /**
2136
+ * Pick the proxy this context should use, advancing the rotation when its
2137
+ * interval has elapsed.
2138
+ *
2139
+ * Called per context rather than per launch. The old call site ran once, at
2140
+ * browser launch, and the browser is cached for the life of the process — so
2141
+ * rotationInterval could never elapse anywhere that mattered and the second
2142
+ * proxy in a list was never reached.
2143
+ */
2144
+ resolveProxy(config) {
2145
+ const proxies = config.proxyRotation?.enabled ? (config.proxyRotation.proxies || []) : [];
2146
+ if (!proxies.length) {
1967
2147
  return null;
1968
2148
  }
1969
-
2149
+
1970
2150
  const now = Date.now();
1971
- const { rotationInterval, proxies } = config.proxyRotation;
1972
-
1973
- if (now - this.proxyManager.lastRotation > rotationInterval) {
2151
+ if (this.proxyManager.currentProxy === null) {
2152
+ // First use takes proxies[0]. The old code advanced the index before its
2153
+ // first read, so a single-proxy list worked by wrapping to 0 and every
2154
+ // longer list silently started at the second entry.
2155
+ this.proxyManager.proxyIndex = 0;
2156
+ this.proxyManager.lastRotation = now;
2157
+ } else if (now - this.proxyManager.lastRotation > config.proxyRotation.rotationInterval) {
1974
2158
  this.proxyManager.proxyIndex = (this.proxyManager.proxyIndex + 1) % proxies.length;
1975
- this.proxyManager.currentProxy = proxies[this.proxyManager.proxyIndex];
1976
2159
  this.proxyManager.lastRotation = now;
1977
-
1978
- console.error('Rotated to proxy:', this.proxyManager.currentProxy);
1979
2160
  }
1980
-
1981
- return this.proxyManager.currentProxy;
2161
+
2162
+ const entry = proxies[this.proxyManager.proxyIndex % proxies.length];
2163
+ const parsed = this.parseProxyEntry(entry);
2164
+ // Only ever hold the redacted form: getStats() returns currentProxy to the
2165
+ // caller, and these strings carry a password.
2166
+ this.proxyManager.currentProxy = this.redactProxy(entry);
2167
+ this.proxyManager.activeProxies = proxies.map((p) => this.redactProxy(p));
2168
+ return parsed;
1982
2169
  }
1983
2170
 
1984
2171
  /**
@@ -2056,6 +2243,80 @@ export class StealthBrowserManager {
2056
2243
  return Date.now() - started;
2057
2244
  }
2058
2245
 
2246
+ /**
2247
+ * Give the page the chance to finish rendering before it is read.
2248
+ *
2249
+ * Navigation returns at DOMContentLoaded, and everything before this only
2250
+ * waits for the document to become non-empty (_waitOutEmptyDocument) or to
2251
+ * stop being an interstitial (_waitOutChallenge). A page that already has
2252
+ * prose and writes the part the caller came for in a load handler passed all
2253
+ * of those as finished, and the read landed mid-render.
2254
+ *
2255
+ * The 6.6.2 bench recorded that as a format bug — "markdown silently dropped
2256
+ * the verdict, text captured it" — but its two calls were two page loads and
2257
+ * only one of them raced. One call asking for markdown and text returns the
2258
+ * same content in both, because both are built from one render. The defect
2259
+ * was the timing.
2260
+ *
2261
+ * Two bounded waits: the page's own load event, then quiet in the DOM. What
2262
+ * this cannot do is predict a payload injected into a still page some
2263
+ * arbitrary time later — that is what the caller's `wait_for` is for.
2264
+ *
2265
+ * @returns {Promise<number>} milliseconds actually waited, 0 for a page that
2266
+ * was already finished.
2267
+ */
2268
+ async _settleRender(page, { loadTimeoutMs = 3000, quietMs = 400, capMs = 2500 } = {}) {
2269
+ const started = Date.now();
2270
+ // Subresources are often what the render waits on. Bounded, because a page
2271
+ // with a hanging tracker request never fires load at all.
2272
+ await page.waitForLoadState('load', { timeout: loadTimeoutMs }).catch(() => {});
2273
+ const loadMs = Date.now() - started;
2274
+ return loadMs + await this._settleDom(page, { quietMs, capMs });
2275
+ }
2276
+
2277
+ /**
2278
+ * Resolve once the DOM has been unchanged for `quietMs`, or at `capMs`.
2279
+ *
2280
+ * A MutationObserver in the page, so this is one round trip and returns
2281
+ * immediately on a page that was already still. The cap bounds a page that
2282
+ * never stops animating.
2283
+ *
2284
+ * @returns {Promise<number>} milliseconds the DOM went on changing for — 0
2285
+ * when the page was already still, so a settled page reports no extra wait.
2286
+ */
2287
+ async _settleDom(page, { quietMs = 400, capMs = 2500 } = {}) {
2288
+ try {
2289
+ return await page.evaluate(({ quiet, cap }) => new Promise((resolve) => {
2290
+ if (!document.documentElement) {
2291
+ resolve(0);
2292
+ return;
2293
+ }
2294
+ const started = Date.now();
2295
+ let quietTimer;
2296
+ const finish = () => {
2297
+ observer.disconnect();
2298
+ clearTimeout(quietTimer);
2299
+ clearTimeout(capTimer);
2300
+ // Subtract the quiet window itself: what is worth reporting is how
2301
+ // long the page kept changing, not the time spent confirming it had
2302
+ // stopped.
2303
+ resolve(Math.max(0, Date.now() - started - quiet));
2304
+ };
2305
+ const observer = new MutationObserver(() => {
2306
+ clearTimeout(quietTimer);
2307
+ quietTimer = setTimeout(finish, quiet);
2308
+ });
2309
+ const capTimer = setTimeout(finish, cap);
2310
+ quietTimer = setTimeout(finish, quiet);
2311
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
2312
+ }), { quiet: quietMs, cap: capMs });
2313
+ } catch {
2314
+ // Navigated away, closed or crashed mid-settle. The read that follows
2315
+ // reports that properly; there is nothing to add here.
2316
+ return 0;
2317
+ }
2318
+ }
2319
+
2059
2320
  async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
2060
2321
  let title;
2061
2322
  try {
@@ -2100,7 +2361,13 @@ export class StealthBrowserManager {
2100
2361
  // up, so the interstitial came back as success:true (R17, 2026-09-04).
2101
2362
  // An auto-solving challenge gets one bounded wait to finish first.
2102
2363
  await this._waitOutChallenge(page);
2103
- const gracedMs = await this._waitOutEmptyDocument(page);
2364
+ const emptyGraceMs = await this._waitOutEmptyDocument(page);
2365
+ // Last: let whatever is still rendering finish. Without this the read
2366
+ // below can land between "the page has content" and "the page has the
2367
+ // content the caller came for", and a half-rendered page is returned as
2368
+ // a successful scrape.
2369
+ const renderMs = await this._settleRender(page);
2370
+ const gracedMs = emptyGraceMs + renderMs;
2104
2371
 
2105
2372
  // A failure to read the document is a failure. With every read wrapped
2106
2373
  // in .catch(() => ''), a renderer that crashed or a page closed during
@@ -2166,8 +2433,13 @@ export class StealthBrowserManager {
2166
2433
  }
2167
2434
  });
2168
2435
 
2169
- // Add request headers
2170
- await page.setExtraHTTPHeaders(fingerprint.headers);
2436
+ // Add request headers — Chromium only. These carry sec-ch-ua,
2437
+ // sec-ch-ua-mobile and sec-ch-ua-platform, client hints Gecko does not
2438
+ // implement, so setting them here would put them straight back onto a
2439
+ // camoufox page after createStealthContext had taken them off the context.
2440
+ if (isChromium(page)) {
2441
+ await page.setExtraHTTPHeaders(fingerprint.headers);
2442
+ }
2171
2443
 
2172
2444
  // Emulate realistic network conditions.
2173
2445
  //
@@ -2556,14 +2828,52 @@ export class CamoufoxAdapter extends BrowserEngine {
2556
2828
 
2557
2829
  await this._ensureMacOSLayout(camoufox);
2558
2830
 
2831
+ // Before any Firefox page can run: camoufox reports an uncaught page error
2832
+ // with no location, and playwright reads one anyway. See the guard.
2833
+ guardFirefoxPageErrors();
2834
+
2559
2835
  // camoufox's launcher is Camoufox(options) — the package has no launch()
2560
2836
  // export. It resolves the fetched Firefox binary (npx camoufox fetch) and
2561
2837
  // returns a Playwright-compatible Browser. Takes `headless` directly plus
2562
2838
  // passthrough Playwright Firefox launch options.
2563
- return camoufox.Camoufox({
2839
+ //
2840
+ // The snake_case names below are camoufox's own option names. They are the
2841
+ // reason to run this engine at all: camoufox spoofs at the C++/Juggler
2842
+ // level, where a page cannot see the seam, and every one of these was
2843
+ // simply not being passed — camoufox ran with its own features off.
2844
+ const options = {
2564
2845
  headless: config.headless !== false,
2565
2846
  ...config.launchOptions
2566
- });
2847
+ };
2848
+ // A bare `{ server }` is fine here; camoufox normalises both shapes.
2849
+ if (config.proxy) options.proxy = config.proxy;
2850
+ // geoip derives longitude, latitude, timezone, country and locale from the
2851
+ // proxy's exit IP, which is the one thing that makes a proxied browser
2852
+ // coherent. It costs a request through the proxy, and the first ever call
2853
+ // downloads MaxMind's city database (~60 MB) into camoufox's install dir.
2854
+ if (config.geoip) options.geoip = true;
2855
+ // Blocking WebRTC is itself a signal. With geoip camoufox instead reports
2856
+ // the proxy's exit IP through WebRTC, which is the coherent answer — so
2857
+ // `blockWebRTC: false` is the stealthier setting behind a proxy.
2858
+ if (config.blockWebRTC) options.block_webrtc = true;
2859
+ // Native cursor humanization: camoufox moves the pointer along a plausible
2860
+ // path rather than teleporting it.
2861
+ if (config.humanize) options.humanize = true;
2862
+
2863
+ try {
2864
+ return await camoufox.Camoufox(options);
2865
+ } catch (err) {
2866
+ if (options.geoip) {
2867
+ // Do not quietly retry without geoip. A proxied camoufox whose
2868
+ // geolocation says one country while its exit IP says another is a
2869
+ // cleaner detection signal than an unproxied one.
2870
+ throw new Error(
2871
+ `camoufox failed to launch with geoip through the configured proxy: ${err.message}. ` +
2872
+ 'Check the proxy credentials and that the proxy can reach the internet.'
2873
+ );
2874
+ }
2875
+ throw err;
2876
+ }
2567
2877
  }
2568
2878
 
2569
2879
  /**
@@ -6,6 +6,23 @@
6
6
 
7
7
  import { createHash } from 'crypto';
8
8
 
9
+ /**
10
+ * The stdio transport frames one JSON-RPC message at a time, and since SDK
11
+ * 1.30 its ReadBuffer CLOSES the transport when a message runs past
12
+ * STDIO_MESSAGE_CEILING_BYTES: the whole session dies, every tool with it, and
13
+ * the overflow is not recoverable (modelcontextprotocol/typescript-sdk#2793).
14
+ * A full-page PNG of a long article is 17.4 MB — en.wikipedia.org/wiki/World_War_II
15
+ * is 61,341px tall — and JPEG only brings it to 11.3 MB, so neither format saves
16
+ * a caller who passes full_page (R23, 2026-09-13).
17
+ *
18
+ * Hence a budget on the blob a read may emit: base64 costs four bytes per three,
19
+ * and a tenth of the ceiling is left for the JSON-RPC envelope around it.
20
+ */
21
+ const STDIO_MESSAGE_CEILING_BYTES = 10 * 1024 * 1024;
22
+ export const MAX_RESOURCE_BLOB_BYTES =
23
+ Number(process.env.CRAWLFORGE_MAX_RESOURCE_BLOB_BYTES) ||
24
+ Math.floor(STDIO_MESSAGE_CEILING_BYTES * 0.9 * 3 / 4);
25
+
9
26
  /**
10
27
  * Supported resource types and their MIME types.
11
28
  */
@@ -84,6 +101,12 @@ export class ResourceRegistry {
84
101
  createdAt: Date.now(),
85
102
  ttl: this.defaultTtl,
86
103
  });
104
+ // Handed back so the tool that took the shot can say, in the same result
105
+ // that carries the URI, whether that URI is readable over stdio at all.
106
+ return {
107
+ bytes: buf.length,
108
+ withinInlineBudget: buf.length <= MAX_RESOURCE_BLOB_BYTES,
109
+ };
87
110
  }
88
111
 
89
112
  /**
@@ -152,7 +175,7 @@ export class ResourceRegistry {
152
175
  resources.push({
153
176
  uri: `crawlforge://screenshot/${actionId}`,
154
177
  name: `Screenshot ${actionId}`,
155
- description: 'Screenshot from scrape_with_actions',
178
+ description: 'Screenshot from a CrawlForge browser tool',
156
179
  mimeType: RESOURCE_MIME.screenshot,
157
180
  });
158
181
  }
@@ -265,6 +288,17 @@ export class ResourceRegistry {
265
288
  if (!entry || Date.now() - entry.createdAt >= entry.ttl) {
266
289
  throw new Error(`Screenshot not found or expired: ${actionId}`);
267
290
  }
291
+ // Refuse rather than emit: an oversized message costs the caller their
292
+ // whole session, and this error costs them one read they can act on.
293
+ if (entry.data.length > MAX_RESOURCE_BLOB_BYTES) {
294
+ throw new Error(
295
+ `Screenshot ${actionId} is ${entry.data.length} bytes, over the ` +
296
+ `${MAX_RESOURCE_BLOB_BYTES}-byte limit for one MCP message. Returning it would ` +
297
+ `close the connection instead of failing this read, so it is refused. Take the ` +
298
+ `shot again without full_page, or as format:"jpeg" with a lower quality, or ` +
299
+ `scoped to one element with selector.`
300
+ );
301
+ }
268
302
  return {
269
303
  contents: [{
270
304
  uri,
@@ -68,6 +68,30 @@ Operations: `configure`, `enable`, `disable`, `create_context`, `create_page`,
68
68
 
69
69
  Full decision table: [engine selection](references/engine-selection.md).
70
70
 
71
+ ### Proxies
72
+
73
+ A block that survives both engines is usually the IP, not the fingerprint:
74
+ Cloudflare scores the address and its ASN before it serves a challenge. Route
75
+ through your own residential proxy — CrawlForge supplies none.
76
+
77
+ ```json
78
+ {
79
+ "tool": "stealth_mode",
80
+ "params": {
81
+ "operation": "scrape", "url": "https://protected-site.com", "engine": "camoufox",
82
+ "stealthConfig": {
83
+ "proxyRotation": { "enabled": true, "proxies": ["http://user:p%40ss@gw.provider.net:8080"] }
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Credentials go in the URL, percent-encoded if the password contains `@`, `:` or
90
+ `/`. `http`, `https`, `socks4` and `socks5` are accepted. With a proxy, camoufox
91
+ derives its timezone, locale and geolocation from the exit IP, so the browser
92
+ agrees with the address the site sees — that lookup happens at launch, so a
93
+ camoufox browser keeps one proxy until `cleanup`.
94
+
71
95
  ### CLI
72
96
 
73
97
  ```bash
@@ -170,6 +170,21 @@ function withJsResult(result) {
170
170
  return { ...result, jsResult: result.result.result };
171
171
  }
172
172
 
173
+ /**
174
+ * The gate's warnings — a respect_robots override, a crawl-delay note — are
175
+ * what the shared parameter description promises the caller gets back
176
+ * ("returns a warning in the response"). `scrape` publishes them through
177
+ * unifiedScrape; the browser path dropped them on the floor, so a session that
178
+ * overrode robots.txt was told nothing at all (R23, 2026-09-13).
179
+ *
180
+ * Last navigation wins, the same rule `__crawlforgeNavigation` follows: the
181
+ * warnings describe the hop the caller just made, not every hop of the session.
182
+ */
183
+ function gateWarningFields(page) {
184
+ const warnings = page?.__crawlforgeGateWarnings;
185
+ return warnings?.length ? { warnings } : {};
186
+ }
187
+
173
188
  export class BrowserSessionTool {
174
189
  constructor(options = {}) {
175
190
  const {
@@ -278,7 +293,8 @@ export class BrowserSessionTool {
278
293
  viewportWidth: params.viewport?.width,
279
294
  viewportHeight: params.viewport?.height,
280
295
  timeout: params.timeout,
281
- respectRobots: params.respect_robots
296
+ respectRobots: params.respect_robots,
297
+ tool: 'browser_session'
282
298
  };
283
299
  if (params.stealth) {
284
300
  browserOptions.stealthMode = { enabled: true };
@@ -325,6 +341,7 @@ export class BrowserSessionTool {
325
341
  operation: 'open',
326
342
  ...sessionInfo(session),
327
343
  ...verdictFields(verdict),
344
+ ...gateWarningFields(page),
328
345
  // The page is a wall, but the session behind it is real and holds a
329
346
  // browser context — say so, or a caller reading only `success` abandons
330
347
  // it to its TTL instead of closing it or acting through the challenge.
@@ -411,7 +428,7 @@ export class BrowserSessionTool {
411
428
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
412
429
  continueOnError: params.continue_on_error,
413
430
  timeout: params.timeout,
414
- browserOptions: { respectRobots: params.respect_robots }
431
+ browserOptions: { respectRobots: params.respect_robots, tool: 'browser_session' }
415
432
  });
416
433
 
417
434
  this.store.touch(session, result.finalUrl);
@@ -424,6 +441,7 @@ export class BrowserSessionTool {
424
441
  ...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
425
442
  ? { httpStatus: session.page.__crawlforgeNavigation.status }
426
443
  : {}),
444
+ ...gateWarningFields(session.page),
427
445
  error: result.error,
428
446
  actionResults: result.results.map(withJsResult),
429
447
  screenshots: result.screenshots,
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Stops a Firefox page's own JavaScript error from taking the process down.
3
+ *
4
+ * Camoufox's Juggler reports an uncaught page error without a `location`:
5
+ *
6
+ * ◀ RECV {"method":"Page.uncaughtError","params":{
7
+ * "frameId":"mainframe-8",
8
+ * "message":"TypeError: null has no properties",
9
+ * "stack":"@http://127.0.0.1:55712/:3:13\n"}}
10
+ *
11
+ * playwright-core 1.62's FFPage._onUncaughtError forwards that missing
12
+ * `params.location` into Page.addPageError, and the BrowserContext dispatcher
13
+ * then reads it unconditionally:
14
+ *
15
+ * location: { url: pageError.location.url, ... }
16
+ *
17
+ * The resulting "Cannot read properties of undefined (reading 'url')" is thrown
18
+ * inside the protocol dispatch loop, where nothing awaits it — it arrives as an
19
+ * uncaughtException. A plain `<script>null.boom;</script>` on any page is enough
20
+ * to trigger it, and bot.sannysoft.com does it in the ordinary course of
21
+ * running its checks.
22
+ *
23
+ * Under server.js the catch-all uncaughtException handler absorbs it and the
24
+ * scrape still returns, but every other consumer — the CLI, deep_research when
25
+ * it is embedded, any direct use of StealthBrowserManager — dies on the spot.
26
+ * Leaning on that handler is not a fix either: it logs a stack trace on a page
27
+ * that did nothing wrong, and Node treats the process state as undefined
28
+ * afterwards.
29
+ *
30
+ * So the missing value is filled in at its source. Firefox told us a page error
31
+ * happened but not where; the zeroed location says exactly that, and everything
32
+ * downstream — including the `pageerror` event a caller can listen for —
33
+ * behaves normally. Chromium and WebKit always send a location, so they never
34
+ * reach the fallback.
35
+ *
36
+ * Reaching into playwright's internals is deliberate and bounded: this patches
37
+ * one method, only when it exists and has not already been patched, and any
38
+ * failure leaves the original in place. A playwright that fixes this upstream,
39
+ * or renames the method, turns it into a no-op rather than a breakage. The
40
+ * `playwright-core/lib/coreBundle` specifier is one playwright-core publishes in
41
+ * its own `exports` map, and it resolves to the same module instance playwright
42
+ * itself loads. playwright-core is not declared as a dependency on purpose: it
43
+ * must stay the exact version playwright pins, so it is only ever reached
44
+ * through the copy playwright brought.
45
+ */
46
+
47
+ import { createRequire } from 'module';
48
+
49
+ const require = createRequire(import.meta.url);
50
+
51
+ // Firefox reports that an error happened but not where. Zeros say "unknown"
52
+ // without inventing a file or a line number.
53
+ const UNKNOWN_LOCATION = Object.freeze({ url: '', lineNumber: 0, columnNumber: 0 });
54
+
55
+ let applied = false;
56
+
57
+ /**
58
+ * Idempotent, best-effort, and safe to call before every Firefox/Camoufox
59
+ * launch. Returns true when the guard is in place (including when a previous
60
+ * call installed it), false when this playwright build did not match.
61
+ */
62
+ export function guardFirefoxPageErrors() {
63
+ if (applied) return true;
64
+
65
+ try {
66
+ const bundle = require('playwright-core/lib/coreBundle');
67
+ const prototype = bundle?.server?.Page?.prototype;
68
+ if (!prototype || typeof prototype.addPageError !== 'function') return false;
69
+ if (prototype.addPageError.__crawlforgeGuarded) {
70
+ applied = true;
71
+ return true;
72
+ }
73
+
74
+ const original = prototype.addPageError;
75
+ function addPageError(error, location) {
76
+ return original.call(this, error, location || UNKNOWN_LOCATION);
77
+ }
78
+ addPageError.__crawlforgeGuarded = true;
79
+ prototype.addPageError = addPageError;
80
+
81
+ applied = true;
82
+ return true;
83
+ } catch {
84
+ // A playwright that moved or sealed this is not a reason to fail a launch;
85
+ // the crash it prevents is rarer than the launch it would break.
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Test seam: forget that the guard was installed. Does not un-patch. */
91
+ export function _resetFirefoxPageErrorGuard() {
92
+ applied = false;
93
+ }