crawlforge-mcp-server 6.19.2 → 6.20.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,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.19.2",
3
+ "version": "6.20.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
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",
package/server.js CHANGED
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
113
113
  // Create the server
114
114
  const server = new McpServer({
115
115
  name: "crawlforge",
116
- version: "6.19.2",
116
+ version: "6.20.0",
117
117
  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.",
118
118
  homepage: "https://www.crawlforge.dev",
119
119
  icon: "https://www.crawlforge.dev/icon.png",
@@ -8,12 +8,16 @@ import BrowserProcessor from './processing/BrowserProcessor.js';
8
8
  import { EventEmitter } from 'events';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
11
- import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode } from '../utils/robotsGate.js';
11
+ import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode, paceBrowserAction } from '../utils/robotsGate.js';
12
12
  import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
13
13
  import { settlePage } from './browser/settle.js';
14
14
  import { handleConsent } from './browser/consent.js';
15
15
  import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
16
16
 
17
+ // Actions that can make the page send a request (follow a link, submit a form,
18
+ // trigger a load): each waits out the page host's Crawl-delay first.
19
+ const REQUESTING_ACTION_TYPES = new Set(['click', 'press', 'select', 'check']);
20
+
17
21
  // executeJavaScript hardening limits (only relevant when the deploy-time flag
18
22
  // ALLOW_JAVASCRIPT_EXECUTION=true is set; JS execution stays off by default).
19
23
  const JS_MAX_SCRIPT_LENGTH = parseInt(process.env.JS_MAX_SCRIPT_LENGTH || '10000', 10);
@@ -799,6 +803,9 @@ export class ActionExecutor extends EventEmitter {
799
803
  // A navigate leaves the page it is on and gates the URL it is sent to.
800
804
  if (action.type !== 'navigate') await this.assertPageAllowed(page, browserOptions);
801
805
  const urlBefore = page.url();
806
+ // A navigate waits its turn in its own pre-flight; these can send the page
807
+ // somewhere too, and the request goes out before anything can ask.
808
+ if (REQUESTING_ACTION_TYPES.has(action.type)) await paceBrowserAction(urlBefore);
802
809
  const actionResult = await this.executeActionInternal(page, action, executionContext);
803
810
  await this.assertPageAllowed(page, browserOptions);
804
811
  // A click that followed a link or a submit that posted a form is a
@@ -11,7 +11,7 @@ import { randomUUID } from 'crypto';
11
11
  import { isCreatorModeVerified } from './creatorMode.js';
12
12
  import { resolveApiEndpoint } from './endpointGuard.js';
13
13
  import { logger } from '../utils/Logger.js';
14
- import { maskSecrets } from '../utils/secretMask.js';
14
+ import { maskSecrets, maskLocalPaths } from '../utils/secretMask.js';
15
15
  import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
16
16
  import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
17
17
  import { agentEscalationSurcharge } from '../tools/agent/escalation.js';
@@ -385,7 +385,8 @@ class AuthManager {
385
385
  creditsUsed,
386
386
  // Never send raw tool params to the backend — they can carry third-party
387
387
  // API keys, auth headers, or webhook secrets. Mask before it leaves the process.
388
- requestData: maskSecrets(requestData),
388
+ // Local file paths too: they name the customer's clients and folders.
389
+ requestData: maskLocalPaths(maskSecrets(requestData)),
389
390
  responseStatus,
390
391
  processingTime,
391
392
  timestamp: new Date().toISOString(),
@@ -51,6 +51,23 @@ export const FormatSchema = z.union([
51
51
 
52
52
  const QUERY_FORMATS = new Set(['highlights', 'question']);
53
53
 
54
+ /**
55
+ * True when `formats` holds at most one `highlights` and one `question`. The
56
+ * result has one slot of each, so a second entry overwrote the first while
57
+ * the add-on still charged; the schema refuses it instead.
58
+ *
59
+ * @param {Array<unknown>} formats
60
+ * @returns {boolean}
61
+ */
62
+ export function oneOfEachQueryFormat(formats) {
63
+ return [...QUERY_FORMATS].every(
64
+ (type) => formats.filter((fmt) => fmt && typeof fmt === 'object' && fmt.type === type).length <= 1
65
+ );
66
+ }
67
+
68
+ export const ONE_OF_EACH_QUERY_FORMAT_MESSAGE =
69
+ "At most one 'highlights' and one 'question' format per call; make another call for another query";
70
+
54
71
  /**
55
72
  * What the query-scoped formats add to `scrape`'s base price: 1 credit once
56
73
  * per call when any `highlights` or `question` format is present, 3 more
@@ -19,6 +19,7 @@ import { segmentUnits, rankUnits } from 'crawlforge-extractors';
19
19
  import { stealthDocumentVerdict as documentVerdict } from '../../utils/stealthVerdict.js';
20
20
  import {
21
21
  SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
22
+ oneOfEachQueryFormat, ONE_OF_EACH_QUERY_FORMAT_MESSAGE,
22
23
  scrapeFormatSurcharge
23
24
  } from './formats.js';
24
25
  import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
@@ -57,7 +58,9 @@ const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.t
57
58
  // keeping its own copy (Phase 0, 0.3).
58
59
  export const SCRAPE_INPUT_SHAPE = {
59
60
  url: z.string().url().describe('The URL to scrape'),
60
- formats: z.array(FormatSchema).min(1).optional().default(['markdown']).describe('Formats to return (default: ["markdown"])'),
61
+ formats: z.array(FormatSchema).min(1)
62
+ .refine(oneOfEachQueryFormat, ONE_OF_EACH_QUERY_FORMAT_MESSAGE)
63
+ .optional().default(['markdown']).describe('Formats to return (default: ["markdown"]); at most one highlights and one question format per call'),
61
64
  onlyMainContent: z.boolean().optional().default(true).describe('Strip boilerplate via Readability (default: true)'),
62
65
  // Pass-through to fetchAndParse
63
66
  timeoutMs: z.number().min(1000).max(60000).optional().default(15000).describe('Fetch timeout in ms'),
@@ -175,9 +175,11 @@ export async function throttleHost(url, options = {}) {
175
175
  waits.push(state.lastRequestAt + crawlDelayMs - Date.now());
176
176
  }
177
177
  const wait = Math.max(0, ...waits);
178
+ // Recorded before the sleep, as the time this request will go out: a
179
+ // concurrent caller then queues a delay behind it instead of reading the
180
+ // same lastRequestAt and waking at the same moment.
181
+ state.lastRequestAt = Date.now() + wait;
178
182
  if (wait > 0) await sleep(wait);
179
-
180
- state.lastRequestAt = Date.now();
181
183
  }
182
184
 
183
185
  if (config.rateLimit.perDomain === false) return; // our own throttle disabled
@@ -362,6 +362,33 @@ export async function browserPreflight(url, options = {}) {
362
362
  return [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)];
363
363
  }
364
364
 
365
+ /**
366
+ * Wait the page's host out before a browser action that can send a request:
367
+ * a click on a link, a submit, an Enter press, a select or checkbox wired to
368
+ * a load. The browser makes that request the moment the action runs and the
369
+ * gate only sees where it led afterwards (`alreadyRequested`), so the wait
370
+ * has to come first. Spaced by the CURRENT page's host — where the action will
371
+ * lead is not known until it has run.
372
+ *
373
+ * Reads Crawl-delay from the cached robots.txt with no allow/deny decision
374
+ * and no audit row: the page itself already passed the gate, and an override
375
+ * is recorded once per URL, not once per click. An action that sends nothing
376
+ * still takes a slot; overspacing a polite crawl is the safe way to be wrong.
377
+ *
378
+ * @param {string} url the page's current URL
379
+ * @returns {Promise<void>}
380
+ */
381
+ export async function paceBrowserAction(url) {
382
+ if (!/^https?:/i.test(url || '')) return;
383
+ let crawlDelayMs = 0;
384
+ try {
385
+ crawlDelayMs = (await checkerFor(resolveUserAgent()).fetchCrawlDelay(url)) * 1000;
386
+ } catch {
387
+ // Unreadable robots.txt asks for no delay (see robotsPreflight).
388
+ }
389
+ await throttleHost(url, { crawlDelayMs });
390
+ }
391
+
365
392
  /** Test/diagnostic hook: drop every cached robots.txt. */
366
393
  export function _resetRobotsGate() {
367
394
  checkers.clear();
@@ -81,6 +81,43 @@ export function maskSecrets(obj, depth = 0) {
81
81
  return obj;
82
82
  }
83
83
 
84
+ // A string that names a place on the customer's disk: home-relative, a file
85
+ // URL, a Windows drive or UNC share, or an absolute POSIX path under a root
86
+ // that holds user files. A bare "/" or a URL path like "/pricing" is not one.
87
+ const LOCAL_PATH_RE = /^(?:~[\\/]|file:\/\/|[A-Za-z]:[\\/]|\\\\|\/(?:Users|home|root|tmp|private|var|mnt|Volumes|media|opt|srv)\/)/;
88
+ // process_document reads `source` from disk for these, relative paths included.
89
+ const LOCAL_SOURCE_TYPES = new Set(['file', 'pdf_file']);
90
+
91
+ /** "[local file]" plus the extension, so usage stays countable by file type. */
92
+ function maskPath(value) {
93
+ const ext = /\.([A-Za-z0-9]{1,8})$/.exec(value.split(/[\\/]/).pop() || '');
94
+ return ext ? `[local file].${ext[1].toLowerCase()}` : '[local file]';
95
+ }
96
+
97
+ /**
98
+ * Deep-clone obj with every local file path reduced to its extension. For the
99
+ * usage report, which leaves the machine: a path names the customer's clients
100
+ * and folders, and maskSecrets matches key names, so it never saw one. Local
101
+ * logs keep the path. Does NOT mutate the original.
102
+ * @param {*} obj
103
+ * @param {number} depth - internal recursion guard
104
+ * @returns {*}
105
+ */
106
+ export function maskLocalPaths(obj, depth = 0) {
107
+ if (depth > 10) return obj;
108
+ if (typeof obj === 'string') return LOCAL_PATH_RE.test(obj) ? maskPath(obj) : obj;
109
+ if (Array.isArray(obj)) return obj.map(item => maskLocalPaths(item, depth + 1));
110
+ if (obj !== null && typeof obj === 'object') {
111
+ const localSource = LOCAL_SOURCE_TYPES.has(obj.sourceType) && typeof obj.source === 'string';
112
+ const result = {};
113
+ for (const [key, value] of Object.entries(obj)) {
114
+ result[key] = key === 'source' && localSource ? maskPath(value) : maskLocalPaths(value, depth + 1);
115
+ }
116
+ return result;
117
+ }
118
+ return obj;
119
+ }
120
+
84
121
  /**
85
122
  * Redact secrets from an Error's message and stack.
86
123
  * Returns a new plain-object representation safe for logging.