crawlforge-mcp-server 6.6.0 → 6.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.6.0",
3
+ "version": "6.6.2",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -61,13 +61,13 @@ import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
61
61
  import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
62
62
  import { markPreflightRefusal } from "./src/server/requestContext.js";
63
63
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
64
- import { ResourceRegistry } from "./src/resources/ResourceRegistry.js";
64
+ import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
65
65
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
66
66
  import { ElicitationHelper } from "./src/core/ElicitationHelper.js";
67
67
  // Phase 6: MCP-spec adoption — structured output, tool filtering, spec hygiene
68
68
  import { OUTPUT_SCHEMAS } from "./src/schemas/toolOutputSchemas.js";
69
69
  import { dualOutput } from "./src/server/registerTool.js";
70
- import { createToolFilter } from "./src/server/toolFilter.js";
70
+ import { createToolFilter, TOOL_GROUPS } from "./src/server/toolFilter.js";
71
71
  import { applySpecHygiene } from "./src/server/specHygiene.js";
72
72
 
73
73
  // Initialize Authentication Manager
@@ -108,7 +108,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
108
108
  // Create the server
109
109
  const server = new McpServer({
110
110
  name: "crawlforge",
111
- version: "6.6.0",
111
+ version: "6.6.2",
112
112
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
113
113
  homepage: "https://www.crawlforge.dev",
114
114
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1051,7 +1051,8 @@ registerToolIfEnabled("browser_session", {
1051
1051
  full_page: z.boolean().default(false).describe("screenshot: capture the full scrollable page"),
1052
1052
  format: z.enum(["png", "jpeg"]).default("png").describe("screenshot: image format"),
1053
1053
  quality: z.number().min(0).max(100).default(80).describe("screenshot: JPEG quality"),
1054
- selector: z.string().optional().describe("screenshot: capture just this element (a ref like \"@e2\" works)")
1054
+ selector: z.string().optional().describe("screenshot: capture just this element (a ref like \"@e2\" works)"),
1055
+ ...MAX_INLINE_CHARS_PARAM
1055
1056
  }
1056
1057
  }, withAuth("browser_session", async (params) => {
1057
1058
  try {
@@ -1062,9 +1063,24 @@ registerToolIfEnabled("browser_session", {
1062
1063
  // is megabytes beside a few lines of JSON (R21, 2026-09-09).
1063
1064
  const publish = (shot) => {
1064
1065
  if (!shot?.actionId || !shot?.data) return shot;
1065
- resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1066
+ const { bytes, withinInlineBudget } = resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1066
1067
  const { data, ...rest } = shot;
1067
- return { ...rest, resourceUri: `crawlforge://screenshot/${shot.actionId}` };
1068
+ return {
1069
+ ...rest,
1070
+ resourceUri: `crawlforge://screenshot/${shot.actionId}`,
1071
+ bytes,
1072
+ // full_page is a knob this tool hands the caller, and on a long page it
1073
+ // produces an image no MCP message can carry. Saying so here costs one
1074
+ // field; learning it from the read costs a wasted call (R23).
1075
+ ...(withinInlineBudget
1076
+ ? {}
1077
+ : {
1078
+ warning: `This image is ${bytes} bytes, too large to read back over MCP ` +
1079
+ `(limit ${MAX_RESOURCE_BLOB_BYTES} bytes) — reading the resource will be refused. ` +
1080
+ `Take it again without full_page, or as format:"jpeg" with a lower quality, or ` +
1081
+ `scoped to one element with selector.`
1082
+ })
1083
+ };
1068
1084
  };
1069
1085
  if (result.screenshot) result.screenshot = publish(result.screenshot);
1070
1086
  if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
@@ -1712,16 +1728,11 @@ async function runServer() {
1712
1728
  console.error(`Environment: ${config.server.nodeEnv}`);
1713
1729
  console.error("Search enabled: true (via CrawlForge proxy)");
1714
1730
 
1715
- const allTools = [
1716
- "fetch_url", "extract_text", "extract_links", "extract_metadata", "scrape_structured", "extract_embedded_state",
1717
- "search_web", "serp_rank", "reddit_search", "crawl_deep", "map_site",
1718
- "extract_content", "process_document", "summarize_content", "analyze_content",
1719
- "batch_scrape", "get_batch_results", "read_result", "scrape_with_actions",
1720
- "deep_research", "track_changes", "generate_llms_txt",
1721
- "stealth_mode", "localization", "extract_structured", "extract_with_llm",
1722
- "list_ollama_models", "scrape_template", // D3.3
1723
- "scrape", "agent" // D4
1724
- ];
1731
+ // Derived from TOOL_GROUPS, the same list the filter itself is built on, so a
1732
+ // new tool is announced the moment it is grouped. The hand-written copy that
1733
+ // used to live here silently under-reported: it never learned browser_session
1734
+ // and so claimed "30/30" while 31 tools were registered and advertised.
1735
+ const allTools = Object.values(TOOL_GROUPS).flat();
1725
1736
  const enabledTools = allTools.filter((name) => toolFilter.isEnabled(name));
1726
1737
  console.error(`Tools available (${enabledTools.length}/${allTools.length}): ${enabledTools.join(", ")}`);
1727
1738
 
@@ -1184,12 +1184,13 @@ export class ActionExecutor extends EventEmitter {
1184
1184
  const timeout = this.actionTimeout(action);
1185
1185
 
1186
1186
  await assertUrlAllowed(action.url, { resolveDns: true });
1187
- await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1187
+ const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
1188
1188
 
1189
1189
  await this.navigateToUrl(page, action.url, {
1190
1190
  waitUntil: action.waitUntil,
1191
1191
  timeout
1192
1192
  });
1193
+ page.__crawlforgeGateWarnings = gateWarnings;
1193
1194
 
1194
1195
  return {
1195
1196
  url: action.url,
@@ -1380,9 +1381,13 @@ export class ActionExecutor extends EventEmitter {
1380
1381
  * @throws {BlockedHostError|RobotsDisallowedError}
1381
1382
  */
1382
1383
  async assertRobotsAllowed(url, browserOptions = {}) {
1383
- await browserPreflight(url, {
1384
+ return await browserPreflight(url, {
1384
1385
  respectRobots: browserOptions?.respectRobots,
1385
- tool: 'scrape_with_actions'
1386
+ // The audit row is the record of the CUSTOMER's decision (G5), so it has
1387
+ // to name the tool that actually made it. browser_session borrows this
1388
+ // executor, and until R23 every session's override was filed against
1389
+ // scrape_with_actions.
1390
+ tool: browserOptions?.tool || 'scrape_with_actions'
1386
1391
  });
1387
1392
  }
1388
1393
 
@@ -1402,13 +1407,17 @@ export class ActionExecutor extends EventEmitter {
1402
1407
  // or a disallowed path never costs a Chromium process. preflightFetch is
1403
1408
  // deliberately not used here: its identity/signature headers belong on an
1404
1409
  // HTTP fetch, not on a browser context.
1405
- await this.assertRobotsAllowed(url, browserOptions);
1410
+ const gateWarnings = await this.assertRobotsAllowed(url, browserOptions);
1406
1411
 
1407
1412
  const isStealth = !!browserOptions.stealthMode?.enabled;
1408
1413
 
1409
1414
  // Use the enhanced BrowserProcessor initialization that supports stealth mode
1410
1415
  const page = await this.browserProcessor.initializePage(browserOptions);
1411
1416
 
1417
+ // Stamped on the page for the same reason __crawlforgeNavigation is: the
1418
+ // caller's result is assembled a layer up, and the gate ran a layer down.
1419
+ page.__crawlforgeGateWarnings = gateWarnings;
1420
+
1412
1421
  try {
1413
1422
  // Apply CloudFlare and reCAPTCHA detection if stealth mode is enabled
1414
1423
  if (isStealth && this.browserProcessor.stealthManager) {
@@ -6,6 +6,23 @@
6
6
 
7
7
  import { createHash } from 'crypto';
8
8
 
9
+ /**
10
+ * The stdio transport frames one JSON-RPC message at a time, and since SDK
11
+ * 1.30 its ReadBuffer CLOSES the transport when a message runs past
12
+ * STDIO_MESSAGE_CEILING_BYTES: the whole session dies, every tool with it, and
13
+ * the overflow is not recoverable (modelcontextprotocol/typescript-sdk#2793).
14
+ * A full-page PNG of a long article is 17.4 MB — en.wikipedia.org/wiki/World_War_II
15
+ * is 61,341px tall — and JPEG only brings it to 11.3 MB, so neither format saves
16
+ * a caller who passes full_page (R23, 2026-09-13).
17
+ *
18
+ * Hence a budget on the blob a read may emit: base64 costs four bytes per three,
19
+ * and a tenth of the ceiling is left for the JSON-RPC envelope around it.
20
+ */
21
+ const STDIO_MESSAGE_CEILING_BYTES = 10 * 1024 * 1024;
22
+ export const MAX_RESOURCE_BLOB_BYTES =
23
+ Number(process.env.CRAWLFORGE_MAX_RESOURCE_BLOB_BYTES) ||
24
+ Math.floor(STDIO_MESSAGE_CEILING_BYTES * 0.9 * 3 / 4);
25
+
9
26
  /**
10
27
  * Supported resource types and their MIME types.
11
28
  */
@@ -84,6 +101,12 @@ export class ResourceRegistry {
84
101
  createdAt: Date.now(),
85
102
  ttl: this.defaultTtl,
86
103
  });
104
+ // Handed back so the tool that took the shot can say, in the same result
105
+ // that carries the URI, whether that URI is readable over stdio at all.
106
+ return {
107
+ bytes: buf.length,
108
+ withinInlineBudget: buf.length <= MAX_RESOURCE_BLOB_BYTES,
109
+ };
87
110
  }
88
111
 
89
112
  /**
@@ -152,7 +175,7 @@ export class ResourceRegistry {
152
175
  resources.push({
153
176
  uri: `crawlforge://screenshot/${actionId}`,
154
177
  name: `Screenshot ${actionId}`,
155
- description: 'Screenshot from scrape_with_actions',
178
+ description: 'Screenshot from a CrawlForge browser tool',
156
179
  mimeType: RESOURCE_MIME.screenshot,
157
180
  });
158
181
  }
@@ -265,6 +288,17 @@ export class ResourceRegistry {
265
288
  if (!entry || Date.now() - entry.createdAt >= entry.ttl) {
266
289
  throw new Error(`Screenshot not found or expired: ${actionId}`);
267
290
  }
291
+ // Refuse rather than emit: an oversized message costs the caller their
292
+ // whole session, and this error costs them one read they can act on.
293
+ if (entry.data.length > MAX_RESOURCE_BLOB_BYTES) {
294
+ throw new Error(
295
+ `Screenshot ${actionId} is ${entry.data.length} bytes, over the ` +
296
+ `${MAX_RESOURCE_BLOB_BYTES}-byte limit for one MCP message. Returning it would ` +
297
+ `close the connection instead of failing this read, so it is refused. Take the ` +
298
+ `shot again without full_page, or as format:"jpeg" with a lower quality, or ` +
299
+ `scoped to one element with selector.`
300
+ );
301
+ }
268
302
  return {
269
303
  contents: [{
270
304
  uri,
@@ -24,6 +24,9 @@ export const MAX_INLINE_CHARS_PARAM = {
24
24
  * false` keeps the whole result inline and only adds the handle
25
25
  * (extract_embedded_state's never-truncate rule). `when` gates on params.
26
26
  */
27
+ /** The browser_session operations that hand back content worth shaping. */
28
+ const BROWSER_SESSION_CONTENT_OPERATIONS = new Set(['snapshot', 'act', 'read']);
29
+
27
30
  export const INLINE_THRESHOLD_TOOLS = Object.freeze({
28
31
  scrape: { textPaths: ['content.markdown', 'content.text', 'content.html', 'content.rawHtml'], truncate: true },
29
32
  fetch_url: { textPaths: ['body'], truncate: true },
@@ -35,6 +38,18 @@ export const INLINE_THRESHOLD_TOOLS = Object.freeze({
35
38
  get_batch_results: { textPaths: [], truncate: true },
36
39
  stealth_mode: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true, when: (params) => params?.operation === 'scrape' },
37
40
  scrape_with_actions: { textPaths: ['content.markdown', 'content.text', 'content.html'], truncate: true },
41
+ // `read` hands back the same content shape scrape_with_actions does, and was
42
+ // the one content-returning tool with no cap: a read of the World War II
43
+ // article returned 541,308 characters inline where scrape returned 42,259
44
+ // (2026-09-12). The operations, the paths and their order are the REST
45
+ // route's (src/app/api/v1/tools/browser_session/route.ts, CONTENT_OPERATIONS)
46
+ // so the same call is shaped the same way whichever surface serves it; the
47
+ // other four return a session id and an expiry and must not be shaped.
48
+ browser_session: {
49
+ textPaths: ['content.markdown', 'content.text', 'content.html', 'snapshot.tree'],
50
+ truncate: true,
51
+ when: (params) => BROWSER_SESSION_CONTENT_OPERATIONS.has(params?.operation)
52
+ },
38
53
  process_document: { textPaths: ['content.text'], truncate: true },
39
54
  deep_research: { textPaths: [], truncate: true },
40
55
  extract_embedded_state: { textPaths: [], truncate: false }
@@ -40,6 +40,7 @@ import { isCreatorModeVerified } from '../../core/creatorMode.js';
40
40
  import { internalOwnerToken, isInternalRequest } from '../../server/requestContext.js';
41
41
  import { isRemoteTransport } from '../../utils/remoteMode.js';
42
42
  import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
43
+ import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
43
44
 
44
45
  const SECOND = 1000;
45
46
 
@@ -76,7 +77,14 @@ const REST_MAX_SESSIONS_PER_OWNER = 1;
76
77
  const SessionActionSchema = z.object({
77
78
  type: z.string(),
78
79
  continueOnError: z.boolean().default(false),
79
- retries: z.number().min(0).max(5).default(1)
80
+ retries: z.number().min(0).max(5).default(1),
81
+ // The third default ActionExecutor declares and then throws away. It is read
82
+ // only by executeJavaScript, where `action.returnResult ? result : undefined`
83
+ // decides whether the script's return value survives at all — so without it
84
+ // stamped here every executeJavaScript in a session succeeded and handed back
85
+ // nothing, while the same action through scrape_with_actions (which keeps its
86
+ // parsed value) returned the data. Harmless on the action types that ignore it.
87
+ returnResult: z.boolean().default(true)
80
88
  }).passthrough();
81
89
 
82
90
  const BrowserSessionSchema = z.object({
@@ -140,6 +148,43 @@ function sessionInfo(session) {
140
148
  };
141
149
  }
142
150
 
151
+ /**
152
+ * The verdict fields a result carries when there is something to say about the
153
+ * document — the same names `scrape` and `scrape_with_actions` publish.
154
+ */
155
+ function verdictFields(verdict) {
156
+ return {
157
+ ...(Number.isInteger(verdict.status) ? { httpStatus: verdict.status } : {}),
158
+ ...(verdict.blocked ? { blocked: verdict.blocked } : {})
159
+ };
160
+ }
161
+
162
+ /**
163
+ * `scrape_with_actions` publishes an executeJavaScript action's return value as
164
+ * a flat `jsResult` beside the nested one (processActionResults); a session's
165
+ * `act` returned the nested shape alone, so the same action read differently
166
+ * depending on which tool ran it. Same hoist, same field name.
167
+ */
168
+ function withJsResult(result) {
169
+ if (result?.type !== 'executeJavaScript' || !result.result) return result;
170
+ return { ...result, jsResult: result.result.result };
171
+ }
172
+
173
+ /**
174
+ * The gate's warnings — a respect_robots override, a crawl-delay note — are
175
+ * what the shared parameter description promises the caller gets back
176
+ * ("returns a warning in the response"). `scrape` publishes them through
177
+ * unifiedScrape; the browser path dropped them on the floor, so a session that
178
+ * overrode robots.txt was told nothing at all (R23, 2026-09-13).
179
+ *
180
+ * Last navigation wins, the same rule `__crawlforgeNavigation` follows: the
181
+ * warnings describe the hop the caller just made, not every hop of the session.
182
+ */
183
+ function gateWarningFields(page) {
184
+ const warnings = page?.__crawlforgeGateWarnings;
185
+ return warnings?.length ? { warnings } : {};
186
+ }
187
+
143
188
  export class BrowserSessionTool {
144
189
  constructor(options = {}) {
145
190
  const {
@@ -248,7 +293,8 @@ export class BrowserSessionTool {
248
293
  viewportWidth: params.viewport?.width,
249
294
  viewportHeight: params.viewport?.height,
250
295
  timeout: params.timeout,
251
- respectRobots: params.respect_robots
296
+ respectRobots: params.respect_robots,
297
+ tool: 'browser_session'
252
298
  };
253
299
  if (params.stealth) {
254
300
  browserOptions.stealthMode = { enabled: true };
@@ -282,7 +328,63 @@ export class BrowserSessionTool {
282
328
  throw error;
283
329
  }
284
330
 
285
- return { success: true, operation: 'open', ...sessionInfo(session) };
331
+ // The session opened; whether the document it landed on is the page is a
332
+ // separate question. g2.com answered `open` with a DataDome 403 whose body
333
+ // was empty, and this returned success:true with no status at all, while
334
+ // `scrape` on the same URL named the vendor and the 403 — the same fault
335
+ // R18 found in scrape_with_actions (2026-09-04), in the one browser tool
336
+ // that never learned the lesson.
337
+ const verdict = await this.pageVerdict(page, { stealth: params.stealth });
338
+
339
+ return {
340
+ success: verdict.success,
341
+ operation: 'open',
342
+ ...sessionInfo(session),
343
+ ...verdictFields(verdict),
344
+ ...gateWarningFields(page),
345
+ // The page is a wall, but the session behind it is real and holds a
346
+ // browser context — say so, or a caller reading only `success` abandons
347
+ // it to its TTL instead of closing it or acting through the challenge.
348
+ ...(verdict.error
349
+ ? { error: `${verdict.error} The session is open as ${session.id}: act on it, or close it.` }
350
+ : {})
351
+ };
352
+ }
353
+
354
+ /**
355
+ * What the session's page currently is: the page, a bot wall, an HTTP error
356
+ * page, or an error placeholder. One helper, shared with `scrape` and
357
+ * `scrape_with_actions`, so all three name a block identically instead of
358
+ * this tool staying silent about it.
359
+ *
360
+ * `allowEmpty` because a session is routinely opened on an app shell that
361
+ * only paints after the actions the caller is about to send — an empty
362
+ * document is a normal starting state here, not a failure. A real wall still
363
+ * fails on its challenge signature or its HTTP status, which is what the
364
+ * empty-document rule would have caught anyway.
365
+ *
366
+ * Never throws. A verdict is a diagnosis; a page that cannot be read for one
367
+ * (closed, mid-navigation) must not fail the operation being diagnosed.
368
+ */
369
+ async pageVerdict(page, { stealth = false, ...known } = {}) {
370
+ try {
371
+ const title = known.title !== undefined ? known.title : await page.title();
372
+ const html = known.html !== undefined ? known.html : await page.content();
373
+ const text = known.text !== undefined
374
+ ? known.text
375
+ : await page.evaluate(() => document.body?.innerText || '');
376
+
377
+ return stealthDocumentVerdict(
378
+ { url: page.url(), title, text, html, status: page.__crawlforgeNavigation?.status ?? null },
379
+ // The verdict's messages name whatever fetched the document, and its
380
+ // default is the stealth browser. A plain session is not that, and
381
+ // telling someone "the stealth browser did not pass it" when they never
382
+ // asked for stealth hides the one retry that might work.
383
+ { allowEmpty: true, fetcher: stealth ? 'the stealth browser session' : 'the browser session' }
384
+ );
385
+ } catch {
386
+ return { success: true, status: null };
387
+ }
286
388
  }
287
389
 
288
390
  async snapshotSession(params, ownerId) {
@@ -326,7 +428,7 @@ export class BrowserSessionTool {
326
428
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
327
429
  continueOnError: params.continue_on_error,
328
430
  timeout: params.timeout,
329
- browserOptions: { respectRobots: params.respect_robots }
431
+ browserOptions: { respectRobots: params.respect_robots, tool: 'browser_session' }
330
432
  });
331
433
 
332
434
  this.store.touch(session, result.finalUrl);
@@ -334,8 +436,14 @@ export class BrowserSessionTool {
334
436
  success: result.success,
335
437
  operation: 'act',
336
438
  ...sessionInfo(session),
439
+ // The status of the last navigation this chain made, if it made one —
440
+ // a `navigate` action onto a 404 or a wall is otherwise invisible.
441
+ ...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
442
+ ? { httpStatus: session.page.__crawlforgeNavigation.status }
443
+ : {}),
444
+ ...gateWarningFields(session.page),
337
445
  error: result.error,
338
- actionResults: result.results,
446
+ actionResults: result.results.map(withJsResult),
339
447
  screenshots: result.screenshots,
340
448
  ...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
341
449
  stats: result.stats
@@ -378,11 +486,24 @@ export class BrowserSessionTool {
378
486
  };
379
487
  }
380
488
 
489
+ // Read is where the content is actually handed over, so it is the last
490
+ // place a wall can be named before a caller treats it as the page: g2.com
491
+ // came back here as the single word "g2.com" with success:true. The
492
+ // document is already in hand, so this costs no extra page work.
493
+ const verdict = await this.pageVerdict(session.page, {
494
+ stealth: session.stealth,
495
+ title: extracted.title ?? '',
496
+ text: extracted.content?.text || '',
497
+ html
498
+ });
499
+
381
500
  this.store.touch(session, url);
382
501
  return {
383
- success: true,
502
+ success: verdict.success,
384
503
  operation: 'read',
385
504
  ...sessionInfo(session),
505
+ ...verdictFields(verdict),
506
+ ...(verdict.error ? { error: verdict.error } : {}),
386
507
  title: extracted.title ?? null,
387
508
  extractionMethod: extracted.extractionMethod,
388
509
  content