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.
Files changed (52) 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 +228 -21
  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 +76 -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 +22 -14
  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/providers/searxng.js +20 -3
  45. package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
  46. package/src/tools/tracking/trackChanges/differ.js +2 -1
  47. package/src/tools/tracking/trackChanges/index.js +6 -1
  48. package/src/tools/tracking/trackChanges/schema.js +15 -1
  49. package/src/utils/fetchError.js +47 -0
  50. package/src/utils/fetchLadder.js +5 -5
  51. package/src/utils/htmlToMarkdown.js +36 -2
  52. 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, url);
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, url);
305
- if (absoluteUrl && !this.visited.has(normalizeUrl(absoluteUrl))) {
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
- return this.parsePage(html, url);
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 linkElement = $(`a[href="${href}"]`).first();
697
+ const element = anchorsByHref.get(href);
666
698
 
667
- if (linkElement.length === 0) {
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
- if (processingOptions.removeBoilerplate) {
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
- return this.queue.add(fn, { priority });
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 -> 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 ──────────────────────────────────────────────────────────────
@@ -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.1
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.21.1
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.21.1
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.21.1
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.21.1
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
- - Errors are charged at half the tool cost; creator mode is unlimited.
88
+ - Failed calls are not charged; creator mode is unlimited.