crawlforge-mcp-server 6.21.0 → 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.
Files changed (54) hide show
  1. package/package.json +3 -2
  2. package/server.js +12 -12
  3. package/src/core/ActionExecutor.js +23 -4
  4. package/src/core/AgentOrchestrator.js +141 -19
  5. package/src/core/AuthManager.js +4 -1
  6. package/src/core/LLMsTxtAnalyzer.js +6 -7
  7. package/src/core/LocalizationManager.js +29 -4
  8. package/src/core/ResearchOrchestrator.js +48 -13
  9. package/src/core/SamplingClient.js +38 -8
  10. package/src/core/SnapshotManager.js +32 -13
  11. package/src/core/analysis/ContentAnalyzer.js +26 -25
  12. package/src/core/crawlers/BFSCrawler.js +48 -10
  13. package/src/core/processing/ContentProcessor.js +4 -2
  14. package/src/core/queue/QueueManager.js +16 -2
  15. package/src/resources/ResourceRegistry.js +19 -5
  16. package/src/schemas/toolOutputSchemas.js +23 -15
  17. package/src/server/fallbackHints.js +37 -7
  18. package/src/server/withAuth.js +27 -44
  19. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  20. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  21. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
  22. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
  26. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  28. package/src/tools/advanced/BrowserSessionTool.js +11 -2
  29. package/src/tools/advanced/batchScrape/queue.js +4 -2
  30. package/src/tools/advanced/batchScrape/worker.js +50 -11
  31. package/src/tools/agent/agent.js +1 -1
  32. package/src/tools/basic/_fetch.js +2 -1
  33. package/src/tools/basic/extractMetadata.js +2 -1
  34. package/src/tools/basic/extractText.js +12 -8
  35. package/src/tools/crawl/mapSite.js +8 -0
  36. package/src/tools/extract/extractContent.js +2 -1
  37. package/src/tools/extract/extractStructured.js +4 -2
  38. package/src/tools/extract/extractWithLlm.js +8 -4
  39. package/src/tools/extract/processDocument.js +29 -1
  40. package/src/tools/extract/summarizeContent.js +5 -1
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
  42. package/src/tools/scrape/_mainContent.js +54 -2
  43. package/src/tools/scrape/unifiedScrape.js +94 -6
  44. package/src/tools/search/adapters/braveSearch.js +27 -5
  45. package/src/tools/search/providers/searxng.js +20 -3
  46. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  47. package/src/tools/search/searchWeb.js +2 -2
  48. package/src/tools/tracking/trackChanges/differ.js +2 -1
  49. package/src/tools/tracking/trackChanges/index.js +6 -1
  50. package/src/tools/tracking/trackChanges/schema.js +15 -1
  51. package/src/utils/fetchError.js +47 -0
  52. package/src/utils/fetchLadder.js +5 -5
  53. package/src/utils/htmlToMarkdown.js +36 -2
  54. 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 -> PNG buffer
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 - PNG buffer or base64 string
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: RESOURCE_MIME.screenshot,
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: RESOURCE_MIME.screenshot,
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` is injected by withAuth into the legacy JSON-text copy of the
21
- // result (never into structuredContent directly) — included here anyway so
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, half-rate on error)'),
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), present when injected into the text copy of the result');
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 ──────────────────────────────────────────────────────────────
@@ -306,7 +310,7 @@ const searchWebShape = {
306
310
  effective_query: z.string().optional().describe('Present when query expansion changed the query actually used'),
307
311
  expanded_queries: z.array(z.string()).optional().describe('Present only when the original query returned nothing: the queries searched, in order - the original, then its expanded form'),
308
312
  results: z.array(searchWebResultShape).optional(),
309
- total_results: z.union([z.string(), z.number()]).optional(),
313
+ total_results: z.union([z.string(), z.number()]).nullable().optional().describe('The backend\'s total match count; null when it reports none (Brave Search API)'),
310
314
  search_time: z.number().optional(),
311
315
  offset: z.number().optional(),
312
316
  limit: z.number().optional(),
@@ -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
- // A wait for a selector that never appeared is a selector problem, which the
141
- // tool's own hint covers; a longer timeout would not help.
142
- if (/\btime[ds]? ?out\b|\bTimeout \d+ ?ms exceeded|\bETIMEDOUT\b|aborted due to timeout/i.test(msg) &&
143
- !/waiting for (?:locator|selector)|waitFor/i.test(msg)) return { cls: 'timeout' };
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}`;
@@ -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, preflightRefusal, reportedActualCost, requestContext } from './requestContext.js';
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). That is still an ERROR
161
- // outcome: bill it the half-credit error rate — not full — and log/metric
162
- // it as an error, honoring CLAUDE.md's "half credits on error" contract.
163
- const isErrorResult = result?.isError === true;
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
- const base = billable();
195
- const charge = creditCost === 0 || refused
196
- ? 0
197
- : (isErrorResult ? Math.max(1, Math.floor(base * 0.5)) : base);
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
- ? Infinity
206
- : internal
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). Both act on isError results and on a JSON body
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 && creditCost > 0 && !refused && charge > 0) {
246
- await authManager.reportUsage(toolName, charge, params, isErrorResult ? 500 : 200, Date.now() - startTime);
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.21.0
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.21.0
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.21.0
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.21.0
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.21.0
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
- - Errors are charged at half the tool cost; creator mode is unlimited.
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.21.0
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. Typical flow: create a context, then create a
36
- page that navigates to the target URL.
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
- ```json
39
- {
40
- "tool": "stealth_mode",
41
- "params": {
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.21.0
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.21.0
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
- content.markdown = (params.onlyMainContent && extracted.content?.markdown) || htmlToMarkdown(html);
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: result.reason?.message || 'Unknown error',
64
+ error,
65
+ next_step: failedEntryHint(error),
64
66
  timestamp: Date.now()
65
67
  };
66
68
  });