crawlforge-mcp-server 5.3.0 → 5.3.1
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/README.md +2 -0
- package/package.json +2 -1
- package/server.js +1 -1
- package/src/core/ResearchOrchestrator.js +16 -22
- package/src/core/llm/LLMManager.js +26 -3
- package/src/core/llm/OllamaProvider.js +14 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +5 -0
- package/src/utils/ollamaConfig.js +36 -2
package/README.md
CHANGED
|
@@ -238,6 +238,8 @@ export CRAWLFORGE_API_URL="https://api.crawlforge.dev"
|
|
|
238
238
|
# and deep_research all use Ollama when no cloud key is set
|
|
239
239
|
export OLLAMA_BASE_URL="http://localhost:11434" # default; set https://ollama.com for Ollama Cloud
|
|
240
240
|
export OLLAMA_DEFAULT_MODEL="gemma3:4b" # optional; unset = pick the best installed model automatically
|
|
241
|
+
# deep_research judges claims with gemma3:12b when it is installed (ollama pull gemma3:12b);
|
|
242
|
+
# conflict detection is on only with that model, or a cloud provider
|
|
241
243
|
export OLLAMA_EMBEDDING_MODEL="nomic-embed-text" # default: OLLAMA_DEFAULT_MODEL; used for semantic ranking in deep_research
|
|
242
244
|
export OLLAMA_API_KEY="..." # only for authenticated endpoints (required by Ollama Cloud; a local instance needs none)
|
|
243
245
|
export DISABLE_OLLAMA="true" # skip Ollama entirely and use CSS/keyword fallbacks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.3.
|
|
3
|
+
"version": "5.3.1",
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"test:real-world": "node test-real-world.js",
|
|
24
24
|
"test:all": "bash run-all-tests.sh",
|
|
25
25
|
"skills:gen": "node scripts/generate-skill-md.mjs",
|
|
26
|
+
"sweep": "node scripts/tool-sweep.mjs",
|
|
26
27
|
"postinstall": "echo '\nCrawlForge MCP Server installed!\n\nQuick start: run \"npx crawlforge init\" to configure your API key, install skills, and register the MCP server with your AI clients.\nOr run \"npx crawlforge-setup\" to configure your API key only.\n'",
|
|
27
28
|
"docker:build": "docker build -t crawlforge .",
|
|
28
29
|
"docker:dev": "docker-compose up crawlforge-dev",
|
package/server.js
CHANGED
|
@@ -104,7 +104,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
104
104
|
// Create the server
|
|
105
105
|
const server = new McpServer({
|
|
106
106
|
name: "crawlforge",
|
|
107
|
-
version: "5.3.
|
|
107
|
+
version: "5.3.1",
|
|
108
108
|
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.",
|
|
109
109
|
homepage: "https://www.crawlforge.dev",
|
|
110
110
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -49,26 +49,17 @@ const MAX_FINDING_SHARE_PER_SOURCE = 0.4;
|
|
|
49
49
|
// many findings have to be diverse before depth matters.
|
|
50
50
|
const SUMMARY_SLICE = 5;
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// pairs
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// Zero conflicts is the honest answer: a research tool that invents
|
|
64
|
-
// contradictions between sources that agree is worse than one that reports
|
|
65
|
-
// none. Semantic grouping (which this replaced the lexical key with) DID make
|
|
66
|
-
// detection structurally reachable — that half of the question is answered —
|
|
67
|
-
// and consensus, which needed the same grouping, now works. The judgement
|
|
68
|
-
// itself lives in LLMManager.findContradictions, is unit-tested, and becomes
|
|
69
|
-
// useful the moment a model that can do natural-language inference is wired
|
|
70
|
-
// in; a purpose-built NLI cross-encoder is the documented next step.
|
|
71
|
-
const ENABLE_LLM_CONFLICT_DETECTION = false;
|
|
52
|
+
// Conflict detection runs only when the judging model is one measured not to
|
|
53
|
+
// invent disagreement. The default 4B local model, measured 2026-08-28 against
|
|
54
|
+
// a live run's own claims, named 29, 13 and 28 non-contradictions at batch
|
|
55
|
+
// sizes 30, 8 and 1; the consistency-veto control cut that to 7 but then
|
|
56
|
+
// missed "X does not use Y" against "X uses Y" outright. Replaying the same
|
|
57
|
+
// claims through gemma3:12b (three runs): 0 false contradictions on 27 real
|
|
58
|
+
// pairs and every planted one caught. So the gate is the model, not a flag:
|
|
59
|
+
// LLMManager.canJudgeContradictions() answers from JUDGEMENT_MODELS, and a
|
|
60
|
+
// machine without such a model reports zero conflicts — the honest answer,
|
|
61
|
+
// since a research tool that invents contradictions between sources that
|
|
62
|
+
// agree is worse than one that reports none.
|
|
72
63
|
|
|
73
64
|
// Contradiction checking is quadratic in a group's size, and every candidate
|
|
74
65
|
// pair costs prompt tokens in the one batched call. Compare a group's most
|
|
@@ -1441,8 +1432,8 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1441
1432
|
* sentence-shape repair for that, so there is no fallback path here.
|
|
1442
1433
|
*/
|
|
1443
1434
|
async detectInformationConflicts(claimGroups, topic) {
|
|
1444
|
-
if (!ENABLE_LLM_CONFLICT_DETECTION) return [];
|
|
1445
1435
|
if (!this.enableLLMFeatures) return [];
|
|
1436
|
+
if (!(await this.llmManager.canJudgeContradictions())) return [];
|
|
1446
1437
|
|
|
1447
1438
|
const pairs = [];
|
|
1448
1439
|
for (const group of claimGroups) {
|
|
@@ -1469,7 +1460,10 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
1469
1460
|
try {
|
|
1470
1461
|
contradicting = await this.llmManager.findContradictions(
|
|
1471
1462
|
candidates.map(({ a, b }) => ({ a: a.claim, b: b.claim })),
|
|
1472
|
-
topic
|
|
1463
|
+
topic,
|
|
1464
|
+
// The judge's own default examined 30; every candidate formed here is
|
|
1465
|
+
// meant to be judged, so the caps agree.
|
|
1466
|
+
{ maxPairs: MAX_CONFLICT_PAIRS }
|
|
1473
1467
|
);
|
|
1474
1468
|
this.metrics.llmAnalysisCalls++;
|
|
1475
1469
|
} catch (error) {
|
|
@@ -2,6 +2,7 @@ import { OpenAIProvider } from './OpenAIProvider.js';
|
|
|
2
2
|
import { AnthropicProvider } from './AnthropicProvider.js';
|
|
3
3
|
import { OllamaProvider } from './OllamaProvider.js';
|
|
4
4
|
import { Logger } from '../../utils/Logger.js';
|
|
5
|
+
import { isJudgementModel } from '../../utils/ollamaConfig.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* LLM Manager
|
|
@@ -136,6 +137,25 @@ export class LLMManager {
|
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Whether conflict detection may run: only a model measured not to invent
|
|
142
|
+
* contradictions between sources that agree is asked (JUDGEMENT_MODELS in
|
|
143
|
+
* ollamaConfig.js). A cloud provider is assumed capable — the measurement
|
|
144
|
+
* that gated this off was of a 4B local model, and cloud models were not
|
|
145
|
+
* measured; that assumption is deliberate. A pinned OLLAMA_DEFAULT_MODEL is
|
|
146
|
+
* judged by the same list, so pinning the extraction winner keeps the gate
|
|
147
|
+
* closed rather than routing around the measurement.
|
|
148
|
+
*/
|
|
149
|
+
async canJudgeContradictions() {
|
|
150
|
+
if (!this.defaultProvider) return false;
|
|
151
|
+
if (this.defaultProvider !== 'ollama') return true;
|
|
152
|
+
try {
|
|
153
|
+
return isJudgementModel(await this.getProvider('ollama').resolveModel('judgement'));
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/**
|
|
140
160
|
* Generate embeddings with fallback support
|
|
141
161
|
*/
|
|
@@ -503,7 +523,8 @@ Rate these ${batch.length} sentences:`;
|
|
|
503
523
|
systemPrompt,
|
|
504
524
|
maxTokens: 100 + batch.length * 20,
|
|
505
525
|
temperature: 0.1,
|
|
506
|
-
format: scoreSchema
|
|
526
|
+
format: scoreSchema,
|
|
527
|
+
role: 'judgement'
|
|
507
528
|
});
|
|
508
529
|
|
|
509
530
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -633,7 +654,8 @@ Group these ${batch.length} sentences:`;
|
|
|
633
654
|
systemPrompt,
|
|
634
655
|
maxTokens: 200 + batch.length * 10,
|
|
635
656
|
temperature: 0.1,
|
|
636
|
-
format: groupSchema
|
|
657
|
+
format: groupSchema,
|
|
658
|
+
role: 'judgement'
|
|
637
659
|
});
|
|
638
660
|
|
|
639
661
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -805,7 +827,8 @@ ${question.replace('${n}', String(batch.length))}`;
|
|
|
805
827
|
systemPrompt,
|
|
806
828
|
maxTokens: 100 + batch.length * 6,
|
|
807
829
|
temperature: 0.1,
|
|
808
|
-
format: schema
|
|
830
|
+
format: schema,
|
|
831
|
+
role: 'judgement'
|
|
809
832
|
});
|
|
810
833
|
|
|
811
834
|
const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
|
|
@@ -14,28 +14,37 @@ export class OllamaProvider extends LLMProvider {
|
|
|
14
14
|
super(options);
|
|
15
15
|
|
|
16
16
|
// Resolved lazily: choosing the best installed model needs an HTTP call.
|
|
17
|
+
// An explicit model applies to every role; otherwise each role resolves
|
|
18
|
+
// (and caches) its own choice.
|
|
17
19
|
this.model = options.model || null;
|
|
20
|
+
this.modelByRole = new Map();
|
|
18
21
|
this.embeddingModel = options.embeddingModel || process.env.OLLAMA_EMBEDDING_MODEL || null;
|
|
19
22
|
this.timeout = options.timeout || 120000;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The model to use, selecting the best installed one for the role on first
|
|
27
|
+
* use. Extraction and judgement have different winners — see JUDGEMENT_MODELS.
|
|
28
|
+
* @param {'default'|'judgement'} [role]
|
|
29
|
+
*/
|
|
30
|
+
async resolveModel(role = 'default') {
|
|
31
|
+
if (this.model) return this.model;
|
|
32
|
+
if (!this.modelByRole.has(role)) this.modelByRole.set(role, await selectOllamaModel(role));
|
|
33
|
+
return this.modelByRole.get(role);
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
async generateCompletion(prompt, options = {}) {
|
|
29
|
-
const model = await this.resolveModel();
|
|
30
37
|
const {
|
|
31
38
|
maxTokens = 1000,
|
|
32
39
|
temperature = 0.7,
|
|
33
40
|
systemPrompt = null,
|
|
41
|
+
role = 'default',
|
|
34
42
|
// 'json' constrains the model to emit a parseable object, or pass a JSON
|
|
35
43
|
// Schema to constrain the shape as well. Small local models otherwise
|
|
36
44
|
// wrap JSON in prose and the caller's JSON.parse fails.
|
|
37
45
|
format = null
|
|
38
46
|
} = options;
|
|
47
|
+
const model = await this.resolveModel(role);
|
|
39
48
|
|
|
40
49
|
const messages = [];
|
|
41
50
|
if (systemPrompt) {
|
|
@@ -127,5 +127,10 @@ With no LLM configured, `deep_research` returns structured **raw evidence** for
|
|
|
127
127
|
the calling assistant (e.g. Claude Code) to synthesize — this is expected, do
|
|
128
128
|
not suggest adding API keys.
|
|
129
129
|
|
|
130
|
+
On local Ollama, claim judgement (relevance, grouping, contradiction) uses
|
|
131
|
+
`gemma3:12b` when it is installed and the extraction model otherwise; conflicts
|
|
132
|
+
are reported only with that model or a cloud provider, so `conflictsFound: 0`
|
|
133
|
+
on a machine without it is expected, not a failure.
|
|
134
|
+
|
|
130
135
|
See [research workflows](references/workflows.md) for pipelines, depth tiers,
|
|
131
136
|
and parameter detail.
|
|
@@ -55,6 +55,32 @@ const PREFERRED_MODELS = [
|
|
|
55
55
|
'qwen2.5:3b'
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Models measured fit to JUDGE claims — relevance to a topic, same-meaning
|
|
60
|
+
* grouping, and contradiction — as opposed to extracting fields. Measured
|
|
61
|
+
* 2026-08-28 by replaying a live deep_research run's own 136 claims through
|
|
62
|
+
* each installed model, three runs each:
|
|
63
|
+
*
|
|
64
|
+
* gemma3:12b 0 false contradictions on 27 real pairs, 3/3 planted caught,
|
|
65
|
+
* 7-9 cross-source groups (the 4B model: 1-2 false, 0-1/3
|
|
66
|
+
* caught, 1 group)
|
|
67
|
+
* gemma3:4b the extraction winner, but it scored "Playwright vs Selenium"
|
|
68
|
+
* marketing 0.9 relevant to an anti-bot topic and put it in the
|
|
69
|
+
* research summary
|
|
70
|
+
* gemma4:31b judged as cleanly as gemma3:12b but only with thinking turned
|
|
71
|
+
* off — under the default it spends the whole token budget on
|
|
72
|
+
* hidden reasoning and returns empty content — and it grouped so
|
|
73
|
+
* strictly that consensus vanished. Not ranked.
|
|
74
|
+
* gpt-oss:20b empty content at these token budgets for the same reason,
|
|
75
|
+
* and `think: false` makes it emit nothing at all. Not ranked.
|
|
76
|
+
*
|
|
77
|
+
* Membership here is what turns conflict detection on: a model that invents
|
|
78
|
+
* disagreement between sources that agree is worse than one that reports none,
|
|
79
|
+
* so a model absent from this list is never asked. When none is installed the
|
|
80
|
+
* judgement role falls through to the extraction ranking above.
|
|
81
|
+
*/
|
|
82
|
+
export const JUDGEMENT_MODELS = ['gemma3:12b'];
|
|
83
|
+
|
|
58
84
|
/** Used only when Ollama cannot be reached, so the error names a real model. */
|
|
59
85
|
export const FALLBACK_OLLAMA_MODEL = 'llama3.2';
|
|
60
86
|
|
|
@@ -102,9 +128,11 @@ export async function installedOllamaModels() {
|
|
|
102
128
|
* instead would break anyone who has not pulled it, so the best *installed*
|
|
103
129
|
* model is chosen, and an explicit OLLAMA_DEFAULT_MODEL always wins.
|
|
104
130
|
*
|
|
131
|
+
* @param {'default'|'judgement'} [role] 'judgement' tries JUDGEMENT_MODELS
|
|
132
|
+
* first and falls through to the extraction ranking when none is installed.
|
|
105
133
|
* @returns {Promise<string>}
|
|
106
134
|
*/
|
|
107
|
-
export async function selectOllamaModel() {
|
|
135
|
+
export async function selectOllamaModel(role = 'default') {
|
|
108
136
|
const explicit = process.env.OLLAMA_DEFAULT_MODEL;
|
|
109
137
|
if (explicit) return explicit;
|
|
110
138
|
|
|
@@ -112,10 +140,16 @@ export async function selectOllamaModel() {
|
|
|
112
140
|
if (installed.length === 0) return FALLBACK_OLLAMA_MODEL;
|
|
113
141
|
|
|
114
142
|
const byBase = new Map(installed.map((name) => [baseName(name), name]));
|
|
115
|
-
|
|
143
|
+
const ranking = role === 'judgement' ? [...JUDGEMENT_MODELS, ...PREFERRED_MODELS] : PREFERRED_MODELS;
|
|
144
|
+
for (const preferred of ranking) {
|
|
116
145
|
const match = byBase.get(baseName(preferred));
|
|
117
146
|
if (match) return match;
|
|
118
147
|
}
|
|
119
148
|
// Nothing recognised — use whatever is there rather than failing.
|
|
120
149
|
return installed[0];
|
|
121
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Whether a model name is one measured fit to judge contradictions. */
|
|
153
|
+
export function isJudgementModel(name) {
|
|
154
|
+
return typeof name === 'string' && JUDGEMENT_MODELS.some((m) => baseName(m) === baseName(name));
|
|
155
|
+
}
|