crawlforge-mcp-server 5.0.3 → 5.0.5
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 +2 -2
- package/package.json +3 -2
- package/server.js +2 -2
- package/src/constants/config.js +5 -1
- package/src/core/AgentOrchestrator.js +18 -3
- package/src/core/AuthManager.js +1 -1
- package/src/server/transports/streamableHttp.js +44 -0
- package/src/tools/extract/_fetchAndParse.js +32 -1
- package/src/tools/search/adapters/dataforseoSearch.js +19 -8
- package/src/tools/search/serpRank.js +4 -1
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.
|
|
65
|
+
**Current Version:** 5.0.5
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
|
@@ -181,7 +181,7 @@ fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
|
181
181
|
search_web, serp_rank, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
182
182
|
|
|
183
183
|
**serp_rank (DataForSEO):**
|
|
184
|
-
- `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002
|
|
184
|
+
- `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002 per 10 results of `depth`, so $0.004 at the default `depth:20` and $0.02 at `depth:100`), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
|
|
185
185
|
|
|
186
186
|
**v4.6.0 additions (Phase D):**
|
|
187
187
|
- `scrape` — single fetch + one cheerio load dispatching a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema) + `onlyMainContent`; partial-success via per-format `warnings[]`. Cost: 2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.5",
|
|
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",
|
|
@@ -127,7 +127,8 @@
|
|
|
127
127
|
"turndown-plugin-gfm": "^1.0.2",
|
|
128
128
|
"undici": "^7.24.0",
|
|
129
129
|
"winston": "^3.11.0",
|
|
130
|
-
"zod": "^3.23.8"
|
|
130
|
+
"zod": "^3.23.8",
|
|
131
|
+
"zod-to-json-schema": "^3.25.1"
|
|
131
132
|
},
|
|
132
133
|
"optionalDependencies": {
|
|
133
134
|
"camoufox": "^0.1.19"
|
package/server.js
CHANGED
|
@@ -99,7 +99,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
99
99
|
// Create the server
|
|
100
100
|
const server = new McpServer({
|
|
101
101
|
name: "crawlforge",
|
|
102
|
-
version: "5.0.
|
|
102
|
+
version: "5.0.5",
|
|
103
103
|
description: "Production-ready MCP server with 27 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, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
104
104
|
homepage: "https://www.crawlforge.dev",
|
|
105
105
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -427,7 +427,7 @@ registerToolIfEnabled("serp_rank", {
|
|
|
427
427
|
location_code: z.number().optional().describe("Numeric DataForSEO location code (overrides location_name)"),
|
|
428
428
|
language_code: z.string().optional().describe("Language code (e.g. 'en')"),
|
|
429
429
|
device: z.enum(["desktop", "mobile"]).optional().describe("Device to emulate"),
|
|
430
|
-
depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (
|
|
430
|
+
depth: z.number().min(10).max(200).optional().describe("How many results to scan, 10-200 (default 20; DataForSEO bills ~$0.002 per 10 and gets slower the deeper it goes)")
|
|
431
431
|
},
|
|
432
432
|
outputSchema: OUTPUT_SCHEMAS.serp_rank
|
|
433
433
|
}, withAuth("serp_rank", async ({ keyword, target, location_name, location_code, language_code, device, depth }) => {
|
package/src/constants/config.js
CHANGED
|
@@ -303,7 +303,11 @@ export function validateConfig() {
|
|
|
303
303
|
export function getToolConfig(toolName) {
|
|
304
304
|
const toolConfigs = {
|
|
305
305
|
search_web: {
|
|
306
|
-
|
|
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:
|
|
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
|
|
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
|
|
package/src/core/AuthManager.js
CHANGED
|
@@ -644,7 +644,7 @@ class AuthManager {
|
|
|
644
644
|
case 'serp_rank':
|
|
645
645
|
note = projected === 0
|
|
646
646
|
? 'DataForSEO not configured — no-op, no credits charged. Set DATAFORSEO_LOGIN/PASSWORD to enable.'
|
|
647
|
-
: 'DataForSEO SERP API (~US$0.002
|
|
647
|
+
: 'DataForSEO SERP API (~US$0.002 per 10 results of depth — $0.004 at the default depth 20) billed to your own DataForSEO account, separate from the credit cost.';
|
|
648
648
|
break;
|
|
649
649
|
case 'scrape': {
|
|
650
650
|
projected = base;
|
|
@@ -30,10 +30,50 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
30
30
|
import { createServer } from 'node:http';
|
|
31
31
|
import { randomUUID } from 'node:crypto';
|
|
32
32
|
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
33
35
|
|
|
34
36
|
const pkg = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'));
|
|
35
37
|
const SERVER_VERSION = pkg.version;
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Build the `tools` array for the Smithery static server card, straight from
|
|
41
|
+
* the live tool registry.
|
|
42
|
+
*
|
|
43
|
+
* Smithery scans a published server to populate its listing, but our /mcp
|
|
44
|
+
* endpoint 401s without a key, so the scan cannot enumerate anything. Their
|
|
45
|
+
* documented fallback is a static server card carrying the metadata — and a
|
|
46
|
+
* card with no `tools` leaves the listing showing whatever was typed in by
|
|
47
|
+
* hand at publish time, which is how it goes stale.
|
|
48
|
+
*
|
|
49
|
+
* Deriving it here means the card tracks the registry on every release instead
|
|
50
|
+
* of drifting. Tool `inputSchema`s are registered as ZodRawShapes, so wrap
|
|
51
|
+
* before converting; a tool whose schema will not convert is still listed,
|
|
52
|
+
* with an open object schema, rather than dropped.
|
|
53
|
+
*/
|
|
54
|
+
function buildToolCards(server) {
|
|
55
|
+
const registered = server?._registeredTools ?? {};
|
|
56
|
+
return Object.entries(registered)
|
|
57
|
+
.filter(([, tool]) => tool?.enabled !== false)
|
|
58
|
+
.map(([name, tool]) => {
|
|
59
|
+
let inputSchema = { type: 'object', properties: {} };
|
|
60
|
+
try {
|
|
61
|
+
const shape = tool?.inputSchema;
|
|
62
|
+
if (shape) {
|
|
63
|
+
const zodObject = typeof shape?.safeParse === 'function' ? shape : z.object(shape);
|
|
64
|
+
const converted = zodToJsonSchema(zodObject, { $refStrategy: 'none' });
|
|
65
|
+
delete converted.$schema;
|
|
66
|
+
inputSchema = converted;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// Keep the tool visible with an open schema rather than hiding it.
|
|
70
|
+
}
|
|
71
|
+
const card = { name, description: tool?.description ?? tool?.annotations?.title ?? '', inputSchema };
|
|
72
|
+
if (tool?.annotations) card.annotations = tool.annotations;
|
|
73
|
+
return card;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* The MCP SDK's Protocol.connect() allows at most one active transport per
|
|
39
79
|
* Server/McpServer instance (it throws 'Already connected to a transport'
|
|
@@ -163,6 +203,10 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
163
203
|
icon: 'https://www.crawlforge.dev/icon.png'
|
|
164
204
|
},
|
|
165
205
|
transport: { type: 'streamable-http', url: '/mcp' },
|
|
206
|
+
authentication: { required: true, schemes: ['apiKey'] },
|
|
207
|
+
tools: buildToolCards(server),
|
|
208
|
+
resources: [],
|
|
209
|
+
prompts: [],
|
|
166
210
|
configSchema: {
|
|
167
211
|
type: 'object',
|
|
168
212
|
properties: {
|
|
@@ -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 = $
|
|
183
|
+
const textContent = flattenBodyText($);
|
|
153
184
|
|
|
154
185
|
return { html, $, textContent, finalUrl: response.url };
|
|
155
186
|
}
|
|
@@ -11,9 +11,13 @@
|
|
|
11
11
|
* Endpoint (Live Advanced, synchronous — one request, one response):
|
|
12
12
|
* POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced
|
|
13
13
|
*
|
|
14
|
-
* Cost:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Cost: US$0.002 per 10 results of `depth` on Live Advanced — measured live,
|
|
15
|
+
* not estimated: depth 10 bills $0.002, the depth 20 default bills $0.004, and
|
|
16
|
+
* depth 100 bills $0.02. Deeper scans are also slower (see the timeout note
|
|
17
|
+
* below), so raise `depth` only when a rank below the default is worth paying
|
|
18
|
+
* for. For high-volume scheduled tracking, DataForSEO's task-based "Standard"
|
|
19
|
+
* queue (task_post → tasks_ready → task_get) is cheaper; swap the endpoint +
|
|
20
|
+
* poll if cost matters.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
export class DataForSEOSearchAdapter {
|
|
@@ -25,9 +29,16 @@ export class DataForSEOSearchAdapter {
|
|
|
25
29
|
this.login = login;
|
|
26
30
|
this.password = password;
|
|
27
31
|
this.apiBaseUrl = options.apiBaseUrl || 'https://api.dataforseo.com';
|
|
28
|
-
// Live Advanced is synchronous and
|
|
29
|
-
//
|
|
30
|
-
|
|
32
|
+
// Live Advanced is synchronous and runs a real-time Google scrape, so its
|
|
33
|
+
// latency swings widely with their capacity AND with `depth`. Measured on
|
|
34
|
+
// one account in a single session: ~13-15s at depth 10, and 30s / 45s /
|
|
35
|
+
// over 60s (twice) for the SAME depth-100 request. A killed request is
|
|
36
|
+
// still billed — DataForSEO has already run the scrape — so the cap exists
|
|
37
|
+
// only to stop a hung connection wedging the tool, and sits well above the
|
|
38
|
+
// slow end rather than through the middle of it.
|
|
39
|
+
// Overridable via DATAFORSEO_TIMEOUT_MS, or directly for tests/self-host.
|
|
40
|
+
this.timeoutMs =
|
|
41
|
+
options.timeoutMs ?? (Number(process.env.DATAFORSEO_TIMEOUT_MS) || 120000);
|
|
31
42
|
// HTTP Basic auth header, computed once.
|
|
32
43
|
this.authHeader = 'Basic ' + Buffer.from(`${login}:${password}`).toString('base64');
|
|
33
44
|
}
|
|
@@ -40,7 +51,7 @@ export class DataForSEOSearchAdapter {
|
|
|
40
51
|
* @param {number} [params.locationCode] - Numeric DataForSEO location code (overrides locationName)
|
|
41
52
|
* @param {string} [params.languageCode='en'] - Language code
|
|
42
53
|
* @param {('desktop'|'mobile')} [params.device='desktop'] - Device to emulate
|
|
43
|
-
* @param {number} [params.depth=
|
|
54
|
+
* @param {number} [params.depth=20] - How many results to scan (billed per 10)
|
|
44
55
|
* @returns {Promise<{items: Array<Object>, meta: Object}>} Normalized organic results + metadata
|
|
45
56
|
*/
|
|
46
57
|
async search(params) {
|
|
@@ -50,7 +61,7 @@ export class DataForSEOSearchAdapter {
|
|
|
50
61
|
locationCode,
|
|
51
62
|
languageCode = 'en',
|
|
52
63
|
device = 'desktop',
|
|
53
|
-
depth =
|
|
64
|
+
depth = 20,
|
|
54
65
|
} = params;
|
|
55
66
|
|
|
56
67
|
if (!keyword) {
|
|
@@ -24,7 +24,10 @@ const SerpRankSchema = z.object({
|
|
|
24
24
|
location_code: z.number().int().optional(),
|
|
25
25
|
language_code: z.string().optional().default('en'),
|
|
26
26
|
device: z.enum(['desktop', 'mobile']).optional().default('desktop'),
|
|
27
|
-
|
|
27
|
+
// DataForSEO bills per 10 results scanned and gets slower the deeper it goes,
|
|
28
|
+
// so the default stays shallow: 20 covers Google's first two pages for $0.004.
|
|
29
|
+
// Raise it (max 200, their cap) when a deeper position is worth the spend.
|
|
30
|
+
depth: z.number().int().min(10).max(200).optional().default(20),
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
/** Reduce a domain or URL to a bare, comparable host: "https://www.Example.com/x" → "example.com". */
|