crawlforge-mcp-server 6.6.1 → 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.1",
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,7 +61,7 @@ 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
@@ -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.1",
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",
@@ -1063,9 +1063,24 @@ registerToolIfEnabled("browser_session", {
1063
1063
  // is megabytes beside a few lines of JSON (R21, 2026-09-09).
1064
1064
  const publish = (shot) => {
1065
1065
  if (!shot?.actionId || !shot?.data) return shot;
1066
- resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1066
+ const { bytes, withinInlineBudget } = resourceRegistry.storeScreenshot(shot.actionId, shot.data);
1067
1067
  const { data, ...rest } = shot;
1068
- 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
+ };
1069
1084
  };
1070
1085
  if (result.screenshot) result.screenshot = publish(result.screenshot);
1071
1086
  if (Array.isArray(result.screenshots)) result.screenshots = result.screenshots.map(publish);
@@ -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,
@@ -170,6 +170,21 @@ function withJsResult(result) {
170
170
  return { ...result, jsResult: result.result.result };
171
171
  }
172
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
+
173
188
  export class BrowserSessionTool {
174
189
  constructor(options = {}) {
175
190
  const {
@@ -278,7 +293,8 @@ export class BrowserSessionTool {
278
293
  viewportWidth: params.viewport?.width,
279
294
  viewportHeight: params.viewport?.height,
280
295
  timeout: params.timeout,
281
- respectRobots: params.respect_robots
296
+ respectRobots: params.respect_robots,
297
+ tool: 'browser_session'
282
298
  };
283
299
  if (params.stealth) {
284
300
  browserOptions.stealthMode = { enabled: true };
@@ -325,6 +341,7 @@ export class BrowserSessionTool {
325
341
  operation: 'open',
326
342
  ...sessionInfo(session),
327
343
  ...verdictFields(verdict),
344
+ ...gateWarningFields(page),
328
345
  // The page is a wall, but the session behind it is real and holds a
329
346
  // browser context — say so, or a caller reading only `success` abandons
330
347
  // it to its TTL instead of closing it or acting through the challenge.
@@ -411,7 +428,7 @@ export class BrowserSessionTool {
411
428
  const result = await this.actionExecutor.executeActionsOnPage(session.page, params.actions, {
412
429
  continueOnError: params.continue_on_error,
413
430
  timeout: params.timeout,
414
- browserOptions: { respectRobots: params.respect_robots }
431
+ browserOptions: { respectRobots: params.respect_robots, tool: 'browser_session' }
415
432
  });
416
433
 
417
434
  this.store.touch(session, result.finalUrl);
@@ -424,6 +441,7 @@ export class BrowserSessionTool {
424
441
  ...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
425
442
  ? { httpStatus: session.page.__crawlforgeNavigation.status }
426
443
  : {}),
444
+ ...gateWarningFields(session.page),
427
445
  error: result.error,
428
446
  actionResults: result.results.map(withJsResult),
429
447
  screenshots: result.screenshots,