crawlforge-mcp-server 5.0.5 → 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.
- package/CLAUDE.md +9 -6
- package/README.md +28 -8
- package/package.json +6 -5
- package/server.js +56 -16
- package/src/core/ActionExecutor.js +246 -66
- package/src/core/AuthManager.js +1 -0
- package/src/core/ChangeTracker.js +215 -22
- package/src/core/ResearchOrchestrator.js +9 -3
- package/src/core/SamplingClient.js +4 -5
- package/src/core/StealthBrowserManager.js +64 -18
- package/src/core/cache/CacheManager.js +7 -2
- package/src/core/crawlers/BFSCrawler.js +14 -6
- package/src/core/llm/LLMManager.js +61 -11
- package/src/core/llm/OllamaProvider.js +139 -0
- package/src/core/processing/BrowserProcessor.js +28 -2
- package/src/schemas/toolOutputSchemas.js +53 -1
- package/src/server/requestContext.js +26 -0
- package/src/server/transports/streamableHttp.js +54 -11
- package/src/server/withAuth.js +24 -6
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +26 -3
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +5 -4
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -0
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +6 -4
- package/src/skills/agent-skills/crawlforge-structured-extraction/references/templates.md +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -1
- package/src/tools/basic/_fetch.js +8 -2
- package/src/tools/basic/fetchUrl.js +4 -1
- package/src/tools/crawl/crawlDeep.js +19 -5
- package/src/tools/extract/extractStructured.js +16 -4
- package/src/tools/extract/extractWithLlm.js +80 -10
- package/src/tools/extract/listOllamaModels.js +4 -6
- package/src/tools/scrape/_brandingExtractor.js +1 -1
- package/src/tools/scrape/unifiedScrape.js +71 -5
- package/src/tools/search/adapters/redditOfficialApi.js +196 -0
- package/src/tools/search/redditNormalize.js +95 -0
- package/src/tools/search/redditSearch.js +326 -0
- package/src/tools/templates/ScrapeTemplateTool.js +8 -3
- package/src/utils/hiddenContent.js +330 -0
- package/src/utils/htmlToMarkdown.js +12 -2
- package/src/utils/ollamaConfig.js +121 -0
- 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:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
/**
|
|
@@ -148,6 +148,55 @@ const serpRankShape = {
|
|
|
148
148
|
_cost: costShape
|
|
149
149
|
};
|
|
150
150
|
|
|
151
|
+
// ── reddit_search ───────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
const redditPostShape = z.object({
|
|
154
|
+
id: z.string().nullable().optional(),
|
|
155
|
+
title: z.string().nullable().optional(),
|
|
156
|
+
author: z.string().nullable().optional(),
|
|
157
|
+
subreddit: z.string().nullable().optional(),
|
|
158
|
+
created_utc: z.number().nullable().optional(),
|
|
159
|
+
created_iso: z.string().nullable().optional(),
|
|
160
|
+
score: z.number().nullable().optional(),
|
|
161
|
+
num_comments: z.number().nullable().optional(),
|
|
162
|
+
selftext: z.string().nullable().optional(),
|
|
163
|
+
selftext_truncated: z.boolean().optional(),
|
|
164
|
+
url: z.string().nullable().optional(),
|
|
165
|
+
permalink: z.string().nullable().optional().describe('Full reddit.com URL of the post')
|
|
166
|
+
}).passthrough();
|
|
167
|
+
|
|
168
|
+
const redditCommentShape = z.object({
|
|
169
|
+
id: z.string().nullable().optional(),
|
|
170
|
+
author: z.string().nullable().optional(),
|
|
171
|
+
subreddit: z.string().nullable().optional(),
|
|
172
|
+
created_utc: z.number().nullable().optional(),
|
|
173
|
+
created_iso: z.string().nullable().optional(),
|
|
174
|
+
score: z.number().nullable().optional(),
|
|
175
|
+
body: z.string().nullable().optional(),
|
|
176
|
+
body_truncated: z.boolean().optional(),
|
|
177
|
+
link_id: z.string().nullable().optional(),
|
|
178
|
+
parent_id: z.string().nullable().optional(),
|
|
179
|
+
permalink: z.string().nullable().optional()
|
|
180
|
+
}).passthrough();
|
|
181
|
+
|
|
182
|
+
const redditSearchShape = {
|
|
183
|
+
source: z.enum(['arctic_shift', 'pullpush']).optional().describe('Which community archive served this result'),
|
|
184
|
+
mode: z.string().optional(),
|
|
185
|
+
query: z.string().nullable().optional(),
|
|
186
|
+
subreddit: z.string().nullable().optional(),
|
|
187
|
+
author: z.string().nullable().optional(),
|
|
188
|
+
link_id: z.string().optional().describe('Present in thread mode'),
|
|
189
|
+
count: z.number().optional(),
|
|
190
|
+
results: z.array(z.union([redditPostShape, redditCommentShape])).optional().describe('posts/comments modes'),
|
|
191
|
+
post: redditPostShape.nullable().optional().describe('thread mode: the post itself'),
|
|
192
|
+
comments: z.array(z.unknown()).optional().describe('thread mode: nested comment tree ({...comment, replies:[...]}); collapsed branches appear as {more_count, more_ids}'),
|
|
193
|
+
comment_count: z.number().optional(),
|
|
194
|
+
fallback_used: z.string().optional().describe('Present when the primary archive failed and the fallback served the result'),
|
|
195
|
+
notes: z.array(z.string()).optional().describe('Data-provenance caveats (archive freshness, coverage gaps)'),
|
|
196
|
+
checkedAt: z.string().optional(),
|
|
197
|
+
_cost: costShape
|
|
198
|
+
};
|
|
199
|
+
|
|
151
200
|
// ── search_web ──────────────────────────────────────────────────────────────
|
|
152
201
|
|
|
153
202
|
const searchWebResultShape = z.object({
|
|
@@ -199,7 +248,7 @@ const searchWebShape = {
|
|
|
199
248
|
const extractStructuredShape = {
|
|
200
249
|
url: z.string().optional(),
|
|
201
250
|
data: z.record(z.unknown()).optional().describe('Extracted fields matching the requested schema'),
|
|
202
|
-
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "none"'),
|
|
251
|
+
extraction_method: z.string().optional().describe('"llm" | "css_fallback" | "keyword_fallback" | "none"'),
|
|
203
252
|
confidence: z.number().optional(),
|
|
204
253
|
schema_used: z.record(z.unknown()).optional(),
|
|
205
254
|
processingTime: z.number().optional(),
|
|
@@ -252,6 +301,8 @@ const crawlDeepShape = {
|
|
|
252
301
|
enabled: z.boolean().optional(),
|
|
253
302
|
cookies_captured: z.number().optional()
|
|
254
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'),
|
|
255
306
|
_cost: costShape
|
|
256
307
|
};
|
|
257
308
|
|
|
@@ -261,6 +312,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
261
312
|
scrape: scrapeShape,
|
|
262
313
|
map_site: mapSiteShape,
|
|
263
314
|
serp_rank: serpRankShape,
|
|
315
|
+
reddit_search: redditSearchShape,
|
|
264
316
|
search_web: searchWebShape,
|
|
265
317
|
extract_structured: extractStructuredShape,
|
|
266
318
|
crawl_deep: crawlDeepShape
|
|
@@ -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
|
|
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.
|
|
92
|
-
* names (`_registered*`, `set*RequestHandlers`
|
|
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
|
-
|
|
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;
|
package/src/server/withAuth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|