crawlforge-mcp-server 5.6.10 → 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.
- package/CLAUDE.md +5 -4
- package/README.md +8 -5
- package/package.json +3 -3
- package/server.js +49 -37
- package/src/core/AuthManager.js +18 -3
- package/src/core/ResultStore.js +230 -0
- package/src/schemas/toolOutputSchemas.js +106 -12
- package/src/server/fallbackHints.js +1 -0
- package/src/server/inlineThreshold.js +149 -0
- package/src/server/requestContext.js +22 -0
- package/src/server/toolFilter.js +11 -4
- package/src/server/withAuth.js +40 -6
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +43 -1
- 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/extract/_fetchAndParse.js +19 -10
- package/src/tools/result/readResult.js +147 -0
- package/src/tools/scrape/_highlights.js +79 -0
- package/src/tools/scrape/formats.js +69 -0
- package/src/tools/scrape/unifiedScrape.js +232 -31
- package/src/utils/challengeDetection.js +6 -75
- package/src/utils/hostRateLimiter.js +54 -4
- package/src/utils/secretMask.js +27 -1
- package/src/utils/stealthVerdict.js +6 -78
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { z } from 'zod';
|
|
18
|
+
import { SCRAPE_STRING_FORMATS, SCRAPE_OBJECT_FORMATS } from '../tools/scrape/formats.js';
|
|
18
19
|
|
|
19
20
|
// `_cost` is injected by withAuth into the legacy JSON-text copy of the
|
|
20
21
|
// result (never into structuredContent directly) — included here anyway so
|
|
@@ -27,6 +28,20 @@ const costShape = z.object({
|
|
|
27
28
|
projection_note: z.string().optional().describe('Human-readable note about how the cost was projected')
|
|
28
29
|
}).passthrough().optional().describe('Cost-transparency metadata (D3.5), present when injected into the text copy of the result');
|
|
29
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
|
+
|
|
30
45
|
// ── scrape ──────────────────────────────────────────────────────────────────
|
|
31
46
|
|
|
32
47
|
const scrapeLinkShape = z.object({
|
|
@@ -58,21 +73,60 @@ const scrapeMetadataShape = z.object({
|
|
|
58
73
|
url: z.string().optional()
|
|
59
74
|
}).passthrough();
|
|
60
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
|
+
|
|
87
|
+
// One content key per string format `scrape` accepts (the "screenshot"
|
|
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).
|
|
93
|
+
const scrapeFormatShapes = {
|
|
94
|
+
markdown: z.string().optional(),
|
|
95
|
+
html: z.string().optional(),
|
|
96
|
+
rawHtml: z.string().optional(),
|
|
97
|
+
text: z.string().optional(),
|
|
98
|
+
links: scrapeLinksShape.optional(),
|
|
99
|
+
metadata: scrapeMetadataShape.optional(),
|
|
100
|
+
branding: z.record(z.unknown()).optional().describe('Static design tokens: colors, fonts, logo'),
|
|
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')
|
|
109
|
+
};
|
|
110
|
+
const scrapeContentKey = (format) => ({ screenshot: 'screenshots', question: 'answer' }[format] ?? format);
|
|
111
|
+
for (const format of [...SCRAPE_STRING_FORMATS, ...SCRAPE_OBJECT_FORMATS]) {
|
|
112
|
+
if (!scrapeFormatShapes[scrapeContentKey(format)]) {
|
|
113
|
+
throw new Error(`toolOutputSchemas: scrape format "${format}" has no output shape`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
61
117
|
const scrapeShape = {
|
|
62
118
|
success: z.boolean().optional().describe('Whether the scrape completed'),
|
|
63
119
|
url: z.string().optional().describe('Final URL after redirects'),
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
screenshots: z.array(z.object({}).passthrough()).optional().describe('Present for the "screenshot" format; each item carries a resourceUri once published'),
|
|
73
|
-
json: z.unknown().optional().describe('Result of the {type:"json"} format (LLM-structured extraction)')
|
|
74
|
-
}).passthrough().optional().describe('One key per requested format'),
|
|
120
|
+
status: z.number().optional().describe('HTTP status of the fetch; present when success is false'),
|
|
121
|
+
title: z.string().optional().describe('Document title; present when success is false'),
|
|
122
|
+
error: z.string().optional().describe('Why success is false: a challenge page, an empty shell or an error placeholder was served instead of the content'),
|
|
123
|
+
blocked: z.object({
|
|
124
|
+
vendor: z.string().optional(),
|
|
125
|
+
evidence: z.string().optional()
|
|
126
|
+
}).passthrough().optional().describe('Present when a bot-defence vendor served a challenge page; the fallback hint names the tool to try next'),
|
|
127
|
+
content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
|
|
75
128
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
129
|
+
...resultHandleShape,
|
|
76
130
|
_cost: costShape
|
|
77
131
|
};
|
|
78
132
|
|
|
@@ -321,6 +375,45 @@ const crawlDeepShape = {
|
|
|
321
375
|
}).passthrough().optional(),
|
|
322
376
|
crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
|
|
323
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(),
|
|
324
417
|
_cost: costShape
|
|
325
418
|
};
|
|
326
419
|
|
|
@@ -333,7 +426,8 @@ export const OUTPUT_SCHEMAS = {
|
|
|
333
426
|
reddit_search: redditSearchShape,
|
|
334
427
|
search_web: searchWebShape,
|
|
335
428
|
extract_structured: extractStructuredShape,
|
|
336
|
-
crawl_deep: crawlDeepShape
|
|
429
|
+
crawl_deep: crawlDeepShape,
|
|
430
|
+
read_result: readResultShape
|
|
337
431
|
};
|
|
338
432
|
|
|
339
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
|
+
}
|
|
@@ -47,3 +47,25 @@ export function markPreflightRefusal(code) {
|
|
|
47
47
|
export function preflightRefusal() {
|
|
48
48
|
return requestContext.getStore()?.preflightRefusal ?? null;
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Report what this invocation actually spent, in credits, when that is less
|
|
53
|
+
* than the projection. A handler that skipped expensive work — Phase 3's
|
|
54
|
+
* escalation stops at the first tier that succeeds — has no other way to say
|
|
55
|
+
* so: withAuth otherwise derives the charge from the tool's price alone.
|
|
56
|
+
*
|
|
57
|
+
* withAuth clamps the value to the projection. `_cost.projected` is the
|
|
58
|
+
* ceiling a caller saw before the call, so a report can only ever lower the
|
|
59
|
+
* charge, never raise it. Non-finite or negative values are ignored.
|
|
60
|
+
*
|
|
61
|
+
* @param {number} n credits actually spent
|
|
62
|
+
*/
|
|
63
|
+
export function setActualCost(n) {
|
|
64
|
+
const store = requestContext.getStore();
|
|
65
|
+
if (store && Number.isFinite(n) && n >= 0) store.actualCost = n;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The actual cost reported for this invocation, or null when unreported. */
|
|
69
|
+
export function reportedActualCost() {
|
|
70
|
+
return requestContext.getStore()?.actualCost ?? null;
|
|
71
|
+
}
|
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
|
@@ -17,8 +17,24 @@
|
|
|
17
17
|
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { recordToolInvocation } from '../observability/tracing.js';
|
|
20
|
-
import { isInternalRequest, preflightRefusal, requestContext } from './requestContext.js';
|
|
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 {
|
|
@@ -52,6 +68,13 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
52
68
|
// A throw from the credit check itself (backend down, key rejected, etc.)
|
|
53
69
|
// means the tool never executed and must cost nothing.
|
|
54
70
|
let handlerStarted = false;
|
|
71
|
+
// A handler may report a lower spend (setActualCost). The projection is
|
|
72
|
+
// the ceiling the caller saw before the call, so a report can only ever
|
|
73
|
+
// lower the charge, never raise it.
|
|
74
|
+
const billable = () => {
|
|
75
|
+
const reported = reportedActualCost();
|
|
76
|
+
return reported == null ? creditCost : Math.min(reported, creditCost);
|
|
77
|
+
};
|
|
55
78
|
|
|
56
79
|
try {
|
|
57
80
|
// billingExempt covers creator mode and authenticated internal-proxy
|
|
@@ -92,9 +115,19 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
92
115
|
// multi-URL tool that skipped one disallowed URL and still returned a
|
|
93
116
|
// result did real work and bills for it.
|
|
94
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
|
+
|
|
127
|
+
const base = billable();
|
|
95
128
|
const charge = creditCost === 0 || refused
|
|
96
129
|
? 0
|
|
97
|
-
: (isErrorResult ? Math.max(1, Math.floor(
|
|
130
|
+
: (isErrorResult ? Math.max(1, Math.floor(base * 0.5)) : base);
|
|
98
131
|
|
|
99
132
|
// D3.5: Surface cost transparency in all tool responses. For internal
|
|
100
133
|
// proxy requests the meaningful balance is the end user's, which only
|
|
@@ -135,8 +168,9 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
135
168
|
// creditCost === 0 means a genuinely free call (e.g. serp_rank when
|
|
136
169
|
// DataForSEO is unconfigured — a no-op). Emit NO usage event at all so
|
|
137
170
|
// the backend has nothing to (re-)price; reporting 0 would still create
|
|
138
|
-
// a serp_rank record the backend could recompute to full cost.
|
|
139
|
-
|
|
171
|
+
// a serp_rank record the backend could recompute to full cost. The
|
|
172
|
+
// same goes for a handler that reported a 0 spend.
|
|
173
|
+
if (!creatorMode && creditCost > 0 && !refused && charge > 0) {
|
|
140
174
|
await authManager.reportUsage(toolName, charge, params, isErrorResult ? 500 : 200, Date.now() - startTime);
|
|
141
175
|
}
|
|
142
176
|
|
|
@@ -150,7 +184,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
150
184
|
if (!creatorMode && creditCost > 0 && handlerStarted && preflightRefusal() === null) {
|
|
151
185
|
await authManager.reportUsage(
|
|
152
186
|
toolName,
|
|
153
|
-
Math.max(1, Math.floor(
|
|
187
|
+
Math.max(1, Math.floor(billable() * 0.5)),
|
|
154
188
|
params,
|
|
155
189
|
500,
|
|
156
190
|
Date.now() - startTime
|
|
@@ -204,7 +238,7 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
|
|
|
204
238
|
// HTTP transport's `internal` flag) is spread in, not replaced — and stdio
|
|
205
239
|
// callers, who have no transport-provided store, get one here.
|
|
206
240
|
return async (params) => requestContext.run(
|
|
207
|
-
{ ...(requestContext.getStore() ?? {}), preflightRefusal: null },
|
|
241
|
+
{ ...(requestContext.getStore() ?? {}), preflightRefusal: null, actualCost: null },
|
|
208
242
|
() => invoke(params)
|
|
209
243
|
);
|
|
210
244
|
};
|
|
@@ -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
|
|
@@ -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`)
|
|
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.
|