crawlforge-mcp-server 6.21.1 → 6.22.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/package.json +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +228 -21
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +76 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +22 -14
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
|
@@ -44,6 +44,10 @@ export class BFSCrawler {
|
|
|
44
44
|
this.sessionContext = sessionContext;
|
|
45
45
|
|
|
46
46
|
this.visited = new Map(); // normalized key -> the URL as fetched
|
|
47
|
+
// Normalized keys of the pages fetched, by where each fetch landed after
|
|
48
|
+
// redirects. Kept apart from visited so the page a redirect lands on is
|
|
49
|
+
// not fetched again, without spending a second max_pages slot on it.
|
|
50
|
+
this.landed = new Set();
|
|
47
51
|
this.results = [];
|
|
48
52
|
this.errors = [];
|
|
49
53
|
this.filterDecisions = []; // Track filtering decisions for analysis
|
|
@@ -153,7 +157,7 @@ export class BFSCrawler {
|
|
|
153
157
|
|
|
154
158
|
// Check if already visited
|
|
155
159
|
const normalizedUrl = normalizeUrl(url);
|
|
156
|
-
if (this.visited.has(normalizedUrl)) {
|
|
160
|
+
if (this.visited.has(normalizedUrl) || this.landed.has(normalizedUrl)) {
|
|
157
161
|
return;
|
|
158
162
|
}
|
|
159
163
|
// The form that goes on the wire: a bare origin gains its root "/",
|
|
@@ -215,7 +219,7 @@ export class BFSCrawler {
|
|
|
215
219
|
// top of this task ran before the awaited robots lookup, so concurrent
|
|
216
220
|
// queue tasks may have filled the budget (or claimed this URL) since.
|
|
217
221
|
// This block is synchronous, so the cap is exact.
|
|
218
|
-
if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl)) {
|
|
222
|
+
if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl) || this.landed.has(normalizedUrl)) {
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
221
225
|
this.visited.set(normalizedUrl, url);
|
|
@@ -263,17 +267,41 @@ export class BFSCrawler {
|
|
|
263
267
|
if (cacheKey) await this.cache.set(cacheKey, pageData);
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
// Links resolve against where the fetch landed, not the URL asked for:
|
|
271
|
+
// docs.python.org answers /3/tutorial with a redirect to /3/tutorial/,
|
|
272
|
+
// and appetite.html resolved against the first is /3/appetite.html, a
|
|
273
|
+
// 404 (R25). A seed that lands on another origin (http -> https, apex
|
|
274
|
+
// -> www) moves the crawl's origin with it, or every link it holds
|
|
275
|
+
// would read as external.
|
|
276
|
+
const pageUrl = pageData.finalUrl || url;
|
|
277
|
+
const landedKey = normalizeUrl(pageUrl);
|
|
278
|
+
if (isSeed && landedKey !== normalizedUrl) this.baseUrl = new URL(landedKey);
|
|
279
|
+
// Another link (one that redirects here, or this URL itself) reached the
|
|
280
|
+
// same page first: this copy is a duplicate.
|
|
281
|
+
if (this.landed.has(landedKey)) return;
|
|
282
|
+
this.landed.add(landedKey);
|
|
283
|
+
|
|
266
284
|
// Process links for analysis
|
|
267
285
|
if (this.enableLinkAnalysis && this.linkAnalyzer && pageData.links) {
|
|
268
286
|
// Parse the page once and reuse for every link; re-parsing per link is
|
|
269
287
|
// O(links × page size) and starves the event loop on link-dense pages.
|
|
270
288
|
const $page = pageData.originalHtml ? load(pageData.originalHtml) : null;
|
|
271
289
|
const pageBodyText = $page ? $page('body').text() : '';
|
|
290
|
+
// Index the anchors once by raw href, first one winning: a per-link
|
|
291
|
+
// `a[href="..."]` query scanned the whole page for every link (16,766
|
|
292
|
+
// scans of the 7.3 MB python changelog, R25) and blocked other calls.
|
|
293
|
+
const anchorsByHref = new Map();
|
|
294
|
+
if ($page) {
|
|
295
|
+
$page('a[href]').each((_, element) => {
|
|
296
|
+
const href = $page(element).attr('href');
|
|
297
|
+
if (!anchorsByHref.has(href)) anchorsByHref.set(href, element);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
272
300
|
for (const link of pageData.links) {
|
|
273
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
301
|
+
const absoluteUrl = this.resolveUrl(link, pageUrl);
|
|
274
302
|
if (absoluteUrl) {
|
|
275
303
|
// Extract anchor text and context from link
|
|
276
|
-
const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText);
|
|
304
|
+
const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText, anchorsByHref);
|
|
277
305
|
this.linkAnalyzer.addLink(normalizedUrl, normalizeUrl(absoluteUrl), linkMetadata);
|
|
278
306
|
}
|
|
279
307
|
}
|
|
@@ -301,8 +329,9 @@ export class BFSCrawler {
|
|
|
301
329
|
for (const link of pageData.links) {
|
|
302
330
|
if (this.visited.size >= this.maxPages) break;
|
|
303
331
|
|
|
304
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
305
|
-
|
|
332
|
+
const absoluteUrl = this.resolveUrl(link, pageUrl);
|
|
333
|
+
const key = absoluteUrl && normalizeUrl(absoluteUrl);
|
|
334
|
+
if (key && !this.visited.has(key) && !this.landed.has(key)) {
|
|
306
335
|
// Not awaited: this task already holds a queue slot, so awaiting a
|
|
307
336
|
// child would keep that slot pinned for the rest of the recursive
|
|
308
337
|
// crawl (starving other tasks when concurrency <= depth, and making
|
|
@@ -395,7 +424,9 @@ export class BFSCrawler {
|
|
|
395
424
|
// readBody decodes with the body's real charset — response.text() is
|
|
396
425
|
// UTF-8-only and mangled ISO-8859-1 pages ("portugu�s" on lua.org).
|
|
397
426
|
const html = await readBody(response);
|
|
398
|
-
|
|
427
|
+
// response.url is where the redirects ended; links resolve against it.
|
|
428
|
+
const finalUrl = response.url || url;
|
|
429
|
+
return { ...this.parsePage(html, finalUrl), finalUrl };
|
|
399
430
|
} catch (error) {
|
|
400
431
|
clearTimeout(timeoutId);
|
|
401
432
|
throw error;
|
|
@@ -656,18 +687,21 @@ export class BFSCrawler {
|
|
|
656
687
|
* @param {string} href - The href attribute value
|
|
657
688
|
* @param {Object} $ - Cheerio document for the page (parsed once per page)
|
|
658
689
|
* @param {string} bodyText - Pre-computed body text of the page
|
|
690
|
+
* @param {Map} anchorsByHref - Raw href -> first <a> element with it
|
|
659
691
|
* @returns {Object} Link metadata
|
|
660
692
|
*/
|
|
661
|
-
extractLinkMetadata(href, $, bodyText = '') {
|
|
693
|
+
extractLinkMetadata(href, $, bodyText = '', anchorsByHref) {
|
|
662
694
|
if (!$) return {};
|
|
663
695
|
|
|
664
696
|
try {
|
|
665
|
-
const
|
|
697
|
+
const element = anchorsByHref.get(href);
|
|
666
698
|
|
|
667
|
-
if (
|
|
699
|
+
if (!element) {
|
|
668
700
|
return { href };
|
|
669
701
|
}
|
|
670
702
|
|
|
703
|
+
const linkElement = $(element);
|
|
704
|
+
|
|
671
705
|
const anchorText = linkElement.text().trim();
|
|
672
706
|
const title = linkElement.attr('title');
|
|
673
707
|
const rel = linkElement.attr('rel');
|
|
@@ -968,6 +1002,10 @@ export class BFSCrawler {
|
|
|
968
1002
|
const segments = pathname.split('/').filter(s => s);
|
|
969
1003
|
|
|
970
1004
|
if (segments.length === 0) return '/';
|
|
1005
|
+
// A file (bugs.html) is a page, not a directory: no trailing "/" or "/...".
|
|
1006
|
+
if (segments.length <= 2 && /.\.[a-z0-9]+$/i.test(segments[segments.length - 1])) {
|
|
1007
|
+
return `/${segments.join('/')}`;
|
|
1008
|
+
}
|
|
971
1009
|
if (segments.length === 1) return `/${segments[0]}/`;
|
|
972
1010
|
|
|
973
1011
|
// Return first two segments as pattern
|
|
@@ -8,7 +8,7 @@ import { JSDOM } from 'jsdom';
|
|
|
8
8
|
import * as cheerio from 'cheerio';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { ContentQualityAssessor } from '../../utils/contentUtils.js';
|
|
11
|
-
import { recoverDroppedTables } from '../../tools/scrape/_mainContent.js';
|
|
11
|
+
import { recoverDroppedTables, unwrapMediaWikiHeadings, tooLargeForReadability } from '../../tools/scrape/_mainContent.js';
|
|
12
12
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
13
13
|
import { flattenText } from 'crawlforge-extractors';
|
|
14
14
|
|
|
@@ -73,7 +73,9 @@ export class ContentProcessor {
|
|
|
73
73
|
const document = dom.window.document;
|
|
74
74
|
|
|
75
75
|
// Extract main content using Mozilla Readability
|
|
76
|
-
|
|
76
|
+
// A page too large for Readability keeps no `readability` block (R25).
|
|
77
|
+
if (processingOptions.removeBoilerplate && !tooLargeForReadability(html)) {
|
|
78
|
+
unwrapMediaWikiHeadings(document);
|
|
77
79
|
const reader = new Readability(document, {
|
|
78
80
|
debug: false,
|
|
79
81
|
maxElemsToDivide: 300,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import PQueue from 'p-queue';
|
|
1
|
+
import PQueue, { TimeoutError } from 'p-queue';
|
|
2
2
|
|
|
3
3
|
export class QueueManager {
|
|
4
4
|
constructor(options = {}) {
|
|
@@ -46,7 +46,21 @@ export class QueueManager {
|
|
|
46
46
|
|
|
47
47
|
async add(fn, options = {}) {
|
|
48
48
|
const { priority = 0 } = options;
|
|
49
|
-
|
|
49
|
+
try {
|
|
50
|
+
return await this.queue.add(fn, { priority });
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// p-queue's message gives the queue state when the task STARTED, which
|
|
53
|
+
// reads like a queue problem. The task itself ran too long, often
|
|
54
|
+
// because synchronous work elsewhere held the event loop.
|
|
55
|
+
if (error instanceof TimeoutError) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Task ran over its ${this.queue.timeout / 1000} s time budget; ` +
|
|
58
|
+
'it may have been starved by other work running in this process.',
|
|
59
|
+
{ cause: error }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
async addAll(tasks, options = {}) {
|
|
@@ -34,6 +34,19 @@ const RESOURCE_MIME = {
|
|
|
34
34
|
screenshot: 'image/png',
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A screenshot's MIME type, read from its magic bytes: callers ask for png or
|
|
39
|
+
* jpeg (and could get webp), and the bytes cannot disagree with themselves.
|
|
40
|
+
* Anything unrecognised keeps the old image/png label.
|
|
41
|
+
* @param {Buffer} buf
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function sniffImageMime(buf) {
|
|
45
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
|
|
46
|
+
if (buf.length >= 12 && buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
|
|
47
|
+
return RESOURCE_MIME.screenshot;
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* Parse a crawlforge:// URI into its components.
|
|
39
52
|
* @param {string} uri
|
|
@@ -67,8 +80,8 @@ export class ResourceRegistry {
|
|
|
67
80
|
// In-memory stores for lightweight resource tracking
|
|
68
81
|
/** @type {Map<string, { data: any, createdAt: number, ttl: number }>} */
|
|
69
82
|
this._crawlSitemaps = new Map(); // sessionId -> sitemap
|
|
70
|
-
/** @type {Map<string, { data: Buffer, createdAt: number, ttl: number }>} */
|
|
71
|
-
this._screenshots = new Map(); // actionId ->
|
|
83
|
+
/** @type {Map<string, { data: Buffer, mimeType: string, createdAt: number, ttl: number }>} */
|
|
84
|
+
this._screenshots = new Map(); // actionId -> image buffer
|
|
72
85
|
|
|
73
86
|
// Default TTL: 1 hour
|
|
74
87
|
this.defaultTtl = 60 * 60 * 1000;
|
|
@@ -90,7 +103,7 @@ export class ResourceRegistry {
|
|
|
90
103
|
/**
|
|
91
104
|
* Store a screenshot for later retrieval.
|
|
92
105
|
* @param {string} actionId
|
|
93
|
-
* @param {Buffer|string} screenshotData -
|
|
106
|
+
* @param {Buffer|string} screenshotData - image buffer or base64 string
|
|
94
107
|
*/
|
|
95
108
|
storeScreenshot(actionId, screenshotData) {
|
|
96
109
|
const buf = Buffer.isBuffer(screenshotData)
|
|
@@ -98,6 +111,7 @@ export class ResourceRegistry {
|
|
|
98
111
|
: Buffer.from(screenshotData, 'base64');
|
|
99
112
|
this._screenshots.set(actionId, {
|
|
100
113
|
data: buf,
|
|
114
|
+
mimeType: sniffImageMime(buf),
|
|
101
115
|
createdAt: Date.now(),
|
|
102
116
|
ttl: this.defaultTtl,
|
|
103
117
|
});
|
|
@@ -176,7 +190,7 @@ export class ResourceRegistry {
|
|
|
176
190
|
uri: `crawlforge://screenshot/${actionId}`,
|
|
177
191
|
name: `Screenshot ${actionId}`,
|
|
178
192
|
description: 'Screenshot from a CrawlForge browser tool',
|
|
179
|
-
mimeType:
|
|
193
|
+
mimeType: entry.mimeType,
|
|
180
194
|
});
|
|
181
195
|
}
|
|
182
196
|
}
|
|
@@ -302,7 +316,7 @@ export class ResourceRegistry {
|
|
|
302
316
|
return {
|
|
303
317
|
contents: [{
|
|
304
318
|
uri,
|
|
305
|
-
mimeType:
|
|
319
|
+
mimeType: entry.mimeType,
|
|
306
320
|
blob: entry.data.toString('base64'),
|
|
307
321
|
}],
|
|
308
322
|
};
|
|
@@ -17,16 +17,16 @@
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
18
|
import { SCRAPE_STRING_FORMATS, SCRAPE_OBJECT_FORMATS } from '../tools/scrape/formats.js';
|
|
19
19
|
|
|
20
|
-
// `_cost`
|
|
21
|
-
// result
|
|
22
|
-
// clients that copy `content[0].text` into structuredContent-shaped code
|
|
23
|
-
// don't fail validation.
|
|
20
|
+
// `_cost` and `next_step` are injected by withAuth into both the JSON-text copy
|
|
21
|
+
// of the result and structuredContent (R25 0.2), so every shape declares them.
|
|
24
22
|
const costShape = z.object({
|
|
25
23
|
projected: z.number().optional().describe('Credits projected for this call before execution'),
|
|
26
|
-
actual: z.number().optional().describe('Credits actually charged (0 in creator mode,
|
|
24
|
+
actual: z.number().optional().describe('Credits actually charged (0 in creator mode, and 0 for a failed call)'),
|
|
27
25
|
remaining_credits: z.number().nullable().optional().describe('Credits remaining on the account after this call, if known'),
|
|
28
26
|
projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
|
|
29
|
-
}).passthrough().optional().describe('Cost-transparency metadata (D3.5)
|
|
27
|
+
}).passthrough().optional().describe('Cost-transparency metadata (D3.5): what this call was projected to cost and what it was charged');
|
|
28
|
+
|
|
29
|
+
const nextStepShape = z.string().optional().describe('Failed calls only: the tool or fix to try next');
|
|
30
30
|
|
|
31
31
|
// ── result handles (Phase 2) ────────────────────────────────────────────────
|
|
32
32
|
|
|
@@ -143,7 +143,8 @@ const scrapeShape = {
|
|
|
143
143
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
144
144
|
redaction: redactionShape,
|
|
145
145
|
...resultHandleShape,
|
|
146
|
-
_cost: costShape
|
|
146
|
+
_cost: costShape,
|
|
147
|
+
next_step: nextStepShape
|
|
147
148
|
};
|
|
148
149
|
|
|
149
150
|
// ── map_site ────────────────────────────────────────────────────────────────
|
|
@@ -186,7 +187,8 @@ const mapSiteShape = {
|
|
|
186
187
|
}).passthrough()).optional().describe('Present only when the `search` param was set'),
|
|
187
188
|
warnings: z.array(z.string()).optional().describe('Present when the map is partial, e.g. the start page could not be read and the URLs come from the sitemap only, or when the result is over max_inline_chars'),
|
|
188
189
|
...resultHandleShape,
|
|
189
|
-
_cost: costShape
|
|
190
|
+
_cost: costShape,
|
|
191
|
+
next_step: nextStepShape
|
|
190
192
|
};
|
|
191
193
|
|
|
192
194
|
// ── serp_rank ───────────────────────────────────────────────────────────────
|
|
@@ -220,7 +222,8 @@ const serpRankShape = {
|
|
|
220
222
|
checkUrl: z.string().optional().describe('Link to view the real SERP on DataForSEO'),
|
|
221
223
|
cost: z.number().optional().describe('USD charged by DataForSEO for this lookup (separate from CrawlForge credits)'),
|
|
222
224
|
checkedAt: z.string().optional(),
|
|
223
|
-
_cost: costShape
|
|
225
|
+
_cost: costShape,
|
|
226
|
+
next_step: nextStepShape
|
|
224
227
|
};
|
|
225
228
|
|
|
226
229
|
// ── reddit_search ───────────────────────────────────────────────────────────
|
|
@@ -273,7 +276,8 @@ const redditSearchShape = {
|
|
|
273
276
|
fallback_used: z.string().optional().describe('Present when the primary archive failed and the fallback served the result'),
|
|
274
277
|
notes: z.array(z.string()).optional().describe('Data-provenance caveats (archive freshness, coverage gaps)'),
|
|
275
278
|
checkedAt: z.string().optional(),
|
|
276
|
-
_cost: costShape
|
|
279
|
+
_cost: costShape,
|
|
280
|
+
next_step: nextStepShape
|
|
277
281
|
};
|
|
278
282
|
|
|
279
283
|
// ── search_web ──────────────────────────────────────────────────────────────
|
|
@@ -334,7 +338,8 @@ const searchWebShape = {
|
|
|
334
338
|
redaction: redactionShape,
|
|
335
339
|
warnings: z.array(z.string()).optional().describe('Notes on this result; over max_inline_chars, where the full result is kept and how to read it'),
|
|
336
340
|
...resultHandleShape,
|
|
337
|
-
_cost: costShape
|
|
341
|
+
_cost: costShape,
|
|
342
|
+
next_step: nextStepShape
|
|
338
343
|
};
|
|
339
344
|
|
|
340
345
|
// ── extract_structured ───────────────────────────────────────────────────────
|
|
@@ -366,7 +371,8 @@ const extractStructuredShape = {
|
|
|
366
371
|
}).passthrough()).optional(),
|
|
367
372
|
skipped: z.string().optional().describe('"empty_source" when there was nothing to check against')
|
|
368
373
|
}).passthrough().optional(),
|
|
369
|
-
_cost: costShape
|
|
374
|
+
_cost: costShape,
|
|
375
|
+
next_step: nextStepShape
|
|
370
376
|
};
|
|
371
377
|
|
|
372
378
|
// ── crawl_deep ────────────────────────────────────────────────────────────────
|
|
@@ -417,7 +423,8 @@ const crawlDeepShape = {
|
|
|
417
423
|
cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
|
|
418
424
|
redaction: redactionShape,
|
|
419
425
|
...resultHandleShape,
|
|
420
|
-
_cost: costShape
|
|
426
|
+
_cost: costShape,
|
|
427
|
+
next_step: nextStepShape
|
|
421
428
|
};
|
|
422
429
|
|
|
423
430
|
// ── read_result ───────────────────────────────────────────────────────────────
|
|
@@ -455,7 +462,8 @@ const readResultShape = {
|
|
|
455
462
|
preview: z.string().optional(),
|
|
456
463
|
truncated: z.boolean().optional().describe('search: more matches than returned; json_path: value replaced by a preview'),
|
|
457
464
|
warnings: z.array(z.string()).optional(),
|
|
458
|
-
_cost: costShape
|
|
465
|
+
_cost: costShape,
|
|
466
|
+
next_step: nextStepShape
|
|
459
467
|
};
|
|
460
468
|
|
|
461
469
|
// ── Export ────────────────────────────────────────────────────────────────────
|
|
@@ -92,10 +92,22 @@ export const CLASS_HINTS = Object.freeze({
|
|
|
92
92
|
'The URL points at a private or internal address (localhost, a private network, cloud metadata), which every CrawlForge tool refuses. Do not retry it with this or any other tool; use a public URL.',
|
|
93
93
|
not_found: () =>
|
|
94
94
|
'The URL does not exist (HTTP 404, or the host does not resolve). Check it for typos, or find the right one with map_site on the site or search_web. Retrying it, or switching to stealth_mode, will not help.',
|
|
95
|
+
model_not_pulled: () =>
|
|
96
|
+
'The named Ollama model is not installed: pull it (the error gives the command), or call again without `model` so an installed one is picked; list_ollama_models lists them.',
|
|
97
|
+
refused: () =>
|
|
98
|
+
'The host refused the connection: nothing is listening on that port, or the server is down. Check the URL and port; retrying right away, or switching to stealth_mode or another tool, will not help.',
|
|
95
99
|
timeout: () =>
|
|
96
100
|
'The request timed out. Retry once with a longer timeout or less work (fewer pages, URLs or actions); if it times out again, the site is too slow from here - do not keep retrying.',
|
|
97
101
|
validation: (tool, params) =>
|
|
98
|
-
`The input was rejected: fix ${params.length ? params.join(', ') : 'the parameter the error names'} as the error describes and call ${tool} again. Do not switch tools for this
|
|
102
|
+
`The input was rejected: fix ${params.length ? params.join(', ') : 'the parameter the error names'} as the error describes and call ${tool} again. Do not switch tools for this.`,
|
|
103
|
+
// R25 3.2/3.3: these were told the site was slow (a click) or to escalate (extract_text).
|
|
104
|
+
selector: (tool) =>
|
|
105
|
+
'Nothing on the page matched the selector (absent, hidden, or not actionable in time) - a selector problem, not a slow site, so do not retry with a longer timeout. ' +
|
|
106
|
+
(tool === 'browser_session'
|
|
107
|
+
? 'Take another snapshot and act on its refs: navigation invalidates them.'
|
|
108
|
+
: 'Check the selectors against scrape formats:["html"] output, or use extract_structured when the markup varies.'),
|
|
109
|
+
unknown_context: () =>
|
|
110
|
+
'That contextId is not open in this server process (never created, already cleaned up, or lost on a restart). Run stealth_mode operation:"create_context" first and pass the contextId it returns.'
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
/** The strings that describe what went wrong: the error fields, or the whole text. */
|
|
@@ -133,14 +145,28 @@ export function classifyError(parsed, text) {
|
|
|
133
145
|
if (codes.includes('ROBOTS_DISALLOWED') || refusal === 'ROBOTS_DISALLOWED' || /robots\.txt on \S+ disallows/.test(msg)) return { cls: 'robots' };
|
|
134
146
|
if (codes.includes('HOST_BLOCKED') || refusal === 'HOST_BLOCKED' || /permanent blocklist/.test(msg)) return { cls: 'host_blocked' };
|
|
135
147
|
if (status === 404 || /\bHTTP 404\b|\b404 Not Found\b|\bENOTFOUND\b/i.test(msg)) return { cls: 'not_found' };
|
|
148
|
+
// "Could not reach <url> (connect ECONNREFUSED 1.2.3.4:9)": after ssrf, so a refused private address stays ssrf.
|
|
149
|
+
if (/\bECONNREFUSED\b/.test(msg)) return { cls: 'refused' };
|
|
150
|
+
// "url is required for scrape operation" (stealth_mode, localization): the name is the parameter.
|
|
151
|
+
const missing = msg.match(/\b([a-z][A-Za-z_]*) is required for (?:\w+ )?operation\b/);
|
|
136
152
|
// Before timeout: a schema message may mention a `timeout` parameter.
|
|
137
|
-
if (/ZodError|"code":\s*"(?:invalid_[a-z_]+|too_small|too_big|unrecognized_keys|custom)"|\bInvalid (?:input|option|enum value|arguments?)\b|\bvalidation (?:failed|error)\b|\bparameter is required\b/i.test(msg)) {
|
|
138
|
-
return { cls: 'validation', params: zodPaths(msg) };
|
|
153
|
+
if (missing || /ZodError|"code":\s*"(?:invalid_[a-z_]+|too_small|too_big|unrecognized_keys|custom)"|\bInvalid (?:input|option|enum value|arguments?)\b|\bvalidation (?:failed|error)\b|\bparameter is required\b/i.test(msg)) {
|
|
154
|
+
return { cls: 'validation', params: missing ? [missing[1]] : zodPaths(msg) };
|
|
139
155
|
}
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
if (/\
|
|
143
|
-
|
|
156
|
+
if (/\bContext not found\b/.test(msg)) return { cls: 'unknown_context' };
|
|
157
|
+
// extract_with_llm: 'Ollama model "x" is not pulled. Run: "ollama pull x"' - Ollama is up, the model is missing.
|
|
158
|
+
if (/\bOllama model "[^"]*" is not pulled\b/.test(msg)) return { cls: 'model_not_pulled' };
|
|
159
|
+
const timedOut = /\btime[ds]? ?out\b|\bTimeout \d+ ?ms exceeded|\bETIMEDOUT\b|aborted due to timeout/i.test(msg);
|
|
160
|
+
// A selector that matched nothing is a selector problem, not a slow site.
|
|
161
|
+
// Playwright's "Call log: waiting for locator(...)" is stripped before this
|
|
162
|
+
// runs (errorText.js), but the API prefix of an element wait stays:
|
|
163
|
+
// "locator.click: Timeout 4000ms exceeded."
|
|
164
|
+
if (/\bNo elements? (?:found|matched) (?:for )?selector\b/i.test(msg) ||
|
|
165
|
+
(timedOut && /\b(?:locator|elementHandle)\.\w+:|\bwaitForSelector\b|waiting for (?:locator|selector)/i.test(msg))) {
|
|
166
|
+
return { cls: 'selector' };
|
|
167
|
+
}
|
|
168
|
+
// Any other wait (page.waitForFunction for a text wait) is the tool hint's.
|
|
169
|
+
if (timedOut && !/waiting for (?:locator|selector)|waitFor/i.test(msg)) return { cls: 'timeout' };
|
|
144
170
|
return null;
|
|
145
171
|
}
|
|
146
172
|
|
|
@@ -174,6 +200,10 @@ export function appendFallbackHint(toolName, result) {
|
|
|
174
200
|
if (parsed) {
|
|
175
201
|
parsed.next_step = hint;
|
|
176
202
|
first.text = JSON.stringify(parsed, null, 2);
|
|
203
|
+
// Clients that show structuredContent never see the text copy (R25 0.2).
|
|
204
|
+
if (result.structuredContent && typeof result.structuredContent === 'object') {
|
|
205
|
+
result.structuredContent.next_step = hint;
|
|
206
|
+
}
|
|
177
207
|
return result;
|
|
178
208
|
}
|
|
179
209
|
first.text = `${first.text}\nNext step: ${hint}`;
|
package/src/server/withAuth.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* handler did no work, so it is billed NOTHING and reports no usage. The
|
|
14
14
|
* SDK re-enters the handler with the reply and the terminal entry bills
|
|
15
15
|
* once, so a confirmation costs exactly what the call always cost (G4).
|
|
16
|
+
* - a failed call (an isError result, a JSON body with success:false, or a
|
|
17
|
+
* throw) is charged NOTHING and reports no usage: failures are free
|
|
16
18
|
* - error results get a "Next step:" hint naming the tool to try next
|
|
17
19
|
* (src/server/fallbackHints.js) so a failure is not followed by a blind retry
|
|
18
20
|
* - emits an OTel span via src/observability/tracing.js (no-op if disabled)
|
|
@@ -22,9 +24,9 @@
|
|
|
22
24
|
import { createHash } from 'node:crypto';
|
|
23
25
|
import { isInputRequiredResult } from '@modelcontextprotocol/server';
|
|
24
26
|
import { recordToolInvocation } from '../observability/tracing.js';
|
|
25
|
-
import { isInternalRequest,
|
|
27
|
+
import { isInternalRequest, reportedActualCost, requestContext } from './requestContext.js';
|
|
26
28
|
import { appendFallbackHint } from './fallbackHints.js';
|
|
27
|
-
import { normalizeErrorResult } from './errorText.js';
|
|
29
|
+
import { failure, normalizeErrorResult } from './errorText.js';
|
|
28
30
|
import { INLINE_THRESHOLD_TOOLS, applyInlineThreshold } from './inlineThreshold.js';
|
|
29
31
|
import { REDACTION_TOOLS, redactionSurcharge, runRedactionStage } from './redaction.js';
|
|
30
32
|
import { getResultStore } from '../core/ResultStore.js';
|
|
@@ -96,10 +98,6 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
96
98
|
let redactionModelRan = false;
|
|
97
99
|
let outcome = 'pending';
|
|
98
100
|
let thrown = null;
|
|
99
|
-
// Only bill the error-path half-charge once the handler has actually run.
|
|
100
|
-
// A throw from the credit check itself (backend down, key rejected, etc.)
|
|
101
|
-
// means the tool never executed and must cost nothing.
|
|
102
|
-
let handlerStarted = false;
|
|
103
101
|
// A handler may report a lower spend (setActualCost). The projection is
|
|
104
102
|
// the ceiling the caller saw before the call, so a report can only ever
|
|
105
103
|
// lower the charge, never raise it.
|
|
@@ -139,7 +137,6 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
139
137
|
}
|
|
140
138
|
}
|
|
141
139
|
|
|
142
|
-
handlerStarted = true;
|
|
143
140
|
const result = await handler(params, ctx);
|
|
144
141
|
|
|
145
142
|
// Phase 4.4: a multi-round-trip handler answers `input_required` when it
|
|
@@ -157,18 +154,14 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
157
154
|
}
|
|
158
155
|
|
|
159
156
|
// Tools catch their own failures and return { isError:true } rather than
|
|
160
|
-
// throwing (the shared pattern in server.js)
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
157
|
+
// throwing (the shared pattern in server.js), and several return a JSON
|
|
158
|
+
// body with success:false and no flag at all (R25 0.1). Both are an
|
|
159
|
+
// ERROR outcome: charged nothing, logged and metered as an error. The
|
|
160
|
+
// flag is set on the second kind too, so the client sees an error and
|
|
161
|
+
// the SDK skips outputSchema validation of a failure body.
|
|
162
|
+
const isErrorResult = result?.isError === true || failure(result) !== null;
|
|
163
|
+
if (isErrorResult) result.isError = true;
|
|
164
164
|
outcome = isErrorResult ? 'error' : 'success';
|
|
165
|
-
// A pre-flight refusal (robots.txt disallowed the path, or the host is
|
|
166
|
-
// blocklisted) means we fetched nothing at all, so it costs nothing —
|
|
167
|
-
// not even the half-credit error rate, which exists for work that ran
|
|
168
|
-
// and then failed. Only when the refusal actually sank the call: a
|
|
169
|
-
// multi-URL tool that skipped one disallowed URL and still returned a
|
|
170
|
-
// result did real work and bills for it.
|
|
171
|
-
const refused = isErrorResult && preflightRefusal() !== null;
|
|
172
165
|
|
|
173
166
|
// Phase 5 (5.3): redact_pii scrubs the page text this call returns.
|
|
174
167
|
// It runs BEFORE the inline-threshold stage below, so what gets
|
|
@@ -191,21 +184,20 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
191
184
|
try { shapeLargeResult(toolName, result, params); } catch { /* shaping must never break the request path */ }
|
|
192
185
|
}
|
|
193
186
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
187
|
+
// Failures are free, as the website promises. A pre-flight refusal that
|
|
188
|
+
// sank the call is one of them; a multi-URL tool that skipped one
|
|
189
|
+
// disallowed URL and still returned a result is a success and bills.
|
|
190
|
+
const charge = creditCost === 0 || isErrorResult ? 0 : billable();
|
|
198
191
|
|
|
199
192
|
// D3.5: Surface cost transparency in all tool responses. For internal
|
|
200
193
|
// proxy requests the meaningful balance is the end user's, which only
|
|
201
194
|
// the website knows — report null rather than the static key's cache.
|
|
195
|
+
// Creator mode has no balance: null, since outputSchema rejects Infinity.
|
|
202
196
|
try {
|
|
203
197
|
const projection = authManager.projectCost(toolName, params);
|
|
204
|
-
const remainingCredits = creatorMode
|
|
205
|
-
?
|
|
206
|
-
:
|
|
207
|
-
? null
|
|
208
|
-
: (authManager.creditCache ? [...authManager.creditCache.values()][0] ?? null : null);
|
|
198
|
+
const remainingCredits = creatorMode || internal
|
|
199
|
+
? null
|
|
200
|
+
: (authManager.creditCache ? [...authManager.creditCache.values()][0] ?? null : null);
|
|
209
201
|
const costMeta = {
|
|
210
202
|
projected: creditCost,
|
|
211
203
|
actual: creatorMode ? 0 : charge,
|
|
@@ -223,15 +215,17 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
223
215
|
// Not JSON — skip injection silently
|
|
224
216
|
}
|
|
225
217
|
}
|
|
218
|
+
// ...and into structuredContent, which is all some clients show (R25 0.2).
|
|
219
|
+
if (result?.structuredContent && typeof result.structuredContent === 'object') {
|
|
220
|
+
result.structuredContent._cost = costMeta;
|
|
221
|
+
}
|
|
226
222
|
} catch {
|
|
227
223
|
// Cost injection must never break the request path
|
|
228
224
|
}
|
|
229
225
|
|
|
230
226
|
// Selection hint: tell the model what to try instead of the same call,
|
|
231
227
|
// after stripping ANSI codes, call logs and doubled prefixes from the
|
|
232
|
-
// error text (R24 4.5).
|
|
233
|
-
// with success:false, which several tools return without the flag
|
|
234
|
-
// (R24 4.1). Neither touches the charge, settled above.
|
|
228
|
+
// error text (R24 4.5). Neither touches the charge, settled above.
|
|
235
229
|
try {
|
|
236
230
|
normalizeErrorResult(result);
|
|
237
231
|
appendFallbackHint(toolName, result);
|
|
@@ -242,26 +236,15 @@ export function makeWithAuth({ authManager, logger, metrics = null, mcpServer =
|
|
|
242
236
|
// the backend has nothing to (re-)price; reporting 0 would still create
|
|
243
237
|
// a serp_rank record the backend could recompute to full cost. The
|
|
244
238
|
// same goes for a handler that reported a 0 spend.
|
|
245
|
-
if (!creatorMode &&
|
|
246
|
-
await authManager.reportUsage(toolName, charge, params,
|
|
239
|
+
if (!creatorMode && charge > 0) {
|
|
240
|
+
await authManager.reportUsage(toolName, charge, params, 200, Date.now() - startTime);
|
|
247
241
|
}
|
|
248
242
|
|
|
249
243
|
return result;
|
|
250
244
|
} catch (error) {
|
|
245
|
+
// A throw is a failure, and failures are free: nothing is reported.
|
|
251
246
|
outcome = 'error';
|
|
252
247
|
thrown = error;
|
|
253
|
-
// Half-charge on error — but never charge a free (0-cost) call, never
|
|
254
|
-
// let Math.max(1, …) floor a 0 up to 1 credit, and never bill at all
|
|
255
|
-
// if the handler never ran (e.g. the credit check itself threw).
|
|
256
|
-
if (!creatorMode && creditCost > 0 && handlerStarted && preflightRefusal() === null) {
|
|
257
|
-
await authManager.reportUsage(
|
|
258
|
-
toolName,
|
|
259
|
-
Math.max(1, Math.floor(billable() * 0.5)),
|
|
260
|
-
params,
|
|
261
|
-
500,
|
|
262
|
-
Date.now() - startTime
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
248
|
throw error;
|
|
266
249
|
} finally {
|
|
267
250
|
const durationMs = Date.now() - startTime;
|
|
@@ -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.
|
|
5
|
+
version: 6.22.1
|
|
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.
|
|
5
|
+
version: 6.22.1
|
|
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.
|
|
5
|
+
version: 6.22.1
|
|
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.
|
|
5
|
+
version: 6.22.1
|
|
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.
|
|
5
|
+
version: 6.22.1
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -85,4 +85,4 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
85
85
|
only 2 when the plain fetch works; `scrape` then `stealth_mode` costs 7 either
|
|
86
86
|
way. Do not set it on a site that reads fine — it raises the projection your
|
|
87
87
|
balance is checked against.
|
|
88
|
-
-
|
|
88
|
+
- Failed calls are not charged; creator mode is unlimited.
|