crawlforge-mcp-server 6.21.1 → 6.22.0
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 +141 -19
- 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 +48 -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
|
@@ -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.0
|
|
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.0
|
|
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.0
|
|
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.0
|
|
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.0
|
|
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.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-stealth-browsing
|
|
3
3
|
description: "Bypasses bot detection with CrawlForge's stealth_mode tool and looks up a country's locale settings with localization. Use when a site returns 403 or 429, CAPTCHAs, 'please enable JavaScript', or empty content, or is protected by Cloudflare, DataDome, or PerimeterX, or when the user needs a page in a specific locale, language, or timezone. stealth_mode runs a stealth browser (engine auto by default: camoufox/Firefox for advanced fingerprinting, playwright/Chromium when pinned for speed) and can screenshot; localization returns a country's Accept-Language, timezone and currency for you to pass to fetch_url or stealth_mode - it applies nothing itself and uses no proxy. Explains when to escalate from a normal scrape to stealth, and why a hard block needs a residential proxy."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -32,20 +32,13 @@ Still never reach for `stealth_mode` first.
|
|
|
32
32
|
|
|
33
33
|
## stealth_mode (cost: 5)
|
|
34
34
|
|
|
35
|
-
`stealth_mode` is operation-based.
|
|
36
|
-
|
|
35
|
+
`stealth_mode` is operation-based. `operation:"scrape"` is the one-shot path:
|
|
36
|
+
it renders one URL and returns the requested formats (examples below).
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"operation": "create_context",
|
|
43
|
-
"stealthConfig": { "level": "advanced", "simulateHumanBehavior": true }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Then use the returned `contextId`:
|
|
38
|
+
`create_context` then `create_page` is a navigation and challenge check, not a
|
|
39
|
+
multi-step path: `create_page` loads `urlToTest`, reports its status, title and
|
|
40
|
+
any challenge page it detected, and closes the page before the call returns.
|
|
41
|
+
Nothing on that page carries over to a later call.
|
|
49
42
|
|
|
50
43
|
```json
|
|
51
44
|
{
|
|
@@ -54,6 +47,10 @@ Then use the returned `contextId`:
|
|
|
54
47
|
}
|
|
55
48
|
```
|
|
56
49
|
|
|
50
|
+
For work over several calls on one page (log in, then read; click through a
|
|
51
|
+
flow), use `browser_session` with `stealth:true` (see the
|
|
52
|
+
crawlforge-browser-sessions skill).
|
|
53
|
+
|
|
57
54
|
Operations: `scrape`, `configure`, `create_context`, `create_page`,
|
|
58
55
|
`get_stats`, `cleanup`. `stealthConfig.level` is `basic` / `medium` (default) /
|
|
59
56
|
`advanced`. Always run `cleanup` when done to release the browser.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-structured-extraction
|
|
3
3
|
description: "Extracts structured JSON and analyzes content with CrawlForge's extract_structured, extract_with_llm, extract_embedded_state, scrape_structured, scrape_template, process_document, analyze_content, summarize_content, and list_ollama_models tools. Use when the user wants to extract specific fields, pull data into a JSON schema, extract by natural-language prompt, scrape with CSS selectors, read a page's embedded JavaScript state (__NEXT_DATA__, React Server Components, Nuxt, Apollo, Redux), get product, profile, or repo data from known sites (Amazon, LinkedIn, GitHub, YouTube, Reddit, and more), parse a PDF or DOCX, summarize a page, or analyze sentiment, entities, or keywords. Defaults to local Ollama for LLM extraction; OpenAI and Anthropic optional."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: crawlforge-web-scraping
|
|
3
3
|
description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
|
|
4
4
|
metadata:
|
|
5
|
-
version: 6.
|
|
5
|
+
version: 6.22.0
|
|
6
6
|
source: crawlforge-mcp-server
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from 'zod';
|
|
27
27
|
import { load } from 'cheerio';
|
|
28
|
+
import { absoluteUrls } from 'crawlforge-extractors';
|
|
28
29
|
import { createHash } from 'node:crypto';
|
|
29
30
|
|
|
30
31
|
import ActionExecutor from '../../core/ActionExecutor.js';
|
|
@@ -577,8 +578,16 @@ export class BrowserSessionTool {
|
|
|
577
578
|
if (params.formats.includes('markdown')) {
|
|
578
579
|
// Readability finds no article on most app pages, and then no markdown is
|
|
579
580
|
// produced at all (R20, 2026-09-07). Convert the DOM we hold instead of
|
|
580
|
-
// handing back a placeholder.
|
|
581
|
-
|
|
581
|
+
// handing back a placeholder. The raw DOM carries no base URL, so its
|
|
582
|
+
// links are resolved against the page first: HN's "item?id=…" hrefs came
|
|
583
|
+
// back relative and unusable (R25, 2026-10-19).
|
|
584
|
+
let markdown = params.onlyMainContent && extracted.content?.markdown;
|
|
585
|
+
if (!markdown) {
|
|
586
|
+
const $page = load(html);
|
|
587
|
+
absoluteUrls($page, url);
|
|
588
|
+
markdown = htmlToMarkdown($page.html());
|
|
589
|
+
}
|
|
590
|
+
content.markdown = markdown;
|
|
582
591
|
}
|
|
583
592
|
if (params.formats.includes('json')) {
|
|
584
593
|
content.json = {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Semaphore-based concurrency runner that dispatches work to worker.js.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { scrapeUrl } from './worker.js';
|
|
6
|
+
import { scrapeUrl, failedEntryHint } from './worker.js';
|
|
7
7
|
|
|
8
8
|
/** Semaphore for concurrency limiting. */
|
|
9
9
|
class Semaphore {
|
|
@@ -57,10 +57,12 @@ export async function scrapeUrlsBatch(urlConfigs, options, defaultTimeout) {
|
|
|
57
57
|
|
|
58
58
|
return settled.map((result, index) => {
|
|
59
59
|
if (result.status === 'fulfilled') return result.value;
|
|
60
|
+
const error = result.reason?.message || 'Unknown error';
|
|
60
61
|
return {
|
|
61
62
|
success: false,
|
|
62
63
|
url: urlConfigs[index].url,
|
|
63
|
-
error
|
|
64
|
+
error,
|
|
65
|
+
next_step: failedEntryHint(error),
|
|
64
66
|
timestamp: Date.now()
|
|
65
67
|
};
|
|
66
68
|
});
|