crawlforge-mcp-server 6.5.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 +6 -5
- package/package.json +1 -1
- package/server.js +76 -12
- package/src/cli/commands/browser.js +77 -0
- package/src/cli/index.js +3 -1
- package/src/core/ActionExecutor.js +176 -6
- package/src/core/AuthManager.js +26 -0
- package/src/core/browser/SessionStore.js +331 -0
- package/src/core/browser/snapshot.js +346 -0
- package/src/server/fallbackHints.js +1 -0
- package/src/server/requestContext.js +25 -4
- 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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot — the accessibility-style page tree that hands an agent stable
|
|
3
|
+
* element refs (`@e1`, `@e2`, …) to act on instead of guessed CSS selectors.
|
|
4
|
+
*
|
|
5
|
+
* Why ours and not Playwright's: 1.62's `locator.ariaSnapshot()` emits YAML
|
|
6
|
+
* with no element refs at all, and `page._snapshotForAI()` is private API we
|
|
7
|
+
* will not depend on. The walk below is injected by us and returns the tree
|
|
8
|
+
* and the refs from one pass.
|
|
9
|
+
*
|
|
10
|
+
* A REF LIVES IN TWO PLACES, and the halves do different jobs:
|
|
11
|
+
*
|
|
12
|
+
* 1. In the page — the walk stamps `data-cf-ref="e1"` onto every element it
|
|
13
|
+
* refs, so a ref resolves to an ordinary CSS selector,
|
|
14
|
+
* `[data-cf-ref="e1"]`. That is what makes refs work with every existing
|
|
15
|
+
* action path for free, including the stealth human-behaviour code that
|
|
16
|
+
* takes a raw selector string.
|
|
17
|
+
* 2. In Node — a WeakMap<Page, state> cleared on every main-frame
|
|
18
|
+
* navigation. This is the half that DETECTS staleness. Without it a ref
|
|
19
|
+
* from a previous page would merely fail to match, and the caller would
|
|
20
|
+
* be told "selector not found" instead of "take a new snapshot".
|
|
21
|
+
*
|
|
22
|
+
* So a stale ref fails loudly, with the reason and the fix (StaleRefError),
|
|
23
|
+
* and never silently hits the wrong element.
|
|
24
|
+
*
|
|
25
|
+
* Not to be confused with src/core/SnapshotManager.js, which is change-
|
|
26
|
+
* tracking history.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
export const REF_ATTRIBUTE = 'data-cf-ref';
|
|
32
|
+
export const DEFAULT_MAX_NODES = 200;
|
|
33
|
+
export const MAX_NODES_LIMIT = 1000;
|
|
34
|
+
|
|
35
|
+
// Walks of one snapshot call, including the retry when the page navigates
|
|
36
|
+
// mid-walk. Two: one retry is enough for a page that settles, and a page
|
|
37
|
+
// navigating repeatedly is not one a snapshot can describe.
|
|
38
|
+
const MAX_WALK_ATTEMPTS = 2;
|
|
39
|
+
|
|
40
|
+
// Indentation follows the nesting of EMITTED nodes, and stops deepening past
|
|
41
|
+
// this many levels so a deep DOM cannot produce runaway leading whitespace.
|
|
42
|
+
const MAX_INDENT = 10;
|
|
43
|
+
const MAX_NAME_LENGTH = 120;
|
|
44
|
+
|
|
45
|
+
const REF_PATTERN = /^@e[1-9]\d*$/;
|
|
46
|
+
|
|
47
|
+
/** Thrown when a ref cannot be resolved against the page's current snapshot. */
|
|
48
|
+
export class StaleRefError extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = 'StaleRefError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Page -> { snapshotId, refs, invalidated, tracking }. WeakMap so a closed
|
|
56
|
+
// page's refs go with it.
|
|
57
|
+
const pageState = new WeakMap();
|
|
58
|
+
|
|
59
|
+
/** True for an element ref (`@e1`), false for anything else, including non-strings. */
|
|
60
|
+
export function isRef(selector) {
|
|
61
|
+
return typeof selector === 'string' && REF_PATTERN.test(selector);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The injected walk. Fixed source, written by us — it is not caller-supplied
|
|
66
|
+
* JavaScript, so it has nothing to do with the ALLOW_JAVASCRIPT_EXECUTION flag
|
|
67
|
+
* that gates the `executeJavaScript` action. Do not put it behind that flag.
|
|
68
|
+
*/
|
|
69
|
+
function snapshotScript({ refAttribute, interactiveOnly, maxNodes, maxIndent, maxNameLength }) {
|
|
70
|
+
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head']);
|
|
71
|
+
const INTERACTIVE_ROLES = new Set([
|
|
72
|
+
'button', 'link', 'checkbox', 'radio', 'textbox', 'combobox', 'menuitem',
|
|
73
|
+
'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'option', 'searchbox',
|
|
74
|
+
'slider', 'spinbutton'
|
|
75
|
+
]);
|
|
76
|
+
const STRUCTURAL_ROLES = new Set([
|
|
77
|
+
'heading', 'banner', 'navigation', 'contentinfo', 'complementary', 'main',
|
|
78
|
+
'form', 'region', 'search'
|
|
79
|
+
]);
|
|
80
|
+
const LANDMARK_TAGS = {
|
|
81
|
+
main: 'main', nav: 'navigation', header: 'banner', footer: 'contentinfo',
|
|
82
|
+
aside: 'complementary', form: 'form'
|
|
83
|
+
};
|
|
84
|
+
const INPUT_ROLES = {
|
|
85
|
+
text: 'textbox', search: 'textbox', email: 'textbox', tel: 'textbox',
|
|
86
|
+
url: 'textbox', password: 'textbox', checkbox: 'checkbox', radio: 'radio',
|
|
87
|
+
submit: 'button', button: 'button', reset: 'button'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Double quotes delimit the name in the tree, so a name may not contain one.
|
|
91
|
+
const clean = (value) => (value || '').replace(/\s+/g, ' ').trim().replace(/"/g, "'");
|
|
92
|
+
const truncate = (value) =>
|
|
93
|
+
(value.length > maxNameLength ? `${value.slice(0, maxNameLength - 1)}…` : value);
|
|
94
|
+
|
|
95
|
+
function roleOf(el, tag) {
|
|
96
|
+
const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
|
|
97
|
+
if (explicit) return explicit.split(/\s+/)[0];
|
|
98
|
+
if (tag === 'input') return INPUT_ROLES[(el.getAttribute('type') || 'text').toLowerCase()] || tag;
|
|
99
|
+
if (tag === 'a') return el.hasAttribute('href') ? 'link' : tag;
|
|
100
|
+
if (tag === 'button' || tag === 'summary') return 'button';
|
|
101
|
+
if (tag === 'select') return 'combobox';
|
|
102
|
+
if (tag === 'textarea') return 'textbox';
|
|
103
|
+
if (/^h[1-6]$/.test(tag)) return 'heading';
|
|
104
|
+
return LANDMARK_TAGS[tag] || tag;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nameOf(el, tag, allowTextContent) {
|
|
108
|
+
const ariaLabel = clean(el.getAttribute('aria-label'));
|
|
109
|
+
if (ariaLabel) return ariaLabel;
|
|
110
|
+
|
|
111
|
+
const labelledBy = (el.getAttribute('aria-labelledby') || '').trim();
|
|
112
|
+
if (labelledBy) {
|
|
113
|
+
const referenced = clean(labelledBy.split(/\s+/)
|
|
114
|
+
.map((id) => (document.getElementById(id) || {}).textContent || '')
|
|
115
|
+
.join(' '));
|
|
116
|
+
if (referenced) return referenced;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// el.labels covers both `label[for=id]` and an ancestor <label>; closest()
|
|
120
|
+
// is the fallback for elements that have no .labels (a contenteditable).
|
|
121
|
+
const labels = el.labels;
|
|
122
|
+
const labelText = clean(labels && labels.length
|
|
123
|
+
? labels[0].textContent
|
|
124
|
+
: (el.closest('label') || {}).textContent);
|
|
125
|
+
if (labelText) return labelText;
|
|
126
|
+
|
|
127
|
+
for (const attribute of ['placeholder', 'title', 'alt']) {
|
|
128
|
+
const value = clean(el.getAttribute(attribute));
|
|
129
|
+
if (value) return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A push button's label is its value; a text field's value is user data,
|
|
133
|
+
// not a name, which is why this is narrowed to the button types.
|
|
134
|
+
if (tag === 'input' && /^(button|submit|reset)$/.test((el.getAttribute('type') || '').toLowerCase())) {
|
|
135
|
+
const value = clean(el.value);
|
|
136
|
+
if (value) return value;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A landmark is named by its label, never by everything inside it —
|
|
140
|
+
// otherwise <main> would be captioned with the whole page.
|
|
141
|
+
return allowTextContent ? clean(el.textContent) : '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isInteractive(el, tag, role) {
|
|
145
|
+
if (INTERACTIVE_ROLES.has(role)) return true;
|
|
146
|
+
if (tag === 'a') return el.hasAttribute('href');
|
|
147
|
+
if (tag === 'input') return (el.getAttribute('type') || '').toLowerCase() !== 'hidden';
|
|
148
|
+
if (tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'summary') return true;
|
|
149
|
+
const editable = el.getAttribute('contenteditable');
|
|
150
|
+
if (editable !== null && editable !== 'false') return true;
|
|
151
|
+
const tabindex = el.getAttribute('tabindex');
|
|
152
|
+
if (tabindex !== null && tabindex.trim() !== '-1') return true;
|
|
153
|
+
return el.hasAttribute('onclick');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isHidden(el, tag) {
|
|
157
|
+
if (SKIP_TAGS.has(tag)) return true;
|
|
158
|
+
if (el.hasAttribute('hidden')) return true;
|
|
159
|
+
if (el.getAttribute('aria-hidden') === 'true') return true;
|
|
160
|
+
const style = getComputedStyle(el);
|
|
161
|
+
return style.display === 'none' || style.visibility === 'hidden';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasSize = (el) => {
|
|
165
|
+
const rect = el.getBoundingClientRect();
|
|
166
|
+
return rect.width > 0 && rect.height > 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const lines = [];
|
|
170
|
+
const refs = [];
|
|
171
|
+
let truncated = false;
|
|
172
|
+
|
|
173
|
+
function walk(el, depth) {
|
|
174
|
+
if (truncated) return;
|
|
175
|
+
const tag = el.tagName.toLowerCase();
|
|
176
|
+
if (isHidden(el, tag)) return; // the subtree is hidden with it
|
|
177
|
+
|
|
178
|
+
const role = roleOf(el, tag);
|
|
179
|
+
const interactive = isInteractive(el, tag, role);
|
|
180
|
+
const structural = !interactive && !interactiveOnly &&
|
|
181
|
+
(/^h[1-6]$/.test(tag) || Boolean(LANDMARK_TAGS[tag]) || STRUCTURAL_ROLES.has(role));
|
|
182
|
+
let childDepth = depth;
|
|
183
|
+
|
|
184
|
+
// A zero-size element is not emitted, but its children still are: a
|
|
185
|
+
// collapsed wrapper is common, an unreachable subtree is not.
|
|
186
|
+
if ((interactive || structural) && hasSize(el)) {
|
|
187
|
+
if (lines.length >= maxNodes) {
|
|
188
|
+
truncated = true;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const name = truncate(nameOf(el, tag, interactive || role === 'heading'));
|
|
192
|
+
let ref = '';
|
|
193
|
+
// Only interactive nodes get a ref — a structural line is context, not a target.
|
|
194
|
+
if (interactive) {
|
|
195
|
+
const id = `e${refs.length + 1}`;
|
|
196
|
+
el.setAttribute(refAttribute, id);
|
|
197
|
+
refs.push({ id, role, name, tag });
|
|
198
|
+
ref = `@${id} `;
|
|
199
|
+
}
|
|
200
|
+
lines.push(`${' '.repeat(Math.min(depth, maxIndent))}${ref}[${role}]${name ? ` "${name}"` : ''}`);
|
|
201
|
+
childDepth = depth + 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const child of el.children) walk(child, childDepth);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Drop refs from an earlier walk so a re-snapshot of the same page numbers
|
|
208
|
+
// cleanly instead of leaving elements answering to a retired id.
|
|
209
|
+
for (const stale of document.querySelectorAll(`[${refAttribute}]`)) {
|
|
210
|
+
stale.removeAttribute(refAttribute);
|
|
211
|
+
}
|
|
212
|
+
walk(document.body || document.documentElement, 1);
|
|
213
|
+
|
|
214
|
+
return { title: clean(document.title), lines, refs, truncated };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stateFor(page) {
|
|
218
|
+
let state = pageState.get(page);
|
|
219
|
+
if (!state) {
|
|
220
|
+
state = { snapshotId: null, refs: null, invalidated: false, tracking: false, generation: 0 };
|
|
221
|
+
pageState.set(page, state);
|
|
222
|
+
}
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Walk the page and return its tree plus the refs it assigned.
|
|
228
|
+
*
|
|
229
|
+
* @param {import('playwright').Page} page
|
|
230
|
+
* @param {object} [options]
|
|
231
|
+
* @param {boolean} [options.interactiveOnly=true] — false also emits headings and landmarks, unreffed
|
|
232
|
+
* @param {number} [options.maxNodes=200] — cap on emitted nodes, clamped to [1, MAX_NODES_LIMIT]
|
|
233
|
+
*/
|
|
234
|
+
export async function captureSnapshot(page, options = {}) {
|
|
235
|
+
const interactiveOnly = options.interactiveOnly !== false;
|
|
236
|
+
const requested = Number(options.maxNodes);
|
|
237
|
+
const maxNodes = Number.isFinite(requested)
|
|
238
|
+
? Math.min(Math.max(Math.floor(requested), 1), MAX_NODES_LIMIT)
|
|
239
|
+
: DEFAULT_MAX_NODES;
|
|
240
|
+
|
|
241
|
+
// Idempotent, and the guarantee that a later navigation invalidates these
|
|
242
|
+
// refs rather than leaving them to fail as a missing selector.
|
|
243
|
+
attachRefTracking(page);
|
|
244
|
+
|
|
245
|
+
const state = stateFor(page);
|
|
246
|
+
let title, lines, refs, truncated;
|
|
247
|
+
|
|
248
|
+
// A navigation that commits WHILE the walk is running would otherwise leave us
|
|
249
|
+
// holding refs for a document that has gone — the attributes were stamped on
|
|
250
|
+
// the old page, so `@e1` would match nothing and surface as a locator timeout
|
|
251
|
+
// instead of the named error D2 requires. `generation` moves on every
|
|
252
|
+
// main-frame navigation, so a change across the evaluate means exactly that.
|
|
253
|
+
// Walk the new document instead; if it navigates again, give up and leave the
|
|
254
|
+
// refs invalidated rather than publishing a tree for a page nobody is on.
|
|
255
|
+
for (let attempt = 0; ; attempt++) {
|
|
256
|
+
const generation = state.generation;
|
|
257
|
+
({ title, lines, refs, truncated } = await page.evaluate(snapshotScript, {
|
|
258
|
+
refAttribute: REF_ATTRIBUTE,
|
|
259
|
+
interactiveOnly,
|
|
260
|
+
maxNodes,
|
|
261
|
+
maxIndent: MAX_INDENT,
|
|
262
|
+
maxNameLength: MAX_NAME_LENGTH
|
|
263
|
+
}));
|
|
264
|
+
if (state.generation === generation) break;
|
|
265
|
+
if (attempt >= MAX_WALK_ATTEMPTS - 1) {
|
|
266
|
+
clearRefs(page);
|
|
267
|
+
throw new StaleRefError(
|
|
268
|
+
'The page navigated while the snapshot was being taken — take a new snapshot.'
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const snapshotId = randomUUID().slice(0, 8);
|
|
274
|
+
state.snapshotId = snapshotId;
|
|
275
|
+
state.refs = new Map(refs.map(({ id, role, name, tag }) => [id, { role, name, tag }]));
|
|
276
|
+
state.invalidated = false;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
snapshotId,
|
|
280
|
+
url: page.url(),
|
|
281
|
+
title,
|
|
282
|
+
tree: [`[document]${title ? ` "${title}"` : ''}`, ...lines].join('\n'),
|
|
283
|
+
refCount: refs.length,
|
|
284
|
+
nodeCount: lines.length,
|
|
285
|
+
truncated,
|
|
286
|
+
interactiveOnly
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Turn `@e1` into the CSS selector every action path already understands.
|
|
292
|
+
* Throws StaleRefError when the ref does not belong to the page's current
|
|
293
|
+
* snapshot — it never guesses.
|
|
294
|
+
*/
|
|
295
|
+
export function resolveRef(page, selector) {
|
|
296
|
+
if (!isRef(selector)) {
|
|
297
|
+
// A programming error, not a stale ref: callers gate on isRef().
|
|
298
|
+
throw new Error(`resolveRef expects an element ref like "@e1", got ${JSON.stringify(selector)}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const state = pageState.get(page);
|
|
302
|
+
if (!state || (!state.refs && !state.invalidated)) {
|
|
303
|
+
throw new StaleRefError(
|
|
304
|
+
`Unknown element ref ${selector}: no snapshot has been taken on this page — add a { "type": "snapshot" } action before acting on refs.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (!state.refs) {
|
|
308
|
+
throw new StaleRefError(
|
|
309
|
+
`Stale element ref ${selector}: the page navigated since the last snapshot — take a new snapshot before acting on refs.`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const id = selector.slice(1);
|
|
314
|
+
if (!state.refs.has(id)) {
|
|
315
|
+
const count = state.refs.size;
|
|
316
|
+
throw new StaleRefError(count === 0
|
|
317
|
+
? `Unknown element ref ${selector}: the current snapshot has no refs — take a new snapshot.`
|
|
318
|
+
: `Unknown element ref ${selector}: the current snapshot has ${count} refs (@e1-@e${count}) — take a new snapshot.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return `[${REF_ATTRIBUTE}="${id}"]`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Register the navigation listener that invalidates this page's refs. Idempotent. */
|
|
325
|
+
export function attachRefTracking(page) {
|
|
326
|
+
const state = stateFor(page);
|
|
327
|
+
if (state.tracking) return;
|
|
328
|
+
state.tracking = true;
|
|
329
|
+
page.on('framenavigated', (frame) => {
|
|
330
|
+
if (frame === page.mainFrame()) clearRefs(page);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Drop the page's refs. A ref resolved afterwards reports the navigation, not a miss. */
|
|
335
|
+
export function clearRefs(page) {
|
|
336
|
+
const state = pageState.get(page);
|
|
337
|
+
if (!state) return;
|
|
338
|
+
// `invalidated` is only meaningful once a snapshot existed — it is what
|
|
339
|
+
// separates "the page navigated" from "no snapshot has been taken".
|
|
340
|
+
if (state.refs) state.invalidated = true;
|
|
341
|
+
state.refs = null;
|
|
342
|
+
state.snapshotId = null;
|
|
343
|
+
// Bumped on every clear so a walk in flight can tell the document changed
|
|
344
|
+
// under it — see captureSnapshot.
|
|
345
|
+
state.generation++;
|
|
346
|
+
}
|
|
@@ -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.',
|
|
@@ -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
|
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 |
|