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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * BrowserSessionTool — `browser_session`: one browser page the caller keeps
3
+ * across several tool calls, driven by an `operation` enum.
4
+ *
5
+ * `scrape_with_actions` is one-shot and blind: the agent has to name up to 20
6
+ * actions up front, guessing selectors for a page it has never seen, and the
7
+ * browser closes when the call returns. A session inverts that loop — open,
8
+ * look (`snapshot`), act on the refs the snapshot handed back, look again —
9
+ * and one login is paid for once instead of once per call.
10
+ *
11
+ * Everything here is assembled from parts that already exist, deliberately:
12
+ * - `ActionExecutor.initializePage()` is the `open` primitive, SSRF guard and
13
+ * robots gate included, and `executeActionsOnPage()` runs actions against a
14
+ * page it does not own (the `navigate` action re-gates every hop itself).
15
+ * - Element refs live in a page-scoped WeakMap in core/browser/snapshot.js, so
16
+ * a session that keeps its page keeps its refs across calls for free, and
17
+ * loses them exactly when it should — on navigation.
18
+ * - `BrowserSessionStore` holds the sessions, the two TTL clocks and the caps.
19
+ * - `ExtractContentTool` turns the live DOM into the requested formats.
20
+ *
21
+ * Ownership is a tenant boundary, not a nicety — see ownerId() for what that
22
+ * means for the hosted REST path, which is served only to a request carrying a
23
+ * per-user owner token and refused to any other.
24
+ */
25
+
26
+ import { z } from 'zod';
27
+ import { createHash } from 'node:crypto';
28
+
29
+ import ActionExecutor from '../../core/ActionExecutor.js';
30
+ import ExtractContentTool from '../extract/extractContent.js';
31
+ import BrowserSessionStore, {
32
+ TTL_MIN_MS,
33
+ TTL_MAX_MS,
34
+ ACTIVITY_TTL_MIN_MS,
35
+ ACTIVITY_TTL_MAX_MS
36
+ } from '../../core/browser/SessionStore.js';
37
+ import { captureSnapshot } from '../../core/browser/snapshot.js';
38
+ import authManager from '../../core/AuthManager.js';
39
+ import { isCreatorModeVerified } from '../../core/creatorMode.js';
40
+ import { internalOwnerToken, isInternalRequest } from '../../server/requestContext.js';
41
+ import { isRemoteTransport } from '../../utils/remoteMode.js';
42
+ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
+
44
+ const SECOND = 1000;
45
+
46
+ /** The prefix that marks an owner id derived from a hosted REST owner token. */
47
+ const REST_OWNER_PREFIX = 'rest:';
48
+
49
+ /**
50
+ * A hosted REST customer may hold ONE session at a time, where a stdio install
51
+ * keeps the store's default of three.
52
+ *
53
+ * Arithmetic, not caution. On Render MAX_BROWSER_CONTEXTS=6, so
54
+ * DEFAULT_MAX_SESSIONS_TOTAL is floor(6/2) = 3 for the WHOLE box while
55
+ * DEFAULT_MAX_SESSIONS_PER_OWNER is 3 — meaning one REST customer opening three
56
+ * sessions occupies the entire hosted session capacity, and every other paying
57
+ * customer is refused until those sessions age out, up to ten minutes later.
58
+ * Capping REST owners at one lets three distinct customers work at once.
59
+ *
60
+ * stdio and self-hosted installs keep the three: there the process IS the
61
+ * customer, the box is theirs, and there is nobody else to lock out.
62
+ */
63
+ const REST_MAX_SESSIONS_PER_OWNER = 1;
64
+
65
+ /**
66
+ * The action array is `scrape_with_actions`' own, passed through untouched:
67
+ * ActionExecutor validates each action against its own union as it runs it, and
68
+ * a fourth copy of that union here could only drift from the three that exist.
69
+ *
70
+ * The two fields below are not decoration. ActionExecutor parses each action but
71
+ * discards the parsed value, so an action that arrives without them keeps the
72
+ * undefined it came with — and `action.retries > 0` is the gate on error
73
+ * recovery, `action.continueOnError` the per-action failure policy. These are
74
+ * the same defaults ScrapeWithActionsTool's schema stamps.
75
+ */
76
+ const SessionActionSchema = z.object({
77
+ type: z.string(),
78
+ continueOnError: z.boolean().default(false),
79
+ retries: z.number().min(0).max(5).default(1)
80
+ }).passthrough();
81
+
82
+ const BrowserSessionSchema = z.object({
83
+ operation: z.enum(['open', 'snapshot', 'act', 'read', 'screenshot', 'close', 'list']),
84
+ session_id: z.string().optional(),
85
+
86
+ // open. ttl/activity_ttl are seconds, as Firecrawl's are, so anyone arriving
87
+ // from their docs reads the same numbers; the store works in milliseconds.
88
+ url: z.string().url().optional(),
89
+ stealth: z.boolean().default(false),
90
+ ttl: z.number().min(TTL_MIN_MS / SECOND).max(TTL_MAX_MS / SECOND).optional(),
91
+ activity_ttl: z.number().min(ACTIVITY_TTL_MIN_MS / SECOND).max(ACTIVITY_TTL_MAX_MS / SECOND).optional(),
92
+ viewport: z.object({
93
+ width: z.number().min(800).max(1920),
94
+ height: z.number().min(600).max(1080)
95
+ }).optional(),
96
+ timeout: z.number().min(10000).max(120000).default(30000),
97
+
98
+ // Applies to the call it is sent on: on `open` to the first load, on `act` to
99
+ // every navigate in that call. It is never remembered by the session, so an
100
+ // override has to be repeated as deliberately as it was made.
101
+ respect_robots: z.boolean().optional(),
102
+
103
+ // snapshot
104
+ interactive_only: z.boolean().default(true),
105
+ max_nodes: z.number().min(1).max(1000).optional(),
106
+
107
+ // act
108
+ actions: z.array(SessionActionSchema).min(1).max(20).optional(),
109
+ continue_on_error: z.boolean().default(false),
110
+
111
+ // read
112
+ formats: z.array(z.enum(['markdown', 'html', 'text', 'json'])).default(['markdown']),
113
+
114
+ // screenshot
115
+ full_page: z.boolean().default(false),
116
+ format: z.enum(['png', 'jpeg']).default('png'),
117
+ quality: z.number().min(0).max(100).default(80),
118
+ selector: z.string().optional()
119
+ });
120
+
121
+ /** A refusal a caller (and a test) can match on by code rather than by prose. */
122
+ function refuse(code, message) {
123
+ const error = new Error(message);
124
+ error.name = 'BrowserSessionRefusal';
125
+ error.code = code;
126
+ return error;
127
+ }
128
+
129
+ /**
130
+ * The session fields every operation echoes back, in the same shape a store
131
+ * list row carries. Read it after touch() so the idle clock is the fresh one.
132
+ */
133
+ function sessionInfo(session) {
134
+ return {
135
+ sessionId: session.id,
136
+ url: session.url,
137
+ stealth: session.stealth,
138
+ expiresAt: session.createdAt + session.ttlMs,
139
+ idleExpiresAt: session.lastUsedAt + session.activityTtlMs
140
+ };
141
+ }
142
+
143
+ export class BrowserSessionTool {
144
+ constructor(options = {}) {
145
+ const {
146
+ actionExecutor = null,
147
+ extractContentTool = null,
148
+ store = null,
149
+ storeOptions = {},
150
+ enableLogging = true
151
+ } = options;
152
+
153
+ // An injected executor belongs to whoever built it (server.js hands us
154
+ // scrape_with_actions'), and destroying it would take that tool's browser
155
+ // down with ours. Only an executor we made ourselves is ours to destroy.
156
+ this._ownsExecutor = !actionExecutor;
157
+ this.actionExecutor = actionExecutor || new ActionExecutor({ enableLogging });
158
+ this.extractContentTool = extractContentTool || new ExtractContentTool();
159
+ this.storeOptions = storeOptions;
160
+ this.store = store || new BrowserSessionStore(storeOptions);
161
+ }
162
+
163
+ async execute(params) {
164
+ const validated = BrowserSessionSchema.parse(params);
165
+ const ownerId = this.ownerId();
166
+
167
+ switch (validated.operation) {
168
+ case 'open': return await this.openSession(validated, ownerId);
169
+ case 'snapshot': return await this.snapshotSession(validated, ownerId);
170
+ case 'act': return await this.actOnSession(validated, ownerId);
171
+ case 'read': return await this.readSession(validated, ownerId);
172
+ case 'screenshot': return await this.screenshotSession(validated, ownerId);
173
+ case 'close': return await this.closeSession(validated, ownerId);
174
+ case 'list': return this.listSessions(ownerId);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Who the caller is — the identity every session is bound to and every
180
+ * lookup is scoped by.
181
+ *
182
+ * On the hosted REST path the shared secret is not an identity. The website's
183
+ * proxy authenticates to this server with a single X-Internal-Secret
184
+ * (`authenticateRequest` in src/server/transports/streamableHttp.js), so
185
+ * every REST customer arrives as the same internal caller; binding a session
186
+ * to THAT would put all of them inside one tenant, where any customer could
187
+ * name any other customer's session id and be handed their logged-in browser.
188
+ * The per-user owner token is the missing half: the proxy derives it per end
189
+ * user and sends it on X-CrawlForge-Owner, the transport honours it only on a
190
+ * request that already proved the secret, and it lands here as the tenant key.
191
+ *
192
+ * NO FALLBACK WHEN IT IS ABSENT, DELIBERATELY — do not "tidy" the refusal
193
+ * below into a default owner. It is what makes the two repos safe to deploy
194
+ * in either order: an old server ignores the new header, a new server meets
195
+ * an old website that sends none, and in both cases sessions are refused
196
+ * rather than silently collapsing into one shared tenant. A missing owner is
197
+ * a missing tenant boundary, and the honest answer to that is "no session".
198
+ *
199
+ * Everywhere else the install is the tenant: over stdio, and over self-hosted
200
+ * HTTP authenticated with the install's API key or an OAuth token, the same
201
+ * configured key stands behind every request. Its digest is the owner id; the
202
+ * key itself never leaves this method.
203
+ */
204
+ ownerId() {
205
+ if (isInternalRequest()) {
206
+ const ownerToken = internalOwnerToken();
207
+ if (ownerToken) return `${REST_OWNER_PREFIX}${ownerToken}`;
208
+
209
+ throw refuse(
210
+ 'SESSIONS_NOT_AVAILABLE_OVER_REST',
211
+ 'browser_session could not be opened over the CrawlForge REST API: the API proxy ' +
212
+ 'authenticated as a shared internal caller without saying which customer this request ' +
213
+ 'belongs to, so a session id could not be bound to the account that opened it. That is ' +
214
+ 'usually a version skew mid-deploy — try again shortly. Meanwhile, use ' +
215
+ 'scrape_with_actions for a one-shot interaction chain, or run the CrawlForge MCP ' +
216
+ 'server locally (stdio) where sessions work normally.'
217
+ );
218
+ }
219
+
220
+ const apiKey = authManager.getConfig()?.apiKey;
221
+ return apiKey
222
+ ? `key:${createHash('sha256').update(apiKey).digest('hex').slice(0, 16)}`
223
+ : 'local';
224
+ }
225
+
226
+ /**
227
+ * The session this operation names, or the same "session not found" an
228
+ * unknown id gets. The store raises one error for unknown, wrong-owner and
229
+ * expired alike, which is what keeps ids non-enumerable — never answer a
230
+ * wrong owner with "forbidden".
231
+ */
232
+ requireSession(params, ownerId) {
233
+ if (!params.session_id) {
234
+ throw new Error(
235
+ `operation "${params.operation}" requires session_id — the id returned by operation:"open".`
236
+ );
237
+ }
238
+ return this.store.get(params.session_id, ownerId);
239
+ }
240
+
241
+ async openSession(params, ownerId) {
242
+ if (!params.url) {
243
+ throw new Error('operation "open" requires a url to load the session on.');
244
+ }
245
+
246
+ const browserOptions = {
247
+ headless: true,
248
+ viewportWidth: params.viewport?.width,
249
+ viewportHeight: params.viewport?.height,
250
+ timeout: params.timeout,
251
+ respectRobots: params.respect_robots
252
+ };
253
+ if (params.stealth) {
254
+ browserOptions.stealthMode = { enabled: true };
255
+ }
256
+
257
+ // initializePage runs the SSRF guard, then the blocklist/robots gate, and
258
+ // only then creates a page and navigates — closing the page itself if any
259
+ // of that fails. That is the whole of 2.5's gating on `open`, which is why
260
+ // this is not page.goto() behind a gate written here.
261
+ const page = await this.actionExecutor.initializePage(params.url, browserOptions);
262
+ const releasePage = this.releaser(page, params.stealth);
263
+
264
+ let session;
265
+ try {
266
+ session = this.store.create({
267
+ ownerId,
268
+ page,
269
+ releasePage,
270
+ url: page.url(),
271
+ stealth: params.stealth,
272
+ ttlMs: params.ttl === undefined ? undefined : params.ttl * SECOND,
273
+ activityTtlMs: params.activity_ttl === undefined ? undefined : params.activity_ttl * SECOND,
274
+ // undefined for every other owner, which leaves the store's own cap in
275
+ // force — the override exists for the hosted box alone.
276
+ maxPerOwner: ownerId.startsWith(REST_OWNER_PREFIX) ? REST_MAX_SESSIONS_PER_OWNER : undefined
277
+ });
278
+ } catch (error) {
279
+ // A cap refusal arrives with a live page in hand. Give it back before
280
+ // rethrowing, or the refused call leaks the context it just pinned.
281
+ await releasePage();
282
+ throw error;
283
+ }
284
+
285
+ return { success: true, operation: 'open', ...sessionInfo(session) };
286
+ }
287
+
288
+ async snapshotSession(params, ownerId) {
289
+ const session = this.requireSession(params, ownerId);
290
+
291
+ const snapshot = await captureSnapshot(session.page, {
292
+ interactiveOnly: params.interactive_only,
293
+ maxNodes: params.max_nodes
294
+ });
295
+
296
+ this.store.touch(session, session.page.url());
297
+ return { success: true, operation: 'snapshot', ...sessionInfo(session), snapshot };
298
+ }
299
+
300
+ async actOnSession(params, ownerId) {
301
+ if (!params.actions?.length) {
302
+ throw new Error('operation "act" requires an actions array.');
303
+ }
304
+ const session = this.requireSession(params, ownerId);
305
+
306
+ // D6: arbitrary JavaScript in a browser on OUR infrastructure is a
307
+ // materially different act from the same JavaScript in a browser on the
308
+ // caller's own laptop. Over stdio or loopback the caller is the local user
309
+ // and executeJavaScriptAction's own ALLOW_JAVASCRIPT_EXECUTION flag is the
310
+ // control; served to a network, it is refused outright. Creator mode is the
311
+ // maintainer's own box, so it keeps the local answer.
312
+ if (params.actions.some((action) => action.type === 'executeJavaScript') &&
313
+ isRemoteTransport() && !isCreatorModeVerified()) {
314
+ throw refuse(
315
+ 'JS_EXECUTION_REFUSED_REMOTE',
316
+ 'executeJavaScript is refused in a browser session on a remotely-served CrawlForge ' +
317
+ 'instance: the script would run in a browser on the server, not on your machine. ' +
318
+ 'Use the click / type / select / press actions, or run the MCP server locally over stdio.'
319
+ );
320
+ }
321
+
322
+ // Every `navigate` in here re-runs the SSRF guard and the blocklist/robots
323
+ // gate inside executeNavigateAction — verified, and the reason the gate is
324
+ // not repeated here. A long-lived session is a repeatable navigation
325
+ // primitive, so that per-hop check is what stops it becoming an SSRF hop.
326
+ const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
327
+ continueOnError: params.continue_on_error,
328
+ timeout: params.timeout,
329
+ browserOptions: { respectRobots: params.respect_robots }
330
+ });
331
+
332
+ this.store.touch(session, result.finalUrl);
333
+ return {
334
+ success: result.success,
335
+ operation: 'act',
336
+ ...sessionInfo(session),
337
+ error: result.error,
338
+ actionResults: result.results,
339
+ screenshots: result.screenshots,
340
+ ...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
341
+ stats: result.stats
342
+ };
343
+ }
344
+
345
+ async readSession(params, ownerId) {
346
+ const session = this.requireSession(params, ownerId);
347
+ const url = session.page.url();
348
+ const html = await session.page.content();
349
+
350
+ const options = {};
351
+ if (params.formats.includes('markdown')) options.outputFormat = 'markdown';
352
+ if (params.formats.includes('html')) options.includeRawHTML = true;
353
+
354
+ // The live post-action DOM is already in hand, so extract_content is handed
355
+ // that rather than the url: a re-fetch would arrive without the session's
356
+ // cookies and before everything the session has clicked, which is the whole
357
+ // point of having one.
358
+ const extracted = await this.extractContentTool.execute({ url, html, options });
359
+
360
+ const content = {};
361
+ if (params.formats.includes('text')) {
362
+ content.text = extracted.content?.text || '';
363
+ }
364
+ if (params.formats.includes('html')) {
365
+ content.html = extracted.content?.html || html;
366
+ }
367
+ if (params.formats.includes('markdown')) {
368
+ // Readability finds no article on most app pages, and then no markdown is
369
+ // produced at all (R20, 2026-09-07). Convert the DOM we hold instead of
370
+ // handing back a placeholder.
371
+ content.markdown = extracted.content?.markdown || htmlToMarkdown(html);
372
+ }
373
+ if (params.formats.includes('json')) {
374
+ content.json = {
375
+ title: extracted.title ?? null,
376
+ metadata: extracted.metadata || {},
377
+ structuredData: extracted.structuredData
378
+ };
379
+ }
380
+
381
+ this.store.touch(session, url);
382
+ return {
383
+ success: true,
384
+ operation: 'read',
385
+ ...sessionInfo(session),
386
+ title: extracted.title ?? null,
387
+ extractionMethod: extracted.extractionMethod,
388
+ content
389
+ };
390
+ }
391
+
392
+ async screenshotSession(params, ownerId) {
393
+ const session = this.requireSession(params, ownerId);
394
+
395
+ const shot = await this.actionExecutor.captureScreenshot(session.page, {
396
+ fullPage: params.full_page,
397
+ format: params.format,
398
+ quality: params.quality,
399
+ selector: params.selector
400
+ });
401
+
402
+ this.store.touch(session, session.page.url());
403
+ // The actionId is what lets the server publish the image as a
404
+ // crawlforge://screenshot/{actionId} resource and drop the base64 from the
405
+ // result — a full-page PNG inline is megabytes (R21, 2026-09-09).
406
+ return {
407
+ success: true,
408
+ operation: 'screenshot',
409
+ ...sessionInfo(session),
410
+ screenshot: { actionId: this.actionExecutor.generateActionId(), ...shot }
411
+ };
412
+ }
413
+
414
+ async closeSession(params, ownerId) {
415
+ if (!params.session_id) {
416
+ throw new Error('operation "close" requires session_id.');
417
+ }
418
+ await this.store.close(params.session_id, ownerId);
419
+ return { success: true, operation: 'close', sessionId: params.session_id, closed: true };
420
+ }
421
+
422
+ listSessions(ownerId) {
423
+ const sessions = this.store.list(ownerId).map(({ id, ...rest }) => ({ sessionId: id, ...rest }));
424
+ return { success: true, operation: 'list', count: sessions.length, sessions };
425
+ }
426
+
427
+ /**
428
+ * How a page goes back: the closure the store calls on close, on expiry and
429
+ * at shutdown.
430
+ *
431
+ * Copied from executeActionChain's `finally`, and the halves are not
432
+ * interchangeable. A stealth page goes through the manager so its pooled
433
+ * context slot is freed as well as the renderer; a standard page must also
434
+ * close the BrowserContext createPage() gave it, because nothing else tracks
435
+ * that one. Getting this wrong pins a context until the process dies, which
436
+ * on the 2 GB Render box is an outage rather than a leak.
437
+ */
438
+ releaser(page, stealth) {
439
+ return async () => {
440
+ if (stealth) {
441
+ await this.actionExecutor.browserProcessor.releaseStealthPage(page);
442
+ return;
443
+ }
444
+ const context = page.context();
445
+ try { await page.close(); } catch (_) { /* ignore close errors */ }
446
+ try { await context.close(); } catch (_) { /* ignore close errors */ }
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Close every live session, leaving the tool able to open more.
452
+ *
453
+ * This is the half the stealth-cleanup lever needs: `stealth_mode`
454
+ * operation:"cleanup" tears down the stealth browser, so every page a stealth
455
+ * session is holding dies with it and those sessions have to go too — but the
456
+ * tool itself must still work afterwards. The store is replaced rather than
457
+ * reused because destroy() also stops its sweep timer, and an injected store
458
+ * is replaced along with the rest.
459
+ */
460
+ async cleanup() {
461
+ await this.store.destroy();
462
+ this.store = new BrowserSessionStore(this.storeOptions);
463
+ }
464
+
465
+ /**
466
+ * Process exit: close the sessions, then the browser — but only if the
467
+ * executor is ours. Sessions always close here; that is why this tool is in
468
+ * server.js's shutdown list even when it shares another tool's executor.
469
+ */
470
+ async destroy() {
471
+ await this.store.destroy();
472
+ if (this._ownsExecutor) await this.actionExecutor.destroy();
473
+ }
474
+ }
475
+
476
+ export default BrowserSessionTool;
@@ -129,6 +129,14 @@ const ExecuteJavaScriptActionSchema = BaseActionSchema.extend({
129
129
  returnResult: z.boolean().default(true)
130
130
  });
131
131
 
132
+ // camelCase fields, matching ActionExecutor's SnapshotActionSchema - this
133
+ // union runs first, so a mismatch here rejects the action before it gets there.
134
+ const SnapshotActionSchema = BaseActionSchema.extend({
135
+ type: z.literal('snapshot'),
136
+ interactiveOnly: z.boolean().default(true),
137
+ maxNodes: z.number().min(1).max(1000).optional()
138
+ });
139
+
132
140
  const ActionSchema = z.union([
133
141
  WaitActionSchema,
134
142
  ClickActionSchema,
@@ -139,7 +147,8 @@ const ActionSchema = z.union([
139
147
  HoverActionSchema,
140
148
  NavigateActionSchema,
141
149
  ScreenshotActionSchema,
142
- ExecuteJavaScriptActionSchema
150
+ ExecuteJavaScriptActionSchema,
151
+ SnapshotActionSchema
143
152
  ]);
144
153
 
145
154
  // Form field schema for auto-fill