crawlforge-mcp-server 5.2.7 → 5.2.9

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 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
64
64
 
65
- **Current Version:** 5.2.7
65
+ **Current Version:** 5.2.9
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.2.7",
3
+ "version": "5.2.9",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 28 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
@@ -100,7 +100,7 @@ const taskStore = createTaskStore({ logger });
100
100
  // Create the server
101
101
  const server = new McpServer({
102
102
  name: "crawlforge",
103
- version: "5.2.7",
103
+ version: "5.2.9",
104
104
  description: "Production-ready MCP server with 28 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured 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.",
105
105
  homepage: "https://www.crawlforge.dev",
106
106
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1254,7 +1254,7 @@ export class ResearchOrchestrator extends EventEmitter {
1254
1254
  return claimGroups
1255
1255
  .filter(group => group.sourceCount >= 2 && group.avgCredibility >= 0.6)
1256
1256
  .map(group => ({
1257
- topic: group.keywords.join(' '),
1257
+ topic: this.claimGroupLabel(group),
1258
1258
  supportingClaims: group.claims.length,
1259
1259
  supportingSources: group.sourceCount,
1260
1260
  averageCredibility: group.avgCredibility,
@@ -1510,27 +1510,37 @@ export class ResearchOrchestrator extends EventEmitter {
1510
1510
  });
1511
1511
  }
1512
1512
 
1513
+ // A claim group rendered for humans is its most credible claim. Claims are
1514
+ // extractive sentences from source content, so this is readable prose —
1515
+ // joining the group's keywords produces stopword-stripped gibberish
1516
+ // ("scraping server model context protocol server that...").
1517
+ mostCredibleClaim(group) {
1518
+ return group.claims.reduce(
1519
+ (best, c) => ((c.credibility || 0) > (best.credibility || 0) ? c : best),
1520
+ group.claims[0]
1521
+ );
1522
+ }
1523
+
1524
+ // Compact label for a claim group: its best claim, cut at a word break.
1525
+ claimGroupLabel(group, maxChars = 120) {
1526
+ const claim = this.mostCredibleClaim(group)?.claim || '';
1527
+ if (claim.length <= maxChars) return claim;
1528
+ const cut = claim.slice(0, maxChars);
1529
+ const lastSpace = cut.lastIndexOf(' ');
1530
+ return (lastSpace > 40 ? cut.slice(0, lastSpace) : cut) + '…';
1531
+ }
1532
+
1513
1533
  generateKeyFindings(claimGroups, sources) {
1514
1534
  return claimGroups
1515
1535
  .filter(group => group.avgCredibility >= this.credibilityThreshold)
1516
1536
  .sort((a, b) => b.consensusStrength - a.consensusStrength)
1517
1537
  .slice(0, 10)
1518
- .map(group => {
1519
- // Findings must be readable prose. Claims are extractive sentences
1520
- // from the source content, so surface the most credible one verbatim
1521
- // — joining the group's keywords here produced stopword-stripped
1522
- // gibberish ("scraping server model context protocol server that...").
1523
- const representative = group.claims.reduce(
1524
- (best, c) => ((c.credibility || 0) > (best.credibility || 0) ? c : best),
1525
- group.claims[0]
1526
- );
1527
- return {
1528
- finding: representative.claim,
1529
- supportingClaims: group.claims.length,
1530
- credibility: group.avgCredibility,
1531
- sources: group.claims.map(c => c.source)
1532
- };
1533
- });
1538
+ .map(group => ({
1539
+ finding: this.mostCredibleClaim(group).claim,
1540
+ supportingClaims: group.claims.length,
1541
+ credibility: group.avgCredibility,
1542
+ sources: group.claims.map(c => c.source)
1543
+ }));
1534
1544
  }
1535
1545
 
1536
1546
  compileSupportingEvidence(sources) {
@@ -1584,10 +1594,11 @@ export class ResearchOrchestrator extends EventEmitter {
1584
1594
  );
1585
1595
 
1586
1596
  weakAreas.forEach(area => {
1597
+ const label = this.claimGroupLabel(area);
1587
1598
  gaps.push({
1588
- area: area.keywords.join(' '),
1599
+ area: label,
1589
1600
  issue: 'Limited reliable sources',
1590
- suggestion: `More research needed on ${area.keywords.join(' ')} related to ${topic}`
1601
+ suggestion: `Corroborate with additional sources: "${label}"`
1591
1602
  });
1592
1603
  });
1593
1604
 
@@ -1609,7 +1620,7 @@ export class ResearchOrchestrator extends EventEmitter {
1609
1620
  recommendations.push({
1610
1621
  type: 'gap_filling',
1611
1622
  priority: 'medium',
1612
- description: `Address research gaps in ${synthesis.gaps.map(g => g.area).join(', ')}`
1623
+ description: `Address ${synthesis.gaps.length} under-sourced claim(s) see researchGaps, e.g. "${synthesis.gaps[0].area}"`
1613
1624
  });
1614
1625
  }
1615
1626
 
@@ -259,7 +259,9 @@ Return a JSON object with:
259
259
  "keyPoints": ["point1", "point2", ...],
260
260
  "topicAlignment": "description of alignment",
261
261
  "credibilityIndicators": ["indicator1", "indicator2", ...]
262
- }`;
262
+ }
263
+
264
+ Be brief: at most 5 items per array, one short sentence each.`;
263
265
 
264
266
  const prompt = `Research Topic: "${topic}"
265
267
 
@@ -269,19 +271,48 @@ ${truncatedContent}
269
271
  Analyze the relevance of this content to the research topic:`;
270
272
 
271
273
  try {
272
- const response = await this.generateCompletion(prompt, {
273
- systemPrompt,
274
- maxTokens: 500,
275
- temperature: 0.3
276
- });
277
-
278
- const analysis = JSON.parse(response);
279
- return {
280
- relevanceScore: Math.max(0, Math.min(1, analysis.relevanceScore || 0.5)),
281
- keyPoints: analysis.keyPoints || [],
282
- topicAlignment: analysis.topicAlignment || '',
283
- credibilityIndicators: analysis.credibilityIndicators || []
274
+ // Same discipline as synthesizeFindings: constrain the output shape
275
+ // (small local models otherwise wrap the JSON in markdown fences —
276
+ // the raw JSON.parse here failed on every Ollama run), strip fences,
277
+ // validate the load-bearing field, and retry a truncated response
278
+ // once before falling back.
279
+ const relevanceSchema = {
280
+ type: 'object',
281
+ properties: {
282
+ relevanceScore: { type: 'number' },
283
+ keyPoints: { type: 'array', items: { type: 'string' } },
284
+ topicAlignment: { type: 'string' },
285
+ credibilityIndicators: { type: 'array', items: { type: 'string' } }
286
+ },
287
+ required: ['relevanceScore']
284
288
  };
289
+
290
+ let lastError;
291
+ for (let attempt = 0; attempt < 2; attempt++) {
292
+ try {
293
+ const response = await this.generateCompletion(prompt, {
294
+ systemPrompt,
295
+ maxTokens: 800,
296
+ temperature: 0.3,
297
+ format: relevanceSchema
298
+ });
299
+
300
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
301
+ const analysis = JSON.parse(cleaned);
302
+ if (!analysis || typeof analysis.relevanceScore !== 'number') {
303
+ throw new Error('Relevance response missing relevanceScore');
304
+ }
305
+ return {
306
+ relevanceScore: Math.max(0, Math.min(1, analysis.relevanceScore)),
307
+ keyPoints: analysis.keyPoints || [],
308
+ topicAlignment: analysis.topicAlignment || '',
309
+ credibilityIndicators: analysis.credibilityIndicators || []
310
+ };
311
+ } catch (error) {
312
+ lastError = error;
313
+ }
314
+ }
315
+ throw lastError;
285
316
  } catch (error) {
286
317
  this.logger.warn('LLM relevance analysis failed, using fallback', { error: error.message });
287
318
  return this.fallbackRelevanceAnalysis(content, topic);
@@ -306,10 +337,18 @@ Generate a JSON response with:
306
337
  "confidence": 0.0-1.0,
307
338
  "gaps": ["gap1", "gap2", ...],
308
339
  "recommendations": ["rec1", "rec2", ...]
309
- }`;
340
+ }
341
+
342
+ Be brief: summary at most 3 sentences; each array at most 5 items, one short sentence each.`;
310
343
 
311
344
  const findingsText = limitedFindings
312
- .map((finding, index) => `${index + 1}. ${finding.finding || finding.text || finding}`)
345
+ .map((finding, index) => {
346
+ // A finding can be a whole flattened page section (sitemap dumps run
347
+ // 1500+ chars). Passing it whole bloats the prompt and pulls a long
348
+ // answer that overruns the token budget, truncating the JSON.
349
+ const text = String(finding.finding || finding.text || finding);
350
+ return `${index + 1}. ${text.length > 300 ? text.slice(0, 300) + '…' : text}`;
351
+ })
313
352
  .join('\n');
314
353
 
315
354
  const prompt = `Research Topic: "${topic}"
@@ -337,20 +376,32 @@ Synthesize these findings into a comprehensive analysis:`;
337
376
  required: ['summary', 'keyInsights', 'themes', 'confidence']
338
377
  };
339
378
 
340
- const response = await this.generateCompletion(prompt, {
341
- systemPrompt,
342
- maxTokens: 800,
343
- temperature: 0.4,
344
- format: synthesisSchema
345
- });
346
-
347
- // Strip markdown code fences if present
348
- const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
349
- const parsed = JSON.parse(cleaned);
350
- if (!parsed || typeof parsed.summary !== 'string' || parsed.summary.length === 0) {
351
- throw new Error('Synthesis response missing summary');
379
+ // Two attempts: small local models occasionally overrun the token
380
+ // budget mid-string, and a truncated response cannot be parsed. 1600
381
+ // tokens gives the brevity-capped answer ~2x headroom (800 truncated
382
+ // roughly two runs in three on real findings).
383
+ let lastError;
384
+ for (let attempt = 0; attempt < 2; attempt++) {
385
+ try {
386
+ const response = await this.generateCompletion(prompt, {
387
+ systemPrompt,
388
+ maxTokens: 1600,
389
+ temperature: 0.4,
390
+ format: synthesisSchema
391
+ });
392
+
393
+ // Strip markdown code fences if present
394
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
395
+ const parsed = JSON.parse(cleaned);
396
+ if (!parsed || typeof parsed.summary !== 'string' || parsed.summary.length === 0) {
397
+ throw new Error('Synthesis response missing summary');
398
+ }
399
+ return parsed;
400
+ } catch (error) {
401
+ lastError = error;
402
+ }
352
403
  }
353
- return parsed;
404
+ throw lastError;
354
405
  } catch (error) {
355
406
  this.logger.warn('LLM synthesis failed, using fallback', { error: error.message });
356
407
  return this.fallbackSynthesis(findings, topic);