crawlforge-mcp-server 6.4.0 → 6.6.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 -5
- package/README.md +7 -6
- package/package.json +2 -1
- package/server.js +83 -15
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +185 -7
- package/src/core/AuthManager.js +26 -0
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +4 -0
- package/src/server/inlineThreshold.js +31 -1
- package/src/server/requestContext.js +26 -5
- package/src/server/toolFilter.js +2 -2
- package/src/server/transports/streamableHttp.js +38 -8
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +50 -4
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +178 -0
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +7 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/cli.md +6 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +4 -0
- package/src/skills/installer.js +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +476 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +10 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +24 -17
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
|
@@ -27,6 +27,70 @@ function endsMidJson(text) {
|
|
|
27
27
|
return !trimmed.endsWith('}') && !trimmed.endsWith(']');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Recover the complete rows of a JSON response the model stopped writing.
|
|
32
|
+
*
|
|
33
|
+
* Walks the text tracking strings and the bracket stack, remembers the end of
|
|
34
|
+
* every complete element written directly inside an array, cuts there and
|
|
35
|
+
* closes what is still open. Returns null when no element completed, so a
|
|
36
|
+
* caller can still fall back. A schema asking for a table of rows routinely
|
|
37
|
+
* overruns a small model's output budget — the ECB key-rates table came back
|
|
38
|
+
* cut off at 1,800 and again at 3,600 tokens and the whole extraction failed,
|
|
39
|
+
* although dozens of rows were complete (R21, 2026-09-09).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {{ data: unknown, rows: number }|null}
|
|
43
|
+
*/
|
|
44
|
+
export function salvageTruncatedJson(text) {
|
|
45
|
+
const src = text.trim();
|
|
46
|
+
if (!src.startsWith('{') && !src.startsWith('[')) return null;
|
|
47
|
+
const stack = [];
|
|
48
|
+
let inString = false;
|
|
49
|
+
let cut = -1;
|
|
50
|
+
for (let i = 0; i < src.length; i++) {
|
|
51
|
+
const ch = src[i];
|
|
52
|
+
if (inString) {
|
|
53
|
+
if (ch === '\\') i++;
|
|
54
|
+
else if (ch === '"') {
|
|
55
|
+
inString = false;
|
|
56
|
+
// A string written directly into an array is a complete element.
|
|
57
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ch === '"') inString = true;
|
|
62
|
+
else if (ch === '{' || ch === '[') stack.push(ch);
|
|
63
|
+
else if (ch === '}' || ch === ']') {
|
|
64
|
+
stack.pop();
|
|
65
|
+
if (stack[stack.length - 1] === '[') cut = i + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (cut < 0) return null;
|
|
69
|
+
// Close everything still open at the cut, innermost first.
|
|
70
|
+
const open = [];
|
|
71
|
+
inString = false;
|
|
72
|
+
for (let i = 0; i < cut; i++) {
|
|
73
|
+
const ch = src[i];
|
|
74
|
+
if (inString) { if (ch === '\\') i++; else if (ch === '"') inString = false; continue; }
|
|
75
|
+
if (ch === '"') inString = true;
|
|
76
|
+
else if (ch === '{' || ch === '[') open.push(ch);
|
|
77
|
+
else if (ch === '}' || ch === ']') open.pop();
|
|
78
|
+
}
|
|
79
|
+
const closers = open.reverse().map((c) => (c === '{' ? '}' : ']')).join('');
|
|
80
|
+
let data;
|
|
81
|
+
try {
|
|
82
|
+
data = JSON.parse(src.slice(0, cut) + closers);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let rows = 0;
|
|
87
|
+
(function count(v) {
|
|
88
|
+
if (Array.isArray(v)) { rows += v.length; v.forEach(count); }
|
|
89
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(count);
|
|
90
|
+
})(data);
|
|
91
|
+
return rows > 0 ? { data, rows } : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
/**
|
|
31
95
|
* LLM Manager
|
|
32
96
|
* Manages multiple LLM providers and provides unified interface
|
|
@@ -987,12 +1051,25 @@ Extract the data and return valid JSON:`;
|
|
|
987
1051
|
try {
|
|
988
1052
|
parsed = JSON.parse(cleaned);
|
|
989
1053
|
} catch (parseError) {
|
|
1054
|
+
if (!endsMidJson(cleaned)) throw parseError;
|
|
990
1055
|
// Name the cause when the JSON simply stops: "unexpected end of
|
|
991
1056
|
// input at position 2608" tells a caller nothing they can act on,
|
|
992
1057
|
// whereas "cut off at the 1000-token limit" points at the schema.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1058
|
+
const cutOff = `model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`;
|
|
1059
|
+
// The doubled budget was the retry; when that is cut off too, the
|
|
1060
|
+
// rows it did complete are worth more than a fallback that has none.
|
|
1061
|
+
const salvaged = attempt > 0 ? salvageTruncatedJson(cleaned) : null;
|
|
1062
|
+
if (!salvaged) throw new Error(cutOff);
|
|
1063
|
+
const validation = this.validateAgainstSchema(salvaged.data, schema);
|
|
1064
|
+
this.logger.warn('LLM structured extraction salvaged a cut-off response', { budget, rows: salvaged.rows });
|
|
1065
|
+
return {
|
|
1066
|
+
data: salvaged.data,
|
|
1067
|
+
method: 'llm',
|
|
1068
|
+
valid: validation.valid,
|
|
1069
|
+
validationErrors: validation.errors,
|
|
1070
|
+
partial: true,
|
|
1071
|
+
warning: `${cutOff}; kept the ${salvaged.rows} complete row(s) it had written. Ask for fewer rows (a prompt naming the rows you need, or a narrower schema) to get the rest.`
|
|
1072
|
+
};
|
|
996
1073
|
}
|
|
997
1074
|
|
|
998
1075
|
const validation = this.validateAgainstSchema(parsed, schema);
|
|
@@ -1016,7 +1093,7 @@ Extract the data and return valid JSON:`;
|
|
|
1016
1093
|
// Report which path produced the data. Callers previously labelled this
|
|
1017
1094
|
// result "llm", so a failed LLM call was returned as a high-confidence
|
|
1018
1095
|
// LLM extraction.
|
|
1019
|
-
return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
|
|
1096
|
+
return { ...this.fallbackStructuredExtraction(content, schema, lastError.message), error: lastError.message };
|
|
1020
1097
|
}
|
|
1021
1098
|
|
|
1022
1099
|
/**
|
|
@@ -1036,7 +1113,7 @@ Extract the data and return valid JSON:`;
|
|
|
1036
1113
|
/**
|
|
1037
1114
|
* Fallback structured extraction without LLM — keyword/regex matching for primitives
|
|
1038
1115
|
*/
|
|
1039
|
-
fallbackStructuredExtraction(content, schema) {
|
|
1116
|
+
fallbackStructuredExtraction(content, schema, reason = 'no LLM provider available') {
|
|
1040
1117
|
const extracted = {};
|
|
1041
1118
|
const properties = schema.properties || {};
|
|
1042
1119
|
|
|
@@ -1065,7 +1142,10 @@ Extract the data and return valid JSON:`;
|
|
|
1065
1142
|
data: extracted,
|
|
1066
1143
|
method: 'keyword_fallback',
|
|
1067
1144
|
valid: false,
|
|
1068
|
-
|
|
1145
|
+
// Name the real reason: this fallback also runs after an LLM attempt
|
|
1146
|
+
// that failed, and "no LLM provider available" beside a working Ollama
|
|
1147
|
+
// sent a caller looking at the wrong thing (R21, 2026-09-09).
|
|
1148
|
+
validationErrors: [`Used fallback extraction — ${reason}`]
|
|
1069
1149
|
};
|
|
1070
1150
|
}
|
|
1071
1151
|
|
|
@@ -12,7 +12,9 @@ import { config } from '../../constants/config.js';
|
|
|
12
12
|
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
13
13
|
|
|
14
14
|
const PDFProcessorSchema = z.object({
|
|
15
|
-
source:
|
|
15
|
+
// A Buffer is the 'buffer' source: process_document hands over a body it
|
|
16
|
+
// has already fetched and sniffed as a PDF (R21, 2026-09-09).
|
|
17
|
+
source: z.union([z.string().min(1), z.instanceof(Buffer)]),
|
|
16
18
|
sourceType: z.enum(['url', 'file', 'buffer']).default('url'),
|
|
17
19
|
options: z.object({
|
|
18
20
|
extractMetadata: z.boolean().default(true),
|
|
@@ -32,6 +32,7 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
32
32
|
extract_with_llm: 'If Ollama is unreachable pass provider:"openai" or "anthropic" with a key, or use extract_structured (CSS fallback needs no LLM).',
|
|
33
33
|
list_ollama_models: 'Ollama is not reachable - use extract_with_llm with provider:"openai"/"anthropic", or extract_structured.',
|
|
34
34
|
scrape_with_actions: 'Check the selector against scrape formats:["html"] output; for a one-shot render of a blocked page use stealth_mode operation:"scrape".',
|
|
35
|
+
browser_session: 'A session that expired or was closed cannot be reused - start again with operation:"open". If a ref missed, take another snapshot first: navigation invalidates refs. For a chain that needs no session, use scrape_with_actions.',
|
|
35
36
|
deep_research: 'Use agent for a shorter answer, or search_web followed by scrape on the sources that matter.',
|
|
36
37
|
scrape: 'After a 403/429/CAPTCHA/challenge page or an empty shell use stealth_mode operation:"scrape"; if the content needs a click or login use scrape_with_actions.',
|
|
37
38
|
agent: 'Use deep_research for exhaustive sourcing, or search_web followed by scrape on the sources that matter.',
|
|
@@ -80,6 +81,9 @@ export function appendFallbackHint(toolName, result) {
|
|
|
80
81
|
// plain text
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// An error that already names its next step — a reddit.com refusal points
|
|
85
|
+
// at reddit_search — keeps it: the tool's generic hint would contradict it.
|
|
86
|
+
if (parsed?.next_step || /\bNext step:/.test(first.text)) return result;
|
|
83
87
|
const hint = hintFor(toolName, parsed);
|
|
84
88
|
if (!hint || first.text.includes(hint)) return result;
|
|
85
89
|
if (parsed) {
|
|
@@ -72,6 +72,27 @@ export function resultTextView(resultObject, textPaths = []) {
|
|
|
72
72
|
return { view: 'json', view_path: null, text: JSON.stringify(resultObject, null, 2) };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The non-text fields of `content` small enough to stay inline: everything
|
|
77
|
+
* except the text views (markdown, text, html, …) up to a quarter of the
|
|
78
|
+
* inline budget each. Null when there is nothing to keep.
|
|
79
|
+
*/
|
|
80
|
+
export function keepSmallContentFields(content, textPaths = [], maxInline = DEFAULT_MAX_INLINE_CHARS) {
|
|
81
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return null;
|
|
82
|
+
const textLeaves = new Set(
|
|
83
|
+
textPaths.filter((p) => p.startsWith('content.')).map((p) => p.slice('content.'.length))
|
|
84
|
+
);
|
|
85
|
+
const cap = Math.max(1000, Math.floor(maxInline / 4));
|
|
86
|
+
const kept = {};
|
|
87
|
+
for (const [key, value] of Object.entries(content)) {
|
|
88
|
+
if (textLeaves.has(key) || value === undefined) continue;
|
|
89
|
+
if (typeof value === 'string' && value.length > cap) continue;
|
|
90
|
+
if (typeof value === 'object' && value !== null && JSON.stringify(value).length > cap) continue;
|
|
91
|
+
kept[key] = value;
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(kept).length > 0 ? kept : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
75
96
|
function warningsOf(resultObject) {
|
|
76
97
|
return Array.isArray(resultObject.warnings) ? resultObject.warnings.filter((w) => typeof w === 'string') : [];
|
|
77
98
|
}
|
|
@@ -129,7 +150,9 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
const preview = text.slice(0, maxInline);
|
|
132
|
-
const
|
|
153
|
+
const keptFields = keepSmallContentFields(resultObject.content, config.textPaths, maxInline);
|
|
154
|
+
const keptDesc = keptFields ? `; content.${Object.keys(keptFields).join(', content.')} kept inline` : '';
|
|
155
|
+
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)${keptDesc} and the full result is kept for 1 hour under result_handle ${handle}: ${readWith}.`;
|
|
133
156
|
|
|
134
157
|
const shaped = {};
|
|
135
158
|
for (const [key, value] of Object.entries(resultObject)) {
|
|
@@ -143,6 +166,13 @@ export function applyInlineThreshold(toolName, resultObject, params, { store, en
|
|
|
143
166
|
if (resultObject.redaction && typeof resultObject.redaction === 'object') {
|
|
144
167
|
shaped.redaction = resultObject.redaction;
|
|
145
168
|
}
|
|
169
|
+
// The query-scoped formats live beside the page text under `content`
|
|
170
|
+
// (highlights, answer, json, metadata, links). They are the small, exact
|
|
171
|
+
// answer the caller paid for, and truncating the markdown must not drop
|
|
172
|
+
// them: an nhs.uk scrape with highlights and a question came back as a
|
|
173
|
+
// markdown preview and nothing else (R21, 2026-09-09). Keep every
|
|
174
|
+
// non-text `content` field that fits a quarter of the inline budget.
|
|
175
|
+
if (keptFields) shaped.content = keptFields;
|
|
146
176
|
Object.assign(shaped, {
|
|
147
177
|
preview,
|
|
148
178
|
result_handle: handle,
|
|
@@ -6,14 +6,18 @@
|
|
|
6
6
|
* plumbing. AsyncLocalStorage bridges that gap: the transport runs each
|
|
7
7
|
* request inside a context, and withAuth reads it at invocation time.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* The first flag is `internal`: a request authenticated with the
|
|
10
10
|
* INTERNAL_PROXY_SECRET (the crawlforge-website REST proxy). Internal requests
|
|
11
11
|
* run tools normally but are billing-exempt — the website has already checked
|
|
12
12
|
* and charged the end user's credits, so metering here would double-bill.
|
|
13
13
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* The second is `ownerToken`, which says WHICH of the website's customers an
|
|
15
|
+
* internal request is being made for (see internalOwnerToken below).
|
|
16
|
+
*
|
|
17
|
+
* Both live on the request context, never on the MCP session: a session id
|
|
18
|
+
* created by an internal request grants nothing to a later request that
|
|
19
|
+
* authenticates by other means, and an owner established on one request is not
|
|
20
|
+
* inherited by the next one down the same MCP session.
|
|
17
21
|
*/
|
|
18
22
|
|
|
19
23
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
@@ -25,6 +29,23 @@ export function isInternalRequest() {
|
|
|
25
29
|
return requestContext.getStore()?.internal === true;
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The end user this internal-proxy request is being made for, or null.
|
|
34
|
+
*
|
|
35
|
+
* An opaque per-user token the website derives with an HMAC keyed on the shared
|
|
36
|
+
* internal secret (mcpOwnerToken in crawlforge-website
|
|
37
|
+
* src/lib/tools/mcp-proxy.ts), carried on the X-CrawlForge-Owner header. It is
|
|
38
|
+
* not reversible to a user id here and is not meant to be: all a stateful tool
|
|
39
|
+
* needs is a value that is stable for one customer and distinct between them.
|
|
40
|
+
*
|
|
41
|
+
* Only ever set on a request that already proved the internal secret, and only
|
|
42
|
+
* after the transport has validated its shape — authenticateRequest in
|
|
43
|
+
* transports/streamableHttp.js is the single place that decides both.
|
|
44
|
+
*/
|
|
45
|
+
export function internalOwnerToken() {
|
|
46
|
+
return requestContext.getStore()?.ownerToken ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
/**
|
|
29
50
|
* Record that the compliance gate refused this invocation before anything was
|
|
30
51
|
* fetched — robots.txt disallowed the path, or the host is on the permanent
|
|
@@ -36,7 +57,7 @@ export function isInternalRequest() {
|
|
|
36
57
|
* reaches withAuth. It also survives both routes a refusal can take — thrown,
|
|
37
58
|
* or swallowed into an isError result.
|
|
38
59
|
*
|
|
39
|
-
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED'
|
|
60
|
+
* @param {string} code 'ROBOTS_DISALLOWED' | 'HOST_BLOCKED' | 'USE_REDDIT_SEARCH'
|
|
40
61
|
*/
|
|
41
62
|
export function markPreflightRefusal(code) {
|
|
42
63
|
const store = requestContext.getStore();
|
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 31 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
|
|
@@ -17,7 +17,7 @@ export const TOOL_GROUPS = {
|
|
|
17
17
|
search: ['search_web', 'serp_rank', 'reddit_search'],
|
|
18
18
|
crawl: ['crawl_deep', 'map_site'],
|
|
19
19
|
extract: ['extract_content', 'process_document', 'summarize_content', 'analyze_content', 'extract_structured', 'extract_with_llm', 'list_ollama_models', 'extract_embedded_state'],
|
|
20
|
-
batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions'],
|
|
20
|
+
batch: ['batch_scrape', 'get_batch_results', 'scrape_with_actions', 'browser_session'],
|
|
21
21
|
research: ['deep_research'],
|
|
22
22
|
tracking: ['track_changes'],
|
|
23
23
|
llmstxt: ['generate_llms_txt'],
|
|
@@ -314,9 +314,11 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
314
314
|
// — never expose an unauthenticated MCP endpoint on a public interface.
|
|
315
315
|
// `internal` marks a request from the website's REST proxy
|
|
316
316
|
// (INTERNAL_PROXY_SECRET): it is billing-exempt in withAuth because the
|
|
317
|
-
// website already charged the end user.
|
|
318
|
-
//
|
|
317
|
+
// website already charged the end user. `ownerToken` says which of the
|
|
318
|
+
// website's customers it is being made for. Both are request-scoped only
|
|
319
|
+
// — never persisted on the session.
|
|
319
320
|
let internal = false;
|
|
321
|
+
let ownerToken;
|
|
320
322
|
if (!(authManager.isCreatorMode() && hostIsLoopback)) {
|
|
321
323
|
const authResult = await authenticateRequest(req, authManager, oauthProvider);
|
|
322
324
|
if (!authResult.ok) {
|
|
@@ -332,6 +334,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
332
334
|
return;
|
|
333
335
|
}
|
|
334
336
|
internal = authResult.internal === true;
|
|
337
|
+
ownerToken = authResult.ownerToken;
|
|
335
338
|
}
|
|
336
339
|
|
|
337
340
|
// Era routing. Only a POST can carry the 2026-07-28 per-request envelope;
|
|
@@ -355,7 +358,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
355
358
|
if (parsedBody !== undefined) {
|
|
356
359
|
const probe = await toWebRequest(req, parsedBody);
|
|
357
360
|
if (!(await isLegacyRequest(probe, parsedBody))) {
|
|
358
|
-
await requestContext.run({ internal }, () => serveModern(req, res, parsedBody));
|
|
361
|
+
await requestContext.run({ internal, ownerToken }, () => serveModern(req, res, parsedBody));
|
|
359
362
|
return;
|
|
360
363
|
}
|
|
361
364
|
}
|
|
@@ -370,7 +373,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
370
373
|
|
|
371
374
|
if (existing) {
|
|
372
375
|
await requestContext.run(
|
|
373
|
-
{ internal, servingServer: existing.server, servingEra: 'legacy' },
|
|
376
|
+
{ internal, ownerToken, servingServer: existing.server, servingEra: 'legacy' },
|
|
374
377
|
() => existing.transport.handleRequest(req, res, parsedBody)
|
|
375
378
|
);
|
|
376
379
|
return;
|
|
@@ -406,7 +409,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
406
409
|
try {
|
|
407
410
|
await sessionServer.connect(transport);
|
|
408
411
|
await requestContext.run(
|
|
409
|
-
{ internal, servingServer: sessionServer, servingEra: 'legacy' },
|
|
412
|
+
{ internal, ownerToken, servingServer: sessionServer, servingEra: 'legacy' },
|
|
410
413
|
() => transport.handleRequest(req, res, parsedBody)
|
|
411
414
|
);
|
|
412
415
|
} catch (err) {
|
|
@@ -453,6 +456,28 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
453
456
|
};
|
|
454
457
|
}
|
|
455
458
|
|
|
459
|
+
/**
|
|
460
|
+
* Hex, and bounded. The website emits 32 characters (mcpOwnerToken in
|
|
461
|
+
* crawlforge-website src/lib/tools/mcp-proxy.ts); the range is wider so the two
|
|
462
|
+
* repos can pick a different HMAC slice without a lockstep deploy, and narrow
|
|
463
|
+
* enough that nothing unbounded, non-printable or structured can ever become
|
|
464
|
+
* part of an owner id.
|
|
465
|
+
*/
|
|
466
|
+
const OWNER_TOKEN_RE = /^[0-9a-f]{16,64}$/;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The owner token an internal request claims, or undefined.
|
|
470
|
+
*
|
|
471
|
+
* Undefined covers absent AND malformed alike, and the difference must not
|
|
472
|
+
* matter: everything downstream treats "no owner" as "no session", so a value
|
|
473
|
+
* that fails this check is simply not an owner rather than a strange one. Never
|
|
474
|
+
* relax this into a coercion — the token becomes part of a tenant key.
|
|
475
|
+
*/
|
|
476
|
+
function readOwnerToken(req) {
|
|
477
|
+
const value = (req.headers['x-crawlforge-owner'] || '').toString();
|
|
478
|
+
return OWNER_TOKEN_RE.test(value) ? value : undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
456
481
|
/**
|
|
457
482
|
* Validate a request's credentials.
|
|
458
483
|
*
|
|
@@ -460,13 +485,14 @@ export async function connectStreamableHttp(server, authManager, logger, options
|
|
|
460
485
|
* - `X-Internal-Secret: <INTERNAL_PROXY_SECRET>` — server-to-server requests
|
|
461
486
|
* from the crawlforge-website REST proxy. Returns { ok, internal: true };
|
|
462
487
|
* internal requests are billing-exempt in withAuth (the website already
|
|
463
|
-
* charged the end user). Only active when the env var is set.
|
|
488
|
+
* charged the end user). Only active when the env var is set. Such a
|
|
489
|
+
* request may also carry `X-CrawlForge-Owner` — see readOwnerToken.
|
|
464
490
|
* - `Authorization: Bearer <crawlforge-api-key>` (legacy static key)
|
|
465
491
|
* - `X-API-Key: <crawlforge-api-key>` (legacy static key)
|
|
466
492
|
* - `Authorization: Bearer <oauth-access-token>` if OAuth is enabled —
|
|
467
493
|
* the OAuth provider validates the token and maps it to the API key.
|
|
468
494
|
*
|
|
469
|
-
* @returns {Promise<{ok: true, internal?: boolean} | {ok: false, status: number, error: string, message: string, reason: string}>}
|
|
495
|
+
* @returns {Promise<{ok: true, internal?: boolean, ownerToken?: string} | {ok: false, status: number, error: string, message: string, reason: string}>}
|
|
470
496
|
*/
|
|
471
497
|
async function authenticateRequest(req, authManager, oauthProvider) {
|
|
472
498
|
// Internal proxy path first: presenting the header at all means the caller
|
|
@@ -480,7 +506,11 @@ async function authenticateRequest(req, authManager, oauthProvider) {
|
|
|
480
506
|
const provided = createHash('sha256').update(providedSecret).digest();
|
|
481
507
|
const expected = createHash('sha256').update(internalSecret).digest();
|
|
482
508
|
if (timingSafeEqual(provided, expected)) {
|
|
483
|
-
|
|
509
|
+
// Read ONLY here, on the branch that has just proved the secret. A
|
|
510
|
+
// request that authenticated any other way — or none — never has its
|
|
511
|
+
// owner header looked at, so claiming an owner requires already being
|
|
512
|
+
// the proxy.
|
|
513
|
+
return { ok: true, internal: true, ownerToken: readOwnerToken(req) };
|
|
484
514
|
}
|
|
485
515
|
}
|
|
486
516
|
return {
|
|
@@ -97,13 +97,20 @@ browser actions before extraction.
|
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Allowed action types: `wait`, `click`, `type`, `press`, `scroll`,
|
|
101
|
-
`
|
|
100
|
+
Allowed action types: `snapshot`, `wait`, `click`, `type`, `press`, `scroll`,
|
|
101
|
+
`screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`. Start a chain
|
|
102
|
+
with `{"type": "snapshot"}` to list the page's interactive elements with stable
|
|
103
|
+
refs (`@e1`, `@e2` …) and target those in later actions instead of guessing CSS
|
|
104
|
+
selectors. `executeJavaScript` is disabled unless the deploy sets
|
|
102
105
|
`ALLOW_JAVASCRIPT_EXECUTION=true`. 1–20 actions per call. Screenshots are stored
|
|
103
106
|
as `crawlforge://screenshot/{actionId}` resources. Full action schemas:
|
|
104
107
|
[actions](references/actions.md). CLI:
|
|
105
108
|
`crawlforge actions https://example.com --script login.json --screenshot`.
|
|
106
109
|
|
|
110
|
+
One-shot: the browser closes when the call returns. When the flow spans more
|
|
111
|
+
than one call, or you need to see the page before choosing what to click, use
|
|
112
|
+
`browser_session` instead (crawlforge-browser-sessions).
|
|
113
|
+
|
|
107
114
|
## generate_llms_txt — AI policy file (cost: 5)
|
|
108
115
|
|
|
109
116
|
```json
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# scrape_with_actions — Action Types
|
|
2
2
|
|
|
3
3
|
`scrape_with_actions` runs an ordered `actions[]` array (1–20 items) before
|
|
4
|
-
scraping. Only these
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
scraping. Only these action types are allowed (allow-listed in ActionExecutor):
|
|
5
|
+
`snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`,
|
|
6
|
+
`executeJavaScript`, `select`, `hover`, `navigate`. Each action object has a
|
|
7
|
+
`type` plus type-specific fields. Common optional fields on every action:
|
|
8
|
+
`timeout`, `description`, `continueOnError`, `retries` (0–5), `captureAfter`.
|
|
9
|
+
|
|
10
|
+
**Start with a snapshot.** Section 8 returns the page's interactive elements
|
|
11
|
+
with stable refs, and any action's `selector` may name one (`@e1`) instead of a
|
|
12
|
+
CSS selector you have not seen. That is the difference between landing a
|
|
13
|
+
multi-step flow first try and burning the call on a guess.
|
|
8
14
|
|
|
9
15
|
## 1. wait
|
|
10
16
|
|
|
@@ -101,6 +107,46 @@ Disabled unless the deployment sets `ALLOW_JAVASCRIPT_EXECUTION=true`.
|
|
|
101
107
|
{ "type": "executeJavaScript", "script": "return document.title", "returnResult": true }
|
|
102
108
|
```
|
|
103
109
|
|
|
110
|
+
## 8. snapshot
|
|
111
|
+
|
|
112
|
+
List the page's interactive elements, each with a stable ref later actions can
|
|
113
|
+
target. Refs are assigned `@e1…@eN` in document order and are **invalidated by
|
|
114
|
+
navigation** — snapshot again after one, or acting on an old ref fails with a
|
|
115
|
+
named error telling you to.
|
|
116
|
+
|
|
117
|
+
| Field | Type | Notes |
|
|
118
|
+
|-------|------|-------|
|
|
119
|
+
| `interactiveOnly` | boolean | Default true. False also lists headings and landmarks, which carry no ref. |
|
|
120
|
+
| `maxNodes` | number | Cap on nodes listed (default 200, max 1000). The result sets `truncated` when the cap stopped the walk. |
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{ "type": "snapshot" }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The tree comes back in `actionResults[i].result.tree`, alongside `snapshotId`,
|
|
127
|
+
`url`, `title`, `refCount`, `nodeCount`, `truncated` and `interactiveOnly`:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
[document] "Sign in"
|
|
131
|
+
@e1 [textbox] "Email"
|
|
132
|
+
@e2 [textbox] "Password"
|
|
133
|
+
@e3 [button] "Sign in"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
[
|
|
138
|
+
{ "type": "snapshot" },
|
|
139
|
+
{ "type": "type", "selector": "@e1", "text": "user@example.com" },
|
|
140
|
+
{ "type": "click", "selector": "@e3" }
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The tool is stateless, so a chain cannot adapt to its own snapshot mid-flight:
|
|
145
|
+
read the refs in one call, act on them in the next. A fresh load of the same
|
|
146
|
+
page numbers them the same way, and the second chain snapshots again first so
|
|
147
|
+
the refs are stamped on the document it is acting against. The walk covers the
|
|
148
|
+
main frame only — elements inside iframes and shadow DOM get no refs.
|
|
149
|
+
|
|
104
150
|
## Top-level options
|
|
105
151
|
|
|
106
152
|
| Option | Default | Notes |
|