crawlforge-mcp-server 5.0.0 → 5.0.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/CLAUDE.md CHANGED
@@ -62,7 +62,7 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
62
62
 
63
63
  CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 27 web scraping, crawling, and content processing tools (5 inline + 22 advanced).
64
64
 
65
- **Current Version:** 5.0.0
65
+ **Current Version:** 5.0.1
66
66
 
67
67
  ## Development Commands
68
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 27 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
@@ -99,7 +99,7 @@ const taskStore = createTaskStore({ logger });
99
99
  // Create the server
100
100
  const server = new McpServer({
101
101
  name: "crawlforge",
102
- version: "5.0.0",
102
+ version: "5.0.1",
103
103
  description: "Production-ready MCP server with 27 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, real Google SERP rank tracking, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
104
104
  homepage: "https://www.crawlforge.dev",
105
105
  icon: "https://www.crawlforge.dev/icon.png",
@@ -4,6 +4,7 @@
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { install, installHook } from '../../skills/installer.js';
7
+ import { isCreatorModeVerified } from '../../core/creatorMode.js';
7
8
 
8
9
  const HOME = process.env.HOME || process.env.USERPROFILE || '';
9
10
 
@@ -16,8 +17,8 @@ function loadStoredApiKey() {
16
17
  }
17
18
  }
18
19
 
19
- function mcpStanza(apiKey) {
20
- const stanza = { command: 'npx', args: ['-y', 'crawlforge@latest', 'mcp'] };
20
+ export function mcpStanza(apiKey) {
21
+ const stanza = { command: 'npx', args: ['-y', 'crawlforge-mcp-server@latest', 'mcp'] };
21
22
  if (apiKey) stanza.env = { CRAWLFORGE_API_KEY: apiKey };
22
23
  return stanza;
23
24
  }
@@ -66,15 +67,20 @@ export function register(program) {
66
67
  .action(async (opts) => {
67
68
  const out = (msg) => process.stderr.write(msg + '\n');
68
69
 
69
- // 1. API key check
70
+ // 1. API key check (creator mode proceeds keyless — the registered
71
+ // server re-derives creator mode from its own environment, and the
72
+ // secret must never be written into client configs)
70
73
  const apiKey = loadStoredApiKey() || process.env.CRAWLFORGE_API_KEY;
71
- if (!apiKey) {
74
+ if (apiKey) {
75
+ out('API key: found (' + apiKey.slice(0, 8) + '...)');
76
+ } else if (isCreatorModeVerified()) {
77
+ out('API key: none (creator mode active — proceeding without a key)');
78
+ } else {
72
79
  out('No CrawlForge API key found.');
73
80
  out('Run: npx crawlforge-setup');
74
81
  out('Then re-run: crawlforge init');
75
82
  process.exit(1);
76
83
  }
77
- out('API key: found (' + apiKey.slice(0, 8) + '...)');
78
84
 
79
85
  // 2. Install skills
80
86
  const skillTarget = opts.all ? 'all' : 'claude-code';
@@ -9,7 +9,7 @@ export function register(program) {
9
9
  program
10
10
  .command('stealth <url>')
11
11
  .description('Scrape a URL using stealth/anti-bot browser mode')
12
- .option('--engine <engine>', 'Browser engine: playwright or camoufox', 'playwright')
12
+ .option('--engine <engine>', 'Browser engine: chromium or camoufox', 'chromium')
13
13
  .option('--wait <ms>', 'Wait time after page load in milliseconds', '2000')
14
14
  .option('--screenshot', 'Capture a screenshot')
15
15
  .action(async (url, opts, cmd) => {
@@ -21,7 +21,8 @@ export function register(program) {
21
21
  };
22
22
  await runTool(wrapperTool, {
23
23
  url,
24
- engine: opts.engine,
24
+ // "playwright" was the pre-v4.0.0 name for the chromium engine
25
+ engine: opts.engine === 'playwright' ? 'chromium' : opts.engine,
25
26
  wait_for: parseInt(opts.wait, 10),
26
27
  screenshot: !!opts.screenshot
27
28
  }, cliFlags);
@@ -111,6 +111,7 @@ export class MonitorScheduler {
111
111
  }
112
112
 
113
113
  async stopMonitor(id) {
114
+ if (!this.store._loaded) await this.store.load();
114
115
  this._clearTimer(id);
115
116
  const existed = !!this.store.get(id);
116
117
  await this.store.remove(id);
@@ -118,6 +119,7 @@ export class MonitorScheduler {
118
119
  }
119
120
 
120
121
  async stopByUrl(url) {
122
+ if (!this.store._loaded) await this.store.load();
121
123
  let count = 0;
122
124
  for (const def of this.store.list()) {
123
125
  if (def.url === url) {
@@ -162,10 +164,17 @@ export class MonitorScheduler {
162
164
  const ct = this.tool.changeTracker;
163
165
  if (ct?.snapshots?.has(def.url)) return;
164
166
  try {
165
- const q = await this.tool.snapshotManager.querySnapshots({ url: def.url, limit: 1, includeContent: true });
166
- const content = q?.snapshots?.[0]?.content;
167
- if (content && typeof content === 'string') {
168
- await ct.createBaseline(def.url, content, def.trackingOptions);
167
+ // limit > 1 skips legacy empty junk snapshots; Buffer normalization is
168
+ // required because querySnapshots returns stored content as a Buffer —
169
+ // the old string guard silently disabled rehydration after restart.
170
+ const q = await this.tool.snapshotManager.querySnapshots({ url: def.url, limit: 5, includeContent: true });
171
+ for (const snap of q?.snapshots ?? []) {
172
+ let content = snap?.content;
173
+ if (Buffer.isBuffer(content)) content = content.toString('utf8');
174
+ if (content && typeof content === 'string') {
175
+ await ct.createBaseline(def.url, content, def.trackingOptions);
176
+ return;
177
+ }
169
178
  }
170
179
  } catch {
171
180
  /* no usable snapshot — first fire will create the baseline */
@@ -84,10 +84,15 @@ export class SnapshotManager extends EventEmitter {
84
84
  // the server with a cwd the process cannot write to (e.g. '/').
85
85
  const defaultBaseDir = path.join(os.homedir(), '.crawlforge', 'snapshots');
86
86
 
87
+ // metadata/temp must live under the EFFECTIVE storage dir: anchoring them
88
+ // to defaultBaseDir when a custom storageDir is passed split content from
89
+ // metadata and shared one metadata dir across all custom-dir instances.
90
+ const baseDir = options.storageDir || defaultBaseDir;
91
+
87
92
  this.options = {
88
- storageDir: options.storageDir || defaultBaseDir,
89
- metadataDir: options.metadataDir || path.join(defaultBaseDir, 'metadata'),
90
- tempDir: options.tempDir || path.join(defaultBaseDir, 'temp'),
93
+ storageDir: baseDir,
94
+ metadataDir: options.metadataDir || path.join(baseDir, 'metadata'),
95
+ tempDir: options.tempDir || path.join(baseDir, 'temp'),
91
96
  enableCompression: options.enableCompression !== false,
92
97
  enableDeltaStorage: options.enableDeltaStorage !== false,
93
98
  enableEncryption: options.enableEncryption || false,
@@ -168,7 +168,13 @@ export class BFSCrawler {
168
168
  }
169
169
  }
170
170
 
171
- // Mark as visited
171
+ // Mark as visited. Re-check the cap and dedupe first: the checks at the
172
+ // top of this task ran before the awaited robots lookup, so concurrent
173
+ // queue tasks may have filled the budget (or claimed this URL) since.
174
+ // This block is synchronous, so the cap is exact.
175
+ if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl)) {
176
+ return;
177
+ }
172
178
  this.visited.add(normalizedUrl);
173
179
 
174
180
  try {
@@ -204,11 +210,15 @@ export class BFSCrawler {
204
210
 
205
211
  // Process links for analysis
206
212
  if (this.enableLinkAnalysis && this.linkAnalyzer && pageData.links) {
213
+ // Parse the page once and reuse for every link; re-parsing per link is
214
+ // O(links × page size) and starves the event loop on link-dense pages.
215
+ const $page = pageData.originalHtml ? load(pageData.originalHtml) : null;
216
+ const pageBodyText = $page ? $page('body').text() : '';
207
217
  for (const link of pageData.links) {
208
218
  const absoluteUrl = this.resolveUrl(link, normalizedUrl);
209
219
  if (absoluteUrl) {
210
220
  // Extract anchor text and context from link
211
- const linkMetadata = this.extractLinkMetadata(link, pageData.originalHtml, normalizedUrl);
221
+ const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText);
212
222
  this.linkAnalyzer.addLink(normalizedUrl, absoluteUrl, linkMetadata);
213
223
  }
214
224
  }
@@ -548,19 +558,18 @@ export class BFSCrawler {
548
558
  }
549
559
 
550
560
  /**
551
- * Extract link metadata from HTML
561
+ * Extract link metadata from a pre-parsed page
552
562
  * @param {string} href - The href attribute value
553
- * @param {string} html - Original HTML content
554
- * @param {string} baseUrl - Base URL for context
563
+ * @param {Object} $ - Cheerio document for the page (parsed once per page)
564
+ * @param {string} bodyText - Pre-computed body text of the page
555
565
  * @returns {Object} Link metadata
556
566
  */
557
- extractLinkMetadata(href, html, baseUrl) {
558
- if (!html) return {};
567
+ extractLinkMetadata(href, $, bodyText = '') {
568
+ if (!$) return {};
559
569
 
560
570
  try {
561
- const $ = load(html);
562
571
  const linkElement = $(`a[href="${href}"]`).first();
563
-
572
+
564
573
  if (linkElement.length === 0) {
565
574
  return { href };
566
575
  }
@@ -569,10 +578,8 @@ export class BFSCrawler {
569
578
  const title = linkElement.attr('title');
570
579
  const rel = linkElement.attr('rel');
571
580
  const className = linkElement.attr('class');
572
-
581
+
573
582
  // Get surrounding context (up to 100 characters before and after)
574
- const linkHtml = linkElement.prop('outerHTML');
575
- const bodyText = $('body').text();
576
583
  const linkTextIndex = bodyText.indexOf(anchorText);
577
584
  let context = '';
578
585
 
@@ -122,7 +122,7 @@ const serpRankResultShape = z.object({
122
122
  domain: z.string().optional(),
123
123
  url: z.string().nullable().optional(),
124
124
  title: z.string().nullable().optional(),
125
- snippet: z.string().optional()
125
+ snippet: z.string().nullable().optional()
126
126
  }).passthrough();
127
127
 
128
128
  const serpRankShape = {
@@ -70,9 +70,12 @@ export class GenerateLLMsTxtTool {
70
70
  // per-analysis state on `this.analysis`, so a shared instance would let
71
71
  // concurrent (or successive) calls cross-contaminate results.
72
72
  logger.info(`Analyzing website: ${baseUrl}`);
73
+ // analysisOptions must reach the constructor: the analyzer reads crawl
74
+ // caps (maxPages/maxDepth/…) from this.options, not the per-call arg.
73
75
  const analyzer = new LLMsTxtAnalyzer({
74
76
  timeout: this.options.timeout,
75
- userAgent: this.options.userAgent
77
+ userAgent: this.options.userAgent,
78
+ ...analysisOptions
76
79
  });
77
80
  const analysis = await analyzer.analyzeWebsite(url, analysisOptions);
78
81
 
@@ -130,11 +130,16 @@ export class TrackChangesTool extends EventEmitter {
130
130
 
131
131
  _setupEventHandlers() {
132
132
  this.changeTracker.on('changeDetected', async (changeRecord) => {
133
- if (changeRecord.significance !== 'none') {
133
+ // changeRecord.details is diff analysis and carries no page content —
134
+ // storing `details.current || ''` wrote an empty junk snapshot on every
135
+ // significant change (compareWithBaseline already snapshots the real
136
+ // current content). Only store if a future record ever carries content.
137
+ const current = changeRecord.details?.current;
138
+ if (changeRecord.significance !== 'none' && current) {
134
139
  try {
135
140
  await this.snapshotManager.storeSnapshot(
136
141
  changeRecord.url,
137
- changeRecord.details.current || '',
142
+ current,
138
143
  { changes: changeRecord.details, significance: changeRecord.significance, changeType: changeRecord.changeType }
139
144
  );
140
145
  } catch (error) {
@@ -209,9 +214,34 @@ export class TrackChangesTool extends EventEmitter {
209
214
  };
210
215
  }
211
216
 
217
+ // Rebuild the in-memory baseline from the newest persisted snapshot, so
218
+ // compare works across processes (fresh CLI runs, server restarts). Same
219
+ // fail-soft mechanism as MonitorScheduler._ensureBaseline: no usable
220
+ // snapshot means compare still reports "No baseline" for a genuine first run.
221
+ async rehydrateBaseline(url, trackingOptions = {}) {
222
+ if (this.changeTracker?.snapshots?.has(url)) return;
223
+ try {
224
+ // limit > 1: existing stores contain empty junk snapshots (from the old
225
+ // changeDetected handler) that can tie on timestamp with the real one —
226
+ // take the newest snapshot that actually has content.
227
+ const q = await this.snapshotManager.querySnapshots({ url, limit: 5, includeContent: true });
228
+ for (const snap of q?.snapshots ?? []) {
229
+ let content = snap?.content;
230
+ if (Buffer.isBuffer(content)) content = content.toString('utf8');
231
+ if (content && typeof content === 'string') {
232
+ await this.changeTracker.createBaseline(url, content, trackingOptions);
233
+ return;
234
+ }
235
+ }
236
+ } catch {
237
+ /* no usable snapshot — caller's compare will surface "No baseline" */
238
+ }
239
+ }
240
+
212
241
  async compareWithBaseline(params) {
213
242
  const { url, content, html, trackingOptions, storageOptions = {}, notificationOptions } = params;
214
243
  const enableSnapshots = storageOptions.enableSnapshots !== false;
244
+ await this.rehydrateBaseline(url, trackingOptions);
215
245
 
216
246
  let currentContent = content || html;
217
247
  let fetchMeta = {};
@@ -362,7 +392,10 @@ export class TrackChangesTool extends EventEmitter {
362
392
  const monitorId = scheduledMonitorOptions?.monitorId;
363
393
  if (monitorId) {
364
394
  const result = await this.scheduler.stopMonitor(monitorId);
365
- return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: result.stopped, timestamp: Date.now() };
395
+ if (!result.stopped) {
396
+ return { success: false, operation: 'stop_scheduled_monitor', monitorId, stopped: false, error: `No scheduled monitor found with id ${monitorId}`, timestamp: Date.now() };
397
+ }
398
+ return { success: true, operation: 'stop_scheduled_monitor', monitorId, stopped: true, timestamp: Date.now() };
366
399
  }
367
400
  if (!url) throw new Error('stop_scheduled_monitor requires a url or scheduledMonitorOptions.monitorId');
368
401
  const result = await this.scheduler.stopByUrl(url);