crawlforge-mcp-server 5.6.11 → 5.8.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.
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { z } from 'zod';
18
- import { SCRAPE_STRING_FORMATS } from '../tools/scrape/formats.js';
18
+ import { SCRAPE_STRING_FORMATS, SCRAPE_OBJECT_FORMATS } from '../tools/scrape/formats.js';
19
19
 
20
20
  // `_cost` is injected by withAuth into the legacy JSON-text copy of the
21
21
  // result (never into structuredContent directly) — included here anyway so
@@ -28,6 +28,20 @@ const costShape = z.object({
28
28
  projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
29
29
  }).passthrough().optional().describe('Cost-transparency metadata (D3.5), present when injected into the text copy of the result');
30
30
 
31
+ // ── result handles (Phase 2) ────────────────────────────────────────────────
32
+
33
+ // A result over max_inline_chars comes back as a preview plus a handle for
34
+ // read_result; these fields describe that shape wherever it can appear.
35
+ const resultHandleShape = {
36
+ preview: z.string().optional().describe('The first max_inline_chars characters of the view named by view_path (or of the pretty-printed JSON)'),
37
+ result_handle: z.string().optional().describe('Handle for read_result; the full result is kept 1 hour'),
38
+ total_chars: z.number().optional().describe('Length of the full view in characters'),
39
+ view: z.enum(['text', 'json']).optional().describe('Whether preview and read_result offsets index a text field or the pretty-printed JSON'),
40
+ view_path: z.string().nullable().optional().describe('Dotted path of the text field the view was cut from; null for the JSON view'),
41
+ truncated: z.boolean().optional().describe('True when the inline result is a preview'),
42
+ expires_at: z.string().optional().describe('When the stored result is dropped (ISO 8601)')
43
+ };
44
+
31
45
  // ── scrape ──────────────────────────────────────────────────────────────────
32
46
 
33
47
  const scrapeLinkShape = z.object({
@@ -59,11 +73,23 @@ const scrapeMetadataShape = z.object({
59
73
  url: z.string().optional()
60
74
  }).passthrough();
61
75
 
76
+ // A sentence, table row or fenced code block returned verbatim by the
77
+ // highlights and question formats; `offset`/`length` index the `markdown`
78
+ // format of the same call.
79
+ const scrapeHighlightUnitShape = z.object({
80
+ text: z.string().optional().describe('Verbatim page text: markdown.slice(offset, offset + length) === text'),
81
+ kind: z.enum(['sentence', 'table_row', 'code_block']).optional(),
82
+ offset: z.number().optional().describe('JS string index into the markdown format of this call'),
83
+ length: z.number().optional(),
84
+ score: z.number().optional().describe('BM25 relevance to the query, higher is better')
85
+ }).passthrough();
86
+
62
87
  // One content key per string format `scrape` accepts (the "screenshot"
63
- // format fills `screenshots`), plus `json` for the {type:"json"} object
64
- // format. Checked against the format list the input schema is built from,
65
- // so a format added to formats.js without a shape here fails at load
66
- // instead of shipping a schema that does not describe the result (0.3).
88
+ // format fills `screenshots`), plus one per object format: `json` for
89
+ // {type:"json"}, `highlights` for {type:"highlights"}, `answer` for
90
+ // {type:"question"}. Checked against the format lists the input schema is
91
+ // built from, so a format added to formats.js without a shape here fails at
92
+ // load instead of shipping a schema that does not describe the result (0.3).
67
93
  const scrapeFormatShapes = {
68
94
  markdown: z.string().optional(),
69
95
  html: z.string().optional(),
@@ -72,10 +98,17 @@ const scrapeFormatShapes = {
72
98
  links: scrapeLinksShape.optional(),
73
99
  metadata: scrapeMetadataShape.optional(),
74
100
  branding: z.record(z.unknown()).optional().describe('Static design tokens: colors, fonts, logo'),
75
- screenshots: z.array(z.object({}).passthrough()).optional().describe('Present for the "screenshot" format; each item carries a resourceUri once published')
101
+ screenshots: z.array(z.object({}).passthrough()).optional().describe('Present for the "screenshot" format; each item carries a resourceUri once published'),
102
+ json: z.unknown().optional().describe('Result of the {type:"json"} format (LLM-structured extraction)'),
103
+ highlights: z.array(scrapeHighlightUnitShape).optional().describe('Result of the {type:"highlights"} format: the units matching the query, best first, verbatim with offsets'),
104
+ answer: z.object({
105
+ text: z.string().optional().describe('Extractive mode: the evidence texts joined; model mode: the model\'s answer'),
106
+ grounded: z.boolean().optional().describe('True when every number and proper noun in text appears in the evidence or the question; always true in extractive mode'),
107
+ evidence: z.array(scrapeHighlightUnitShape).optional().describe('The units the answer rests on, verbatim with offsets')
108
+ }).passthrough().optional().describe('Result of the {type:"question"} format')
76
109
  };
77
- const scrapeContentKey = (format) => (format === 'screenshot' ? 'screenshots' : format);
78
- for (const format of SCRAPE_STRING_FORMATS) {
110
+ const scrapeContentKey = (format) => ({ screenshot: 'screenshots', question: 'answer' }[format] ?? format);
111
+ for (const format of [...SCRAPE_STRING_FORMATS, ...SCRAPE_OBJECT_FORMATS]) {
79
112
  if (!scrapeFormatShapes[scrapeContentKey(format)]) {
80
113
  throw new Error(`toolOutputSchemas: scrape format "${format}" has no output shape`);
81
114
  }
@@ -91,11 +124,9 @@ const scrapeShape = {
91
124
  vendor: z.string().optional(),
92
125
  evidence: z.string().optional()
93
126
  }).passthrough().optional().describe('Present when a bot-defence vendor served a challenge page; the fallback hint names the tool to try next'),
94
- content: z.object({
95
- ...scrapeFormatShapes,
96
- json: z.unknown().optional().describe('Result of the {type:"json"} format (LLM-structured extraction)')
97
- }).passthrough().optional().describe('One key per requested format'),
127
+ content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
98
128
  warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
129
+ ...resultHandleShape,
99
130
  _cost: costShape
100
131
  };
101
132
 
@@ -344,6 +375,45 @@ const crawlDeepShape = {
344
375
  }).passthrough().optional(),
345
376
  crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
346
377
  cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
378
+ ...resultHandleShape,
379
+ _cost: costShape
380
+ };
381
+
382
+ // ── read_result ───────────────────────────────────────────────────────────────
383
+
384
+ const readResultMatchShape = z.object({
385
+ offset: z.number().optional().describe('Index of the match in the view'),
386
+ length: z.number().optional(),
387
+ context_offset: z.number().optional().describe('Index of the first character of context in the view'),
388
+ context: z.string().optional().describe('Verbatim text around the match: view.slice(context_offset, context_offset + context.length)')
389
+ }).passthrough();
390
+
391
+ const readResultShape = {
392
+ handle: z.string().optional(),
393
+ tool: z.string().optional().describe('The tool that produced the stored result'),
394
+ operation: z.enum(['slice', 'search', 'lines', 'json_path']).optional(),
395
+ view: z.enum(['text', 'json']).optional(),
396
+ view_path: z.string().nullable().optional(),
397
+ total_chars: z.number().optional().describe('Length of the full view'),
398
+ expires_at: z.string().optional(),
399
+ offset: z.number().optional().describe('slice: first character returned'),
400
+ length: z.number().optional().describe('slice: characters returned'),
401
+ text: z.string().optional().describe('slice: verbatim view.slice(offset, offset + length)'),
402
+ has_more: z.boolean().optional().describe('slice/lines: more follows the returned range'),
403
+ query: z.string().optional(),
404
+ matches: z.array(readResultMatchShape).optional().describe('search: matches with 200 chars of context each side'),
405
+ total_matches: z.number().optional(),
406
+ first_line: z.number().optional(),
407
+ line_count: z.number().optional(),
408
+ total_lines: z.number().optional(),
409
+ char_offset: z.number().optional().describe('lines: view offset of the first returned line'),
410
+ lines: z.array(z.string()).optional(),
411
+ path: z.string().optional(),
412
+ value: z.unknown().optional().describe('json_path: the subtree; null with a preview when it is over max_inline_chars'),
413
+ value_chars: z.number().optional(),
414
+ preview: z.string().optional(),
415
+ truncated: z.boolean().optional().describe('search: more matches than returned; json_path: value replaced by a preview'),
416
+ warnings: z.array(z.string()).optional(),
347
417
  _cost: costShape
348
418
  };
349
419
 
@@ -356,7 +426,8 @@ export const OUTPUT_SCHEMAS = {
356
426
  reddit_search: redditSearchShape,
357
427
  search_web: searchWebShape,
358
428
  extract_structured: extractStructuredShape,
359
- crawl_deep: crawlDeepShape
429
+ crawl_deep: crawlDeepShape,
430
+ read_result: readResultShape
360
431
  };
361
432
 
362
433
  export default OUTPUT_SCHEMAS;
@@ -24,6 +24,7 @@ export const FALLBACK_HINTS = Object.freeze({
24
24
  map_site: 'Use crawl_deep with extract_content:false to discover URLs by following links when there is no sitemap.',
25
25
  batch_scrape: 'For an async job poll get_batch_results with the batchId; scrape one of the URLs alone to diagnose a per-URL failure.',
26
26
  get_batch_results: 'An unknown or expired batchId cannot be recovered - run batch_scrape again only if the results are still needed.',
27
+ read_result: 'An unknown or expired handle cannot be recovered - results are kept 1 hour; run the original tool again only if the result is still needed.',
27
28
  process_document: 'For an HTML page use scrape formats:["markdown"]; sourceType:"pdf_url" needs a URL that serves a PDF.',
28
29
  summarize_content: 'Pass the text itself (e.g. markdown from a scrape result), not a URL.',
29
30
  analyze_content: 'Pass the text itself (e.g. markdown from a scrape result), not a URL.',
@@ -0,0 +1,149 @@
1
+ /**
2
+ * inlineThreshold — the one declaration (G5) of `max_inline_chars` and of
3
+ * the rule that turns an oversized tool result into a preview plus a
4
+ * result_handle for read_result (Phase 2).
5
+ *
6
+ * Applied by withAuth after a successful handler run and before `_cost`
7
+ * injection, for the tools listed in INLINE_THRESHOLD_TOOLS only. Internal
8
+ * (website REST proxy) requests are never shaped here — the website applies
9
+ * its own threshold with its own store.
10
+ */
11
+
12
+ import { z } from 'zod';
13
+
14
+ export const DEFAULT_MAX_INLINE_CHARS = 40000;
15
+
16
+ export const MAX_INLINE_CHARS_PARAM = {
17
+ max_inline_chars: z.number().int().min(1000).max(10_000_000).optional().describe("Largest result to return inline, in characters of its JSON. Over it, the call returns a preview plus a result_handle for read_result instead of the whole result (default 40,000; env CRAWLFORGE_MAX_INLINE_CHARS)")
18
+ };
19
+
20
+ /**
21
+ * Per-tool rule. `textPaths` are dotted paths tried in order; the first one
22
+ * holding a string becomes the text view the preview and read_result work
23
+ * on. An empty list means the pretty-printed JSON is the view. `truncate:
24
+ * false` keeps the whole result inline and only adds the handle
25
+ * (extract_embedded_state's never-truncate rule). `when` gates on params.
26
+ */
27
+ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
28
+ scrape: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.rawHtml'], truncate: true },
29
+ fetch_url: { textPaths: ['body'], truncate: true },
30
+ extract_content: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.cleanedHTML'], truncate: true },
31
+ crawl_deep: { textPaths: [], truncate: true },
32
+ batch_scrape: { textPaths: [], truncate: true },
33
+ stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
34
+ scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
35
+ process_document: { textPaths: ['content.text'], truncate: true },
36
+ deep_research: { textPaths: [], truncate: true },
37
+ extract_embedded_state: { textPaths: [], truncate: false }
38
+ });
39
+
40
+ /** param -> env (an int of at least 1,000) -> default. */
41
+ export function resolveMaxInlineChars(params, env = process.env) {
42
+ const fromParam = params?.max_inline_chars;
43
+ if (Number.isInteger(fromParam) && fromParam >= 1000) return fromParam;
44
+ const fromEnv = Number.parseInt(env?.CRAWLFORGE_MAX_INLINE_CHARS ?? '', 10);
45
+ if (Number.isInteger(fromEnv) && fromEnv >= 1000) return fromEnv;
46
+ return DEFAULT_MAX_INLINE_CHARS;
47
+ }
48
+
49
+ /** Read a dotted path ("content.markdown") off an object; undefined when absent. */
50
+ export function readDottedPath(object, dotted) {
51
+ let current = object;
52
+ for (const key of dotted.split('.')) {
53
+ if (current === null || typeof current !== 'object') return undefined;
54
+ current = current[key];
55
+ }
56
+ return current;
57
+ }
58
+
59
+ /**
60
+ * The text a preview and read_result operate on: the first configured path
61
+ * holding a string, else the pretty-printed JSON of the whole result.
62
+ * @returns {{ view: 'text'|'json', view_path: string|null, text: string }}
63
+ */
64
+ export function resultTextView(resultObject, textPaths = []) {
65
+ for (const path of textPaths) {
66
+ const value = readDottedPath(resultObject, path);
67
+ if (typeof value === 'string') return { view: 'text', view_path: path, text: value };
68
+ }
69
+ return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
70
+ }
71
+
72
+ function warningsOf(resultObject) {
73
+ return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
74
+ }
75
+
76
+ /**
77
+ * @param {string} toolName
78
+ * @param {object} resultObject — the parsed JSON result
79
+ * @param {object} params — the tool params (max_inline_chars, url, operation)
80
+ * @param {{ store: import('../core/ResultStore.js').ResultStore, env?: object }} deps
81
+ * @returns {{ result: object, stored: boolean }}
82
+ */
83
+ export function applyInlineThreshold(toolName, resultObject, params, { store, env = process.env }) {
84
+ const unchanged = { result: resultObject, stored: false };
85
+ const config = INLINE_THRESHOLD_TOOLS[toolName];
86
+ if (!config || (config.when && !config.when(params))) return unchanged;
87
+ if (!resultObject || typeof resultObject !== 'object' || Array.isArray(resultObject)) return unchanged;
88
+
89
+ const maxInline = resolveMaxInlineChars(params, env);
90
+ const json = JSON.stringify(resultObject);
91
+ if (json.length <= maxInline) return unchanged;
92
+
93
+ const { view, view_path, text } = resultTextView(resultObject, config.textPaths);
94
+
95
+ let handle;
96
+ try {
97
+ handle = store.put(toolName, resultObject, {
98
+ meta: { view, view_path, url: resultObject.url ?? params?.url ?? null }
99
+ });
100
+ } catch {
101
+ return {
102
+ result: { ...resultObject, warnings: [...warningsOf(resultObject), 'result could not be stored; returned inline'] },
103
+ stored: false
104
+ };
105
+ }
106
+
107
+ const expires_at = new Date(Date.now() + store.ttlMs).toISOString();
108
+ const viewDesc = view === 'text' ? `the ${view_path} text` : 'the pretty-printed JSON';
109
+ const readWith = 'read it with read_result (1 credit) - operation "search" (query), "slice" (offset, length), "lines" or "json_path" (path) - and do not fetch the page again';
110
+
111
+ if (!config.truncate) {
112
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; it is returned whole (this tool never truncates) and is also kept for 1 hour under result_handle ${handle}: ${readWith}.`;
113
+ return {
114
+ result: {
115
+ ...resultObject,
116
+ result_handle: handle,
117
+ total_chars: text.length,
118
+ view,
119
+ view_path,
120
+ truncated: false,
121
+ expires_at,
122
+ warnings: [...warningsOf(resultObject), hint]
123
+ },
124
+ stored: true
125
+ };
126
+ }
127
+
128
+ const preview = text.slice(0, maxInline);
129
+ const hint = `Result is ${json.length} chars as JSON, over the inline limit of ${maxInline}; preview holds the first ${preview.length} chars of ${viewDesc} (${text.length} chars in total) and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
130
+
131
+ const shaped = {};
132
+ for (const [key, value] of Object.entries(resultObject)) {
133
+ if (key === 'warnings') continue;
134
+ if (value === null || typeof value === 'number' || typeof value === 'boolean' || (typeof value === 'string' && value.length <= 200)) {
135
+ shaped[key] = value;
136
+ }
137
+ }
138
+ Object.assign(shaped, {
139
+ preview,
140
+ result_handle: handle,
141
+ total_chars: text.length,
142
+ view,
143
+ view_path,
144
+ truncated: true,
145
+ expires_at,
146
+ warnings: [...warningsOf(resultObject), hint]
147
+ });
148
+ return { result: shaped, stored: true };
149
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * toolFilter — client-side tool selection (Phase 6).
3
3
  *
4
- * Lets an MCP client load a subset of the 29 registered tools via env vars,
4
+ * Lets an MCP client load a subset of the 30 registered tools via env vars,
5
5
  * cutting context bloat (mirrors Bright Data / Exa's TOOLS / GROUPS pattern).
6
6
  *
7
7
  * Pure module: no I/O, no logging; process.env is only read via
@@ -9,12 +9,14 @@
9
9
  * tests.
10
10
  */
11
11
 
12
+ import { INLINE_THRESHOLD_TOOLS } from './inlineThreshold.js';
13
+
12
14
  // The full set of tool names server.js registers, grouped by category.
13
15
  export const TOOL_GROUPS = {
14
- basic: ['fetch_url', 'extract_text', 'extract_links', 'extract_metadata', 'scrape_structured'],
15
- search: ['search_web', 'serp_rank'],
16
+ basic: ['fetch_url', 'extract_text', 'extract_links', 'extract_metadata', 'scrape_structured', 'read_result'],
17
+ search: ['search_web', 'serp_rank', 'reddit_search'],
16
18
  crawl: ['crawl_deep', 'map_site'],
17
- extract: ['extract_content', 'process_document', 'summarize_content', 'analyze_content', 'extract_structured', 'extract_with_llm', 'list_ollama_models'],
19
+ extract: ['extract_content', 'process_document', 'summarize_content', 'analyze_content', 'extract_structured', 'extract_with_llm', 'list_ollama_models', 'extract_embedded_state'],
18
20
  batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions'],
19
21
  research: ['deep_research'],
20
22
  tracking: ['track_changes'],
@@ -84,6 +86,11 @@ export function createToolFilter(env = process.env) {
84
86
  if (enabled.has('batch_scrape')) {
85
87
  enabled.add('get_batch_results');
86
88
  }
89
+ // Likewise a tool whose large results come back as a result_handle names
90
+ // read_result in its hint, so that hint must never point at an unregistered tool.
91
+ if (Object.keys(INLINE_THRESHOLD_TOOLS).some((toolName) => enabled.has(toolName))) {
92
+ enabled.add('read_result');
93
+ }
87
94
 
88
95
  return {
89
96
  isEnabled(toolName) {
@@ -19,6 +19,22 @@ import { createHash } from 'node:crypto';
19
19
  import { recordToolInvocation } from '../observability/tracing.js';
20
20
  import { isInternalRequest, preflightRefusal, reportedActualCost, requestContext } from './requestContext.js';
21
21
  import { appendFallbackHint } from './fallbackHints.js';
22
+ import { INLINE_THRESHOLD_TOOLS, applyInlineThreshold } from './inlineThreshold.js';
23
+ import { getResultStore } from '../core/ResultStore.js';
24
+
25
+ /**
26
+ * Replace an over-threshold JSON text result (and its structuredContent, if
27
+ * any) with the shaped preview + handle. Non-JSON text is left alone.
28
+ */
29
+ function shapeLargeResult(toolName, result, params) {
30
+ if (!result || !Array.isArray(result.content) || result.content[0]?.type !== 'text') return;
31
+ let parsed;
32
+ try { parsed = JSON.parse(result.content[0].text); } catch { return; }
33
+ const { result: shaped, stored } = applyInlineThreshold(toolName, parsed, params, { store: getResultStore() });
34
+ if (!stored) return;
35
+ result.content[0].text = JSON.stringify(shaped, null, 2);
36
+ if (result.structuredContent) result.structuredContent = shaped;
37
+ }
22
38
 
23
39
  export function hashParams(params) {
24
40
  try {
@@ -99,6 +115,15 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
99
115
  // multi-URL tool that skipped one disallowed URL and still returned a
100
116
  // result did real work and bills for it.
101
117
  const refused = isErrorResult && preflightRefusal() !== null;
118
+
119
+ // Phase 2: a result over max_inline_chars is stored and returned as
120
+ // a preview plus a result_handle for read_result. Internal (website
121
+ // REST proxy) requests are left whole — the website applies its own
122
+ // threshold with its own store.
123
+ if (!isErrorResult && !internal && toolName in INLINE_THRESHOLD_TOOLS) {
124
+ try { shapeLargeResult(toolName, result, params); } catch { /* shaping must never break the request path */ }
125
+ }
126
+
102
127
  const base = billable();
103
128
  const charge = creditCost === 0 || refused
104
129
  ? 0
@@ -71,7 +71,8 @@ CLI: `crawlforge batch urls.txt --format markdown --concurrency 10`.
71
71
  Use the `batchId` from `batch_scrape` to retrieve paginated results for a
72
72
  completed or in-progress job. Cheap (1 credit) because the batch was already
73
73
  paid for. Completed jobs are also exposed as `crawlforge://job/{jobId}`
74
- resources.
74
+ resources. Stored batch results share the local 1-hour result store that
75
+ `read_result` reads, with the same eviction, so page through a batch within the hour.
75
76
 
76
77
  ## scrape_with_actions — interact, then scrape (cost: 5)
77
78
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: crawlforge-getting-started
3
- description: "Orientation and tool-selection guide for the CrawlForge MCP server's 29 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), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
3
+ description: "Orientation and tool-selection guide for the CrawlForge MCP server's 30 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), and explains MCP-tools-vs-CLI, the Ollama-first LLM fallback chain, and per-tool credit costs."
4
4
  metadata:
5
5
  version: 5.6.6
6
6
  source: crawlforge-mcp-server
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # CrawlForge: Getting Started
10
10
 
11
- CrawlForge is an MCP server with **29 tools** for web scraping, crawling,
11
+ CrawlForge is an MCP server with **30 tools** for web scraping, crawling,
12
12
  extraction, research, change tracking, and AI-compliance. This skill orients you
13
13
  and routes each request to the right specialized skill.
14
14
 
@@ -52,9 +52,9 @@ stored at `~/.crawlforge/config.json`.
52
52
  | Watch a page for changes / monitor pricing | **crawlforge-change-tracking** |
53
53
  | Scrape many URLs, run browser actions, generate llms.txt | **crawlforge-batch-automation** |
54
54
 
55
- ## The 29 tools at a glance
55
+ ## The 30 tools at a glance
56
56
 
57
- - **Basic (5):** fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
57
+ - **Basic (6):** fetch_url, extract_text, extract_links, extract_metadata, scrape_structured, read_result
58
58
  - **Unified (1):** scrape (multi-format single fetch)
59
59
  - **Search & research (5):** search_web, serp_rank, reddit_search, deep_research, agent
60
60
  - **Crawl (2):** crawl_deep, map_site
@@ -95,6 +95,7 @@ Do not suggest adding API keys — local Ollama is the intended zero-cost defaul
95
95
  | Single page too slow / many pages | `batch_scrape` (async + webhook) |
96
96
  | Wrong region / currency shown | `localization` |
97
97
  | Need a big report but cost is high | lower `maxUrls` on `deep_research` |
98
+ | Result came back `truncated: true` with a `result_handle` | `read_result` (search, slice, lines, json_path) — never fetch the page again |
98
99
 
99
100
  ## Credits
100
101
 
@@ -14,12 +14,13 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
14
14
  | `scrape_template` | Pre-built site extractor. |
15
15
  | `list_ollama_models` | List local LLMs. |
16
16
  | `get_batch_results` | Retrieve an already-paid batch. |
17
+ | `read_result` | Search, slice, lines or a JSON path over a stored result (`result_handle`). |
17
18
 
18
19
  ## 2 credits
19
20
 
20
21
  | Tool | Notes |
21
22
  |------|-------|
22
- | `scrape` | Unified multi-format single fetch. |
23
+ | `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. |
23
24
  | `scrape_structured` | CSS-selector extraction. |
24
25
  | `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
25
26
  | `extract_content` | Readability-cleaned article. |
@@ -74,4 +75,6 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
74
75
  - Cap dynamic tools: `deep_research`/`agent` via `maxUrls`, `crawl_deep` via
75
76
  `max_pages`, `batch_scrape` via the URL list size.
76
77
  - `get_batch_results` (1) is cheap — submit a batch once, page through results.
78
+ - A result over `max_inline_chars` costs nothing extra; read the stored copy with
79
+ `read_result` (1) instead of fetching again.
77
80
  - Errors are charged at half the tool cost; creator mode is unlimited.
@@ -22,6 +22,7 @@ site discovery (sitemaps and URL maps), and whole-site crawling.
22
22
  - "Get the title / meta / Open Graph / SEO tags" → `extract_metadata`
23
23
  - "Map all the URLs on this site" / "generate a sitemap" → `map_site`
24
24
  - "Crawl the whole docs site" / "index every page" → `crawl_deep`
25
+ - "Read more of that truncated result" → `read_result`
25
26
 
26
27
  ## Tool selection (fastest path first)
27
28
 
@@ -58,6 +59,35 @@ Get markdown + links + metadata in a single call:
58
59
  a failing format adds a `warnings[]` entry instead of failing the whole call.
59
60
  `onlyMainContent` (default `true`) strips boilerplate via Readability.
60
61
 
62
+ Query-scoped formats return only the parts of the page that match, verbatim,
63
+ with offsets into the `markdown` of the same call:
64
+
65
+ ```json
66
+ {
67
+ "tool": "scrape",
68
+ "params": {
69
+ "url": "https://example.com/pricing",
70
+ "formats": [
71
+ "markdown",
72
+ { "type": "highlights", "query": "professional plan price", "max_highlights": 5 },
73
+ { "type": "question", "question": "How much is the professional plan per month?" }
74
+ ]
75
+ }
76
+ }
77
+ ```
78
+
79
+ `{ "type": "highlights", "query", "max_highlights"?, "mode"? }` returns
80
+ `content.highlights: [{ text, kind, offset, length, score }]`, best first;
81
+ `kind` is `sentence`, `table_row` or `code_block`, and
82
+ `markdown.slice(offset, offset + length) === text`. `{ "type": "question",
83
+ "question", "mode"? }` returns `content.answer: { text, grounded, evidence }`,
84
+ with `text` the evidence joined and `grounded: true`. Either adds 1 credit once
85
+ per call and calls no model. `"mode": "model"` adds 3 once per call: the model
86
+ chooses the highlights (never rewrites them) or writes the answer, and a
87
+ grounding check sets `grounded: false` when the answer holds a number or a name
88
+ the evidence does not. Ask for `"markdown"` in the same call to quote with a
89
+ locator.
90
+
61
91
  CLI equivalent:
62
92
 
63
93
  ```bash
@@ -131,6 +161,17 @@ BFS crawl up to depth 5 / 1000 pages. Use `include_patterns` /
131
161
  (on by default). Crawls projected over ~500 pages trigger a confirmation
132
162
  prompt (elicitation). CLI: `crawlforge crawl https://docs.example.com --depth 3 --max-pages 200`.
133
163
 
164
+ ## Large results — `max_inline_chars` and `read_result` (cost: 1)
165
+
166
+ `scrape`, `fetch_url`, `extract_content` and `crawl_deep` accept
167
+ `max_inline_chars` (default 40,000). A larger result comes back as its scalar
168
+ fields plus `preview` (the first `max_inline_chars` characters of its text
169
+ view), `result_handle`, `total_chars`, `truncated: true` and `expires_at`; the
170
+ full result stays 1 hour on the local machine, never uploaded. Read it with
171
+ `read_result` instead of fetching again: `operation:"search"` with a `query`
172
+ finds offsets, then `operation:"slice"` with `offset`/`length` reads around one;
173
+ `operation:"json_path"` with a `path` picks one field (`"lines"` reads by line).
174
+
134
175
  ## Cost note
135
176
 
136
177
  `scrape` (2 credits) is the default page read: one fetch returns every format
@@ -138,6 +179,7 @@ you ask for. `fetch_url`, `extract_text`, `extract_links`, `extract_metadata`
138
179
  (1 each) are cheaper only when one of them is the whole job; `fetch_url` followed
139
180
  by an `extract_*` call on the same URL is a double fetch that costs more than one
140
181
  `scrape`. `extract_content`, `map_site` = 2 · `crawl_deep` = 4 (grows with
141
- `max_pages`). Never re-fetch a page whose content is already in the conversation.
182
+ `max_pages`) · `read_result` = 1 (reads a stored result, no fetch). Never re-fetch
183
+ a page whose content is already in the conversation.
142
184
 
143
185
  See [tool reference](references/tool-reference.md) for the full parameter list.
@@ -8,9 +8,10 @@ CrawlForge credits.
8
8
  | Param | Type | Default | Notes |
9
9
  |-------|------|---------|-------|
10
10
  | `url` | string (URL) | — | Required. |
11
- | `formats` | array | `["markdown"]` | Any of `markdown`, `html`, `rawHtml`, `text`, `links`, `metadata`, `screenshot`, or `{type:"json", schema?, prompt?}`. |
11
+ | `formats` | array | `["markdown"]` | Any of `markdown`, `html`, `rawHtml`, `text`, `links`, `metadata`, `branding`, `screenshot`, `{type:"json", schema?, prompt?}`, `{type:"highlights", query, max_highlights?, mode?}` (the matching sentences, table rows and code blocks, verbatim, with offsets into the markdown; +1 credit once per call) or `{type:"question", question, mode?}` (an answer assembled from the evidence units; same +1). `mode:"model"` adds 3 credits and needs an LLM route. |
12
12
  | `onlyMainContent` | boolean | `true` | Strip boilerplate via Readability. |
13
13
  | `timeoutMs` | number | `15000` | 1000–60000. |
14
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
14
15
 
15
16
  Partial success: a format that fails produces a `warnings[]` entry rather than
16
17
  failing the whole call. `{type:"json"}` may incur external LLM cost (billed by
@@ -23,6 +24,7 @@ your provider).
23
24
  | `url` | string (URL) | — | Required. |
24
25
  | `headers` | object | — | Custom HTTP headers (e.g. auth tokens). |
25
26
  | `timeout` | number | `10000` | 1000–30000 ms. |
27
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
26
28
 
27
29
  ## extract_text (cost: 1)
28
30
 
@@ -53,6 +55,7 @@ your provider).
53
55
  |-------|------|-------|
54
56
  | `url` | string (URL) | Required. Readability-cleaned article body (markdown). |
55
57
  | `options` | object | Additional extraction options. |
58
+ | `max_inline_chars` | number | Default 40000 (1,000–10,000,000). A larger result returns `preview` + `result_handle` for `read_result` (below). |
56
59
 
57
60
  ## map_site (cost: 2)
58
61
 
@@ -82,10 +85,33 @@ your provider).
82
85
  | `concurrency` | number | — | 1–20 concurrent requests. |
83
86
  | `enable_link_analysis` | boolean | — | Compute PageRank over crawled pages. |
84
87
  | `session` | object | — | Shared cookie jar for login-then-crawl. |
88
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. A larger result returns `preview` + `result_handle` for `read_result` (below). |
85
89
 
86
90
  *Server default honors `RESPECT_ROBOTS_TXT`. Crawls projected over ~500 pages
87
91
  trigger an elicitation confirmation.
88
92
 
93
+ ## read_result (cost: 1)
94
+
95
+ Reads a result a tool returned with `truncated: true` and a `result_handle`
96
+ (kept 1 hour, on the local machine; an unknown or expired handle is an error).
97
+
98
+ | Param | Type | Default | Notes |
99
+ |-------|------|---------|-------|
100
+ | `handle` | string | — | Required. The `result_handle` (`res_…`). |
101
+ | `operation` | enum | — | Required. `slice`, `search`, `lines` or `json_path`. |
102
+ | `offset` | number | — | `slice`: character offset; `lines`: first line (default 0). |
103
+ | `length` | number | — | `slice`: characters (default 10,000); `lines`: lines (default 200, at most 5,000). |
104
+ | `query` | string | — | `search`: case-insensitive literal substring. |
105
+ | `max_matches` | number | `20` | `search`: 1–100. |
106
+ | `path` | string | — | `json_path`: path into the stored result object (or the parsed body when the stored text is JSON, e.g. a `fetch_url` body). |
107
+ | `max_inline_chars` | number | `40000` | 1,000–10,000,000. |
108
+
109
+ Every response carries `handle`, `tool`, `operation`, `view` (`text` or `json`),
110
+ `view_path`, `total_chars` and `expires_at`. `slice` returns `text` + `has_more`;
111
+ `search` returns `matches[{offset, length, context_offset, context}]` (200
112
+ characters of context each side) + `total_matches`; `lines` returns `lines[]`
113
+ + `total_lines`, `char_offset`, `has_more`; `json_path` returns `value`.
114
+
89
115
  ## CLI quick map
90
116
 
91
117
  | Tool | CLI |