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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|