crawlforge-mcp-server 5.6.11 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -4
- package/README.md +8 -5
- package/package.json +3 -3
- package/server.js +59 -18
- package/src/core/AuthManager.js +16 -1
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +89 -13
- package/src/server/fallbackHints.js +35 -9
- package/src/server/inlineThreshold.js +149 -0
- package/src/server/toolFilter.js +11 -4
- package/src/server/withAuth.js +25 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +8 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +73 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +27 -1
- package/src/tools/advanced/batchScrape/index.js +23 -43
- package/src/tools/result/readResult.js +147 -0
- package/src/tools/scrape/_highlights.js +79 -0
- package/src/tools/scrape/escalation.js +62 -0
- package/src/tools/scrape/formats.js +47 -1
- package/src/tools/scrape/unifiedScrape.js +345 -55
|
@@ -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
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
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) => (
|
|
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,14 @@ 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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
127
|
+
escalated: z.boolean().optional().describe('Present only when escalate:true was passed: whether the blocked plain fetch was retried in the stealth browser. False means the plain fetch sufficed and the call is charged at the base price'),
|
|
128
|
+
stealth: z.object({
|
|
129
|
+
engine: z.string().optional(),
|
|
130
|
+
vendor_detected: z.string().nullable().optional()
|
|
131
|
+
}).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran, and the bot-defence vendor the plain fetch hit (null when the block named none)'),
|
|
132
|
+
content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
|
|
98
133
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
134
|
+
...resultHandleShape,
|
|
99
135
|
_cost: costShape
|
|
100
136
|
};
|
|
101
137
|
|
|
@@ -344,6 +380,45 @@ const crawlDeepShape = {
|
|
|
344
380
|
}).passthrough().optional(),
|
|
345
381
|
crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
|
|
346
382
|
cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
|
|
383
|
+
...resultHandleShape,
|
|
384
|
+
_cost: costShape
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// ── read_result ───────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
const readResultMatchShape = z.object({
|
|
390
|
+
offset: z.number().optional().describe('Index of the match in the view'),
|
|
391
|
+
length: z.number().optional(),
|
|
392
|
+
context_offset: z.number().optional().describe('Index of the first character of context in the view'),
|
|
393
|
+
context: z.string().optional().describe('Verbatim text around the match: view.slice(context_offset, context_offset + context.length)')
|
|
394
|
+
}).passthrough();
|
|
395
|
+
|
|
396
|
+
const readResultShape = {
|
|
397
|
+
handle: z.string().optional(),
|
|
398
|
+
tool: z.string().optional().describe('The tool that produced the stored result'),
|
|
399
|
+
operation: z.enum(['slice', 'search', 'lines', 'json_path']).optional(),
|
|
400
|
+
view: z.enum(['text', 'json']).optional(),
|
|
401
|
+
view_path: z.string().nullable().optional(),
|
|
402
|
+
total_chars: z.number().optional().describe('Length of the full view'),
|
|
403
|
+
expires_at: z.string().optional(),
|
|
404
|
+
offset: z.number().optional().describe('slice: first character returned'),
|
|
405
|
+
length: z.number().optional().describe('slice: characters returned'),
|
|
406
|
+
text: z.string().optional().describe('slice: verbatim view.slice(offset, offset + length)'),
|
|
407
|
+
has_more: z.boolean().optional().describe('slice/lines: more follows the returned range'),
|
|
408
|
+
query: z.string().optional(),
|
|
409
|
+
matches: z.array(readResultMatchShape).optional().describe('search: matches with 200 chars of context each side'),
|
|
410
|
+
total_matches: z.number().optional(),
|
|
411
|
+
first_line: z.number().optional(),
|
|
412
|
+
line_count: z.number().optional(),
|
|
413
|
+
total_lines: z.number().optional(),
|
|
414
|
+
char_offset: z.number().optional().describe('lines: view offset of the first returned line'),
|
|
415
|
+
lines: z.array(z.string()).optional(),
|
|
416
|
+
path: z.string().optional(),
|
|
417
|
+
value: z.unknown().optional().describe('json_path: the subtree; null with a preview when it is over max_inline_chars'),
|
|
418
|
+
value_chars: z.number().optional(),
|
|
419
|
+
preview: z.string().optional(),
|
|
420
|
+
truncated: z.boolean().optional().describe('search: more matches than returned; json_path: value replaced by a preview'),
|
|
421
|
+
warnings: z.array(z.string()).optional(),
|
|
347
422
|
_cost: costShape
|
|
348
423
|
};
|
|
349
424
|
|
|
@@ -356,7 +431,8 @@ export const OUTPUT_SCHEMAS = {
|
|
|
356
431
|
reddit_search: redditSearchShape,
|
|
357
432
|
search_web: searchWebShape,
|
|
358
433
|
extract_structured: extractStructuredShape,
|
|
359
|
-
crawl_deep: crawlDeepShape
|
|
434
|
+
crawl_deep: crawlDeepShape,
|
|
435
|
+
read_result: readResultShape
|
|
360
436
|
};
|
|
361
437
|
|
|
362
438
|
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.',
|
|
@@ -41,26 +42,51 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
41
42
|
scrape_template: 'Use template:"list" to see valid template ids, template:"auto" to pick from the URL, or scrape for a site without a template.'
|
|
42
43
|
});
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` that came back
|
|
47
|
+
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
48
|
+
* ordinary hint would send the model back to a tool that has just failed.
|
|
49
|
+
* What is left is a region-specific block, which `localization` can change,
|
|
50
|
+
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
51
|
+
* residential proxies, which CrawlForge does not offer.
|
|
52
|
+
*/
|
|
53
|
+
export const SCRAPE_ESCALATED_HINT =
|
|
54
|
+
'The stealth browser has already run on this URL (escalate:true) - do not call stealth_mode or repeat this call. ' +
|
|
55
|
+
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
56
|
+
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
57
|
+
|
|
58
|
+
/** The hint for this result: the tool's, unless a scrape already escalated. */
|
|
59
|
+
function hintFor(toolName, parsed) {
|
|
60
|
+
if (toolName === 'scrape' && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
61
|
+
return FALLBACK_HINTS[toolName];
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/**
|
|
45
65
|
* Append the hint for `toolName` to an error result in place. No-op for
|
|
46
66
|
* success results, unknown tools, non-text content, or a hint already present.
|
|
47
67
|
*/
|
|
48
68
|
export function appendFallbackHint(toolName, result) {
|
|
49
|
-
|
|
50
|
-
if (!hint || result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
69
|
+
if (result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
51
70
|
const first = result.content[0];
|
|
52
71
|
if (first?.type !== 'text' || typeof first.text !== 'string') return result;
|
|
53
|
-
|
|
72
|
+
|
|
73
|
+
// Parsed first: which hint a `scrape` failure gets depends on whether the
|
|
74
|
+
// body says the escalation already ran.
|
|
75
|
+
let parsed = null;
|
|
54
76
|
try {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
parsed.next_step = hint;
|
|
58
|
-
first.text = JSON.stringify(parsed, null, 2);
|
|
59
|
-
return result;
|
|
60
|
-
}
|
|
77
|
+
const candidate = JSON.parse(first.text);
|
|
78
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsed = candidate;
|
|
61
79
|
} catch {
|
|
62
80
|
// plain text
|
|
63
81
|
}
|
|
82
|
+
|
|
83
|
+
const hint = hintFor(toolName, parsed);
|
|
84
|
+
if (!hint || first.text.includes(hint)) return result;
|
|
85
|
+
if (parsed) {
|
|
86
|
+
parsed.next_step = hint;
|
|
87
|
+
first.text = JSON.stringify(parsed, null, 2);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
64
90
|
first.text = `${first.text}\nNext step: ${hint}`;
|
|
65
91
|
return result;
|
|
66
92
|
}
|
|
@@ -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
|
+
}
|
package/src/server/toolFilter.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/server/withAuth.js
CHANGED
|
@@ -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
|
|
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 **
|
|
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
|
|
55
|
+
## The 30 tools at a glance
|
|
56
56
|
|
|
57
|
-
- **Basic (
|
|
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
|
|
@@ -90,11 +90,13 @@ Do not suggest adding API keys — local Ollama is the intended zero-cost defaul
|
|
|
90
90
|
| Symptom | Try next |
|
|
91
91
|
|---------|----------|
|
|
92
92
|
| `scrape` / `fetch_url` returns 403, 429, CAPTCHA, or empty JS shell | `stealth_mode` (crawlforge-stealth-browsing) |
|
|
93
|
+
| A site you already know blocks | `scrape` with `escalate: true` — plain fetch first, stealth browser only if it is walled; never `stealth_mode` first |
|
|
93
94
|
| No template for a known site | `scrape_structured` → `extract_structured` → `extract_with_llm` |
|
|
94
95
|
| LLM extraction unavailable (no Ollama/keys) | `scrape_structured` with CSS selectors |
|
|
95
96
|
| Single page too slow / many pages | `batch_scrape` (async + webhook) |
|
|
96
97
|
| Wrong region / currency shown | `localization` |
|
|
97
98
|
| Need a big report but cost is high | lower `maxUrls` on `deep_research` |
|
|
99
|
+
| Result came back `truncated: true` with a `result_handle` | `read_result` (search, slice, lines, json_path) — never fetch the page again |
|
|
98
100
|
|
|
99
101
|
## Credits
|
|
100
102
|
|
|
@@ -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. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
|
|
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,10 @@ 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.
|
|
80
|
+
- On a site that blocks, one `scrape` with `escalate:true` costs at most 7 and
|
|
81
|
+
only 2 when the plain fetch works; `scrape` then `stealth_mode` costs 7 either
|
|
82
|
+
way. Do not set it on a site that reads fine — it raises the projection your
|
|
83
|
+
balance is checked against.
|
|
77
84
|
- Errors are charged at half the tool cost; creator mode is unlimited.
|
|
@@ -24,6 +24,11 @@ Escalate from a normal `scrape` / `fetch_url` (see crawlforge-web-scraping) when
|
|
|
24
24
|
`stealth_mode` drives a real browser with randomized fingerprints, human
|
|
25
25
|
behavior simulation, and WebRTC/canvas/WebGL spoofing.
|
|
26
26
|
|
|
27
|
+
When you already know the site blocks, `scrape` with `escalate: true` does both
|
|
28
|
+
steps in one call: the plain fetch first, then the same stealth browser only if
|
|
29
|
+
that fetch is walled. Projected at 7, charged 2 when the plain fetch worked.
|
|
30
|
+
Still never reach for `stealth_mode` first.
|
|
31
|
+
|
|
27
32
|
## stealth_mode (cost: 5)
|
|
28
33
|
|
|
29
34
|
`stealth_mode` is operation-based. Typical flow: create a context, then create a
|
|
@@ -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
|
|
|
@@ -35,7 +36,9 @@ site discovery (sitemaps and URL maps), and whole-site crawling.
|
|
|
35
36
|
5. **`crawl_deep`** to walk an entire site and (optionally) extract content.
|
|
36
37
|
|
|
37
38
|
If a page returns 403/429, a CAPTCHA, or empty "enable JavaScript" content,
|
|
38
|
-
switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`).
|
|
39
|
+
switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`). When the
|
|
40
|
+
site is *known* to block, use `scrape` with `escalate: true` instead of two
|
|
41
|
+
calls — still never `stealth_mode` first.
|
|
39
42
|
|
|
40
43
|
## scrape — unified multi-format (cost: 2)
|
|
41
44
|
|
|
@@ -58,6 +61,62 @@ Get markdown + links + metadata in a single call:
|
|
|
58
61
|
a failing format adds a `warnings[]` entry instead of failing the whole call.
|
|
59
62
|
`onlyMainContent` (default `true`) strips boilerplate via Readability.
|
|
60
63
|
|
|
64
|
+
### escalate — one call for a site that blocks (projected 7, charged 2 when it doesn't)
|
|
65
|
+
|
|
66
|
+
`escalate: true` keeps the plain fetch first and retries in the stealth browser
|
|
67
|
+
only if that fetch comes back walled (403/429, a challenge page, an empty
|
|
68
|
+
shell). The escalated page goes through the same formats, so `markdown`,
|
|
69
|
+
`highlights` and the rest work exactly as they do on a plain read.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"tool": "scrape",
|
|
74
|
+
"params": {
|
|
75
|
+
"url": "https://www.producthunt.com/",
|
|
76
|
+
"formats": ["markdown"],
|
|
77
|
+
"escalate": true,
|
|
78
|
+
"escalate_engine": "playwright"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The result adds `escalated: true|false`, and `stealth: { engine,
|
|
84
|
+
vendor_detected }` when the browser ran. The projection is `2 + 5`; the charge
|
|
85
|
+
falls back to the base when the plain fetch succeeded and nothing escalated.
|
|
86
|
+
`escalate_engine` is `"playwright"` (default) or `"camoufox"`. robots.txt is
|
|
87
|
+
respected on the escalated path too, and a second call to a host that blocked
|
|
88
|
+
within the last 24 hours skips the doomed plain fetch and says so in
|
|
89
|
+
`warnings[]`.
|
|
90
|
+
|
|
91
|
+
Query-scoped formats return only the parts of the page that match, verbatim,
|
|
92
|
+
with offsets into the `markdown` of the same call:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"tool": "scrape",
|
|
97
|
+
"params": {
|
|
98
|
+
"url": "https://example.com/pricing",
|
|
99
|
+
"formats": [
|
|
100
|
+
"markdown",
|
|
101
|
+
{ "type": "highlights", "query": "professional plan price", "max_highlights": 5 },
|
|
102
|
+
{ "type": "question", "question": "How much is the professional plan per month?" }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`{ "type": "highlights", "query", "max_highlights"?, "mode"? }` returns
|
|
109
|
+
`content.highlights: [{ text, kind, offset, length, score }]`, best first;
|
|
110
|
+
`kind` is `sentence`, `table_row` or `code_block`, and
|
|
111
|
+
`markdown.slice(offset, offset + length) === text`. `{ "type": "question",
|
|
112
|
+
"question", "mode"? }` returns `content.answer: { text, grounded, evidence }`,
|
|
113
|
+
with `text` the evidence joined and `grounded: true`. Either adds 1 credit once
|
|
114
|
+
per call and calls no model. `"mode": "model"` adds 3 once per call: the model
|
|
115
|
+
chooses the highlights (never rewrites them) or writes the answer, and a
|
|
116
|
+
grounding check sets `grounded: false` when the answer holds a number or a name
|
|
117
|
+
the evidence does not. Ask for `"markdown"` in the same call to quote with a
|
|
118
|
+
locator.
|
|
119
|
+
|
|
61
120
|
CLI equivalent:
|
|
62
121
|
|
|
63
122
|
```bash
|
|
@@ -131,6 +190,17 @@ BFS crawl up to depth 5 / 1000 pages. Use `include_patterns` /
|
|
|
131
190
|
(on by default). Crawls projected over ~500 pages trigger a confirmation
|
|
132
191
|
prompt (elicitation). CLI: `crawlforge crawl https://docs.example.com --depth 3 --max-pages 200`.
|
|
133
192
|
|
|
193
|
+
## Large results — `max_inline_chars` and `read_result` (cost: 1)
|
|
194
|
+
|
|
195
|
+
`scrape`, `fetch_url`, `extract_content` and `crawl_deep` accept
|
|
196
|
+
`max_inline_chars` (default 40,000). A larger result comes back as its scalar
|
|
197
|
+
fields plus `preview` (the first `max_inline_chars` characters of its text
|
|
198
|
+
view), `result_handle`, `total_chars`, `truncated: true` and `expires_at`; the
|
|
199
|
+
full result stays 1 hour on the local machine, never uploaded. Read it with
|
|
200
|
+
`read_result` instead of fetching again: `operation:"search"` with a `query`
|
|
201
|
+
finds offsets, then `operation:"slice"` with `offset`/`length` reads around one;
|
|
202
|
+
`operation:"json_path"` with a `path` picks one field (`"lines"` reads by line).
|
|
203
|
+
|
|
134
204
|
## Cost note
|
|
135
205
|
|
|
136
206
|
`scrape` (2 credits) is the default page read: one fetch returns every format
|
|
@@ -138,6 +208,7 @@ you ask for. `fetch_url`, `extract_text`, `extract_links`, `extract_metadata`
|
|
|
138
208
|
(1 each) are cheaper only when one of them is the whole job; `fetch_url` followed
|
|
139
209
|
by an `extract_*` call on the same URL is a double fetch that costs more than one
|
|
140
210
|
`scrape`. `extract_content`, `map_site` = 2 · `crawl_deep` = 4 (grows with
|
|
141
|
-
`max_pages`)
|
|
211
|
+
`max_pages`) · `read_result` = 1 (reads a stored result, no fetch). Never re-fetch
|
|
212
|
+
a page whose content is already in the conversation.
|
|
142
213
|
|
|
143
214
|
See [tool reference](references/tool-reference.md) for the full parameter list.
|