crawlforge-mcp-server 5.1.0 → 5.2.0

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.
Files changed (40) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +8 -4
  3. package/package.json +5 -4
  4. package/server.js +22 -14
  5. package/src/core/ActionExecutor.js +246 -66
  6. package/src/core/ChangeTracker.js +215 -22
  7. package/src/core/ResearchOrchestrator.js +9 -3
  8. package/src/core/SamplingClient.js +4 -5
  9. package/src/core/StealthBrowserManager.js +64 -18
  10. package/src/core/cache/CacheManager.js +7 -2
  11. package/src/core/crawlers/BFSCrawler.js +14 -6
  12. package/src/core/llm/LLMManager.js +61 -11
  13. package/src/core/llm/OllamaProvider.js +139 -0
  14. package/src/core/processing/BrowserProcessor.js +28 -2
  15. package/src/schemas/toolOutputSchemas.js +3 -1
  16. package/src/server/requestContext.js +26 -0
  17. package/src/server/transports/streamableHttp.js +54 -11
  18. package/src/server/withAuth.js +24 -6
  19. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
  20. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
  21. package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
  22. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
  23. package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
  24. package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
  25. package/src/tools/basic/_fetch.js +8 -2
  26. package/src/tools/basic/fetchUrl.js +4 -1
  27. package/src/tools/crawl/crawlDeep.js +19 -5
  28. package/src/tools/extract/extractStructured.js +16 -4
  29. package/src/tools/extract/extractWithLlm.js +80 -10
  30. package/src/tools/extract/listOllamaModels.js +4 -6
  31. package/src/tools/scrape/_brandingExtractor.js +1 -1
  32. package/src/tools/scrape/unifiedScrape.js +71 -5
  33. package/src/tools/search/adapters/redditOfficialApi.js +196 -0
  34. package/src/tools/search/redditNormalize.js +95 -0
  35. package/src/tools/search/redditSearch.js +67 -91
  36. package/src/tools/templates/ScrapeTemplateTool.js +8 -3
  37. package/src/utils/hiddenContent.js +330 -0
  38. package/src/utils/htmlToMarkdown.js +12 -2
  39. package/src/utils/ollamaConfig.js +121 -0
  40. package/src/tools/templates/TemplateRegistry.js +0 -325
@@ -1,5 +1,6 @@
1
1
  import { OpenAIProvider } from './OpenAIProvider.js';
2
2
  import { AnthropicProvider } from './AnthropicProvider.js';
3
+ import { OllamaProvider } from './OllamaProvider.js';
3
4
  import { Logger } from '../../utils/Logger.js';
4
5
 
5
6
  /**
@@ -12,7 +13,10 @@ export class LLMManager {
12
13
  this.providers = new Map();
13
14
  this.defaultProvider = null;
14
15
  this.fallbackProvider = null;
15
-
16
+ // Ollama needs no credential, so its presence can only be settled by an
17
+ // HTTP probe. Cached here for the lifetime of the manager; see ready().
18
+ this._ollamaProbe = null;
19
+
16
20
  this.initializeProviders(options);
17
21
  }
18
22
 
@@ -23,6 +27,7 @@ export class LLMManager {
23
27
  const {
24
28
  openai = {},
25
29
  anthropic = {},
30
+ ollama = {},
26
31
  defaultProvider = 'auto'
27
32
  } = options;
28
33
 
@@ -40,6 +45,17 @@ export class LLMManager {
40
45
  this.logger.info('Anthropic provider initialized');
41
46
  }
42
47
 
48
+ // Initialize Ollama provider. Unlike the cloud providers there is no API
49
+ // key to gate on — a local Ollama is the zero-config default — so it is
50
+ // registered optimistically and dropped by ready() if the host is not
51
+ // reachable. Without this, a machine running Ollama but holding no cloud
52
+ // keys reported "no LLM providers available" and every caller silently
53
+ // downgraded to keyword/CSS extraction.
54
+ if (ollama.enabled !== false && process.env.DISABLE_OLLAMA !== 'true') {
55
+ this.providers.set('ollama', new OllamaProvider(ollama));
56
+ this.logger.info('Ollama provider initialized');
57
+ }
58
+
43
59
  // Set default provider
44
60
  this.setDefaultProvider(defaultProvider);
45
61
  }
@@ -49,14 +65,12 @@ export class LLMManager {
49
65
  */
50
66
  setDefaultProvider(providerName) {
51
67
  if (providerName === 'auto') {
52
- // Auto-select: prefer OpenAI for embeddings, fallback to Anthropic
53
- if (this.providers.has('openai')) {
54
- this.defaultProvider = 'openai';
55
- this.fallbackProvider = this.providers.has('anthropic') ? 'anthropic' : null;
56
- } else if (this.providers.has('anthropic')) {
57
- this.defaultProvider = 'anthropic';
58
- this.fallbackProvider = null;
59
- }
68
+ // Auto-select in order of preference: a cloud provider is only present
69
+ // when its key was deliberately configured, so it outranks the
70
+ // zero-config local Ollama.
71
+ const preference = ['openai', 'anthropic', 'ollama'].filter(name => this.providers.has(name));
72
+ this.defaultProvider = preference[0] || null;
73
+ this.fallbackProvider = preference[1] || null;
60
74
  } else if (this.providers.has(providerName)) {
61
75
  this.defaultProvider = providerName;
62
76
  // Set fallback to other available provider
@@ -351,7 +365,10 @@ Extract the data and return valid JSON:`;
351
365
  const response = await this.generateCompletion(extractionPrompt, {
352
366
  systemPrompt,
353
367
  maxTokens: scaledTokens,
354
- temperature: 0.1
368
+ temperature: 0.1,
369
+ // Constrain the output to a parseable object. Small local models
370
+ // otherwise wrap the JSON in prose and the parse below throws.
371
+ format: 'json'
355
372
  });
356
373
 
357
374
  // Strip markdown code fences if present
@@ -362,12 +379,16 @@ Extract the data and return valid JSON:`;
362
379
  const validation = this.validateAgainstSchema(parsed, schema);
363
380
  return {
364
381
  data: parsed,
382
+ method: 'llm',
365
383
  valid: validation.valid,
366
384
  validationErrors: validation.errors
367
385
  };
368
386
  } catch (error) {
369
387
  this.logger.warn('LLM structured extraction failed, using fallback', { error: error.message });
370
- return this.fallbackStructuredExtraction(content, schema);
388
+ // Report which path produced the data. Callers previously labelled this
389
+ // result "llm", so a failed LLM call was returned as a high-confidence
390
+ // LLM extraction.
391
+ return { ...this.fallbackStructuredExtraction(content, schema), error: error.message };
371
392
  }
372
393
  }
373
394
 
@@ -434,6 +455,7 @@ Extract the data and return valid JSON:`;
434
455
 
435
456
  return {
436
457
  data: extracted,
458
+ method: 'keyword_fallback',
437
459
  valid: false,
438
460
  validationErrors: ['Used fallback extraction — no LLM provider available']
439
461
  };
@@ -498,6 +520,34 @@ Extract the data and return valid JSON:`;
498
520
  return this.providers.size > 0;
499
521
  }
500
522
 
523
+ /**
524
+ * Resolve whether an LLM can actually be reached, probing Ollama once and
525
+ * caching the answer. Callers that branch on LLM-vs-fallback should await
526
+ * this rather than read isAvailable(), which reports Ollama optimistically
527
+ * because its availability cannot be determined synchronously.
528
+ *
529
+ * An unreachable Ollama is de-registered, so isAvailable() becomes accurate
530
+ * from that point on.
531
+ * @returns {Promise<boolean>}
532
+ */
533
+ async ready() {
534
+ const ollama = this.providers.get('ollama');
535
+ if (ollama) {
536
+ if (this._ollamaProbe === null) {
537
+ this._ollamaProbe = ollama.isAvailable();
538
+ }
539
+ const reachable = await this._ollamaProbe;
540
+ if (!reachable) {
541
+ this.providers.delete('ollama');
542
+ if (this.defaultProvider === 'ollama' || this.fallbackProvider === 'ollama') {
543
+ this.setDefaultProvider('auto');
544
+ }
545
+ this.logger.warn('Ollama is not reachable; provider de-registered');
546
+ }
547
+ }
548
+ return this.isAvailable();
549
+ }
550
+
501
551
  /**
502
552
  * Get available providers metadata
503
553
  */
@@ -0,0 +1,139 @@
1
+ import { LLMProvider } from './LLMProvider.js';
2
+ import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
3
+
4
+ /**
5
+ * Ollama Provider
6
+ * Implements LLM operations against a local (or hosted) Ollama server.
7
+ *
8
+ * Ollama needs no API key, so it is the provider that is available by default.
9
+ * Reachability is established by probing /api/tags rather than by the presence
10
+ * of a credential — see LLMManager.ready().
11
+ */
12
+ export class OllamaProvider extends LLMProvider {
13
+ constructor(options = {}) {
14
+ super(options);
15
+
16
+ // Resolved lazily: choosing the best installed model needs an HTTP call.
17
+ this.model = options.model || null;
18
+ this.embeddingModel = options.embeddingModel || process.env.OLLAMA_EMBEDDING_MODEL || null;
19
+ this.timeout = options.timeout || 120000;
20
+ }
21
+
22
+ /** The model to use, selecting the best installed one on first use. */
23
+ async resolveModel() {
24
+ if (!this.model) this.model = await selectOllamaModel();
25
+ return this.model;
26
+ }
27
+
28
+ async generateCompletion(prompt, options = {}) {
29
+ const model = await this.resolveModel();
30
+ const {
31
+ maxTokens = 1000,
32
+ temperature = 0.7,
33
+ systemPrompt = null,
34
+ // 'json' constrains the model to emit a parseable object, or pass a JSON
35
+ // Schema to constrain the shape as well. Small local models otherwise
36
+ // wrap JSON in prose and the caller's JSON.parse fails.
37
+ format = null
38
+ } = options;
39
+
40
+ const messages = [];
41
+ if (systemPrompt) {
42
+ messages.push({ role: 'system', content: systemPrompt });
43
+ }
44
+ messages.push({ role: 'user', content: prompt });
45
+
46
+ const body = {
47
+ model,
48
+ messages,
49
+ stream: false,
50
+ options: { num_predict: maxTokens, temperature }
51
+ };
52
+ if (format) body.format = format;
53
+
54
+ let response;
55
+ try {
56
+ response = await fetch(`${ollamaBaseUrl()}/api/chat`, {
57
+ method: 'POST',
58
+ headers: ollamaHeaders({ 'Content-Type': 'application/json' }),
59
+ body: JSON.stringify(body),
60
+ signal: AbortSignal.timeout(this.timeout)
61
+ });
62
+ } catch (error) {
63
+ const message = /fetch failed|ECONNREFUSED/i.test(error.message)
64
+ ? `Ollama is not running at ${ollamaBaseUrl()}. Start it with "ollama serve".`
65
+ : `Ollama request failed: ${error.message}`;
66
+ this.logger.error('Ollama completion failed', { error: message });
67
+ throw new Error(message);
68
+ }
69
+
70
+ if (!response.ok) {
71
+ const errText = await response.text().catch(() => '');
72
+ const message = response.status === 404 && /model.*not found|pull/i.test(errText)
73
+ ? `Ollama model "${model}" is not pulled. Run: "ollama pull ${model}"`
74
+ : `Ollama API error ${response.status}: ${errText.slice(0, 200)}`;
75
+ this.logger.error('Ollama completion failed', { error: message });
76
+ throw new Error(message);
77
+ }
78
+
79
+ const json = await response.json();
80
+ const content = json?.message?.content;
81
+ if (!content) {
82
+ throw new Error('No completion generated');
83
+ }
84
+ return content.trim();
85
+ }
86
+
87
+ async generateEmbedding(text) {
88
+ const model = this.embeddingModel || await this.resolveModel();
89
+ const response = await fetch(`${ollamaBaseUrl()}/api/embeddings`, {
90
+ method: 'POST',
91
+ headers: ollamaHeaders({ 'Content-Type': 'application/json' }),
92
+ body: JSON.stringify({ model, prompt: text }),
93
+ signal: AbortSignal.timeout(this.timeout)
94
+ });
95
+
96
+ if (!response.ok) {
97
+ const errText = await response.text().catch(() => '');
98
+ throw new Error(`Ollama embedding error ${response.status}: ${errText.slice(0, 200)}`);
99
+ }
100
+
101
+ const json = await response.json();
102
+ if (!Array.isArray(json.embedding) || json.embedding.length === 0) {
103
+ throw new Error('No embedding generated');
104
+ }
105
+ return json.embedding;
106
+ }
107
+
108
+ /**
109
+ * Reachability check. Lists installed models rather than running a completion,
110
+ * so it costs nothing and stays fast even on a cold model.
111
+ * @returns {Promise<boolean>}
112
+ */
113
+ async isAvailable() {
114
+ try {
115
+ const response = await fetch(`${ollamaBaseUrl()}/api/tags`, {
116
+ headers: ollamaHeaders(),
117
+ signal: AbortSignal.timeout(3000)
118
+ });
119
+ return response.ok;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ getMetadata() {
126
+ return {
127
+ ...super.getMetadata(),
128
+ name: 'Ollama',
129
+ baseUrl: ollamaBaseUrl(),
130
+ model: this.model || '(selected on first use)',
131
+ embeddingModel: this.embeddingModel || '(same as model)',
132
+ capabilities: {
133
+ completion: true,
134
+ embedding: true,
135
+ similarity: true
136
+ }
137
+ };
138
+ }
139
+ }
@@ -246,9 +246,23 @@ export class BrowserProcessor {
246
246
  * @returns {Promise<void>}
247
247
  */
248
248
  async initBrowser() {
249
- if (!this.browser) {
250
- this.browser = await chromium.launch({
249
+ // A Chromium that was OOM-killed or crashed doesn't error on reuse — its
250
+ // protocol calls hang. Detect the corpse and relaunch instead.
251
+ if (this.browser && !this.browser.isConnected()) {
252
+ this.browser = null;
253
+ }
254
+ if (this.browser) {
255
+ return;
256
+ }
257
+ // Only one launch in flight: concurrent cold-start callers would each
258
+ // launch a Chromium and orphan every instance but the last assignment.
259
+ if (!this._launchPromise) {
260
+ this._launchPromise = chromium.launch({
251
261
  headless: true,
262
+ // Playwright never reads this env var itself; hosted images (see
263
+ // Dockerfile) set it to their system Chromium instead of downloading
264
+ // Playwright's browsers.
265
+ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
252
266
  args: [
253
267
  '--no-sandbox',
254
268
  '--disable-dev-shm-usage',
@@ -258,8 +272,20 @@ export class BrowserProcessor {
258
272
  '--disable-backgrounding-occluded-windows',
259
273
  '--disable-renderer-backgrounding'
260
274
  ]
275
+ }).then((browser) => {
276
+ // Drop the handle when Chromium dies so the next call relaunches
277
+ // instead of reusing a corpse.
278
+ browser.on('disconnected', () => {
279
+ if (this.browser === browser) {
280
+ this.browser = null;
281
+ }
282
+ });
283
+ this.browser = browser;
284
+ }).finally(() => {
285
+ this._launchPromise = null;
261
286
  });
262
287
  }
288
+ await this._launchPromise;
263
289
  }
264
290
 
265
291
  /**
@@ -248,7 +248,7 @@ const searchWebShape = {
248
248
  const extractStructuredShape = {
249
249
  url: z.string().optional(),
250
250
  data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
251
- extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "none"'),
251
+ extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
252
252
  confidence: z.number().optional(),
253
253
  schema_used: z.record(z.unknown()).optional(),
254
254
  processingTime: z.number().optional(),
@@ -301,6 +301,8 @@ const crawlDeepShape = {
301
301
  enabled: z.boolean().optional(),
302
302
  cookies_captured: z.number().optional()
303
303
  }).passthrough().optional(),
304
+ crawled_at: z.string().optional().describe('When the pages were actually fetched (ISO 8601)'),
305
+ cached: z.boolean().optional().describe('True when this response was replayed from an earlier crawl rather than crawled now; crawled_at gives its age'),
304
306
  _cost: costShape
305
307
  };
306
308
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-request context for the HTTP transports.
3
+ *
4
+ * The streamable HTTP transport authenticates every request, but tool handlers
5
+ * are wrapped once at registration time (withAuth) with no per-request
6
+ * plumbing. AsyncLocalStorage bridges that gap: the transport runs each
7
+ * request inside a context, and withAuth reads it at invocation time.
8
+ *
9
+ * Today the only flag is `internal`: a request authenticated with the
10
+ * INTERNAL_PROXY_SECRET (the crawlforge-website REST proxy). Internal requests
11
+ * run tools normally but are billing-exempt — the website has already checked
12
+ * and charged the end user's credits, so metering here would double-bill.
13
+ *
14
+ * The flag lives on the request context, never on the MCP session: a session
15
+ * id created by an internal request grants nothing to a later request that
16
+ * authenticates by other means.
17
+ */
18
+
19
+ import { AsyncLocalStorage } from 'node:async_hooks';
20
+
21
+ export const requestContext = new AsyncLocalStorage();
22
+
23
+ /** True when the current async context belongs to an internal-proxy request. */
24
+ export function isInternalRequest() {
25
+ return requestContext.getStore()?.internal === true;
26
+ }
@@ -28,8 +28,9 @@
28
28
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
29
29
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
30
30
  import { createServer } from 'node:http';
31
- import { randomUUID } from 'node:crypto';
31
+ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
32
32
  import { readFileSync } from 'node:fs';
33
+ import { requestContext } from '../requestContext.js';
33
34
  import { z } from 'zod';
34
35
  import { zodToJsonSchema } from 'zod-to-json-schema';
35
36
 
@@ -82,20 +83,30 @@ function buildToolCards(server) {
82
83
  * per session, not just one transport per session.
83
84
  *
84
85
  * connectStreamableHttp() only receives a single already-configured McpServer
85
- * (all 27 tools/resources/prompts registered by server.js before this runs),
86
+ * (all 28 tools/resources/prompts registered by server.js before this runs),
86
87
  * so instead of re-running that registration per session, this clones a
87
88
  * fresh McpServer and copies over the already-registered tool/resource/
88
89
  * prompt tables — plain config + handler-closure references, no per-connection
89
90
  * state — then re-runs the same internal handler-wiring methods McpServer
90
91
  * itself calls from registerTool/registerResource/registerPrompt. This
91
- * depends on @modelcontextprotocol/sdk 1.29.0's internal McpServer field
92
- * names (`_registered*`, `set*RequestHandlers`); re-check on SDK upgrades.
92
+ * depends on @modelcontextprotocol/sdk 1.30.0's internal McpServer/Server
93
+ * field names (`_registered*`, `set*RequestHandlers`, `_capabilities`,
94
+ * `_taskStore`); re-check on SDK upgrades.
93
95
  *
94
96
  * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} templateServer
95
97
  */
96
98
  function cloneServerForSession(templateServer) {
97
99
  const low = templateServer.server;
98
- const sessionServer = new McpServer(low._serverInfo, { instructions: low._instructions });
100
+ // capabilities + taskStore must survive the clone: the SDK's Protocol
101
+ // constructor wires the tasks/* request handlers only when options.taskStore
102
+ // is present, and without it a tools/call on any task-capable tool
103
+ // (crawl_deep, batch_scrape, deep_research, agent) throws
104
+ // 'No task store provided for task-capable tool.'
105
+ const sessionServer = new McpServer(low._serverInfo, {
106
+ instructions: low._instructions,
107
+ capabilities: low._capabilities,
108
+ taskStore: low._taskStore
109
+ });
99
110
 
100
111
  sessionServer._registeredTools = templateServer._registeredTools;
101
112
  sessionServer._registeredResources = templateServer._registeredResources;
@@ -156,7 +167,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
156
167
  // CORS — Smithery + browser-based MCP clients
157
168
  res.setHeader('Access-Control-Allow-Origin', '*');
158
169
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
159
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, mcp-session-id, Authorization, X-API-Key');
170
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id, mcp-session-id, Authorization, X-API-Key, X-Internal-Secret');
160
171
  res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, mcp-session-id');
161
172
 
162
173
  if (req.method === 'OPTIONS') {
@@ -231,7 +242,11 @@ export async function connectStreamableHttp(server, authManager, logger, options
231
242
 
232
243
  // MCP endpoint
233
244
  if (req.url === '/mcp' || req.url === '/' || req.url?.startsWith('/mcp?')) {
234
- // Per-request auth (bypassed in creator mode)
245
+ // Per-request auth (bypassed in creator mode). `internal` marks a
246
+ // request from the website's REST proxy (INTERNAL_PROXY_SECRET): it is
247
+ // billing-exempt in withAuth because the website already charged the
248
+ // end user. Request-scoped only — never persisted on the session.
249
+ let internal = false;
235
250
  if (!authManager.isCreatorMode()) {
236
251
  const authResult = await authenticateRequest(req, authManager, oauthProvider);
237
252
  if (!authResult.ok) {
@@ -246,6 +261,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
246
261
  }));
247
262
  return;
248
263
  }
264
+ internal = authResult.internal === true;
249
265
  }
250
266
 
251
267
  if (legacy) {
@@ -258,7 +274,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
258
274
  sessionServer = cloneServerForSession(server);
259
275
  reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
260
276
  await sessionServer.connect(reqTransport);
261
- await reqTransport.handleRequest(req, res);
277
+ await requestContext.run({ internal }, () => reqTransport.handleRequest(req, res));
262
278
  } catch (err) {
263
279
  logger.error('Legacy Streamable HTTP request failed', { error: err?.message });
264
280
  sendRpcError(res, 500, -32603, 'Internal server error');
@@ -279,7 +295,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
279
295
  const existing = sessionIdHeader ? sessions.get(String(sessionIdHeader)) : undefined;
280
296
 
281
297
  if (existing) {
282
- await existing.transport.handleRequest(req, res);
298
+ await requestContext.run({ internal }, () => existing.transport.handleRequest(req, res));
283
299
  return;
284
300
  }
285
301
 
@@ -312,7 +328,7 @@ export async function connectStreamableHttp(server, authManager, logger, options
312
328
 
313
329
  try {
314
330
  await sessionServer.connect(transport);
315
- await transport.handleRequest(req, res);
331
+ await requestContext.run({ internal }, () => transport.handleRequest(req, res));
316
332
  } catch (err) {
317
333
  logger.error('Streamable HTTP session initialization failed', { error: err?.message });
318
334
  safeClose(transport);
@@ -357,14 +373,41 @@ export async function connectStreamableHttp(server, authManager, logger, options
357
373
  * Validate a request's credentials.
358
374
  *
359
375
  * Accepts:
376
+ * - `X-Internal-Secret: <INTERNAL_PROXY_SECRET>` — server-to-server requests
377
+ * from the crawlforge-website REST proxy. Returns { ok, internal: true };
378
+ * internal requests are billing-exempt in withAuth (the website already
379
+ * charged the end user). Only active when the env var is set.
360
380
  * - `Authorization: Bearer <crawlforge-api-key>` (legacy static key)
361
381
  * - `X-API-Key: <crawlforge-api-key>` (legacy static key)
362
382
  * - `Authorization: Bearer <oauth-access-token>` if OAuth is enabled —
363
383
  * the OAuth provider validates the token and maps it to the API key.
364
384
  *
365
- * @returns {Promise<{ok: true} | {ok: false, status: number, error: string, message: string, reason: string}>}
385
+ * @returns {Promise<{ok: true, internal?: boolean} | {ok: false, status: number, error: string, message: string, reason: string}>}
366
386
  */
367
387
  async function authenticateRequest(req, authManager, oauthProvider) {
388
+ // Internal proxy path first: presenting the header at all means the caller
389
+ // claims to be the website proxy, so a mismatch is a hard 401 rather than a
390
+ // fall-through to the key paths. Compare digests — timingSafeEqual on raw
391
+ // strings throws on length mismatch, which would leak length via timing.
392
+ const internalSecret = process.env.INTERNAL_PROXY_SECRET;
393
+ const providedSecret = (req.headers['x-internal-secret'] || '').toString();
394
+ if (providedSecret) {
395
+ if (internalSecret) {
396
+ const provided = createHash('sha256').update(providedSecret).digest();
397
+ const expected = createHash('sha256').update(internalSecret).digest();
398
+ if (timingSafeEqual(provided, expected)) {
399
+ return { ok: true, internal: true };
400
+ }
401
+ }
402
+ return {
403
+ ok: false,
404
+ status: 401,
405
+ error: 'Unauthorized',
406
+ message: 'Invalid internal secret.',
407
+ reason: 'invalid-internal-secret'
408
+ };
409
+ }
410
+
368
411
  const authHeader = (req.headers['authorization'] || '').toString();
369
412
  const apiKeyHeader = (req.headers['x-api-key'] || '').toString();
370
413
  const expectedKey = authManager.getConfig()?.apiKey;
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { createHash } from 'node:crypto';
17
17
  import { recordToolInvocation } from '../observability/tracing.js';
18
+ import { isInternalRequest } from './requestContext.js';
18
19
 
19
20
  export function hashParams(params) {
20
21
  try {
@@ -36,7 +37,12 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
36
37
  const startTime = Date.now();
37
38
  const paramHash = hashParams(params);
38
39
  const creatorMode = authManager.isCreatorMode();
39
- const creditCost = creatorMode ? 0 : authManager.getToolCost(toolName, params);
40
+ // Internal = a request from the website REST proxy (see requestContext).
41
+ // Billing-exempt like creator mode — the website already checked and
42
+ // charged the end user's credits — but auth still happened per-request.
43
+ const internal = isInternalRequest();
44
+ const billingExempt = creatorMode || internal;
45
+ const creditCost = billingExempt ? 0 : authManager.getToolCost(toolName, params);
40
46
  let outcome = 'pending';
41
47
  let thrown = null;
42
48
  // Only bill the error-path half-charge once the handler has actually run.
@@ -45,7 +51,11 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
45
51
  let handlerStarted = false;
46
52
 
47
53
  try {
48
- if (!creatorMode) {
54
+ // billingExempt covers creator mode and authenticated internal-proxy
55
+ // requests (the website REST layer has already checked AND charged the
56
+ // end user's credits before forwarding — checking the static key's
57
+ // balance here would gate users on an unrelated account).
58
+ if (!billingExempt) {
49
59
  const hasCredits = await authManager.checkCredits(creditCost);
50
60
  if (!hasCredits) {
51
61
  outcome = 'insufficient_credits';
@@ -76,10 +86,16 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
76
86
  ? 0
77
87
  : (isErrorResult ? Math.max(1, Math.floor(creditCost * 0.5)) : creditCost);
78
88
 
79
- // D3.5: Surface cost transparency in all tool responses
89
+ // D3.5: Surface cost transparency in all tool responses. For internal
90
+ // proxy requests the meaningful balance is the end user's, which only
91
+ // the website knows — report null rather than the static key's cache.
80
92
  try {
81
93
  const projection = authManager.projectCost(toolName, params);
82
- const remainingCredits = creatorMode ? Infinity : (authManager.creditCache ? [...authManager.creditCache.values()][0] ?? null : null);
94
+ const remainingCredits = creatorMode
95
+ ? Infinity
96
+ : internal
97
+ ? null
98
+ : (authManager.creditCache ? [...authManager.creditCache.values()][0] ?? null : null);
83
99
  const costMeta = {
84
100
  projected: creditCost,
85
101
  actual: creatorMode ? 0 : charge,
@@ -134,7 +150,8 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
134
150
  durationMs,
135
151
  outcome,
136
152
  creditCost,
137
- creatorMode
153
+ creatorMode,
154
+ internal
138
155
  });
139
156
 
140
157
  // Prometheus (no-op unless registry was supplied)
@@ -161,7 +178,8 @@ export function makeWithAuth({ authManager, logger, metrics = null }) {
161
178
  duration_ms: durationMs,
162
179
  outcome,
163
180
  credit_cost: creditCost,
164
- creator_mode: creatorMode
181
+ creator_mode: creatorMode,
182
+ internal
165
183
  }, thrown);
166
184
  }
167
185
  };
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: crawlforge-deep-research
3
- description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, and search_web tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results. Caps costs via max_urls and confirms before expensive runs."
3
+ description: "Runs multi-source web research and autonomous question-answering with CrawlForge's deep_research, agent, search_web, and reddit_search tools. Use when the user wants to research a topic, do a deep dive, compare competitors, gather facts with citations, answer a question from the web, search the web, or get a synthesized report from many sources, or search Reddit posts, comments, and threads. deep_research expands queries, fetches and dedupes sources, then synthesizes; agent autonomously plans searches and navigation from a plain-English prompt with no URLs needed; search_web returns ranked results; reddit_search searches Reddit via community archives (reddit.com blocks scrapers). Caps costs via max_urls and confirms before expensive runs."
4
4
  metadata:
5
5
  version: 4.8.0
6
6
  source: crawlforge-mcp-server
@@ -9,13 +9,15 @@ metadata:
9
9
  # CrawlForge Deep Research
10
10
 
11
11
  Answer questions and produce reports from many web sources using the CrawlForge
12
- MCP server. Three tools, from lightest to heaviest: `search_web` (ranked
13
- results), `agent` (autonomous plan-and-answer), `deep_research` (exhaustive
12
+ MCP server. Four tools, from lightest to heaviest: `reddit_search` (Reddit
13
+ posts/comments/threads), `search_web` (ranked results), `agent` (autonomous plan-and-answer), `deep_research` (exhaustive
14
14
  multi-source synthesis).
15
15
 
16
16
  ## When to use
17
17
 
18
18
  - "Search the web for X" / "find pages about X" → `search_web`
19
+ - "Search Reddit for X" / "what does Reddit say about X" / "read this Reddit
20
+ thread" → `reddit_search`
19
21
  - "Answer this question from the web" / "what are the top 5 X" (no URLs given) → `agent`
20
22
  - "Research this topic in depth" / "compare competitors" / "give me a cited
21
23
  report" / "gather facts with sources" → `deep_research`
@@ -28,6 +30,7 @@ skill (`scrape` / `extract_content`) instead.
28
30
  | Need | Tool | Cost |
29
31
  |------|------|------|
30
32
  | A ranked list of result URLs + snippets | `search_web` | 5 |
33
+ | Reddit posts, comments, or a full thread | `reddit_search` | 2 |
31
34
  | A direct answer, agent decides what to read | `agent` | 8 (scales) |
32
35
  | A synthesized, multi-source, cited report | `deep_research` | 10+ (scales) |
33
36
 
@@ -48,6 +51,26 @@ Returns titles, URLs, snippets. Supports `lang`, `site` (domain filter),
48
51
  `enable_ranking`, and `enable_deduplication`. CLI:
49
52
  `crawlforge search "MCP server tutorial" --limit 5`.
50
53
 
54
+ ## reddit_search (cost: 2)
55
+
56
+ reddit.com 403-blocks direct scraping, so this queries the Arctic Shift and
57
+ PullPush community archives instead (free, no Reddit credentials).
58
+
59
+ ```json
60
+ {
61
+ "tool": "reddit_search",
62
+ "params": { "query": "best MCP servers", "subreddit": "ClaudeAI", "limit": 10 }
63
+ }
64
+ ```
65
+
66
+ Modes: `posts` (default), `comments`, and `thread` (a post plus its nested
67
+ comment tree via `link_id`). Scope with `subreddit`/`author` for near-real-time
68
+ results (Arctic Shift); an unscoped `query` searches all of Reddit via PullPush
69
+ (best-effort — it rate-limits aggressively). `after`/`before` accept ISO dates,
70
+ epoch seconds, or offsets like `"7d"`. Scores of posts under ~36h old read 0/1
71
+ until the archive backfills. Space calls out rather than firing them in
72
+ parallel — the archives shed load under concurrent bursts.
73
+
51
74
  ## agent — autonomous answer (cost: 8, scales with maxUrls)
52
75
 
53
76
  No URLs required. The agent plans search queries, fetches and filters relevant