crawlforge-mcp-server 5.0.3 → 5.0.4

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/CLAUDE.md CHANGED
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
62
62
 
63
63
  CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 27 web scraping, crawling, and content processing tools (5 inline + 22 advanced).
64
64
 
65
- **Current Version:** 5.0.3
65
+ **Current Version:** 5.0.4
66
66
 
67
67
  ## Development Commands
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 27 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",
@@ -303,7 +303,11 @@ export function validateConfig() {
303
303
  export function getToolConfig(toolName) {
304
304
  const toolConfigs = {
305
305
  search_web: {
306
- apiKey: config.crawlforge.apiKey,
306
+ // Read env at call time, not import time: the CLI resolves the key
307
+ // (--api-key flag / env / ~/.crawlforge/config.json) in a preAction
308
+ // hook that runs AFTER this module was imported, so the frozen
309
+ // config.crawlforge.apiKey snapshot misses stored keys.
310
+ apiKey: process.env.CRAWLFORGE_API_KEY || config.crawlforge.apiKey,
307
311
  apiBaseUrl: config.crawlforge.apiBaseUrl,
308
312
 
309
313
  // Common configuration
@@ -200,9 +200,13 @@ export class AgentOrchestrator {
200
200
  // "news.ycombinator.com") are the most authoritative sources for the task —
201
201
  // queue them ahead of search results.
202
202
  const namedSites = prompt.match(/https?:\/\/[^\s"'<>]+|(?<![\w.@/-])[a-z0-9][\w-]*(?:\.[a-z0-9][\w-]*)*\.[a-z]{2,}(?![\w-])/gi) || [];
203
+ // Seeds and prompt-named sites also outrank search results at SHAPE time
204
+ // (see priorityUrls below) — generic term overlap must not bury them.
205
+ const priorityUrls = [...seedUrls];
203
206
  for (const site of namedSites) {
204
207
  const url = (/^https?:\/\//i.test(site) ? site : `https://${site}`).replace(/[.,;:!?)]+$/, '');
205
208
  if (!urlQueue.includes(url)) urlQueue.push(url);
209
+ if (!priorityUrls.includes(url)) priorityUrls.push(url);
206
210
  }
207
211
  const searchResults = [];
208
212
 
@@ -261,10 +265,16 @@ export class AgentOrchestrator {
261
265
  // url+title+text) instead of raw queue order, then give every source a
262
266
  // per-source slice of the synthesis budget so no source is silently cut off.
263
267
  const promptTerms = prompt.toLowerCase().split(/\s+/).filter(t => t.length > 3);
268
+ // Seeds/prompt-named sites get a fixed boost above any term-overlap score:
269
+ // a generic article mentioning the task's words ("title", "story") must
270
+ // never outrank the source the user explicitly pointed at (live retest
271
+ // 2026-08-20: an NFL article buried the named news.ycombinator.com page).
272
+ const isPriority = url => priorityUrls.some(p => url === p || url.startsWith(`${p}/`) || `${url}/` === p);
264
273
  const orderedEvidence = evidence
265
274
  .map(e => ({
266
275
  ...e,
267
- _score: promptTerms.filter(t => `${e.url} ${e.title || ''} ${e.text}`.toLowerCase().includes(t)).length
276
+ _score: (isPriority(e.url) ? 1000 : 0) +
277
+ promptTerms.filter(t => `${e.url} ${e.title || ''} ${e.text}`.toLowerCase().includes(t)).length
268
278
  }))
269
279
  .sort((a, b) => b._score - a._score);
270
280
  const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
@@ -317,14 +327,19 @@ export class AgentOrchestrator {
317
327
  let degradedReason;
318
328
 
319
329
  try {
330
+ // Wording matters for small local models (llama3.2-class): without the
331
+ // "already fetched / do not refuse" framing they answer "I cannot access
332
+ // the internet" or "the sources do not contain it" even when the answer
333
+ // sits in the first source (live retest 2026-08-20).
320
334
  const synthesisPrompt =
321
- `You are a research assistant. Answer this task using ONLY the sources below:\n\n` +
335
+ `You are a data-extraction assistant. The web sources below have ALREADY been fetched for you and their text is included — no internet access is needed; do not refuse for lack of browsing ability. They are ordered most-relevant first. Answer the task using ONLY this source text:\n\n` +
322
336
  `Task: ${prompt}\n\n` +
323
337
  `${combinedText}\n\n` +
324
338
  `Rules:\n` +
325
339
  `- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
340
+ `- Read the sources carefully before concluding anything is missing from them.\n` +
326
341
  `- Cite the exact source URL(s) you used.\n` +
327
- `- If the sources do not contain the answer, say so explicitly.\n` +
342
+ `- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
328
343
  `- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
329
344
  `Provide a clear, concise answer.`;
330
345
 
@@ -96,6 +96,37 @@ function classifyContentType(contentType) {
96
96
  return 'binary';
97
97
  }
98
98
 
99
+ /**
100
+ * Flatten a parsed document's <body> to text while preserving line structure.
101
+ *
102
+ * Cheerio's .text() joins adjacent elements with no separator, which welds
103
+ * table rows / list items together ("1.Story title329 points") and starves
104
+ * downstream LLM extraction (AgentOrchestrator, extractStructured,
105
+ * extractWithLlm) of any structure to parse. Works on a detached clone so the
106
+ * caller's $ tree is untouched — unifiedScrape reuses $ for other formats.
107
+ *
108
+ * @param {import('cheerio').CheerioAPI} $
109
+ * @returns {string}
110
+ */
111
+ export function flattenBodyText($) {
112
+ // Block boundaries are marked with a U+E000 private-use sentinel so the
113
+ // HTML source's own insignificant newlines can be collapsed to spaces
114
+ // first, and only the sentinels become line breaks. (NUL won't survive:
115
+ // .after()/.replaceWith() parse their argument as HTML and the parser
116
+ // strips NUL; U+E000 passes through and never occurs in real page text.)
117
+ const $body = $('body').clone();
118
+ $body.find('br').replaceWith('\uE000');
119
+ $body.find('td, th').after(' ');
120
+ $body
121
+ .find('p, div, li, tr, h1, h2, h3, h4, h5, h6, blockquote, pre, table, ul, ol, dl, section, article, header, footer')
122
+ .after('\uE000');
123
+ return $body
124
+ .text()
125
+ .replace(/\s+/g, ' ')
126
+ .replace(/ ?(?:\uE000 ?)+/g, '\n')
127
+ .trim();
128
+ }
129
+
99
130
  /**
100
131
  * Fetch a URL and return parsed HTML via Cheerio.
101
132
  *
@@ -149,7 +180,7 @@ export async function fetchAndParse(url, options = {}) {
149
180
  $(stripTags.join(', ')).remove();
150
181
  }
151
182
 
152
- const textContent = $('body').text().replace(/\s+/g, ' ').trim();
183
+ const textContent = flattenBodyText($);
153
184
 
154
185
  return { html, $, textContent, finalUrl: response.url };
155
186
  }