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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.6.0",
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.0",
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
- 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
- ];
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
- return { success: true, operation: 'open', ...sessionInfo(session) };
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: true,
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