crawlforge-mcp-server 6.6.0 → 6.6.1
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 +1 -1
- package/server.js +9 -13
- package/src/server/inlineThreshold.js +15 -0
- package/src/tools/advanced/BrowserSessionTool.js +107 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.6.
|
|
3
|
+
"version": "6.6.1",
|
|
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
|
@@ -67,7 +67,7 @@ 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.
|
|
111
|
+
version: "6.6.1",
|
|
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 {
|
|
@@ -1712,16 +1713,11 @@ async function runServer() {
|
|
|
1712
1713
|
console.error(`Environment: ${config.server.nodeEnv}`);
|
|
1713
1714
|
console.error("Search enabled: true (via CrawlForge proxy)");
|
|
1714
1715
|
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
];
|
|
1716
|
+
// Derived from TOOL_GROUPS, the same list the filter itself is built on, so a
|
|
1717
|
+
// new tool is announced the moment it is grouped. The hand-written copy that
|
|
1718
|
+
// used to live here silently under-reported: it never learned browser_session
|
|
1719
|
+
// and so claimed "30/30" while 31 tools were registered and advertised.
|
|
1720
|
+
const allTools = Object.values(TOOL_GROUPS).flat();
|
|
1725
1721
|
const enabledTools = allTools.filter((name) => toolFilter.isEnabled(name));
|
|
1726
1722
|
console.error(`Tools available (${enabledTools.length}/${allTools.length}): ${enabledTools.join(", ")}`);
|
|
1727
1723
|
|
|
@@ -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,28 @@ 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
|
+
|
|
143
173
|
export class BrowserSessionTool {
|
|
144
174
|
constructor(options = {}) {
|
|
145
175
|
const {
|
|
@@ -282,7 +312,62 @@ export class BrowserSessionTool {
|
|
|
282
312
|
throw error;
|
|
283
313
|
}
|
|
284
314
|
|
|
285
|
-
|
|
315
|
+
// The session opened; whether the document it landed on is the page is a
|
|
316
|
+
// separate question. g2.com answered `open` with a DataDome 403 whose body
|
|
317
|
+
// was empty, and this returned success:true with no status at all, while
|
|
318
|
+
// `scrape` on the same URL named the vendor and the 403 — the same fault
|
|
319
|
+
// R18 found in scrape_with_actions (2026-09-04), in the one browser tool
|
|
320
|
+
// that never learned the lesson.
|
|
321
|
+
const verdict = await this.pageVerdict(page, { stealth: params.stealth });
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
success: verdict.success,
|
|
325
|
+
operation: 'open',
|
|
326
|
+
...sessionInfo(session),
|
|
327
|
+
...verdictFields(verdict),
|
|
328
|
+
// The page is a wall, but the session behind it is real and holds a
|
|
329
|
+
// browser context — say so, or a caller reading only `success` abandons
|
|
330
|
+
// it to its TTL instead of closing it or acting through the challenge.
|
|
331
|
+
...(verdict.error
|
|
332
|
+
? { error: `${verdict.error} The session is open as ${session.id}: act on it, or close it.` }
|
|
333
|
+
: {})
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* What the session's page currently is: the page, a bot wall, an HTTP error
|
|
339
|
+
* page, or an error placeholder. One helper, shared with `scrape` and
|
|
340
|
+
* `scrape_with_actions`, so all three name a block identically instead of
|
|
341
|
+
* this tool staying silent about it.
|
|
342
|
+
*
|
|
343
|
+
* `allowEmpty` because a session is routinely opened on an app shell that
|
|
344
|
+
* only paints after the actions the caller is about to send — an empty
|
|
345
|
+
* document is a normal starting state here, not a failure. A real wall still
|
|
346
|
+
* fails on its challenge signature or its HTTP status, which is what the
|
|
347
|
+
* empty-document rule would have caught anyway.
|
|
348
|
+
*
|
|
349
|
+
* Never throws. A verdict is a diagnosis; a page that cannot be read for one
|
|
350
|
+
* (closed, mid-navigation) must not fail the operation being diagnosed.
|
|
351
|
+
*/
|
|
352
|
+
async pageVerdict(page, { stealth = false, ...known } = {}) {
|
|
353
|
+
try {
|
|
354
|
+
const title = known.title !== undefined ? known.title : await page.title();
|
|
355
|
+
const html = known.html !== undefined ? known.html : await page.content();
|
|
356
|
+
const text = known.text !== undefined
|
|
357
|
+
? known.text
|
|
358
|
+
: await page.evaluate(() => document.body?.innerText || '');
|
|
359
|
+
|
|
360
|
+
return stealthDocumentVerdict(
|
|
361
|
+
{ url: page.url(), title, text, html, status: page.__crawlforgeNavigation?.status ?? null },
|
|
362
|
+
// The verdict's messages name whatever fetched the document, and its
|
|
363
|
+
// default is the stealth browser. A plain session is not that, and
|
|
364
|
+
// telling someone "the stealth browser did not pass it" when they never
|
|
365
|
+
// asked for stealth hides the one retry that might work.
|
|
366
|
+
{ allowEmpty: true, fetcher: stealth ? 'the stealth browser session' : 'the browser session' }
|
|
367
|
+
);
|
|
368
|
+
} catch {
|
|
369
|
+
return { success: true, status: null };
|
|
370
|
+
}
|
|
286
371
|
}
|
|
287
372
|
|
|
288
373
|
async snapshotSession(params, ownerId) {
|
|
@@ -334,8 +419,13 @@ export class BrowserSessionTool {
|
|
|
334
419
|
success: result.success,
|
|
335
420
|
operation: 'act',
|
|
336
421
|
...sessionInfo(session),
|
|
422
|
+
// The status of the last navigation this chain made, if it made one —
|
|
423
|
+
// a `navigate` action onto a 404 or a wall is otherwise invisible.
|
|
424
|
+
...(Number.isInteger(session.page.__crawlforgeNavigation?.status)
|
|
425
|
+
? { httpStatus: session.page.__crawlforgeNavigation.status }
|
|
426
|
+
: {}),
|
|
337
427
|
error: result.error,
|
|
338
|
-
actionResults: result.results,
|
|
428
|
+
actionResults: result.results.map(withJsResult),
|
|
339
429
|
screenshots: result.screenshots,
|
|
340
430
|
...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
|
|
341
431
|
stats: result.stats
|
|
@@ -378,11 +468,24 @@ export class BrowserSessionTool {
|
|
|
378
468
|
};
|
|
379
469
|
}
|
|
380
470
|
|
|
471
|
+
// Read is where the content is actually handed over, so it is the last
|
|
472
|
+
// place a wall can be named before a caller treats it as the page: g2.com
|
|
473
|
+
// came back here as the single word "g2.com" with success:true. The
|
|
474
|
+
// document is already in hand, so this costs no extra page work.
|
|
475
|
+
const verdict = await this.pageVerdict(session.page, {
|
|
476
|
+
stealth: session.stealth,
|
|
477
|
+
title: extracted.title ?? '',
|
|
478
|
+
text: extracted.content?.text || '',
|
|
479
|
+
html
|
|
480
|
+
});
|
|
481
|
+
|
|
381
482
|
this.store.touch(session, url);
|
|
382
483
|
return {
|
|
383
|
-
success:
|
|
484
|
+
success: verdict.success,
|
|
384
485
|
operation: 'read',
|
|
385
486
|
...sessionInfo(session),
|
|
487
|
+
...verdictFields(verdict),
|
|
488
|
+
...(verdict.error ? { error: verdict.error } : {}),
|
|
386
489
|
title: extracted.title ?? null,
|
|
387
490
|
extractionMethod: extracted.extractionMethod,
|
|
388
491
|
content
|