crawlforge-mcp-server 6.22.2 → 6.22.3

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.22.2",
3
+ "version": "6.22.3",
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.22.2",
116
+ version: "6.22.3",
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",
@@ -161,13 +161,15 @@ export function unverifiedUrls(answer, citable, sourceText = '') {
161
161
  });
162
162
  }
163
163
 
164
- /** Absolute http(s) hrefs of the links on a parsed page. */
164
+ /** The http(s) links on a parsed page: absolute href and anchor text. */
165
165
  function pageLinks($, baseUrl) {
166
166
  const links = [];
167
167
  $('a[href]').each((_, el) => {
168
168
  try {
169
169
  const url = new URL($(el).attr('href'), baseUrl);
170
- if (url.protocol === 'http:' || url.protocol === 'https:') links.push(url.href);
170
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
171
+ links.push({ url: url.href, text: $(el).text().replace(/\s+/g, ' ').trim() });
172
+ }
171
173
  } catch {
172
174
  // An unparsable href cites nothing.
173
175
  }
@@ -175,6 +177,38 @@ function pageLinks($, baseUrl) {
175
177
  return links;
176
178
  }
177
179
 
180
+ /** Most links one source lists under its text in the synthesis prompt. */
181
+ const SOURCE_LINKS_MAX = 8;
182
+
183
+ /**
184
+ * The links a source lists under its text in the synthesis prompt. Page text
185
+ * carries no link targets: the model could name the current HN top story but
186
+ * not link it, and cited an item URL from memory (R25 re-check, 2026-10-19).
187
+ * Listed: links whose anchor text is printed in the excerpt the model reads
188
+ * and is long enough to name something (three words: a title, not "97
189
+ * comments" or "next"), links to the page itself left out. Those whose anchor
190
+ * text carries one of the task's terms come first, then page order.
191
+ *
192
+ * @param {{url: string, text: string}[]} links
193
+ * @param {string} excerpt the source text the prompt carries
194
+ * @param {string} pageUrl
195
+ * @param {string[]} terms the task's terms, lowercase
196
+ * @returns {{url: string, text: string}[]}
197
+ */
198
+ export function linksForSynthesis(links, excerpt, pageUrl, terms) {
199
+ const self = citationKey(pageUrl);
200
+ const seen = new Set();
201
+ const listed = (links || []).filter(({ url, text }) => {
202
+ const key = citationKey(url);
203
+ if (!key || key === self || seen.has(key)) return false;
204
+ if (text.split(' ').length < 3 || !excerpt.includes(text)) return false;
205
+ seen.add(key);
206
+ return true;
207
+ });
208
+ const onTask = link => terms.some(t => link.text.toLowerCase().includes(t));
209
+ return [...listed.filter(onTask), ...listed.filter(l => !onTask(l))].slice(0, SOURCE_LINKS_MAX);
210
+ }
211
+
178
212
  /**
179
213
  * Current-state task gate: prompts about the live "now" ("right now",
180
214
  * "current", "currently", "today", "latest", "#1 … now") go stale through search alone —
@@ -665,6 +699,8 @@ export class AgentOrchestrator {
665
699
  // URLs an answer may cite: every evidence page's URL (requested and
666
700
  // final) and the links on it.
667
701
  const citable = [];
702
+ /** evidence url -> the links on that page, for the synthesis prompt. */
703
+ const linksOf = new Map();
668
704
  let urlsFetched = 0;
669
705
  let step = 0;
670
706
  const warnings = [];
@@ -700,7 +736,9 @@ export class AgentOrchestrator {
700
736
  text: truncate(plain.textContent),
701
737
  step
702
738
  });
703
- citable.push(url, plain.finalUrl, ...pageLinks(plain.$, plain.finalUrl));
739
+ const links = pageLinks(plain.$, plain.finalUrl);
740
+ linksOf.set(plain.finalUrl, links);
741
+ citable.push(url, plain.finalUrl, ...links.map(l => l.url));
704
742
  continue;
705
743
  }
706
744
  // A real page that is simply off-topic: nothing to retry.
@@ -745,7 +783,9 @@ export class AgentOrchestrator {
745
783
  via: 'stealth',
746
784
  step
747
785
  });
748
- citable.push(url, stealth.url || url, ...pageLinks(load(stealth.html || ''), stealth.url || url));
786
+ const links = pageLinks(load(stealth.html || ''), stealth.url || url);
787
+ linksOf.set(stealth.url || url, links);
788
+ citable.push(url, stealth.url || url, ...links.map(l => l.url));
749
789
  continue;
750
790
  }
751
791
  warnings.push(`stealth retry of ${url} ${rendered.success ? 'returned a page unrelated to the task' : `did not get the page either${rendered.blocked?.vendor ? ` (${rendered.blocked.vendor})` : ''}`}`);
@@ -782,7 +822,12 @@ export class AgentOrchestrator {
782
822
  const sliceTerms = promptTerms.map(t => t.replace(/[^\p{L}\p{N}]/gu, ''))
783
823
  .filter(t => t.length > 3 && !/^(what|which|when|where|does|that|this|with|from|have|about)$/.test(t));
784
824
  const combinedText = orderedEvidence
785
- .map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${sliceForSynthesis(e.text, perSourceCap, sliceTerms)}`)
825
+ .map(e => {
826
+ const excerpt = sliceForSynthesis(e.text, perSourceCap, sliceTerms);
827
+ const links = linksForSynthesis(linksOf.get(e.url), excerpt, e.url, sliceTerms);
828
+ return `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${excerpt}` +
829
+ (links.length > 0 ? `\nLinks on this page:\n${links.map(l => `- ${l.text}: ${l.url}`).join('\n')}` : '');
830
+ })
786
831
  .join('\n\n');
787
832
 
788
833
  if (!combinedText.trim()) {
@@ -880,7 +925,7 @@ export class AgentOrchestrator {
880
925
  : '') +
881
926
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
882
927
  `- Read the sources carefully before concluding anything is missing from them.\n` +
883
- `- Cite the exact source URL(s) you used.\n` +
928
+ `- Cite the exact URL(s) you used: a source URL, or the URL an item has under "Links on this page".\n` +
884
929
  `- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
885
930
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
886
931
  `Provide a clear, concise answer.`;
@@ -971,14 +1016,22 @@ export class AgentOrchestrator {
971
1016
  // it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
972
1017
  // Main Page, and the HN question from thehackernews.com, both with
973
1018
  // degraded:false. The answer counts as the live page's when it cites
974
- // that host or states a value (version, date, number) the page shows.
1019
+ // that host, states a value (version, date, number) the page shows, or
1020
+ // names an item the page links (anchor text of three words or more:
1021
+ // the HN title "DeepSeek just open-sourced its inference engine",
1022
+ // 2026-10-20). An invented URL on that host is no citation of it: it is
1023
+ // taken out before the host is looked for.
975
1024
  if (currentState && promotedOrigin && !degraded) {
976
1025
  const host = u => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return null; } };
977
1026
  const liveHost = host(promotedOrigin);
978
- const liveText = evidence.filter(e => host(e.url) === liveHost).map(e => e.text || '').join('\n');
1027
+ const livePages = evidence.filter(e => host(e.url) === liveHost);
1028
+ const liveText = livePages.map(e => e.text || '').join('\n');
979
1029
  // unverifiedValues against an empty text lists every value the answer states.
980
1030
  const stated = unverifiedValues(answer, '');
981
- const fromLivePage = answer.toLowerCase().includes(liveHost) ||
1031
+ const cited = unverifiedLinks.reduce((text, url) => text.split(url).join(' '), answer).toLowerCase();
1032
+ const namesLiveItem = livePages.some(e => (linksOf.get(e.url) || [])
1033
+ .some(l => l.text.split(' ').length >= 3 && cited.includes(l.text.toLowerCase())));
1034
+ const fromLivePage = cited.includes(liveHost) || namesLiveItem ||
982
1035
  (liveText !== '' && unverifiedValues(answer, liveText).length < stated.length);
983
1036
  if (!fromLivePage) {
984
1037
  degraded = true;
@@ -120,6 +120,25 @@ export function claimStatements(point) {
120
120
  .filter(Boolean);
121
121
  }
122
122
 
123
+ /**
124
+ * The first `max` characters of a page's text, ended at the last sentence or
125
+ * line end inside them. A plain cut leaves the last sentence half written, and
126
+ * the summarizer took it as a key point: "…the same hostname can range from no
127
+ * bot pro" was a finding in the R25 re-check (2026-10-19). Cut as before when
128
+ * no end falls in the second half.
129
+ *
130
+ * @param {string} text
131
+ * @param {number} max
132
+ * @returns {string}
133
+ */
134
+ export function cutAtSentence(text, max) {
135
+ if (text.length <= max) return text;
136
+ const cut = text.slice(0, max);
137
+ const ends = [...cut.matchAll(/[.!?]["'\u201D\u2019)]*(?=\s)|\n/g)];
138
+ const end = ends.length > 0 ? ends[ends.length - 1].index + ends[ends.length - 1][0].length : 0;
139
+ return end > max / 2 ? cut.slice(0, end).trimEnd() : cut;
140
+ }
141
+
123
142
  /**
124
143
  * Page text for the plain-fetch fallback, one line per block element. The
125
144
  * regex tag-strip it replaces ran every block together on one line.
@@ -896,7 +915,7 @@ export class ResearchOrchestrator extends EventEmitter {
896
915
  const textContent = fallbackPageText(html);
897
916
  if (textContent.length > 50) {
898
917
  contentData = {
899
- content: textContent.slice(0, 5000),
918
+ content: cutAtSentence(textContent, 5000),
900
919
  metadata: { title: source.title || '' },
901
920
  structuredData: {},
902
921
  fallback: true
@@ -1425,7 +1444,7 @@ export class ResearchOrchestrator extends EventEmitter {
1425
1444
  try {
1426
1445
  if (!source.extractedContent) continue;
1427
1446
 
1428
- const content = source.extractedContent.substring(0, 5000); // Limit content length
1447
+ const content = cutAtSentence(source.extractedContent, 5000); // Limit content length
1429
1448
 
1430
1449
  // Use summarization to extract key points
1431
1450
  const summary = await this.summarizeTool.execute({
@@ -40,6 +40,21 @@ const FRONT_MATTER_PATTERNS = [
40
40
  /\bperformance (?:&|and) security by\b/i
41
41
  ];
42
42
 
43
+ // Lines that are not statements about the topic, all three key findings in the
44
+ // R25 re-check (2026-10-19, "What anti-bot systems do major websites use in
45
+ // 2026"): a question, which is an FAQ heading ("What makes a good anti-bot
46
+ // solution?"); an opening hook addressed to the reader ("If you've ever
47
+ // rage-clicked on a … puzzle, you already understand the problem with
48
+ // CAPTCHAs."); and a scoring rubric's weight ("Detection technology
49
+ // (signatures, ML, rules, session/event models): 25%"). Its live re-run added
50
+ // "What you are experiencing is the cat-and-mouse game…" and the signpost
51
+ // "Here's how Cloudflare, Akamai, Kasada, and PerimeterX each weigh…".
52
+ const NON_STATEMENT_PATTERNS = [
53
+ /\?["'”’)]*\s*$/,
54
+ /^(?:if you(?:['’]ve| have) ever|have you ever|ever (?:wondered|tried|noticed)|imagine|picture this|let['’]s|what you(?:['’]re| are)|here['’]s (?:how|what|why|where))\b/i,
55
+ /^[^.!?]{2,150}:\s*\d{1,3}(?:\.\d+)?\s*%\.?$/
56
+ ];
57
+
43
58
  // Tokens that mark an affiliation line rather than a sentence about the topic.
44
59
  const AFFILIATION_TOKENS = new Set([
45
60
  'university', 'universities', 'institute', 'institut', 'department', 'dept',
@@ -159,10 +174,14 @@ export function properNounDensity(text) {
159
174
  // author or affiliation block rather than a sentence about the topic.
160
175
  const PROPER_NOUN_DENSITY_MAX = 0.5;
161
176
 
162
- /** A candidate claim is admissible when it is a verb-bearing, non-front-matter sentence. */
177
+ /**
178
+ * A candidate claim is admissible when it is a verb-bearing, non-front-matter
179
+ * statement: not a question, a hook addressed to the reader, or a rubric weight.
180
+ */
163
181
  export function isAdmissibleClaim(text) {
164
182
  if (typeof text !== 'string' || text.trim().length === 0) return false;
165
183
  if (isFrontMatter(text)) return false;
184
+ if (NON_STATEMENT_PATTERNS.some(pattern => pattern.test(text.trim()))) return false;
166
185
  if (!hasFiniteVerb(text)) return false;
167
186
  return properNounDensity(text) <= PROPER_NOUN_DENSITY_MAX;
168
187
  }
@@ -84,8 +84,10 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
84
84
  scrape_template: { textPaths: [], truncate: true },
85
85
  map_site: { textPaths: [], truncate: true, keep: ['site_map', 'statistics'] },
86
86
  // A prose answer is the text view; a structured or missing one leaves the
87
- // JSON view, and the sources stay inline when they fit.
88
- agent: { textPaths: ['answer'], truncate: true, keep: ['evidence', 'search_results', 'provenance'] },
87
+ // JSON view, and the sources stay inline when they fit. `reason` too: a
88
+ // degraded run's reason runs past the 200 characters kept unasked, and a
89
+ // degraded:true with no reason says nothing (R25 follow-up, 2026-10-20).
90
+ agent: { textPaths: ['answer'], truncate: true, keep: ['reason', 'evidence', 'search_results', 'provenance'] },
89
91
  // llms-full.txt is the large file and the view; llms.txt stays inline under
90
92
  // `files` when it fits. Neither key can be named in a json_path (the "."),
91
93
  // so the view is the way to read the long one.
@@ -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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
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.22.2
5
+ version: 6.22.3
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8