crawlforge-mcp-server 3.0.18 → 3.3.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 +5 -2
- package/server.js +192 -1277
- package/src/core/ActionExecutor.js +2 -43
- package/src/core/AuthManager.js +127 -14
- package/src/core/BrowserContextPool.js +187 -0
- package/src/core/JobManager.js +7 -5
- package/src/core/LocalizationManager.js +14 -125
- package/src/core/StealthBrowserManager.js +26 -18
- package/src/core/cache/CacheManager.js +4 -1
- package/src/core/crawlers/BFSCrawler.js +19 -5
- package/src/observability/metrics.js +137 -0
- package/src/observability/tracing.js +74 -0
- package/src/server/auth/oauth.js +388 -0
- package/src/server/registerTool.js +41 -0
- package/src/server/schemas/common.js +29 -0
- package/src/server/transports/http.js +22 -0
- package/src/server/transports/stdio.js +16 -0
- package/src/server/transports/streamableHttp.js +226 -0
- package/src/server/withAuth.js +121 -0
- package/src/tools/advanced/BatchScrapeTool.js +12 -1086
- package/src/tools/advanced/ScrapeWithActionsTool.js +105 -19
- package/src/tools/advanced/batchScrape/index.js +328 -0
- package/src/tools/advanced/batchScrape/queue.js +91 -0
- package/src/tools/advanced/batchScrape/reporter.js +26 -0
- package/src/tools/advanced/batchScrape/schema.js +37 -0
- package/src/tools/advanced/batchScrape/worker.js +179 -0
- package/src/tools/advanced/scrapeWithActions/recorder.js +188 -0
- package/src/tools/basic/_fetch.js +35 -0
- package/src/tools/basic/extractLinks.js +74 -0
- package/src/tools/basic/extractMetadata.js +74 -0
- package/src/tools/basic/extractText.js +46 -0
- package/src/tools/basic/fetchUrl.js +44 -0
- package/src/tools/basic/scrapeStructured.js +58 -0
- package/src/tools/crawl/_sessionContext.js +234 -0
- package/src/tools/crawl/crawlDeep.js +55 -5
- package/src/tools/crawl/mapSite.js +23 -2
- package/src/tools/extract/_fetchAndParse.js +57 -0
- package/src/tools/extract/extractStructured.js +3 -19
- package/src/tools/extract/extractWithLlm.js +295 -0
- package/src/tools/search/providers/searxng.js +126 -0
- package/src/tools/search/ranking/ResultDeduplicator.js +18 -11
- package/src/tools/search/ranking/ResultRanker.js +17 -10
- package/src/tools/search/ranking/SearchResultCache.js +52 -0
- package/src/tools/search/searchWeb.js +112 -6
- package/src/tools/tracking/trackChanges/differ.js +98 -0
- package/src/tools/tracking/trackChanges/index.js +432 -0
- package/src/tools/tracking/trackChanges/monitor.js +93 -0
- package/src/tools/tracking/trackChanges/notifier.js +105 -0
- package/src/tools/tracking/trackChanges/schema.js +127 -0
- package/src/tools/tracking/trackChanges.js +12 -1374
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 authorization for CrawlForge MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Implements the subset of OAuth 2.1 required by the MCP spec
|
|
5
|
+
* (modelcontextprotocol.io/docs/tutorials/security/authorization):
|
|
6
|
+
*
|
|
7
|
+
* - Authorization Server discovery: GET /.well-known/oauth-authorization-server
|
|
8
|
+
* - Dynamic Client Registration: POST /oauth/register
|
|
9
|
+
* - Authorization endpoint: GET /oauth/authorize (PKCE required)
|
|
10
|
+
* - Token endpoint: POST /oauth/token (PKCE code + refresh)
|
|
11
|
+
* - Token revocation: POST /oauth/revoke
|
|
12
|
+
*
|
|
13
|
+
* Token model: opaque bearer tokens minted server-side. Each access token
|
|
14
|
+
* carries a `mappedApiKey` link to a CrawlForge API key, so downstream
|
|
15
|
+
* credit-tracking continues to work unchanged.
|
|
16
|
+
*
|
|
17
|
+
* Storage: in-memory by default. Production deployments should provide
|
|
18
|
+
* `storage` adapter (set/get/delete) so tokens survive restarts.
|
|
19
|
+
*
|
|
20
|
+
* Stdio transport keeps the static API key — OAuth is HTTP-only.
|
|
21
|
+
*
|
|
22
|
+
* This module is intentionally dependency-free (no `oidc-provider`/`oauth4webapi`)
|
|
23
|
+
* to keep the npm install footprint small. It implements only what MCP requires.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
27
|
+
|
|
28
|
+
const ACCESS_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
29
|
+
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
30
|
+
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
31
|
+
const DEFAULT_SCOPES = ['mcp:read', 'mcp:write'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create an OAuth provider bound to a CrawlForge API key.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {string} options.issuer — public URL of this server, e.g. https://mcp.crawlforge.dev
|
|
38
|
+
* @param {string} options.apiKey — the CrawlForge API key to map tokens to
|
|
39
|
+
* @param {object} [options.storage] — { setClient, getClient, setCode, takeCode, setToken, getToken, deleteToken }
|
|
40
|
+
* @param {object} [options.logger] — Winston-style logger
|
|
41
|
+
* @param {string[]} [options.scopes] — supported scopes
|
|
42
|
+
* @param {number} [options.accessTtlMs]
|
|
43
|
+
* @param {number} [options.refreshTtlMs]
|
|
44
|
+
*/
|
|
45
|
+
export function createOAuthProvider({
|
|
46
|
+
issuer,
|
|
47
|
+
apiKey,
|
|
48
|
+
storage,
|
|
49
|
+
logger = console,
|
|
50
|
+
scopes = DEFAULT_SCOPES,
|
|
51
|
+
accessTtlMs = ACCESS_TTL_MS,
|
|
52
|
+
refreshTtlMs = REFRESH_TTL_MS
|
|
53
|
+
}) {
|
|
54
|
+
if (!issuer) throw new Error('createOAuthProvider: issuer is required');
|
|
55
|
+
if (!apiKey) throw new Error('createOAuthProvider: apiKey is required');
|
|
56
|
+
|
|
57
|
+
const store = storage ?? createInMemoryStorage();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
issuer,
|
|
61
|
+
scopes,
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns true if a request URL/method targets one of this provider's routes.
|
|
65
|
+
*/
|
|
66
|
+
matches(url, method) {
|
|
67
|
+
if (!url) return false;
|
|
68
|
+
const path = url.split('?')[0];
|
|
69
|
+
if (path === '/.well-known/oauth-authorization-server' && method === 'GET') return true;
|
|
70
|
+
if (path === '/oauth/register' && method === 'POST') return true;
|
|
71
|
+
if (path === '/oauth/authorize' && method === 'GET') return true;
|
|
72
|
+
if (path === '/oauth/token' && method === 'POST') return true;
|
|
73
|
+
if (path === '/oauth/revoke' && method === 'POST') return true;
|
|
74
|
+
return false;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate an opaque bearer access token. Returns { ok, mappedApiKey } on success.
|
|
79
|
+
*/
|
|
80
|
+
async validateBearer(token) {
|
|
81
|
+
if (!token) return { ok: false };
|
|
82
|
+
const record = await store.getToken(token);
|
|
83
|
+
if (!record) return { ok: false };
|
|
84
|
+
if (record.type !== 'access') return { ok: false };
|
|
85
|
+
if (record.expiresAt < Date.now()) {
|
|
86
|
+
await store.deleteToken(token);
|
|
87
|
+
return { ok: false };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, mappedApiKey: record.mappedApiKey, clientId: record.clientId, scopes: record.scopes };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Dispatch an OAuth request. Caller must have verified matches() first.
|
|
94
|
+
*/
|
|
95
|
+
async handle(req, res) {
|
|
96
|
+
try {
|
|
97
|
+
const path = req.url.split('?')[0];
|
|
98
|
+
const method = req.method;
|
|
99
|
+
|
|
100
|
+
if (path === '/.well-known/oauth-authorization-server' && method === 'GET') {
|
|
101
|
+
return sendJson(res, 200, buildDiscovery(issuer, scopes));
|
|
102
|
+
}
|
|
103
|
+
if (path === '/oauth/register' && method === 'POST') {
|
|
104
|
+
return await handleRegister(req, res, store);
|
|
105
|
+
}
|
|
106
|
+
if (path === '/oauth/authorize' && method === 'GET') {
|
|
107
|
+
return await handleAuthorize(req, res, store, apiKey);
|
|
108
|
+
}
|
|
109
|
+
if (path === '/oauth/token' && method === 'POST') {
|
|
110
|
+
return await handleToken(req, res, store, apiKey, { accessTtlMs, refreshTtlMs });
|
|
111
|
+
}
|
|
112
|
+
if (path === '/oauth/revoke' && method === 'POST') {
|
|
113
|
+
return await handleRevoke(req, res, store);
|
|
114
|
+
}
|
|
115
|
+
return sendJson(res, 404, { error: 'not_found' });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
logger.error?.('oauth handler error', { error: err?.message, stack: err?.stack });
|
|
118
|
+
return sendJson(res, 500, { error: 'server_error', error_description: err?.message ?? 'unknown' });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Test helpers / internals
|
|
123
|
+
_store: store
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Discovery ────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function buildDiscovery(issuer, scopes) {
|
|
130
|
+
return {
|
|
131
|
+
issuer,
|
|
132
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
133
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
134
|
+
registration_endpoint: `${issuer}/oauth/register`,
|
|
135
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
136
|
+
response_types_supported: ['code'],
|
|
137
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
138
|
+
code_challenge_methods_supported: ['S256'],
|
|
139
|
+
token_endpoint_auth_methods_supported: ['none', 'client_secret_post'],
|
|
140
|
+
scopes_supported: scopes
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async function handleRegister(req, res, store) {
|
|
147
|
+
const body = await readJsonBody(req);
|
|
148
|
+
const redirectUris = Array.isArray(body?.redirect_uris) ? body.redirect_uris : [];
|
|
149
|
+
if (redirectUris.length === 0) {
|
|
150
|
+
return sendJson(res, 400, { error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' });
|
|
151
|
+
}
|
|
152
|
+
for (const uri of redirectUris) {
|
|
153
|
+
if (typeof uri !== 'string' || !/^https?:\/\//.test(uri)) {
|
|
154
|
+
return sendJson(res, 400, { error: 'invalid_redirect_uri', error_description: `bad uri: ${uri}` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clientId = `cf-${randomBytes(8).toString('hex')}`;
|
|
159
|
+
// MCP spec recommends public clients (PKCE only). We don't issue secrets by default.
|
|
160
|
+
const client = {
|
|
161
|
+
client_id: clientId,
|
|
162
|
+
client_name: body?.client_name ?? 'mcp-client',
|
|
163
|
+
redirect_uris: redirectUris,
|
|
164
|
+
token_endpoint_auth_method: 'none',
|
|
165
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
166
|
+
response_types: ['code'],
|
|
167
|
+
created_at: Date.now()
|
|
168
|
+
};
|
|
169
|
+
await store.setClient(clientId, client);
|
|
170
|
+
|
|
171
|
+
return sendJson(res, 201, client);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Authorization endpoint ──────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
async function handleAuthorize(req, res, store, apiKey) {
|
|
177
|
+
const url = new URL(req.url, 'http://x');
|
|
178
|
+
const params = Object.fromEntries(url.searchParams.entries());
|
|
179
|
+
|
|
180
|
+
const required = ['response_type', 'client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method'];
|
|
181
|
+
for (const key of required) {
|
|
182
|
+
if (!params[key]) return sendJson(res, 400, { error: 'invalid_request', error_description: `missing ${key}` });
|
|
183
|
+
}
|
|
184
|
+
if (params.response_type !== 'code') {
|
|
185
|
+
return sendJson(res, 400, { error: 'unsupported_response_type' });
|
|
186
|
+
}
|
|
187
|
+
if (params.code_challenge_method !== 'S256') {
|
|
188
|
+
return sendJson(res, 400, { error: 'invalid_request', error_description: 'only S256 PKCE is supported' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const client = await store.getClient(params.client_id);
|
|
192
|
+
if (!client) {
|
|
193
|
+
return sendJson(res, 400, { error: 'invalid_client' });
|
|
194
|
+
}
|
|
195
|
+
if (!client.redirect_uris.includes(params.redirect_uri)) {
|
|
196
|
+
return sendJson(res, 400, { error: 'invalid_redirect_uri' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Auto-approve: this server is a personal MCP endpoint backed by a single
|
|
200
|
+
// CrawlForge API key the operator has already authenticated. No consent UI
|
|
201
|
+
// is needed — possession of the operator's `apiKey` IS the authorization.
|
|
202
|
+
// (Same trust model as the static-key transport.)
|
|
203
|
+
//
|
|
204
|
+
// For a multi-tenant deployment, replace this with a real consent page that
|
|
205
|
+
// resolves to a CrawlForge user → API key mapping.
|
|
206
|
+
const code = randomBytes(24).toString('base64url');
|
|
207
|
+
await store.setCode(code, {
|
|
208
|
+
clientId: params.client_id,
|
|
209
|
+
redirectUri: params.redirect_uri,
|
|
210
|
+
codeChallenge: params.code_challenge,
|
|
211
|
+
scopes: (params.scope ?? 'mcp:read mcp:write').split(/\s+/).filter(Boolean),
|
|
212
|
+
mappedApiKey: apiKey,
|
|
213
|
+
expiresAt: Date.now() + CODE_TTL_MS
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const redirect = new URL(params.redirect_uri);
|
|
217
|
+
redirect.searchParams.set('code', code);
|
|
218
|
+
if (params.state) redirect.searchParams.set('state', params.state);
|
|
219
|
+
res.writeHead(302, { Location: redirect.toString() });
|
|
220
|
+
res.end();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Token endpoint ──────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
async function handleToken(req, res, store, apiKey, { accessTtlMs, refreshTtlMs }) {
|
|
226
|
+
const body = await readFormBody(req);
|
|
227
|
+
const grant = body.grant_type;
|
|
228
|
+
|
|
229
|
+
if (grant === 'authorization_code') {
|
|
230
|
+
const required = ['code', 'redirect_uri', 'client_id', 'code_verifier'];
|
|
231
|
+
for (const key of required) {
|
|
232
|
+
if (!body[key]) return sendJson(res, 400, { error: 'invalid_request', error_description: `missing ${key}` });
|
|
233
|
+
}
|
|
234
|
+
const codeRecord = await store.takeCode(body.code);
|
|
235
|
+
if (!codeRecord) return sendJson(res, 400, { error: 'invalid_grant', error_description: 'unknown or used code' });
|
|
236
|
+
if (codeRecord.expiresAt < Date.now()) return sendJson(res, 400, { error: 'invalid_grant', error_description: 'code expired' });
|
|
237
|
+
if (codeRecord.clientId !== body.client_id) return sendJson(res, 400, { error: 'invalid_grant' });
|
|
238
|
+
if (codeRecord.redirectUri !== body.redirect_uri) return sendJson(res, 400, { error: 'invalid_grant' });
|
|
239
|
+
if (!verifyPkce(body.code_verifier, codeRecord.codeChallenge)) {
|
|
240
|
+
return sendJson(res, 400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const tokens = await issueTokens(store, {
|
|
244
|
+
clientId: codeRecord.clientId,
|
|
245
|
+
mappedApiKey: codeRecord.mappedApiKey,
|
|
246
|
+
scopes: codeRecord.scopes,
|
|
247
|
+
accessTtlMs,
|
|
248
|
+
refreshTtlMs
|
|
249
|
+
});
|
|
250
|
+
return sendJson(res, 200, tokens);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (grant === 'refresh_token') {
|
|
254
|
+
if (!body.refresh_token) return sendJson(res, 400, { error: 'invalid_request', error_description: 'missing refresh_token' });
|
|
255
|
+
const record = await store.getToken(body.refresh_token);
|
|
256
|
+
if (!record || record.type !== 'refresh') return sendJson(res, 400, { error: 'invalid_grant' });
|
|
257
|
+
if (record.expiresAt < Date.now()) {
|
|
258
|
+
await store.deleteToken(body.refresh_token);
|
|
259
|
+
return sendJson(res, 400, { error: 'invalid_grant', error_description: 'refresh expired' });
|
|
260
|
+
}
|
|
261
|
+
// Rotate refresh token (RFC 6749 §6, MCP best practice)
|
|
262
|
+
await store.deleteToken(body.refresh_token);
|
|
263
|
+
const tokens = await issueTokens(store, {
|
|
264
|
+
clientId: record.clientId,
|
|
265
|
+
mappedApiKey: record.mappedApiKey,
|
|
266
|
+
scopes: record.scopes,
|
|
267
|
+
accessTtlMs,
|
|
268
|
+
refreshTtlMs
|
|
269
|
+
});
|
|
270
|
+
return sendJson(res, 200, tokens);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return sendJson(res, 400, { error: 'unsupported_grant_type' });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Revocation (RFC 7009) ───────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async function handleRevoke(req, res, store) {
|
|
279
|
+
const body = await readFormBody(req);
|
|
280
|
+
if (body.token) {
|
|
281
|
+
await store.deleteToken(body.token);
|
|
282
|
+
}
|
|
283
|
+
res.writeHead(200);
|
|
284
|
+
res.end();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
async function issueTokens(store, { clientId, mappedApiKey, scopes, accessTtlMs, refreshTtlMs }) {
|
|
290
|
+
const accessToken = randomBytes(32).toString('base64url');
|
|
291
|
+
const refreshToken = randomBytes(32).toString('base64url');
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
await store.setToken(accessToken, {
|
|
294
|
+
type: 'access',
|
|
295
|
+
clientId,
|
|
296
|
+
mappedApiKey,
|
|
297
|
+
scopes,
|
|
298
|
+
expiresAt: now + accessTtlMs
|
|
299
|
+
});
|
|
300
|
+
await store.setToken(refreshToken, {
|
|
301
|
+
type: 'refresh',
|
|
302
|
+
clientId,
|
|
303
|
+
mappedApiKey,
|
|
304
|
+
scopes,
|
|
305
|
+
expiresAt: now + refreshTtlMs
|
|
306
|
+
});
|
|
307
|
+
return {
|
|
308
|
+
access_token: accessToken,
|
|
309
|
+
token_type: 'Bearer',
|
|
310
|
+
expires_in: Math.floor(accessTtlMs / 1000),
|
|
311
|
+
refresh_token: refreshToken,
|
|
312
|
+
scope: scopes.join(' ')
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function verifyPkce(verifier, expectedChallenge) {
|
|
317
|
+
try {
|
|
318
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
319
|
+
const a = Buffer.from(challenge);
|
|
320
|
+
const b = Buffer.from(expectedChallenge);
|
|
321
|
+
if (a.length !== b.length) return false;
|
|
322
|
+
return timingSafeEqual(a, b);
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function sendJson(res, status, body) {
|
|
329
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
330
|
+
res.end(JSON.stringify(body));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function readJsonBody(req) {
|
|
334
|
+
const raw = await readRawBody(req);
|
|
335
|
+
if (!raw) return {};
|
|
336
|
+
try {
|
|
337
|
+
return JSON.parse(raw);
|
|
338
|
+
} catch {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function readFormBody(req) {
|
|
344
|
+
const raw = await readRawBody(req);
|
|
345
|
+
if (!raw) return {};
|
|
346
|
+
const ct = (req.headers['content-type'] || '').toLowerCase();
|
|
347
|
+
if (ct.includes('application/json')) {
|
|
348
|
+
try { return JSON.parse(raw); } catch { return {}; }
|
|
349
|
+
}
|
|
350
|
+
// application/x-www-form-urlencoded
|
|
351
|
+
const out = {};
|
|
352
|
+
for (const pair of raw.split('&')) {
|
|
353
|
+
if (!pair) continue;
|
|
354
|
+
const [k, v = ''] = pair.split('=');
|
|
355
|
+
out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' '));
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function readRawBody(req) {
|
|
361
|
+
return new Promise((resolve, reject) => {
|
|
362
|
+
const chunks = [];
|
|
363
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
364
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
365
|
+
req.on('error', reject);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── In-memory storage (default) ─────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function createInMemoryStorage() {
|
|
372
|
+
const clients = new Map();
|
|
373
|
+
const codes = new Map();
|
|
374
|
+
const tokens = new Map();
|
|
375
|
+
return {
|
|
376
|
+
async setClient(id, client) { clients.set(id, client); },
|
|
377
|
+
async getClient(id) { return clients.get(id); },
|
|
378
|
+
async setCode(code, record) { codes.set(code, record); },
|
|
379
|
+
async takeCode(code) {
|
|
380
|
+
const r = codes.get(code);
|
|
381
|
+
codes.delete(code);
|
|
382
|
+
return r;
|
|
383
|
+
},
|
|
384
|
+
async setToken(token, record) { tokens.set(token, record); },
|
|
385
|
+
async getToken(token) { return tokens.get(token); },
|
|
386
|
+
async deleteToken(token) { tokens.delete(token); }
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registerTool — thin wrapper around McpServer.registerTool that:
|
|
3
|
+
* 1. Accepts a plain descriptor object
|
|
4
|
+
* 2. Wraps the handler with withAuth (credit tracking + audit logging)
|
|
5
|
+
* 3. Optionally declares `outputSchema` (MCP SDK ≥1.10 structured outputs, C3)
|
|
6
|
+
*
|
|
7
|
+
* Structured outputs:
|
|
8
|
+
* When `outputSchema` is provided, the handler's return value should include
|
|
9
|
+
* `structuredContent` alongside the legacy `content` array. The MCP SDK
|
|
10
|
+
* validates `structuredContent` against the schema; legacy clients keep
|
|
11
|
+
* reading the JSON-stringified `content` for backward compatibility.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
14
|
+
* @param {Function} withAuth — from makeWithAuth() in src/server/withAuth.js
|
|
15
|
+
* @param {Object} descriptor
|
|
16
|
+
* @param {string} descriptor.name — tool name (MCP identifier)
|
|
17
|
+
* @param {string} descriptor.description — human-readable description
|
|
18
|
+
* @param {Object} descriptor.inputSchema — Zod shape (plain object, not z.object())
|
|
19
|
+
* @param {Object} [descriptor.outputSchema] — Zod shape for structured output (optional)
|
|
20
|
+
* @param {Object} [descriptor.annotations] — MCP annotations (readOnlyHint, etc.)
|
|
21
|
+
* @param {Function} descriptor.handler — async (params) => { content, structuredContent? }
|
|
22
|
+
*/
|
|
23
|
+
export function registerTool(server, withAuth, { name, description, inputSchema, outputSchema, annotations = {}, handler }) {
|
|
24
|
+
const registration = { description, inputSchema, annotations };
|
|
25
|
+
if (outputSchema) registration.outputSchema = outputSchema;
|
|
26
|
+
server.registerTool(name, registration, withAuth(name, handler));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Helper for tool handlers that want to emit both legacy `content` and
|
|
31
|
+
* MCP-2025-06-18 `structuredContent` from one shot.
|
|
32
|
+
*
|
|
33
|
+
* @param {object} structured — JSON-serializable object matching outputSchema
|
|
34
|
+
* @returns {{content: object[], structuredContent: object}}
|
|
35
|
+
*/
|
|
36
|
+
export function dualOutput(structured) {
|
|
37
|
+
return {
|
|
38
|
+
structuredContent: structured,
|
|
39
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Zod schema fragments reused across tool registrations in server.js.
|
|
3
|
+
* Centralizes repeated patterns (url, pagination, webhook, cache opts).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
/** URL field — string that validates as a URL */
|
|
9
|
+
export const urlSchema = z.string().url();
|
|
10
|
+
|
|
11
|
+
/** Optional pagination fields shared by history / list endpoints */
|
|
12
|
+
export const paginationSchema = z.object({
|
|
13
|
+
limit: z.number().min(1).max(500).default(50),
|
|
14
|
+
offset: z.number().min(0).default(0)
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Webhook notification object, used in batch_scrape and deep_research */
|
|
18
|
+
export const webhookSchema = z.object({
|
|
19
|
+
url: urlSchema,
|
|
20
|
+
events: z.array(z.string()).optional(),
|
|
21
|
+
headers: z.record(z.string()).optional(),
|
|
22
|
+
signingSecret: z.string().optional()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Cache behaviour flags, used in crawl and search tools */
|
|
26
|
+
export const cacheOptsSchema = z.object({
|
|
27
|
+
enabled: z.boolean().default(true),
|
|
28
|
+
ttl: z.number().min(0).default(3600000)
|
|
29
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP transport — back-compat shim.
|
|
3
|
+
*
|
|
4
|
+
* As of v3.2.0 ("Modernize") the canonical HTTP entry point is
|
|
5
|
+
* `connectStreamableHttp` in ./streamableHttp.js. This module is retained
|
|
6
|
+
* so older imports (`./http.js`) keep working; it forwards to the new
|
|
7
|
+
* implementation in stateless ("legacy") mode by default.
|
|
8
|
+
*
|
|
9
|
+
* @deprecated Use connectStreamableHttp from ./streamableHttp.js
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { connectStreamableHttp } from './streamableHttp.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
16
|
+
* @param {import('../../core/AuthManager.js').default} authManager
|
|
17
|
+
* @param {import('../../utils/Logger.js').logger} logger
|
|
18
|
+
* @param {number} [port=3000]
|
|
19
|
+
*/
|
|
20
|
+
export async function connectHttp(server, authManager, logger, port = 3000) {
|
|
21
|
+
return connectStreamableHttp(server, authManager, logger, { port, legacy: true });
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio transport setup — extracted from server.js runServer().
|
|
3
|
+
* Used when server is launched without the --http flag.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Connect the MCP server to stdio transport and log startup message.
|
|
10
|
+
* @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
|
|
11
|
+
*/
|
|
12
|
+
export async function connectStdio(server) {
|
|
13
|
+
const transport = new StdioServerTransport();
|
|
14
|
+
await server.connect(transport);
|
|
15
|
+
console.error('CrawlForge MCP Server v3.0 running on stdio');
|
|
16
|
+
}
|