crawlforge-mcp-server 6.23.0 → 6.23.2

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.23.0",
3
+ "version": "6.23.2",
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",
@@ -114,14 +114,14 @@
114
114
  ],
115
115
  "dependencies": {
116
116
  "@duckduckgo/autoconsent": "^16.43.0",
117
- "@modelcontextprotocol/core": "^2.0.0",
118
- "@modelcontextprotocol/node": "^2.0.0",
119
- "@modelcontextprotocol/server": "^2.0.0",
117
+ "@modelcontextprotocol/core": "^2.3.1",
118
+ "@modelcontextprotocol/node": "^2.1.1",
119
+ "@modelcontextprotocol/server": "^2.3.1",
120
120
  "@mozilla/readability": "^0.6.0",
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.18.2",
124
+ "crawlforge-extractors": "^1.18.3",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
@@ -145,7 +145,7 @@
145
145
  },
146
146
  "devDependencies": {
147
147
  "@jest/globals": "^30.3.0",
148
- "@modelcontextprotocol/client": "^2.0.0",
148
+ "@modelcontextprotocol/client": "^2.3.1",
149
149
  "c8": "^11.0.0",
150
150
  "cross-env": "^10.0.0",
151
151
  "jest": "^30.3.0"
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.23.0",
116
+ version: "6.23.2",
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",
@@ -276,7 +276,10 @@ export class LLMsTxtAnalyzer {
276
276
  apis.push({
277
277
  url: new URL(href, baseUrl).toString(),
278
278
  type: 'documentation',
279
- description: text.trim()
279
+ description: text.trim(),
280
+ // Link text as written, the label when the page itself was not
281
+ // classified (live sweep 7.4: it was labelled by its slug).
282
+ title: $(element).text().replace(/\s+/g, ' ').trim()
280
283
  });
281
284
  }
282
285
  });
@@ -279,7 +279,7 @@ export async function resolveStealthEngine(requested) {
279
279
  return { engine: 'chromium', fallbackWarning: null };
280
280
  }
281
281
 
282
- let reason = 'camoufox is not installed (npm install camoufox)';
282
+ let reason = 'camoufox is not installed (npm install camoufox, then npx camoufox fetch)';
283
283
  try {
284
284
  if (await new CamoufoxAdapter().isAvailable()) {
285
285
  return { engine: 'camoufox', fallbackWarning: null };
@@ -606,7 +606,7 @@ export class StealthBrowserManager {
606
606
  const available = await adapter.isAvailable();
607
607
  if (!available) {
608
608
  throw new Error(
609
- 'camoufox is not installed. Run: npm install camoufox to use the Firefox-based stealth engine.'
609
+ 'camoufox is not installed. Run: npm install camoufox, then npx camoufox fetch, to use the Firefox-based stealth engine.'
610
610
  );
611
611
  }
612
612
  // camoufox fixes its fingerprint — and, with geoip, its geolocation,
@@ -3612,7 +3612,15 @@ export class CamoufoxAdapter extends BrowserEngine {
3612
3612
 
3613
3613
  async isAvailable() {
3614
3614
  try {
3615
- await this._load();
3615
+ const camoufox = await this._load();
3616
+ // The npm package ships without the Firefox binary, which comes from a
3617
+ // separate `npx camoufox fetch`. getLaunchPath() throws when it is
3618
+ // missing, and "auto" must then pick Chromium, not fail at launch.
3619
+ try {
3620
+ camoufox.getLaunchPath();
3621
+ } catch {
3622
+ return false;
3623
+ }
3616
3624
  return true;
3617
3625
  } catch (err) {
3618
3626
  if (this._isNotInstalled(err)) {
@@ -38,7 +38,7 @@ import tls from 'node:tls';
38
38
  import { config } from '../../constants/config.js';
39
39
  import { assertUrlAllowed, isSsrfError, ssrfLookup } from '../../utils/ssrfGuard.js';
40
40
 
41
- /** upstream key → Promise<{ server: string }> */
41
+ /** upstream key → Promise<{ server: string, bypass: string }> */
42
42
  const servers = new Map();
43
43
 
44
44
  const hostForUrl = (host) => (net.isIPv6(host) ? `[${host}]` : host);
@@ -261,7 +261,12 @@ function upstreamUrl(proxy) {
261
261
  * proxy, chained to `upstream` when one is given.
262
262
  * @param {{ server: string, username?: string, password?: string }|null} [upstream]
263
263
  * the proxy the context would otherwise have used, in Playwright's shape
264
- * @returns {Promise<{ server: string }>}
264
+ * @returns {Promise<{ server: string, bypass: string }>} `bypass` is Chromium's
265
+ * `<-loopback>` (send loopback through the proxy), stated here rather than
266
+ * left to Playwright: Playwright adds it only while
267
+ * PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK is unset, and with that
268
+ * set every Chromium request to 127.0.0.1 skipped this proxy (measured
269
+ * 2026-10-21). Named in the list, it is applied whatever the env says.
265
270
  */
266
271
  export function guardedProxyFor(upstream = null) {
267
272
  const up = upstreamUrl(upstream);
@@ -274,7 +279,7 @@ export function guardedProxyFor(upstream = null) {
274
279
  server.once('error', reject);
275
280
  server.listen(0, '127.0.0.1', () => {
276
281
  server.unref();
277
- resolve({ server: `http://127.0.0.1:${server.address().port}` });
282
+ resolve({ server: `http://127.0.0.1:${server.address().port}`, bypass: '<-loopback>' });
278
283
  });
279
284
  });
280
285
  started.catch(() => servers.delete(key));
@@ -423,6 +423,7 @@ const crawlDeepShape = {
423
423
  crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
424
424
  cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
425
425
  redaction: redactionShape,
426
+ warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
426
427
  ...resultHandleShape,
427
428
  _cost: costShape,
428
429
  next_step: nextStepShape
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-batch-automation
3
3
  description: "Automates large scraping jobs and browser interactions with CrawlForge's batch_scrape, get_batch_results, scrape_with_actions, and generate_llms_txt tools. Use when the user wants to scrape many URLs at once, batch-scrape a list of pages, collect dozens of product, news, or competitor pages, run browser actions (click, type, scroll, wait) before scraping, log in or fill a form before extracting, or generate an llms.txt for a site. Use sync mode for up to about 25 URLs and async with a webhook for large batches; retrieve paginated output with get_batch_results."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-browser-sessions
3
3
  description: "Keeps one browser page alive across several tool calls with CrawlForge's browser_session tool. Use when the user needs to log in and then read pages behind that login, work through a multi-step flow (search, filter, paginate, fill a wizard), or look at a page before deciding what to click. The loop is: open a session on a URL, snapshot it to list the interactive elements with stable refs (@e1, @e2), act on those refs, read the content, close. Unlike scrape_with_actions, which is one-shot and closes its browser when the call returns, a session survives between calls, so one login is paid for once and a wrong selector costs one call instead of a whole chain."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-change-tracking
3
3
  description: "Monitors web pages for changes over time with CrawlForge's track_changes tool. Use when the user wants to track changes to a page, watch a URL, monitor competitor pricing, detect when content updates, get notified of regulation or product-availability changes, or diff a page against a saved baseline. Workflow: create a baseline with operation create_baseline, then periodically compare with operation compare to get a change percentage and a diff; supports CSS-selector scoping, webhooks, and scheduled monitoring."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-deep-research
3
3
  description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, search_web, and reddit_search tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources, or search Reddit posts, comments, and threads. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results; reddit_search searches Reddit via community archives (reddit.com blocks scrapers). Caps costs via max_urls and confirms before expensive runs."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-getting-started
3
3
  description: "Orientation and tool-selection guide for the CrawlForge MCP server's 31 web tools. Use when the user is getting started with CrawlForge, asks which CrawlForge tool to use, how to set up the API key, how skills or the CLI work, what a tool costs in credits, or when one tool fails and a fallback is needed. Routes requests to the right specialized skill (web scraping, deep research, stealth, structured extraction, change tracking, batch automation, browser sessions), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-stealth-browsing
3
3
  description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-structured-extraction
3
3
  description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-web-scraping
3
3
  description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
4
4
  metadata:
5
- version: 6.23.0
5
+ version: 6.23.2
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -187,15 +187,22 @@ const MAX_TITLE_CHARS = 200;
187
187
  /**
188
188
  * A title for a document with none in its metadata: the first non-empty line
189
189
  * of its text when that line is short enough to be one, else null. arXiv
190
- * stamps and page numbers before it are skipped.
190
+ * stamps, page numbers and lines of a prose paragraph before it are skipped:
191
+ * a line ending a sentence, or one the next line continues in lower case
192
+ * (the arXiv "Attention Is All You Need" PDF opens with a permission notice,
193
+ * live sweep 7.3).
191
194
  * @param {string} text
192
195
  * @returns {string|null}
193
196
  */
194
197
  export function titleFromText(text) {
195
- for (const line of String(text || '').split(/\r?\n/)) {
196
- const candidate = line.replace(/\s+/g, ' ').trim();
197
- if (candidate === '' || NON_TITLE_LINE_RE.test(candidate)) continue;
198
- return candidate.length <= MAX_TITLE_CHARS ? candidate : null;
198
+ const lines = String(text || '')
199
+ .split(/\r?\n/)
200
+ .map((line) => line.replace(/\s+/g, ' ').trim())
201
+ .filter((line) => line !== '' && !NON_TITLE_LINE_RE.test(line));
202
+ for (const [i, candidate] of lines.entries()) {
203
+ if (candidate.length > MAX_TITLE_CHARS) return null;
204
+ const isProse = /[.!?]$/.test(candidate) || /^\p{Ll}/u.test(lines[i + 1] || '');
205
+ if (!isProse) return candidate;
199
206
  }
200
207
  return null;
201
208
  }
@@ -218,18 +218,21 @@ export class GenerateLLMsTxtTool {
218
218
  listed.add(key);
219
219
  return true;
220
220
  };
221
- const emitSection = (heading, urls) => {
222
- // Coerce to an array: sitemap/sections may arrive as a flat array, a
223
- // grouped object ({path: [...]}), or a single value.
221
+ // Coerce to a URL array: sitemap/sections may arrive as a flat array, a
222
+ // grouped object ({path: [...]}), or a single value.
223
+ const toUrlList = (urls) => {
224
224
  let arr = [];
225
225
  if (Array.isArray(urls)) {
226
226
  arr = urls;
227
227
  } else if (urls && typeof urls === 'object') {
228
228
  arr = Object.values(urls).flat();
229
229
  }
230
- const list = arr
230
+ return arr
231
231
  .map((u) => (typeof u === 'string' ? u : (u?.url || u?.loc)))
232
- .filter(Boolean)
232
+ .filter(Boolean);
233
+ };
234
+ const emitSection = (heading, urls) => {
235
+ const list = toUrlList(urls)
233
236
  .filter(firstListing)
234
237
  .slice(0, 25);
235
238
  if (list.length === 0) return;
@@ -262,19 +265,22 @@ export class GenerateLLMsTxtTool {
262
265
  // listed only when no named section matched at all, which silently dropped
263
266
  // the homepage and whole sections (e.g. /specification) from sites that
264
267
  // happen to also have a /docs.
265
- emitSection('Pages', flatten('other'));
268
+ // Only endpoints observed answering JSON/XML are APIs. Links to API
269
+ // documentation are HTML pages ("Python's C API" on docs.python.org was
270
+ // listed under APIs, live sweep 7.4), so they are listed as pages.
271
+ const allApis = (Array.isArray(analysis.apis) ? analysis.apis : []).filter((api) => api?.url);
272
+ const isMachineReadable = (api) => /json|xml/i.test(api.contentType || '');
273
+ const docLinks = allApis.filter((api) => !isMachineReadable(api)).map((api) => api.url);
266
274
 
267
275
  // Fallback: if no categorized section produced output, list the raw
268
- // sitemap so llms.txt always carries a URL inventory. Must run BEFORE the
269
- // APIs section — an APIs entry alone used to set hasBody and suppress it.
270
- const hasBody = lines.some((l) => l.startsWith('## '));
271
- if (!hasBody) {
272
- emitSection('Pages', analysis.structure?.sitemap || []);
273
- }
276
+ // sitemap so llms.txt always carries a URL inventory. Decided BEFORE the
277
+ // linked pages and APIs are added — those alone used to suppress it.
278
+ const pageUrls = flatten('other');
279
+ const hasBody = lines.some((l) => l.startsWith('## ')) || pageUrls.length > 0;
280
+ emitSection('Pages', [...(hasBody ? pageUrls : toUrlList(analysis.structure?.sitemap)), ...docLinks]);
274
281
 
275
282
  // APIs as their own section.
276
- const apis = (Array.isArray(analysis.apis) ? analysis.apis : [])
277
- .filter((api) => api?.url && firstListing(api.url));
283
+ const apis = allApis.filter((api) => isMachineReadable(api) && firstListing(api.url));
278
284
  if (apis.length > 0) {
279
285
  lines.push('## APIs');
280
286
  lines.push('');
@@ -310,6 +316,11 @@ export class GenerateLLMsTxtTool {
310
316
  counts.set(clean, (counts.get(clean) || 0) + 1);
311
317
  };
312
318
 
319
+ // Link text of API documentation links first: a title captured from the
320
+ // page itself replaces it below.
321
+ for (const api of Array.isArray(analysis.apis) ? analysis.apis : []) {
322
+ if (api?.type === 'documentation') add(api.url, api.title);
323
+ }
313
324
  for (const entries of Object.values(analysis.contentTypes || {})) {
314
325
  if (!Array.isArray(entries)) continue;
315
326
  for (const entry of entries) {
@@ -342,7 +342,8 @@ export async function safeFetch(url, options = {}) {
342
342
  return await fetchResigned(url, { ...options, ...guard });
343
343
  } catch (err) {
344
344
  if (isSsrfError(err)) {
345
- throw new Error(err.cause?.message || err.message);
345
+ // Keep the code: callers tell a refusal from a fetch failure by it.
346
+ throw Object.assign(new Error(err.cause?.message || err.message), { code: 'SSRF_BLOCKED' });
346
347
  }
347
348
  throw withFetchCause(err, url);
348
349
  }