@corsenai/corsen-context 1.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/LICENSE +21 -0
- package/README.md +27 -0
- package/dist/index.d.mts +797 -0
- package/dist/index.d.ts +797 -0
- package/dist/index.js +1880 -0
- package/dist/index.mjs +1792 -0
- package/package.json +83 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var corsenContextConfigSchema = z.object({
|
|
4
|
+
siteUrl: z.string().url(),
|
|
5
|
+
siteName: z.string().optional(),
|
|
6
|
+
description: z.string().optional(),
|
|
7
|
+
content: z.object({
|
|
8
|
+
postTypes: z.array(z.string()).default(["post", "page"]),
|
|
9
|
+
excludePaths: z.array(z.string()).default([]),
|
|
10
|
+
maxPages: z.number().int().positive().default(500)
|
|
11
|
+
}).default({}),
|
|
12
|
+
mcp: z.object({
|
|
13
|
+
enabled: z.boolean().default(true),
|
|
14
|
+
endpoint: z.string().default("/v1/mcp"),
|
|
15
|
+
tools: z.array(z.string()).default(["search_site", "get_page_content", "list_content", "get_sitemap"])
|
|
16
|
+
}).default({}),
|
|
17
|
+
static: z.object({
|
|
18
|
+
generateLlmsTxt: z.boolean().default(true),
|
|
19
|
+
includeFullContent: z.boolean().default(true)
|
|
20
|
+
}).default({}),
|
|
21
|
+
security: z.object({
|
|
22
|
+
rateLimit: z.number().int().positive().default(100),
|
|
23
|
+
burstLimit: z.number().int().positive().default(10),
|
|
24
|
+
allowedOrigins: z.array(z.string()).default([]),
|
|
25
|
+
apiKey: z.string().optional(),
|
|
26
|
+
// Only honor forwarding headers (X-Forwarded-For, X-Real-IP, CF-Connecting-IP)
|
|
27
|
+
// when the request reaches the server through a trusted reverse proxy.
|
|
28
|
+
// Left false, the rate limiter keys on the socket address so spoofed
|
|
29
|
+
// forwarding headers cannot each land in a fresh bucket.
|
|
30
|
+
trustProxy: z.boolean().default(false),
|
|
31
|
+
// Advertise the exact server version via the X-Powered-By header and
|
|
32
|
+
// serverInfo. Disable to avoid version fingerprinting on public endpoints.
|
|
33
|
+
exposeVersion: z.boolean().default(true)
|
|
34
|
+
}).default({}),
|
|
35
|
+
cache: z.object({
|
|
36
|
+
enabled: z.boolean().default(true),
|
|
37
|
+
ttl: z.number().int().positive().default(3600),
|
|
38
|
+
driver: z.enum(["memory", "redis"]).default("memory")
|
|
39
|
+
}).default({}),
|
|
40
|
+
credit: z.boolean().default(true)
|
|
41
|
+
});
|
|
42
|
+
function resolveConfig(input) {
|
|
43
|
+
const config = corsenContextConfigSchema.parse(input);
|
|
44
|
+
if (!config.security.apiKey && process.env.CORSEN_CONTEXT_API_KEY) {
|
|
45
|
+
config.security.apiKey = process.env.CORSEN_CONTEXT_API_KEY;
|
|
46
|
+
}
|
|
47
|
+
if (config.cache.driver === "redis" && !process.env.REDIS_URL) {
|
|
48
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
49
|
+
if (isProduction) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
'Corsen Context: cache.driver is "redis" but REDIS_URL environment variable is not set. Set REDIS_URL or switch to driver: "memory".'
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(
|
|
55
|
+
'[corsen-context] WARNING: cache.driver is "redis" but REDIS_URL is not set. Falling back to memory cache. Set REDIS_URL for production.'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/mcp-server.ts
|
|
63
|
+
import { createHash as createHash2 } from "crypto";
|
|
64
|
+
import { z as z3 } from "zod";
|
|
65
|
+
|
|
66
|
+
// src/types.ts
|
|
67
|
+
var CREDIT_LINE = "Powered by Corsen Context \u2022 Built by Corsen AI \u2022 github.com/CorsenAI/corsen-context";
|
|
68
|
+
var JSONRPC_ERRORS = {
|
|
69
|
+
PARSE_ERROR: { code: -32700, message: "Parse error" },
|
|
70
|
+
INVALID_REQUEST: { code: -32600, message: "Invalid Request" },
|
|
71
|
+
METHOD_NOT_FOUND: { code: -32601, message: "Method not found" },
|
|
72
|
+
INVALID_PARAMS: { code: -32602, message: "Invalid params" },
|
|
73
|
+
INTERNAL_ERROR: { code: -32603, message: "Internal error" },
|
|
74
|
+
// Server-defined error (JSON-RPC reserves -32000..-32099 for implementations).
|
|
75
|
+
// Used for auth failures and rate limiting across all adapters.
|
|
76
|
+
UNAUTHORIZED: { code: -32e3, message: "Unauthorized" },
|
|
77
|
+
RATE_LIMITED: { code: -32e3, message: "Rate limit exceeded" },
|
|
78
|
+
RESOURCE_NOT_FOUND: { code: -32002, message: "Resource not found" }
|
|
79
|
+
};
|
|
80
|
+
var SECURITY_HEADERS = {
|
|
81
|
+
"X-Content-Type-Options": "nosniff",
|
|
82
|
+
"X-Frame-Options": "DENY",
|
|
83
|
+
"X-XSS-Protection": "0",
|
|
84
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
85
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
86
|
+
"Cache-Control": "no-store",
|
|
87
|
+
"X-Powered-By": "Corsen Context / Corsen AI"
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// src/version.ts
|
|
91
|
+
var CORSEN_CONTEXT_VERSION = "1.2.0";
|
|
92
|
+
var MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
93
|
+
|
|
94
|
+
// src/security.ts
|
|
95
|
+
import { timingSafeEqual, createHash, createHmac, randomBytes } from "crypto";
|
|
96
|
+
import dns from "dns/promises";
|
|
97
|
+
import { z as z2 } from "zod";
|
|
98
|
+
function isPrivateIp(ip) {
|
|
99
|
+
if (ip.startsWith("10.") || ip.startsWith("127.") || ip.startsWith("169.254.") || ip.startsWith("0.") || ip === "0.0.0.0") {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
const parts = ip.split(".").map(Number);
|
|
103
|
+
if (parts.length === 4 && parts.every((p) => !isNaN(p) && p >= 0 && p <= 255)) {
|
|
104
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
105
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
106
|
+
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true;
|
|
107
|
+
if (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) return true;
|
|
108
|
+
}
|
|
109
|
+
const lower = ip.toLowerCase();
|
|
110
|
+
if (lower === "::1" || lower === "::") {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
const firstHextet = lower.split(":")[0];
|
|
117
|
+
if (/^fe[89ab][0-9a-f]?$/.test(firstHextet)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
const embeddedIpv4 = lower.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
121
|
+
if (embeddedIpv4 && lower.includes(":")) {
|
|
122
|
+
return isPrivateIp(embeddedIpv4[1]);
|
|
123
|
+
}
|
|
124
|
+
const mappedHex = lower.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
125
|
+
if (mappedHex) {
|
|
126
|
+
const hi = parseInt(mappedHex[1], 16);
|
|
127
|
+
const lo = parseInt(mappedHex[2], 16);
|
|
128
|
+
const dotted = `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
|
|
129
|
+
return isPrivateIp(dotted);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
async function isPrivateUrl(url) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = new URL(url);
|
|
136
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
137
|
+
if (hostname === "localhost" || isPrivateIp(hostname)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const results = await dns.lookup(hostname, { all: true });
|
|
142
|
+
for (const { address } of results) {
|
|
143
|
+
if (isPrivateIp(address)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
} catch {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
var undiciAgentFactory;
|
|
156
|
+
async function getUndiciAgentFactory() {
|
|
157
|
+
if (undiciAgentFactory !== void 0) return undiciAgentFactory;
|
|
158
|
+
try {
|
|
159
|
+
const undiciModule = "undici";
|
|
160
|
+
const undici = await import(undiciModule);
|
|
161
|
+
undiciAgentFactory = (ip, family) => new undici.Agent({
|
|
162
|
+
connect: {
|
|
163
|
+
// Force every connection for this request to the vetted IP.
|
|
164
|
+
lookup(_hostname, opts, cb) {
|
|
165
|
+
if (opts && opts.all) cb(null, [{ address: ip, family }]);
|
|
166
|
+
else cb(null, ip, family);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
undiciAgentFactory = null;
|
|
172
|
+
}
|
|
173
|
+
return undiciAgentFactory;
|
|
174
|
+
}
|
|
175
|
+
async function safeFetch(url, options) {
|
|
176
|
+
const parsed = new URL(url);
|
|
177
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
178
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
179
|
+
throw new Error("SSRF protection: only http(s) URLs are allowed");
|
|
180
|
+
}
|
|
181
|
+
if (hostname === "localhost" || isPrivateIp(hostname)) {
|
|
182
|
+
throw new Error("SSRF protection: cannot fetch private/internal URLs");
|
|
183
|
+
}
|
|
184
|
+
let resolvedIp;
|
|
185
|
+
try {
|
|
186
|
+
const results = await dns.lookup(hostname, { all: true });
|
|
187
|
+
for (const { address } of results) {
|
|
188
|
+
if (isPrivateIp(address)) {
|
|
189
|
+
throw new Error("SSRF protection: DNS resolved to private IP");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
resolvedIp = results[0].address;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err instanceof Error && err.message.startsWith("SSRF")) throw err;
|
|
195
|
+
throw new Error("SSRF protection: DNS resolution failed (fail-closed)");
|
|
196
|
+
}
|
|
197
|
+
const family = resolvedIp.includes(":") ? 6 : 4;
|
|
198
|
+
const agentFactory = await getUndiciAgentFactory();
|
|
199
|
+
const dispatcher = agentFactory ? agentFactory(resolvedIp, family) : void 0;
|
|
200
|
+
return fetch(url, {
|
|
201
|
+
...options,
|
|
202
|
+
...dispatcher ? { dispatcher } : {},
|
|
203
|
+
signal: options?.signal ?? AbortSignal.timeout(options?.timeout ?? 1e4),
|
|
204
|
+
redirect: "error"
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
var MemoryRateLimitStore = class {
|
|
208
|
+
store = /* @__PURE__ */ new Map();
|
|
209
|
+
maxKeys;
|
|
210
|
+
cleanupTimer = null;
|
|
211
|
+
constructor(options) {
|
|
212
|
+
this.maxKeys = options?.maxKeys ?? 1e5;
|
|
213
|
+
if (options?.autoCleanup !== false) {
|
|
214
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 6e4);
|
|
215
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
216
|
+
this.cleanupTimer.unref();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Number of tracked keys. */
|
|
221
|
+
get size() {
|
|
222
|
+
return this.store.size;
|
|
223
|
+
}
|
|
224
|
+
/** Stop the cleanup timer (for graceful shutdown or tests). */
|
|
225
|
+
destroy() {
|
|
226
|
+
if (this.cleanupTimer) {
|
|
227
|
+
clearInterval(this.cleanupTimer);
|
|
228
|
+
this.cleanupTimer = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Evict the oldest-inserted keys until under the cap (after a prune pass). */
|
|
232
|
+
enforceCap() {
|
|
233
|
+
if (this.store.size < this.maxKeys) return;
|
|
234
|
+
this.cleanup();
|
|
235
|
+
while (this.store.size >= this.maxKeys) {
|
|
236
|
+
const oldest = this.store.keys().next().value;
|
|
237
|
+
if (oldest === void 0) break;
|
|
238
|
+
this.store.delete(oldest);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async getTimestamps(key, windowStart) {
|
|
242
|
+
const entry = this.store.get(key);
|
|
243
|
+
if (!entry) return [];
|
|
244
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
245
|
+
if (entry.timestamps.length === 0) {
|
|
246
|
+
this.store.delete(key);
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
return entry.timestamps;
|
|
250
|
+
}
|
|
251
|
+
async addTimestamp(key, timestamp) {
|
|
252
|
+
let entry = this.store.get(key);
|
|
253
|
+
if (!entry) {
|
|
254
|
+
this.enforceCap();
|
|
255
|
+
entry = { timestamps: [] };
|
|
256
|
+
this.store.set(key, entry);
|
|
257
|
+
}
|
|
258
|
+
entry.timestamps.push(timestamp);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Atomic prune + record + count. Runs synchronously (no await between the
|
|
262
|
+
* prune and the append), so concurrent requests on the same tick cannot
|
|
263
|
+
* interleave a check between another request's read and write — the TOCTOU
|
|
264
|
+
* race the split getTimestamps()/addTimestamp() path has.
|
|
265
|
+
*/
|
|
266
|
+
async hit(key, windowStart, burstWindowStart, now) {
|
|
267
|
+
let entry = this.store.get(key);
|
|
268
|
+
if (!entry) {
|
|
269
|
+
this.enforceCap();
|
|
270
|
+
entry = { timestamps: [] };
|
|
271
|
+
this.store.set(key, entry);
|
|
272
|
+
}
|
|
273
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
274
|
+
entry.timestamps.push(now);
|
|
275
|
+
const windowCount = entry.timestamps.length;
|
|
276
|
+
const burstCount = entry.timestamps.filter((t) => t > burstWindowStart).length;
|
|
277
|
+
return { windowCount, burstCount };
|
|
278
|
+
}
|
|
279
|
+
async cleanup() {
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
const windowStart = now - 6e4;
|
|
282
|
+
for (const [key, entry] of this.store) {
|
|
283
|
+
entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
|
|
284
|
+
if (entry.timestamps.length === 0) {
|
|
285
|
+
this.store.delete(key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
var RateLimiter = class {
|
|
291
|
+
store;
|
|
292
|
+
windowMs;
|
|
293
|
+
maxRequests;
|
|
294
|
+
burstLimit;
|
|
295
|
+
constructor(maxRequestsPerMinute = 100, burstPerSecond = 10, store) {
|
|
296
|
+
this.windowMs = 6e4;
|
|
297
|
+
this.maxRequests = maxRequestsPerMinute;
|
|
298
|
+
this.burstLimit = burstPerSecond;
|
|
299
|
+
this.store = store || new MemoryRateLimitStore();
|
|
300
|
+
}
|
|
301
|
+
async check(key) {
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const windowStart = now - this.windowMs;
|
|
304
|
+
const burstWindowStart = now - 1e3;
|
|
305
|
+
if (this.store.hit) {
|
|
306
|
+
const { windowCount, burstCount: burstCount2 } = await this.store.hit(
|
|
307
|
+
key,
|
|
308
|
+
windowStart,
|
|
309
|
+
burstWindowStart,
|
|
310
|
+
now
|
|
311
|
+
);
|
|
312
|
+
if (burstCount2 > this.burstLimit) {
|
|
313
|
+
return { allowed: false, remaining: 0, resetAt: burstWindowStart + 1e3, retryAfter: 1 };
|
|
314
|
+
}
|
|
315
|
+
if (windowCount > this.maxRequests) {
|
|
316
|
+
return {
|
|
317
|
+
allowed: false,
|
|
318
|
+
remaining: 0,
|
|
319
|
+
resetAt: now + this.windowMs,
|
|
320
|
+
retryAfter: Math.max(1, Math.ceil(this.windowMs / 1e3))
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
allowed: true,
|
|
325
|
+
remaining: Math.max(0, this.maxRequests - windowCount),
|
|
326
|
+
resetAt: now + this.windowMs
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const timestamps = await this.store.getTimestamps(key, windowStart);
|
|
330
|
+
const burstCount = timestamps.filter((t) => t > burstWindowStart).length;
|
|
331
|
+
if (burstCount >= this.burstLimit) {
|
|
332
|
+
return {
|
|
333
|
+
allowed: false,
|
|
334
|
+
remaining: 0,
|
|
335
|
+
resetAt: burstWindowStart + 1e3,
|
|
336
|
+
retryAfter: 1
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (timestamps.length >= this.maxRequests) {
|
|
340
|
+
const oldestInWindow = timestamps[0];
|
|
341
|
+
const retryAfter = Math.ceil((oldestInWindow + this.windowMs - now) / 1e3);
|
|
342
|
+
return {
|
|
343
|
+
allowed: false,
|
|
344
|
+
remaining: 0,
|
|
345
|
+
resetAt: oldestInWindow + this.windowMs,
|
|
346
|
+
retryAfter: Math.max(1, retryAfter)
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
await this.store.addTimestamp(key, now);
|
|
350
|
+
return {
|
|
351
|
+
allowed: true,
|
|
352
|
+
remaining: this.maxRequests - timestamps.length - 1,
|
|
353
|
+
resetAt: now + this.windowMs
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
function extractClientIp(headers, socketIp, trustProxy = false) {
|
|
358
|
+
if (trustProxy) {
|
|
359
|
+
const cfIp = headers["cf-connecting-ip"];
|
|
360
|
+
if (typeof cfIp === "string" && cfIp) return cfIp.trim();
|
|
361
|
+
const realIp = headers["x-real-ip"];
|
|
362
|
+
if (typeof realIp === "string" && realIp) return realIp.trim();
|
|
363
|
+
const xff = headers["x-forwarded-for"];
|
|
364
|
+
if (typeof xff === "string" && xff) {
|
|
365
|
+
const first = xff.split(",")[0]?.trim();
|
|
366
|
+
if (first) return first;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return socketIp || "unknown";
|
|
370
|
+
}
|
|
371
|
+
var RATE_LIMIT_KEY_LABEL = "corsen-context:ratelimit-bucket";
|
|
372
|
+
function buildRateLimitKey(clientIp, apiKey) {
|
|
373
|
+
if (apiKey) {
|
|
374
|
+
return `key:${createHmac("sha256", RATE_LIMIT_KEY_LABEL).update(apiKey).digest("hex").slice(0, 16)}`;
|
|
375
|
+
}
|
|
376
|
+
return `ip:${clientIp}`;
|
|
377
|
+
}
|
|
378
|
+
var jsonRpcRequestSchema = z2.object({
|
|
379
|
+
jsonrpc: z2.literal("2.0"),
|
|
380
|
+
method: z2.string().min(1).max(100),
|
|
381
|
+
params: z2.record(z2.unknown()).optional(),
|
|
382
|
+
id: z2.union([z2.string(), z2.number(), z2.null()]).optional()
|
|
383
|
+
});
|
|
384
|
+
var searchParamsSchema = z2.object({
|
|
385
|
+
query: z2.string().min(1).max(500),
|
|
386
|
+
limit: z2.number().int().min(1).max(50).default(10)
|
|
387
|
+
});
|
|
388
|
+
var getPageParamsSchema = z2.object({
|
|
389
|
+
uri: z2.string().min(1).max(2e3)
|
|
390
|
+
});
|
|
391
|
+
var listContentParamsSchema = z2.object({
|
|
392
|
+
type: z2.string().min(1).max(50).default("page"),
|
|
393
|
+
page: z2.number().int().min(1).default(1),
|
|
394
|
+
limit: z2.number().int().min(1).max(100).default(20)
|
|
395
|
+
});
|
|
396
|
+
function validateJsonRpcRequest(body) {
|
|
397
|
+
return jsonRpcRequestSchema.parse(body);
|
|
398
|
+
}
|
|
399
|
+
function validateOrigin(origin, allowed) {
|
|
400
|
+
if (allowed.length === 0) return true;
|
|
401
|
+
if (!origin) return false;
|
|
402
|
+
return allowed.includes(origin);
|
|
403
|
+
}
|
|
404
|
+
function validateHost(hostHeader, expectedHost) {
|
|
405
|
+
if (!hostHeader) return false;
|
|
406
|
+
const expected = new URL(expectedHost).host;
|
|
407
|
+
return hostHeader === expected;
|
|
408
|
+
}
|
|
409
|
+
function hashApiKey(key, salt) {
|
|
410
|
+
const keySalt = salt || randomBytes(16).toString("hex");
|
|
411
|
+
const hash = createHash("sha256").update(keySalt + key).digest("hex");
|
|
412
|
+
return { hash, salt: keySalt };
|
|
413
|
+
}
|
|
414
|
+
function validateApiKey(provided, expected) {
|
|
415
|
+
if (!expected) return true;
|
|
416
|
+
if (!provided) return false;
|
|
417
|
+
const compareKey = randomBytes(32);
|
|
418
|
+
const a = createHmac("sha256", compareKey).update(provided).digest();
|
|
419
|
+
const b = createHmac("sha256", compareKey).update(expected).digest();
|
|
420
|
+
return timingSafeEqual(a, b);
|
|
421
|
+
}
|
|
422
|
+
var ApiKeyManager = class {
|
|
423
|
+
keys = /* @__PURE__ */ new Map();
|
|
424
|
+
/**
|
|
425
|
+
* Register a new API key. Returns the plain key (show to user once).
|
|
426
|
+
*/
|
|
427
|
+
generateKey(options) {
|
|
428
|
+
const plainKey = `csk_${randomBytes(32).toString("base64url")}`;
|
|
429
|
+
const { hash, salt } = hashApiKey(plainKey);
|
|
430
|
+
const record = {
|
|
431
|
+
id: randomBytes(8).toString("hex"),
|
|
432
|
+
name: options.name,
|
|
433
|
+
keyHash: hash,
|
|
434
|
+
keySalt: salt,
|
|
435
|
+
scopes: options.scopes || ["*"],
|
|
436
|
+
quotaPerDay: options.quotaPerDay || 0,
|
|
437
|
+
// 0 = unlimited
|
|
438
|
+
requestsToday: 0,
|
|
439
|
+
quotaResetAt: this.nextMidnight(),
|
|
440
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
441
|
+
lastUsedAt: null,
|
|
442
|
+
expiresAt: options.expiresAt?.toISOString() || null,
|
|
443
|
+
revoked: false
|
|
444
|
+
};
|
|
445
|
+
this.keys.set(record.id, record);
|
|
446
|
+
return { plainKey, record };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Validate a provided API key against stored records.
|
|
450
|
+
* Returns the matching record or null.
|
|
451
|
+
*/
|
|
452
|
+
validate(providedKey, requiredScope) {
|
|
453
|
+
for (const record of this.keys.values()) {
|
|
454
|
+
if (record.revoked) continue;
|
|
455
|
+
if (record.expiresAt && new Date(record.expiresAt) < /* @__PURE__ */ new Date()) continue;
|
|
456
|
+
const { hash } = hashApiKey(providedKey, record.keySalt);
|
|
457
|
+
if (!timingSafeEqual(Buffer.from(hash), Buffer.from(record.keyHash))) continue;
|
|
458
|
+
if (requiredScope && !record.scopes.includes("*") && !record.scopes.includes(requiredScope)) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (record.quotaPerDay > 0) {
|
|
462
|
+
if (/* @__PURE__ */ new Date() > new Date(record.quotaResetAt)) {
|
|
463
|
+
record.requestsToday = 0;
|
|
464
|
+
record.quotaResetAt = this.nextMidnight();
|
|
465
|
+
}
|
|
466
|
+
if (record.requestsToday >= record.quotaPerDay) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
record.requestsToday++;
|
|
470
|
+
}
|
|
471
|
+
record.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
472
|
+
return record;
|
|
473
|
+
}
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Revoke an API key by ID.
|
|
478
|
+
*/
|
|
479
|
+
revoke(keyId) {
|
|
480
|
+
const record = this.keys.get(keyId);
|
|
481
|
+
if (!record) return false;
|
|
482
|
+
record.revoked = true;
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* List all keys (without secrets).
|
|
487
|
+
*/
|
|
488
|
+
listKeys() {
|
|
489
|
+
return Array.from(this.keys.values()).map(({ keyHash, keySalt, ...rest }) => rest);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Load keys from external storage (e.g., Redis, database).
|
|
493
|
+
*/
|
|
494
|
+
loadKeys(records) {
|
|
495
|
+
for (const record of records) {
|
|
496
|
+
this.keys.set(record.id, record);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
nextMidnight() {
|
|
500
|
+
const tomorrow = /* @__PURE__ */ new Date();
|
|
501
|
+
tomorrow.setUTCHours(24, 0, 0, 0);
|
|
502
|
+
return tomorrow.toISOString();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// src/cache.ts
|
|
507
|
+
var DEFAULT_MAX_ENTRIES = 1e3;
|
|
508
|
+
var CLEANUP_INTERVAL_MS = 6e4;
|
|
509
|
+
var MemoryCache = class {
|
|
510
|
+
store = /* @__PURE__ */ new Map();
|
|
511
|
+
maxEntries;
|
|
512
|
+
cleanupTimer = null;
|
|
513
|
+
constructor(options) {
|
|
514
|
+
this.maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
515
|
+
if (options?.autoCleanup !== false) {
|
|
516
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL_MS);
|
|
517
|
+
if (this.cleanupTimer && typeof this.cleanupTimer === "object" && "unref" in this.cleanupTimer) {
|
|
518
|
+
this.cleanupTimer.unref();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
async get(key) {
|
|
523
|
+
const entry = this.store.get(key);
|
|
524
|
+
if (!entry) return null;
|
|
525
|
+
if (Date.now() > entry.expiresAt) {
|
|
526
|
+
this.store.delete(key);
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
entry.lastAccessedAt = Date.now();
|
|
530
|
+
return entry.value;
|
|
531
|
+
}
|
|
532
|
+
async set(key, value, ttl) {
|
|
533
|
+
if (!this.store.has(key) && this.store.size >= this.maxEntries) {
|
|
534
|
+
this.evictLRU();
|
|
535
|
+
}
|
|
536
|
+
this.store.set(key, {
|
|
537
|
+
value,
|
|
538
|
+
expiresAt: Date.now() + ttl * 1e3,
|
|
539
|
+
lastAccessedAt: Date.now()
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
async delete(key) {
|
|
543
|
+
this.store.delete(key);
|
|
544
|
+
}
|
|
545
|
+
async clear() {
|
|
546
|
+
this.store.clear();
|
|
547
|
+
}
|
|
548
|
+
/** Current number of entries */
|
|
549
|
+
get size() {
|
|
550
|
+
return this.store.size;
|
|
551
|
+
}
|
|
552
|
+
/** Stop automatic cleanup (for graceful shutdown or testing) */
|
|
553
|
+
destroy() {
|
|
554
|
+
if (this.cleanupTimer) {
|
|
555
|
+
clearInterval(this.cleanupTimer);
|
|
556
|
+
this.cleanupTimer = null;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/** Remove all expired entries */
|
|
560
|
+
cleanup() {
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
for (const [key, entry] of this.store) {
|
|
563
|
+
if (now > entry.expiresAt) {
|
|
564
|
+
this.store.delete(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
/** Evict the least-recently-accessed entry */
|
|
569
|
+
evictLRU() {
|
|
570
|
+
let oldestKey = null;
|
|
571
|
+
let oldestTime = Infinity;
|
|
572
|
+
for (const [key, entry] of this.store) {
|
|
573
|
+
if (entry.lastAccessedAt < oldestTime) {
|
|
574
|
+
oldestTime = entry.lastAccessedAt;
|
|
575
|
+
oldestKey = key;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (oldestKey) {
|
|
579
|
+
this.store.delete(oldestKey);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// src/logger.ts
|
|
585
|
+
import pino from "pino";
|
|
586
|
+
function createLogger(options = {}) {
|
|
587
|
+
return pino({
|
|
588
|
+
name: options.name || "corsen-context",
|
|
589
|
+
level: options.level || process.env.LOG_LEVEL || "info",
|
|
590
|
+
...options.pretty ? {
|
|
591
|
+
transport: {
|
|
592
|
+
target: "pino-pretty",
|
|
593
|
+
options: { colorize: true }
|
|
594
|
+
}
|
|
595
|
+
} : {},
|
|
596
|
+
formatters: {
|
|
597
|
+
level(label) {
|
|
598
|
+
return { level: label };
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
602
|
+
redact: {
|
|
603
|
+
paths: ["apiKey", "authorization", "password", "secret", "token"],
|
|
604
|
+
censor: "[REDACTED]"
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
var defaultLogger = null;
|
|
609
|
+
function getLogger() {
|
|
610
|
+
if (!defaultLogger) {
|
|
611
|
+
defaultLogger = createLogger();
|
|
612
|
+
}
|
|
613
|
+
return defaultLogger;
|
|
614
|
+
}
|
|
615
|
+
function setLogger(logger) {
|
|
616
|
+
defaultLogger = logger;
|
|
617
|
+
}
|
|
618
|
+
function securityLogger(parent) {
|
|
619
|
+
const base = parent || getLogger();
|
|
620
|
+
return base.child({ module: "security" });
|
|
621
|
+
}
|
|
622
|
+
function mcpLogger(parent) {
|
|
623
|
+
const base = parent || getLogger();
|
|
624
|
+
return base.child({ module: "mcp" });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/content-policy.ts
|
|
628
|
+
function siteOrigin(config) {
|
|
629
|
+
return new URL(config.siteUrl).origin;
|
|
630
|
+
}
|
|
631
|
+
function percentDecode(value) {
|
|
632
|
+
let current = value;
|
|
633
|
+
for (let i = 0; i < 3; i++) {
|
|
634
|
+
let decoded;
|
|
635
|
+
try {
|
|
636
|
+
decoded = decodeURIComponent(current);
|
|
637
|
+
} catch {
|
|
638
|
+
return current;
|
|
639
|
+
}
|
|
640
|
+
if (decoded === current) break;
|
|
641
|
+
current = decoded;
|
|
642
|
+
}
|
|
643
|
+
return current;
|
|
644
|
+
}
|
|
645
|
+
function normalizePath(path) {
|
|
646
|
+
const trimmed = percentDecode(path.trim());
|
|
647
|
+
if (!trimmed) return null;
|
|
648
|
+
const withSlash = `/${trimmed.replace(/^\/+/, "")}`;
|
|
649
|
+
const withoutTrailing = withSlash.replace(/\/+$/, "");
|
|
650
|
+
return withoutTrailing || "/";
|
|
651
|
+
}
|
|
652
|
+
function pathFromUrlOrPath(value, config) {
|
|
653
|
+
try {
|
|
654
|
+
const parsed = new URL(value, config.siteUrl);
|
|
655
|
+
return normalizePath(parsed.pathname);
|
|
656
|
+
} catch {
|
|
657
|
+
return normalizePath(value);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function isExcludedPath(pathname, config) {
|
|
661
|
+
const path = normalizePath(pathname);
|
|
662
|
+
if (!path) return false;
|
|
663
|
+
const lowerPath = path.toLowerCase();
|
|
664
|
+
return config.content.excludePaths.some((exclude) => {
|
|
665
|
+
const excluded = pathFromUrlOrPath(exclude, config);
|
|
666
|
+
if (!excluded || excluded === "/") return false;
|
|
667
|
+
const lowerExcluded = excluded.toLowerCase();
|
|
668
|
+
return lowerPath === lowerExcluded || lowerPath.startsWith(`${lowerExcluded}/`);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
function resolvePublicPageUrl(input, config) {
|
|
672
|
+
const raw = input.trim();
|
|
673
|
+
if (!raw) return null;
|
|
674
|
+
const value = raw.startsWith("resource://") ? `/${raw.slice("resource://".length).replace(/^\/+/, "")}` : raw;
|
|
675
|
+
let parsed;
|
|
676
|
+
try {
|
|
677
|
+
parsed = new URL(value, config.siteUrl);
|
|
678
|
+
} catch {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
if (parsed.origin !== siteOrigin(config)) {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
if (isExcludedPath(parsed.pathname, config)) {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
return parsed.toString();
|
|
691
|
+
}
|
|
692
|
+
function isPublicListItem(item, config) {
|
|
693
|
+
if (!config.content.postTypes.includes(item.type)) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
return resolvePublicPageUrl(item.url, config) !== null;
|
|
697
|
+
}
|
|
698
|
+
function filterPublicPages(pages, config) {
|
|
699
|
+
return pages.filter((page) => isPublicListItem(page, config)).slice(0, config.content.maxPages);
|
|
700
|
+
}
|
|
701
|
+
function isPublicPageContent(content, config) {
|
|
702
|
+
return resolvePublicPageUrl(content.url, config) !== null;
|
|
703
|
+
}
|
|
704
|
+
function filterPublicSearchResults(results, config, limit) {
|
|
705
|
+
return results.filter((result) => resolvePublicPageUrl(result.url, config) !== null).slice(0, limit);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// src/mcp-server.ts
|
|
709
|
+
var API_VERSION = "v1";
|
|
710
|
+
var MAX_BODY_SIZE = 100 * 1024;
|
|
711
|
+
var MAX_JSON_DEPTH = 10;
|
|
712
|
+
var REQUEST_TIMEOUT_MS = 8e3;
|
|
713
|
+
function validateBodySize(body) {
|
|
714
|
+
const serialized = JSON.stringify(body);
|
|
715
|
+
if (serialized.length > MAX_BODY_SIZE) {
|
|
716
|
+
throw new Error("Request body too large");
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function checkJsonDepth(obj, currentDepth = 0) {
|
|
720
|
+
if (currentDepth > MAX_JSON_DEPTH) {
|
|
721
|
+
throw new Error("JSON nesting too deep");
|
|
722
|
+
}
|
|
723
|
+
if (obj && typeof obj === "object") {
|
|
724
|
+
for (const value of Object.values(obj)) {
|
|
725
|
+
checkJsonDepth(value, currentDepth + 1);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
var MCPServer = class _MCPServer {
|
|
730
|
+
config;
|
|
731
|
+
provider;
|
|
732
|
+
rateLimiter;
|
|
733
|
+
cache;
|
|
734
|
+
log;
|
|
735
|
+
constructor(config, provider, options) {
|
|
736
|
+
this.config = config;
|
|
737
|
+
this.provider = provider;
|
|
738
|
+
this.rateLimiter = new RateLimiter(
|
|
739
|
+
config.security.rateLimit,
|
|
740
|
+
config.security.burstLimit,
|
|
741
|
+
options?.rateLimitStore
|
|
742
|
+
);
|
|
743
|
+
this.cache = options?.cache || new MemoryCache();
|
|
744
|
+
this.log = (options?.logger || getLogger()).child({ module: "mcp" });
|
|
745
|
+
}
|
|
746
|
+
getSecurityHeaders() {
|
|
747
|
+
return { ...SECURITY_HEADERS };
|
|
748
|
+
}
|
|
749
|
+
getCorsHeaders(origin) {
|
|
750
|
+
const headers = {};
|
|
751
|
+
if (this.config.security.allowedOrigins.length === 0) {
|
|
752
|
+
headers["Access-Control-Allow-Origin"] = "*";
|
|
753
|
+
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS";
|
|
754
|
+
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-MCP-Key";
|
|
755
|
+
headers["Access-Control-Max-Age"] = "86400";
|
|
756
|
+
} else if (origin && validateOrigin(origin, this.config.security.allowedOrigins)) {
|
|
757
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
758
|
+
headers["Access-Control-Allow-Methods"] = "POST, OPTIONS";
|
|
759
|
+
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-MCP-Key";
|
|
760
|
+
headers["Access-Control-Max-Age"] = "86400";
|
|
761
|
+
headers["Vary"] = "Origin";
|
|
762
|
+
}
|
|
763
|
+
return headers;
|
|
764
|
+
}
|
|
765
|
+
async checkRateLimit(clientIp, apiKey) {
|
|
766
|
+
const key = buildRateLimitKey(clientIp, apiKey);
|
|
767
|
+
const result = await this.rateLimiter.check(key);
|
|
768
|
+
const headers = {
|
|
769
|
+
"X-RateLimit-Limit": String(this.config.security.rateLimit),
|
|
770
|
+
"X-RateLimit-Remaining": String(result.remaining),
|
|
771
|
+
"X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1e3))
|
|
772
|
+
};
|
|
773
|
+
if (!result.allowed) {
|
|
774
|
+
const ipHash = createHash2("sha256").update(clientIp).digest("hex").slice(0, 12);
|
|
775
|
+
this.log.warn({ key: key.replace(/:.+/, ":***"), ipHash }, "rate_limit_exceeded");
|
|
776
|
+
if (result.retryAfter) {
|
|
777
|
+
headers["Retry-After"] = String(result.retryAfter);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return { allowed: result.allowed, headers };
|
|
781
|
+
}
|
|
782
|
+
checkAuth(apiKeyHeader) {
|
|
783
|
+
const valid = validateApiKey(apiKeyHeader, this.config.security.apiKey);
|
|
784
|
+
if (!valid && this.config.security.apiKey) {
|
|
785
|
+
this.log.warn({ provided: !!apiKeyHeader }, "auth_failed");
|
|
786
|
+
}
|
|
787
|
+
return valid;
|
|
788
|
+
}
|
|
789
|
+
async handleRequest(body, clientIp, apiKey, options) {
|
|
790
|
+
const start = Date.now();
|
|
791
|
+
let requestId = null;
|
|
792
|
+
let method = "unknown";
|
|
793
|
+
try {
|
|
794
|
+
validateBodySize(body);
|
|
795
|
+
checkJsonDepth(body);
|
|
796
|
+
const request = validateJsonRpcRequest(body);
|
|
797
|
+
requestId = request.id ?? null;
|
|
798
|
+
method = request.method;
|
|
799
|
+
if (clientIp && !options?.skipRateLimit) {
|
|
800
|
+
const limit = await this.checkRateLimit(clientIp, apiKey);
|
|
801
|
+
if (!limit.allowed) {
|
|
802
|
+
return this.errorResponse(requestId, -32e3, "Rate limit exceeded");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (!this.checkAuth(apiKey)) {
|
|
806
|
+
return this.errorResponse(requestId, JSONRPC_ERRORS.UNAUTHORIZED.code, "Unauthorized");
|
|
807
|
+
}
|
|
808
|
+
const isNotification = !("id" in body);
|
|
809
|
+
if (isNotification) {
|
|
810
|
+
await this.dispatch(request);
|
|
811
|
+
this.log.debug({ method, type: "notification", durationMs: Date.now() - start }, "request_handled");
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
const result = await this.dispatch(request);
|
|
815
|
+
const duration = Date.now() - start;
|
|
816
|
+
this.log.info({ method, id: requestId, durationMs: duration, status: "ok" }, "request_handled");
|
|
817
|
+
return result;
|
|
818
|
+
} catch (err) {
|
|
819
|
+
const duration = Date.now() - start;
|
|
820
|
+
if (err instanceof z3.ZodError) {
|
|
821
|
+
this.log.warn({ method, durationMs: duration, error: "invalid_request" }, "request_failed");
|
|
822
|
+
return this.errorResponse(requestId, JSONRPC_ERRORS.INVALID_REQUEST.code, "Invalid JSON-RPC request");
|
|
823
|
+
}
|
|
824
|
+
if (err instanceof Error && (err.message === "Request body too large" || err.message === "JSON nesting too deep")) {
|
|
825
|
+
this.log.warn({ method, durationMs: duration, error: err.message }, "dos_rejected");
|
|
826
|
+
return this.errorResponse(requestId, JSONRPC_ERRORS.INVALID_REQUEST.code, err.message);
|
|
827
|
+
}
|
|
828
|
+
this.log.error({ method, durationMs: duration, error: err instanceof Error ? err.message : "unknown" }, "request_error");
|
|
829
|
+
return this.errorResponse(requestId, JSONRPC_ERRORS.INTERNAL_ERROR.code, "Internal error");
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async dispatch(request) {
|
|
833
|
+
const { method, params, id } = request;
|
|
834
|
+
switch (method) {
|
|
835
|
+
case "initialize":
|
|
836
|
+
return this.handleInitialize(params, id);
|
|
837
|
+
case "notifications/initialized":
|
|
838
|
+
return this.successResponse(id ?? null, {});
|
|
839
|
+
case "ping":
|
|
840
|
+
return this.successResponse(id ?? null, {});
|
|
841
|
+
case "tools/list":
|
|
842
|
+
return this.handleListTools(id);
|
|
843
|
+
case "tools/call":
|
|
844
|
+
return this.handleCallTool(params, id);
|
|
845
|
+
case "resources/list":
|
|
846
|
+
return this.handleListResources(params, id);
|
|
847
|
+
case "resources/read":
|
|
848
|
+
return this.handleReadResource(params, id);
|
|
849
|
+
default:
|
|
850
|
+
return this.errorResponse(
|
|
851
|
+
id ?? null,
|
|
852
|
+
JSONRPC_ERRORS.METHOD_NOT_FOUND.code,
|
|
853
|
+
`Method not found: ${method}`
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
handleInitialize(params, id) {
|
|
858
|
+
this.log.info("mcp_initialized");
|
|
859
|
+
const requested = typeof params?.protocolVersion === "string" ? params.protocolVersion : null;
|
|
860
|
+
const protocolVersion = requested === MCP_PROTOCOL_VERSION ? requested : MCP_PROTOCOL_VERSION;
|
|
861
|
+
return this.successResponse(id ?? null, {
|
|
862
|
+
protocolVersion,
|
|
863
|
+
capabilities: {
|
|
864
|
+
tools: {},
|
|
865
|
+
resources: {}
|
|
866
|
+
},
|
|
867
|
+
serverInfo: {
|
|
868
|
+
name: "corsen-context",
|
|
869
|
+
// Omit the exact version when fingerprinting is disabled.
|
|
870
|
+
...this.config.security.exposeVersion ? { version: CORSEN_CONTEXT_VERSION } : {}
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
handleListTools(id) {
|
|
875
|
+
return this.successResponse(id ?? null, {
|
|
876
|
+
tools: this.getToolDefinitions()
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
async handleCallTool(params, id) {
|
|
880
|
+
if (!params || typeof params.name !== "string") {
|
|
881
|
+
return this.errorResponse(id ?? null, JSONRPC_ERRORS.INVALID_PARAMS.code, "Missing tool name");
|
|
882
|
+
}
|
|
883
|
+
const toolName = params.name;
|
|
884
|
+
const toolArgs = params.arguments || {};
|
|
885
|
+
if (!this.config.mcp.tools.includes(toolName)) {
|
|
886
|
+
return this.errorResponse(
|
|
887
|
+
id ?? null,
|
|
888
|
+
JSONRPC_ERRORS.METHOD_NOT_FOUND.code,
|
|
889
|
+
`Tool not found: ${toolName}`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
const toolStart = Date.now();
|
|
894
|
+
let result;
|
|
895
|
+
switch (toolName) {
|
|
896
|
+
case "search_site": {
|
|
897
|
+
const parsed = searchParamsSchema.parse(toolArgs);
|
|
898
|
+
result = await this.searchSite(parsed.query, parsed.limit);
|
|
899
|
+
break;
|
|
900
|
+
}
|
|
901
|
+
case "get_page_content": {
|
|
902
|
+
const parsed = getPageParamsSchema.parse(toolArgs);
|
|
903
|
+
result = await this.getPageContent(parsed.uri);
|
|
904
|
+
if (!result) {
|
|
905
|
+
return this.errorResponse(id ?? null, -32002, "Resource not found");
|
|
906
|
+
}
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
case "list_content": {
|
|
910
|
+
const parsed = listContentParamsSchema.parse(toolArgs);
|
|
911
|
+
result = await this.listContent(parsed.type, parsed.page, parsed.limit);
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "get_sitemap": {
|
|
915
|
+
result = await this.getSitemap();
|
|
916
|
+
break;
|
|
917
|
+
}
|
|
918
|
+
default:
|
|
919
|
+
return this.errorResponse(id ?? null, JSONRPC_ERRORS.METHOD_NOT_FOUND.code, `Unknown tool: ${toolName}`);
|
|
920
|
+
}
|
|
921
|
+
this.log.debug({ tool: toolName, durationMs: Date.now() - toolStart }, "tool_called");
|
|
922
|
+
return this.successResponse(id ?? null, {
|
|
923
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
|
924
|
+
});
|
|
925
|
+
} catch (err) {
|
|
926
|
+
if (err instanceof z3.ZodError) {
|
|
927
|
+
return this.errorResponse(id ?? null, JSONRPC_ERRORS.INVALID_PARAMS.code, "Invalid tool parameters");
|
|
928
|
+
}
|
|
929
|
+
this.log.error({ tool: toolName, error: err instanceof Error ? err.message : "unknown" }, "tool_error");
|
|
930
|
+
return this.errorResponse(id ?? null, JSONRPC_ERRORS.INTERNAL_ERROR.code, "Tool execution failed");
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/** Page size for resources/list cursor pagination. */
|
|
934
|
+
static RESOURCES_PAGE_SIZE = 100;
|
|
935
|
+
async handleListResources(params, id) {
|
|
936
|
+
const pages = filterPublicPages(await this.provider.getPages(), this.config);
|
|
937
|
+
const all = pages.flatMap((p) => {
|
|
938
|
+
let parsed;
|
|
939
|
+
try {
|
|
940
|
+
parsed = new URL(p.url, this.config.siteUrl);
|
|
941
|
+
} catch {
|
|
942
|
+
return [];
|
|
943
|
+
}
|
|
944
|
+
const pathWithQuery = parsed.pathname + parsed.search;
|
|
945
|
+
return [
|
|
946
|
+
{
|
|
947
|
+
uri: `resource://${pathWithQuery.replace(/^\//, "")}`,
|
|
948
|
+
name: p.title,
|
|
949
|
+
description: p.description,
|
|
950
|
+
mimeType: "text/markdown"
|
|
951
|
+
}
|
|
952
|
+
];
|
|
953
|
+
});
|
|
954
|
+
const pageSize = _MCPServer.RESOURCES_PAGE_SIZE;
|
|
955
|
+
const offset = this.decodeCursor(params?.cursor);
|
|
956
|
+
const slice = all.slice(offset, offset + pageSize);
|
|
957
|
+
const nextOffset = offset + pageSize;
|
|
958
|
+
const result = { resources: slice };
|
|
959
|
+
if (nextOffset < all.length) {
|
|
960
|
+
result.nextCursor = Buffer.from(String(nextOffset)).toString("base64");
|
|
961
|
+
}
|
|
962
|
+
return this.successResponse(id ?? null, result);
|
|
963
|
+
}
|
|
964
|
+
decodeCursor(cursor) {
|
|
965
|
+
if (typeof cursor !== "string" || !cursor) return 0;
|
|
966
|
+
const decoded = Number.parseInt(Buffer.from(cursor, "base64").toString("utf8"), 10);
|
|
967
|
+
return Number.isInteger(decoded) && decoded >= 0 ? decoded : 0;
|
|
968
|
+
}
|
|
969
|
+
async handleReadResource(params, id) {
|
|
970
|
+
if (!params || typeof params.uri !== "string") {
|
|
971
|
+
return this.errorResponse(id ?? null, JSONRPC_ERRORS.INVALID_PARAMS.code, "Missing resource URI");
|
|
972
|
+
}
|
|
973
|
+
const uri = params.uri;
|
|
974
|
+
const pageUrl = resolvePublicPageUrl(uri, this.config);
|
|
975
|
+
if (!pageUrl) {
|
|
976
|
+
return this.errorResponse(id ?? null, -32002, "Resource not found");
|
|
977
|
+
}
|
|
978
|
+
const content = await this.provider.getPageContent(pageUrl);
|
|
979
|
+
if (!content || !isPublicPageContent(content, this.config)) {
|
|
980
|
+
return this.errorResponse(id ?? null, -32002, "Resource not found");
|
|
981
|
+
}
|
|
982
|
+
return this.successResponse(id ?? null, {
|
|
983
|
+
contents: [
|
|
984
|
+
{
|
|
985
|
+
uri,
|
|
986
|
+
mimeType: "text/markdown",
|
|
987
|
+
text: content.markdown
|
|
988
|
+
}
|
|
989
|
+
]
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
// --- Tool Implementations ---
|
|
993
|
+
get cacheEnabled() {
|
|
994
|
+
return this.config.cache.enabled;
|
|
995
|
+
}
|
|
996
|
+
async cacheGet(key) {
|
|
997
|
+
if (!this.cacheEnabled) return null;
|
|
998
|
+
return this.cache.get(key);
|
|
999
|
+
}
|
|
1000
|
+
async cacheSet(key, value) {
|
|
1001
|
+
if (!this.cacheEnabled) return;
|
|
1002
|
+
await this.cache.set(key, value, this.config.cache.ttl);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Drop the cached body for a single page URL. Call this from your CMS's
|
|
1006
|
+
* publish/update/delete hooks so edits and unpublishes propagate before the
|
|
1007
|
+
* TTL expires (otherwise stale content can be served for up to cache.ttl).
|
|
1008
|
+
*/
|
|
1009
|
+
async invalidatePage(url) {
|
|
1010
|
+
const pageUrl = resolvePublicPageUrl(url, this.config);
|
|
1011
|
+
if (pageUrl) await this.cache.delete(`page:${pageUrl}`);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Clear all cached MCP responses (search, page, list, sitemap). Call after
|
|
1015
|
+
* bulk content changes. No-op for cache drivers without prefix enumeration
|
|
1016
|
+
* (see RedisCache.clear notes).
|
|
1017
|
+
*/
|
|
1018
|
+
async clearCache() {
|
|
1019
|
+
await this.cache.clear();
|
|
1020
|
+
}
|
|
1021
|
+
async searchSite(query, limit = 10) {
|
|
1022
|
+
const cacheKey = `search:${query}:${limit}`;
|
|
1023
|
+
const cached = await this.cacheGet(cacheKey);
|
|
1024
|
+
if (cached !== null) return cached;
|
|
1025
|
+
const results = filterPublicSearchResults(
|
|
1026
|
+
await this.provider.searchContent(query, limit),
|
|
1027
|
+
this.config,
|
|
1028
|
+
limit
|
|
1029
|
+
);
|
|
1030
|
+
await this.cacheSet(cacheKey, results);
|
|
1031
|
+
return results;
|
|
1032
|
+
}
|
|
1033
|
+
async getPageContent(uri) {
|
|
1034
|
+
const pageUrl = resolvePublicPageUrl(uri, this.config);
|
|
1035
|
+
if (!pageUrl) return null;
|
|
1036
|
+
const cacheKey = `page:${pageUrl}`;
|
|
1037
|
+
const cached = await this.cacheGet(cacheKey);
|
|
1038
|
+
if (cached !== null) return cached;
|
|
1039
|
+
const content = await this.provider.getPageContent(pageUrl);
|
|
1040
|
+
if (content && isPublicPageContent(content, this.config)) {
|
|
1041
|
+
await this.cacheSet(cacheKey, content);
|
|
1042
|
+
return content;
|
|
1043
|
+
}
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
async listContent(type, page = 1, limit = 20) {
|
|
1047
|
+
const cacheKey = `list:${type}:${page}:${limit}`;
|
|
1048
|
+
const cached = await this.cacheGet(cacheKey);
|
|
1049
|
+
if (cached !== null) return cached;
|
|
1050
|
+
const publicPages = (await this.provider.getPages()).filter(
|
|
1051
|
+
(p) => isPublicListItem(p, this.config)
|
|
1052
|
+
);
|
|
1053
|
+
const filtered = publicPages.filter((p) => p.type === type);
|
|
1054
|
+
const total = filtered.length;
|
|
1055
|
+
const start = (page - 1) * limit;
|
|
1056
|
+
const items = filtered.slice(start, start + limit);
|
|
1057
|
+
const result = {
|
|
1058
|
+
items,
|
|
1059
|
+
total,
|
|
1060
|
+
page,
|
|
1061
|
+
limit,
|
|
1062
|
+
hasMore: start + limit < total
|
|
1063
|
+
};
|
|
1064
|
+
await this.cacheSet(cacheKey, result);
|
|
1065
|
+
return result;
|
|
1066
|
+
}
|
|
1067
|
+
async getSitemap() {
|
|
1068
|
+
const cacheKey = "sitemap";
|
|
1069
|
+
const cached = await this.cacheGet(cacheKey);
|
|
1070
|
+
if (cached !== null) return cached;
|
|
1071
|
+
const pages = filterPublicPages(await this.provider.getPages(), this.config);
|
|
1072
|
+
const sitemap = pages.map((p) => ({
|
|
1073
|
+
url: p.url,
|
|
1074
|
+
title: p.title,
|
|
1075
|
+
type: p.type,
|
|
1076
|
+
lastModified: p.lastModified
|
|
1077
|
+
}));
|
|
1078
|
+
await this.cacheSet(cacheKey, sitemap);
|
|
1079
|
+
return sitemap;
|
|
1080
|
+
}
|
|
1081
|
+
// --- Tool Definitions ---
|
|
1082
|
+
getToolDefinitions() {
|
|
1083
|
+
const tools = [];
|
|
1084
|
+
if (this.config.mcp.tools.includes("search_site")) {
|
|
1085
|
+
tools.push({
|
|
1086
|
+
name: "search_site",
|
|
1087
|
+
description: "Search site content by keyword. Returns matching pages with snippets.",
|
|
1088
|
+
inputSchema: {
|
|
1089
|
+
type: "object",
|
|
1090
|
+
properties: {
|
|
1091
|
+
query: { type: "string", description: "Search query" },
|
|
1092
|
+
limit: { type: "number", description: "Max results (1-50, default 10)" }
|
|
1093
|
+
},
|
|
1094
|
+
required: ["query"]
|
|
1095
|
+
}
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
if (this.config.mcp.tools.includes("get_page_content")) {
|
|
1099
|
+
tools.push({
|
|
1100
|
+
name: "get_page_content",
|
|
1101
|
+
description: "Get full page content as clean markdown with metadata (title, description, dates).",
|
|
1102
|
+
inputSchema: {
|
|
1103
|
+
type: "object",
|
|
1104
|
+
properties: {
|
|
1105
|
+
uri: { type: "string", description: "Page URL or resource URI" }
|
|
1106
|
+
},
|
|
1107
|
+
required: ["uri"]
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
if (this.config.mcp.tools.includes("list_content")) {
|
|
1112
|
+
tools.push({
|
|
1113
|
+
name: "list_content",
|
|
1114
|
+
description: "List content by type (page, post, product) with pagination.",
|
|
1115
|
+
inputSchema: {
|
|
1116
|
+
type: "object",
|
|
1117
|
+
properties: {
|
|
1118
|
+
type: { type: "string", description: "Content type (e.g., post, page, product, or any custom type)" },
|
|
1119
|
+
page: { type: "number", description: "Page number (default 1)" },
|
|
1120
|
+
limit: { type: "number", description: "Items per page (1-100, default 20)" }
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
if (this.config.mcp.tools.includes("get_sitemap")) {
|
|
1126
|
+
tools.push({
|
|
1127
|
+
name: "get_sitemap",
|
|
1128
|
+
description: "Get structured sitemap of the entire site with URLs, titles, types, and dates.",
|
|
1129
|
+
inputSchema: {
|
|
1130
|
+
type: "object",
|
|
1131
|
+
properties: {}
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
return tools;
|
|
1136
|
+
}
|
|
1137
|
+
getCapabilities() {
|
|
1138
|
+
return {
|
|
1139
|
+
tools: this.getToolDefinitions(),
|
|
1140
|
+
resources: []
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
// --- Response Helpers ---
|
|
1144
|
+
successResponse(id, result) {
|
|
1145
|
+
return { jsonrpc: "2.0", result, id };
|
|
1146
|
+
}
|
|
1147
|
+
errorResponse(id, code, message) {
|
|
1148
|
+
return { jsonrpc: "2.0", error: { code, message }, id };
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
// src/llms-txt.ts
|
|
1153
|
+
async function generateLlmsTxt(config, provider) {
|
|
1154
|
+
const pages = filterPublicPages(await provider.getPages(), config);
|
|
1155
|
+
const siteUrl = config.siteUrl.replace(/\/$/, "");
|
|
1156
|
+
const mcpEndpoint = config.mcp.enabled ? `${siteUrl}${config.mcp.endpoint}` : null;
|
|
1157
|
+
const lines = [];
|
|
1158
|
+
lines.push(`# ${config.siteName || new URL(config.siteUrl).hostname}`);
|
|
1159
|
+
lines.push("");
|
|
1160
|
+
if (config.description) {
|
|
1161
|
+
lines.push(`> ${config.description}`);
|
|
1162
|
+
lines.push("");
|
|
1163
|
+
}
|
|
1164
|
+
lines.push("## About this AI Context File");
|
|
1165
|
+
lines.push(
|
|
1166
|
+
"This file is optimized for AI agents and MCP clients (2025-11-25 spec)."
|
|
1167
|
+
);
|
|
1168
|
+
if (mcpEndpoint) {
|
|
1169
|
+
lines.push(`For dynamic structured access use the MCP endpoint below.`);
|
|
1170
|
+
}
|
|
1171
|
+
lines.push("");
|
|
1172
|
+
const grouped = groupByType(pages);
|
|
1173
|
+
if (grouped.page && grouped.page.length > 0) {
|
|
1174
|
+
lines.push("## Main Pages");
|
|
1175
|
+
for (const p of grouped.page) {
|
|
1176
|
+
const desc = p.description ? ` \u2013 ${p.description}` : "";
|
|
1177
|
+
lines.push(`- [${p.title}](${p.url})${desc}`);
|
|
1178
|
+
}
|
|
1179
|
+
lines.push("");
|
|
1180
|
+
}
|
|
1181
|
+
if (grouped.post && grouped.post.length > 0) {
|
|
1182
|
+
lines.push("## Blog & Content");
|
|
1183
|
+
for (const p of grouped.post) {
|
|
1184
|
+
const desc = p.description ? ` \u2013 ${p.description}` : "";
|
|
1185
|
+
const date = p.lastModified ? ` \u2022 ${p.lastModified.split("T")[0]}` : "";
|
|
1186
|
+
lines.push(`- [${p.title}](${p.url})${desc}${date}`);
|
|
1187
|
+
}
|
|
1188
|
+
lines.push("");
|
|
1189
|
+
}
|
|
1190
|
+
if (grouped.product && grouped.product.length > 0) {
|
|
1191
|
+
lines.push("## Products / Services");
|
|
1192
|
+
for (const p of grouped.product) {
|
|
1193
|
+
const desc = p.description ? ` \u2013 ${p.description}` : "";
|
|
1194
|
+
lines.push(`- [${p.title}](${p.url})${desc}`);
|
|
1195
|
+
}
|
|
1196
|
+
lines.push("");
|
|
1197
|
+
}
|
|
1198
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
1199
|
+
if (["page", "post", "product"].includes(type)) continue;
|
|
1200
|
+
if (items.length === 0) continue;
|
|
1201
|
+
lines.push(`## ${capitalize(type)}`);
|
|
1202
|
+
for (const p of items) {
|
|
1203
|
+
const desc = p.description ? ` \u2013 ${p.description}` : "";
|
|
1204
|
+
lines.push(`- [${p.title}](${p.url})${desc}`);
|
|
1205
|
+
}
|
|
1206
|
+
lines.push("");
|
|
1207
|
+
}
|
|
1208
|
+
if (config.credit) {
|
|
1209
|
+
const mcpPart = mcpEndpoint ? ` \u2022 MCP endpoint: ${mcpEndpoint}` : "";
|
|
1210
|
+
lines.push(`**${CREDIT_LINE}**${mcpPart}`);
|
|
1211
|
+
lines.push("");
|
|
1212
|
+
}
|
|
1213
|
+
return lines.join("\n");
|
|
1214
|
+
}
|
|
1215
|
+
async function generateLlmsFullTxt(config, provider) {
|
|
1216
|
+
const pages = filterPublicPages(await provider.getPages(), config);
|
|
1217
|
+
const sections = [];
|
|
1218
|
+
sections.push(`# ${config.siteName || new URL(config.siteUrl).hostname} \u2014 Full Content`);
|
|
1219
|
+
sections.push("");
|
|
1220
|
+
sections.push(
|
|
1221
|
+
"> This file contains the full markdown content of all pages for AI consumption."
|
|
1222
|
+
);
|
|
1223
|
+
sections.push("");
|
|
1224
|
+
for (const page of pages) {
|
|
1225
|
+
const pageUrl = resolvePublicPageUrl(page.url, config);
|
|
1226
|
+
if (!pageUrl) continue;
|
|
1227
|
+
const content = await provider.getPageContent(pageUrl);
|
|
1228
|
+
if (!content || !isPublicPageContent(content, config)) continue;
|
|
1229
|
+
sections.push("---");
|
|
1230
|
+
sections.push("");
|
|
1231
|
+
sections.push(`## ${content.title}`);
|
|
1232
|
+
sections.push(`URL: ${content.url}`);
|
|
1233
|
+
if (content.lastModified) {
|
|
1234
|
+
sections.push(`Last modified: ${content.lastModified}`);
|
|
1235
|
+
}
|
|
1236
|
+
sections.push("");
|
|
1237
|
+
sections.push(content.markdown);
|
|
1238
|
+
sections.push("");
|
|
1239
|
+
}
|
|
1240
|
+
if (config.credit) {
|
|
1241
|
+
sections.push("---");
|
|
1242
|
+
sections.push("");
|
|
1243
|
+
sections.push(`**${CREDIT_LINE}**`);
|
|
1244
|
+
sections.push("");
|
|
1245
|
+
}
|
|
1246
|
+
return sections.join("\n");
|
|
1247
|
+
}
|
|
1248
|
+
function groupByType(pages) {
|
|
1249
|
+
const grouped = {};
|
|
1250
|
+
for (const page of pages) {
|
|
1251
|
+
const type = page.type || "page";
|
|
1252
|
+
if (!grouped[type]) grouped[type] = [];
|
|
1253
|
+
grouped[type].push(page);
|
|
1254
|
+
}
|
|
1255
|
+
return grouped;
|
|
1256
|
+
}
|
|
1257
|
+
function capitalize(s) {
|
|
1258
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/sitemap.ts
|
|
1262
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1263
|
+
var parser = new XMLParser({
|
|
1264
|
+
ignoreAttributes: false,
|
|
1265
|
+
attributeNamePrefix: "@_",
|
|
1266
|
+
processEntities: false
|
|
1267
|
+
// Defense in depth: block XXE even if lib defaults change
|
|
1268
|
+
});
|
|
1269
|
+
var MAX_SITEMAP_SIZE = 5 * 1024 * 1024;
|
|
1270
|
+
async function parseSitemap(sitemapUrl, maxPages = 500) {
|
|
1271
|
+
if (await isPrivateUrl(sitemapUrl)) {
|
|
1272
|
+
throw new Error("SSRF protection: cannot fetch private/internal URLs");
|
|
1273
|
+
}
|
|
1274
|
+
const entries = [];
|
|
1275
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1276
|
+
await fetchAndParseSitemap(sitemapUrl, entries, seen, maxPages, 0);
|
|
1277
|
+
return entries.slice(0, maxPages);
|
|
1278
|
+
}
|
|
1279
|
+
async function readBounded(response) {
|
|
1280
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
1281
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_SITEMAP_SIZE) {
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
const body = response.body;
|
|
1285
|
+
if (!body || typeof body.getReader !== "function") {
|
|
1286
|
+
const text = await response.text();
|
|
1287
|
+
return text.length > MAX_SITEMAP_SIZE ? null : text;
|
|
1288
|
+
}
|
|
1289
|
+
const reader = body.getReader();
|
|
1290
|
+
const chunks = [];
|
|
1291
|
+
let total = 0;
|
|
1292
|
+
try {
|
|
1293
|
+
for (; ; ) {
|
|
1294
|
+
const { done, value } = await reader.read();
|
|
1295
|
+
if (done) break;
|
|
1296
|
+
if (value) {
|
|
1297
|
+
total += value.byteLength;
|
|
1298
|
+
if (total > MAX_SITEMAP_SIZE) {
|
|
1299
|
+
await reader.cancel();
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
chunks.push(value);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
} catch {
|
|
1306
|
+
return null;
|
|
1307
|
+
}
|
|
1308
|
+
return new TextDecoder().decode(concatChunks(chunks, total));
|
|
1309
|
+
}
|
|
1310
|
+
function concatChunks(chunks, total) {
|
|
1311
|
+
const out = new Uint8Array(total);
|
|
1312
|
+
let offset = 0;
|
|
1313
|
+
for (const chunk of chunks) {
|
|
1314
|
+
out.set(chunk, offset);
|
|
1315
|
+
offset += chunk.byteLength;
|
|
1316
|
+
}
|
|
1317
|
+
return out;
|
|
1318
|
+
}
|
|
1319
|
+
async function fetchAndParseSitemap(url, entries, seen, maxPages, depth) {
|
|
1320
|
+
if (depth > 3 || entries.length >= maxPages) return;
|
|
1321
|
+
let response;
|
|
1322
|
+
try {
|
|
1323
|
+
response = await safeFetch(url, {
|
|
1324
|
+
headers: { "User-Agent": "CorsenContext/1.0 (+https://github.com/CorsenAI/corsen-context)" },
|
|
1325
|
+
timeout: 1e4
|
|
1326
|
+
});
|
|
1327
|
+
} catch {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (!response.ok) return;
|
|
1331
|
+
const xml = await readBounded(response);
|
|
1332
|
+
if (xml === null) return;
|
|
1333
|
+
const parsed = parser.parse(xml);
|
|
1334
|
+
if (parsed.sitemapindex?.sitemap) {
|
|
1335
|
+
const sitemaps = Array.isArray(parsed.sitemapindex.sitemap) ? parsed.sitemapindex.sitemap : [parsed.sitemapindex.sitemap];
|
|
1336
|
+
for (const sm of sitemaps) {
|
|
1337
|
+
if (entries.length >= maxPages) break;
|
|
1338
|
+
if (sm.loc) {
|
|
1339
|
+
await fetchAndParseSitemap(sm.loc, entries, seen, maxPages, depth + 1);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (parsed.urlset?.url) {
|
|
1345
|
+
const urls = Array.isArray(parsed.urlset.url) ? parsed.urlset.url : [parsed.urlset.url];
|
|
1346
|
+
for (const u of urls) {
|
|
1347
|
+
if (entries.length >= maxPages) break;
|
|
1348
|
+
if (!u.loc || seen.has(u.loc)) continue;
|
|
1349
|
+
if (await isPrivateUrl(u.loc)) continue;
|
|
1350
|
+
seen.add(u.loc);
|
|
1351
|
+
const priority = typeof u.priority === "string" ? parseFloat(u.priority) : u.priority;
|
|
1352
|
+
entries.push({
|
|
1353
|
+
url: u.loc,
|
|
1354
|
+
lastmod: u.lastmod,
|
|
1355
|
+
changefreq: u.changefreq,
|
|
1356
|
+
priority: typeof priority === "number" && Number.isFinite(priority) ? priority : void 0
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
async function discoverSitemap(siteUrl) {
|
|
1362
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
1363
|
+
const candidates = [
|
|
1364
|
+
`${base}/sitemap.xml`,
|
|
1365
|
+
`${base}/sitemap_index.xml`,
|
|
1366
|
+
`${base}/sitemap/sitemap.xml`
|
|
1367
|
+
];
|
|
1368
|
+
try {
|
|
1369
|
+
const robotsUrl = `${base}/robots.txt`;
|
|
1370
|
+
const res = await safeFetch(robotsUrl, { timeout: 5e3 });
|
|
1371
|
+
if (res.ok) {
|
|
1372
|
+
const text = await res.text();
|
|
1373
|
+
const match = text.match(/^Sitemap:\s*(.+)$/im);
|
|
1374
|
+
if (match?.[1]) {
|
|
1375
|
+
return match[1].trim();
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
} catch {
|
|
1379
|
+
}
|
|
1380
|
+
for (const url of candidates) {
|
|
1381
|
+
try {
|
|
1382
|
+
const res = await safeFetch(url, {
|
|
1383
|
+
method: "HEAD",
|
|
1384
|
+
timeout: 5e3
|
|
1385
|
+
});
|
|
1386
|
+
if (res.ok) return url;
|
|
1387
|
+
} catch {
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// src/converter.ts
|
|
1395
|
+
import * as cheerio from "cheerio";
|
|
1396
|
+
import TurndownService from "turndown";
|
|
1397
|
+
var turndown = new TurndownService({
|
|
1398
|
+
headingStyle: "atx",
|
|
1399
|
+
bulletListMarker: "-",
|
|
1400
|
+
codeBlockStyle: "fenced",
|
|
1401
|
+
emDelimiter: "*"
|
|
1402
|
+
});
|
|
1403
|
+
turndown.remove(["script", "style", "nav", "iframe", "noscript", "svg"]);
|
|
1404
|
+
var DANGEROUS_URL_SCHEME = /^\s*(?:javascript|vbscript|file|data):/i;
|
|
1405
|
+
function sanitizeUrls(markdown) {
|
|
1406
|
+
return markdown.replace(/(!?)\[([^\]]*)\]\(([^)]*)\)/g, (match, bang, label, url) => {
|
|
1407
|
+
if (!DANGEROUS_URL_SCHEME.test(url)) return match;
|
|
1408
|
+
return `[${label}](#)`;
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
turndown.addRule("pre", {
|
|
1412
|
+
filter: "pre",
|
|
1413
|
+
replacement(content, node) {
|
|
1414
|
+
const el = node;
|
|
1415
|
+
const code = el.textContent || content;
|
|
1416
|
+
return `
|
|
1417
|
+
\`\`\`
|
|
1418
|
+
${code.trim()}
|
|
1419
|
+
\`\`\`
|
|
1420
|
+
`;
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
function htmlToMarkdown(html) {
|
|
1424
|
+
const $ = cheerio.load(html);
|
|
1425
|
+
$(
|
|
1426
|
+
'nav, aside, body > header, body > footer, .sidebar, .menu, .navigation, .cookie-banner, .popup, .modal, #comments, .comments, .ad, .advertisement, [role="navigation"], [role="banner"], [role="complementary"]'
|
|
1427
|
+
).remove();
|
|
1428
|
+
const mainSelectors = [
|
|
1429
|
+
"main",
|
|
1430
|
+
"article",
|
|
1431
|
+
'[role="main"]',
|
|
1432
|
+
".entry-content",
|
|
1433
|
+
".post-content",
|
|
1434
|
+
".article-content",
|
|
1435
|
+
".page-content",
|
|
1436
|
+
"#content",
|
|
1437
|
+
".content"
|
|
1438
|
+
];
|
|
1439
|
+
let contentHtml = "";
|
|
1440
|
+
for (const sel of mainSelectors) {
|
|
1441
|
+
const el = $(sel).first();
|
|
1442
|
+
if (el.length && el.text().trim()) {
|
|
1443
|
+
contentHtml = el.html() || "";
|
|
1444
|
+
if (contentHtml) break;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
if (!contentHtml) {
|
|
1448
|
+
contentHtml = $("body").html() || html;
|
|
1449
|
+
}
|
|
1450
|
+
const markdown = turndown.turndown(contentHtml);
|
|
1451
|
+
return sanitizeUrls(
|
|
1452
|
+
markdown.replace(/\n{3,}/g, "\n\n").replace(/^\s+|\s+$/g, "").trim()
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
function extractMetadata(html) {
|
|
1456
|
+
const $ = cheerio.load(html);
|
|
1457
|
+
const meta = {};
|
|
1458
|
+
const title = $("title").first().text().trim();
|
|
1459
|
+
if (title) meta["title"] = title;
|
|
1460
|
+
const desc = $('meta[name="description"]').attr("content") || $('meta[property="og:description"]').attr("content");
|
|
1461
|
+
if (desc) meta["description"] = desc.trim();
|
|
1462
|
+
const ogTitle = $('meta[property="og:title"]').attr("content");
|
|
1463
|
+
if (ogTitle) meta["og:title"] = ogTitle.trim();
|
|
1464
|
+
const ogImage = $('meta[property="og:image"]').attr("content");
|
|
1465
|
+
if (ogImage) meta["og:image"] = ogImage.trim();
|
|
1466
|
+
const ogType = $('meta[property="og:type"]').attr("content");
|
|
1467
|
+
if (ogType) meta["og:type"] = ogType.trim();
|
|
1468
|
+
const canonical = $('link[rel="canonical"]').attr("href");
|
|
1469
|
+
if (canonical) meta["canonical"] = canonical.trim();
|
|
1470
|
+
const author = $('meta[name="author"]').attr("content");
|
|
1471
|
+
if (author) meta["author"] = author.trim();
|
|
1472
|
+
const published = $('meta[property="article:published_time"]').attr("content") || $("time[datetime]").first().attr("datetime");
|
|
1473
|
+
if (published) meta["published"] = published.trim();
|
|
1474
|
+
const modified = $('meta[property="article:modified_time"]').attr("content");
|
|
1475
|
+
if (modified) meta["modified"] = modified.trim();
|
|
1476
|
+
const lang = $("html").attr("lang");
|
|
1477
|
+
if (lang) meta["lang"] = lang.trim();
|
|
1478
|
+
return meta;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// src/redis-cache.ts
|
|
1482
|
+
var RedisCache = class {
|
|
1483
|
+
redis;
|
|
1484
|
+
prefix;
|
|
1485
|
+
constructor(redis, options) {
|
|
1486
|
+
this.redis = redis;
|
|
1487
|
+
this.prefix = options?.prefix || "corsen:cache:";
|
|
1488
|
+
}
|
|
1489
|
+
async get(key) {
|
|
1490
|
+
const raw = await this.redis.get(`${this.prefix}${key}`);
|
|
1491
|
+
if (!raw) return null;
|
|
1492
|
+
try {
|
|
1493
|
+
return JSON.parse(raw);
|
|
1494
|
+
} catch {
|
|
1495
|
+
await this.delete(key);
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
async set(key, value, ttl) {
|
|
1500
|
+
const serialized = JSON.stringify(value);
|
|
1501
|
+
const redisKey = `${this.prefix}${key}`;
|
|
1502
|
+
await this.redis.set(redisKey, serialized);
|
|
1503
|
+
if (ttl > 0) {
|
|
1504
|
+
await this.redis.expire(redisKey, ttl);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async delete(key) {
|
|
1508
|
+
await this.redis.del(`${this.prefix}${key}`);
|
|
1509
|
+
}
|
|
1510
|
+
async clear() {
|
|
1511
|
+
console.warn(
|
|
1512
|
+
`[corsen-context] RedisCache.clear() is a no-op. Use SCAN + DEL with prefix "${this.prefix}" to clear cached entries manually.`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
// src/redis-rate-limit.ts
|
|
1518
|
+
var RedisRateLimitStore = class {
|
|
1519
|
+
redis;
|
|
1520
|
+
prefix;
|
|
1521
|
+
windowMs;
|
|
1522
|
+
constructor(redis, options) {
|
|
1523
|
+
this.redis = redis;
|
|
1524
|
+
this.prefix = options?.prefix || "corsen:rl:";
|
|
1525
|
+
this.windowMs = options?.windowMs || 6e4;
|
|
1526
|
+
}
|
|
1527
|
+
async getTimestamps(key, windowStart) {
|
|
1528
|
+
const redisKey = `${this.prefix}${key}`;
|
|
1529
|
+
await this.redis.zremrangebyscore(redisKey, "-inf", windowStart);
|
|
1530
|
+
const members = await this.redis.zrangebyscore(redisKey, windowStart, "+inf");
|
|
1531
|
+
return members.map((m) => {
|
|
1532
|
+
const ts = parseFloat(m.split(":")[0]);
|
|
1533
|
+
return isNaN(ts) ? 0 : ts;
|
|
1534
|
+
}).filter((t) => t > 0);
|
|
1535
|
+
}
|
|
1536
|
+
async addTimestamp(key, timestamp) {
|
|
1537
|
+
const redisKey = `${this.prefix}${key}`;
|
|
1538
|
+
const member = `${timestamp}:${Math.random().toString(36).slice(2, 8)}`;
|
|
1539
|
+
await this.redis.zadd(redisKey, timestamp, member);
|
|
1540
|
+
const ttlSeconds = Math.ceil(this.windowMs / 1e3) + 1;
|
|
1541
|
+
await this.redis.expire(redisKey, ttlSeconds);
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Combined add-then-count. Records the hit first, prunes the window, then
|
|
1545
|
+
* returns the window and burst counts (both including the current request).
|
|
1546
|
+
* Fewer round-trips than getTimestamps()+addTimestamp() and add-first so a
|
|
1547
|
+
* rejected request still counts against the window.
|
|
1548
|
+
*/
|
|
1549
|
+
async hit(key, windowStart, burstWindowStart, now) {
|
|
1550
|
+
const redisKey = `${this.prefix}${key}`;
|
|
1551
|
+
const member = `${now}:${Math.random().toString(36).slice(2, 8)}`;
|
|
1552
|
+
await this.redis.zadd(redisKey, now, member);
|
|
1553
|
+
await this.redis.expire(redisKey, Math.ceil(this.windowMs / 1e3) + 1);
|
|
1554
|
+
await this.redis.zremrangebyscore(redisKey, "-inf", windowStart);
|
|
1555
|
+
const windowCount = await this.redis.zcard(redisKey);
|
|
1556
|
+
const burstMembers = await this.redis.zrangebyscore(redisKey, burstWindowStart, "+inf");
|
|
1557
|
+
return { windowCount, burstCount: burstMembers.length };
|
|
1558
|
+
}
|
|
1559
|
+
async cleanup() {
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
// src/providers.ts
|
|
1564
|
+
function makeSnippet(text, query) {
|
|
1565
|
+
const haystack = text.toLowerCase();
|
|
1566
|
+
const idx = haystack.indexOf(query.toLowerCase());
|
|
1567
|
+
if (idx === -1) return text.slice(0, 200).trim();
|
|
1568
|
+
const start = Math.max(0, idx - 80);
|
|
1569
|
+
return text.slice(start, start + 200).trim();
|
|
1570
|
+
}
|
|
1571
|
+
function createInMemoryProvider(pages) {
|
|
1572
|
+
const byUrl = new Map(pages.map((p) => [p.url, p]));
|
|
1573
|
+
return {
|
|
1574
|
+
async getPages() {
|
|
1575
|
+
return pages.map((p) => ({
|
|
1576
|
+
url: p.url,
|
|
1577
|
+
title: p.title,
|
|
1578
|
+
description: p.description,
|
|
1579
|
+
type: p.type || "page",
|
|
1580
|
+
lastModified: p.lastModified
|
|
1581
|
+
}));
|
|
1582
|
+
},
|
|
1583
|
+
async getPageContent(url) {
|
|
1584
|
+
return byUrl.get(url) ?? null;
|
|
1585
|
+
},
|
|
1586
|
+
async searchContent(query, limit) {
|
|
1587
|
+
const q = query.toLowerCase();
|
|
1588
|
+
const results = [];
|
|
1589
|
+
for (const p of pages) {
|
|
1590
|
+
const haystack = `${p.title}
|
|
1591
|
+
${p.description}
|
|
1592
|
+
${p.markdown}`.toLowerCase();
|
|
1593
|
+
if (!haystack.includes(q)) continue;
|
|
1594
|
+
results.push({
|
|
1595
|
+
url: p.url,
|
|
1596
|
+
title: p.title,
|
|
1597
|
+
description: p.description,
|
|
1598
|
+
snippet: makeSnippet(p.markdown || p.description || "", query),
|
|
1599
|
+
score: 1
|
|
1600
|
+
});
|
|
1601
|
+
if (results.length >= limit) break;
|
|
1602
|
+
}
|
|
1603
|
+
return results;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
function createSitemapProvider(siteUrl, options) {
|
|
1608
|
+
const maxPages = options?.maxPages ?? 100;
|
|
1609
|
+
let pagesCache = null;
|
|
1610
|
+
async function loadPages() {
|
|
1611
|
+
if (pagesCache) return pagesCache;
|
|
1612
|
+
const sitemapUrl = await discoverSitemap(siteUrl);
|
|
1613
|
+
if (!sitemapUrl) {
|
|
1614
|
+
pagesCache = [];
|
|
1615
|
+
return pagesCache;
|
|
1616
|
+
}
|
|
1617
|
+
const entries = await parseSitemap(sitemapUrl, maxPages);
|
|
1618
|
+
const pages = [];
|
|
1619
|
+
for (const entry of entries) {
|
|
1620
|
+
try {
|
|
1621
|
+
const res = await safeFetch(entry.url, { timeout: 1e4 });
|
|
1622
|
+
if (!res.ok) continue;
|
|
1623
|
+
const meta = extractMetadata(await res.text());
|
|
1624
|
+
pages.push({
|
|
1625
|
+
url: entry.url,
|
|
1626
|
+
title: meta.title || entry.url,
|
|
1627
|
+
description: meta.description || "",
|
|
1628
|
+
type: meta["og:type"] === "article" ? "post" : "page",
|
|
1629
|
+
lastModified: meta.modified || meta.published
|
|
1630
|
+
});
|
|
1631
|
+
} catch {
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
pagesCache = pages;
|
|
1636
|
+
return pages;
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
async getPages() {
|
|
1640
|
+
return loadPages();
|
|
1641
|
+
},
|
|
1642
|
+
async getPageContent(url) {
|
|
1643
|
+
if (await isPrivateUrl(url)) return null;
|
|
1644
|
+
try {
|
|
1645
|
+
const res = await safeFetch(url, { timeout: 1e4 });
|
|
1646
|
+
if (!res.ok) return null;
|
|
1647
|
+
const html = await res.text();
|
|
1648
|
+
const meta = extractMetadata(html);
|
|
1649
|
+
return {
|
|
1650
|
+
url,
|
|
1651
|
+
title: meta.title || url,
|
|
1652
|
+
description: meta.description || "",
|
|
1653
|
+
markdown: htmlToMarkdown(html),
|
|
1654
|
+
lastModified: meta.modified || meta.published,
|
|
1655
|
+
metadata: meta
|
|
1656
|
+
};
|
|
1657
|
+
} catch {
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
},
|
|
1661
|
+
async searchContent() {
|
|
1662
|
+
return [];
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// src/discovery.ts
|
|
1668
|
+
function absoluteEndpoint(config) {
|
|
1669
|
+
const base = config.siteUrl.replace(/\/$/, "");
|
|
1670
|
+
const endpoint = config.mcpEndpoint || "/v1/mcp";
|
|
1671
|
+
return /^https?:\/\//.test(endpoint) ? endpoint : `${base}${endpoint}`;
|
|
1672
|
+
}
|
|
1673
|
+
function generateRobotsTxt(config) {
|
|
1674
|
+
const lines = [`MCP: ${absoluteEndpoint(config)}`];
|
|
1675
|
+
if (config.sitemapUrl) lines.push(`Sitemap: ${config.sitemapUrl}`);
|
|
1676
|
+
return lines.join("\n") + "\n";
|
|
1677
|
+
}
|
|
1678
|
+
function generateWellKnownMcp(config) {
|
|
1679
|
+
return {
|
|
1680
|
+
mcpEndpoint: absoluteEndpoint(config),
|
|
1681
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
1682
|
+
transport: "http"
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
function mcpLinkTag(config) {
|
|
1686
|
+
return `<link rel="mcp" href="${absoluteEndpoint(config)}" />`;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// src/index.ts
|
|
1690
|
+
var CorsenContext = class {
|
|
1691
|
+
config;
|
|
1692
|
+
provider;
|
|
1693
|
+
cache;
|
|
1694
|
+
rateLimitStore;
|
|
1695
|
+
constructor(userConfig, provider, cache, rateLimitStore) {
|
|
1696
|
+
this.config = resolveConfig(userConfig);
|
|
1697
|
+
this.provider = provider;
|
|
1698
|
+
this.cache = cache || new MemoryCache();
|
|
1699
|
+
this.rateLimitStore = rateLimitStore || new MemoryRateLimitStore();
|
|
1700
|
+
}
|
|
1701
|
+
async generateLlmsTxt() {
|
|
1702
|
+
return generateLlmsTxt(this.config, this.provider);
|
|
1703
|
+
}
|
|
1704
|
+
async generateLlmsFullTxt() {
|
|
1705
|
+
return generateLlmsFullTxt(this.config, this.provider);
|
|
1706
|
+
}
|
|
1707
|
+
createMCPServer(options) {
|
|
1708
|
+
return new MCPServer(this.config, this.provider, {
|
|
1709
|
+
cache: this.cache,
|
|
1710
|
+
rateLimitStore: options?.rateLimitStore ?? this.rateLimitStore,
|
|
1711
|
+
logger: options?.logger
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
/** Drop the cached body for a single page URL (wire to CMS update/delete hooks). */
|
|
1715
|
+
async invalidatePage(url) {
|
|
1716
|
+
const pageUrl = resolvePublicPageUrl(url, this.config);
|
|
1717
|
+
if (pageUrl) await this.cache.delete(`page:${pageUrl}`);
|
|
1718
|
+
}
|
|
1719
|
+
/** Clear all cached MCP responses. Call after bulk content changes. */
|
|
1720
|
+
async clearCache() {
|
|
1721
|
+
await this.cache.clear();
|
|
1722
|
+
}
|
|
1723
|
+
async discoverSitemap(url) {
|
|
1724
|
+
return discoverSitemap(url || this.config.siteUrl);
|
|
1725
|
+
}
|
|
1726
|
+
async parseSitemap(sitemapUrl) {
|
|
1727
|
+
return parseSitemap(sitemapUrl, this.config.content.maxPages);
|
|
1728
|
+
}
|
|
1729
|
+
convertToMarkdown(html) {
|
|
1730
|
+
return htmlToMarkdown(html);
|
|
1731
|
+
}
|
|
1732
|
+
extractMetadata(html) {
|
|
1733
|
+
return extractMetadata(html);
|
|
1734
|
+
}
|
|
1735
|
+
getConfig() {
|
|
1736
|
+
return this.config;
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
export {
|
|
1740
|
+
API_VERSION,
|
|
1741
|
+
ApiKeyManager,
|
|
1742
|
+
CORSEN_CONTEXT_VERSION,
|
|
1743
|
+
CREDIT_LINE,
|
|
1744
|
+
CorsenContext,
|
|
1745
|
+
JSONRPC_ERRORS,
|
|
1746
|
+
MAX_BODY_SIZE,
|
|
1747
|
+
MAX_JSON_DEPTH,
|
|
1748
|
+
MCPServer,
|
|
1749
|
+
MCP_PROTOCOL_VERSION,
|
|
1750
|
+
MemoryCache,
|
|
1751
|
+
MemoryRateLimitStore,
|
|
1752
|
+
REQUEST_TIMEOUT_MS,
|
|
1753
|
+
RateLimiter,
|
|
1754
|
+
RedisCache,
|
|
1755
|
+
RedisRateLimitStore,
|
|
1756
|
+
SECURITY_HEADERS,
|
|
1757
|
+
buildRateLimitKey,
|
|
1758
|
+
corsenContextConfigSchema,
|
|
1759
|
+
createInMemoryProvider,
|
|
1760
|
+
createLogger,
|
|
1761
|
+
createSitemapProvider,
|
|
1762
|
+
discoverSitemap,
|
|
1763
|
+
extractClientIp,
|
|
1764
|
+
extractMetadata,
|
|
1765
|
+
filterPublicPages,
|
|
1766
|
+
filterPublicSearchResults,
|
|
1767
|
+
generateLlmsFullTxt,
|
|
1768
|
+
generateLlmsTxt,
|
|
1769
|
+
generateRobotsTxt,
|
|
1770
|
+
generateWellKnownMcp,
|
|
1771
|
+
getLogger,
|
|
1772
|
+
getPageParamsSchema,
|
|
1773
|
+
hashApiKey,
|
|
1774
|
+
htmlToMarkdown,
|
|
1775
|
+
isPrivateIp,
|
|
1776
|
+
isPrivateUrl,
|
|
1777
|
+
isPublicListItem,
|
|
1778
|
+
isPublicPageContent,
|
|
1779
|
+
listContentParamsSchema,
|
|
1780
|
+
mcpLinkTag,
|
|
1781
|
+
mcpLogger,
|
|
1782
|
+
parseSitemap,
|
|
1783
|
+
resolveConfig,
|
|
1784
|
+
resolvePublicPageUrl,
|
|
1785
|
+
safeFetch,
|
|
1786
|
+
searchParamsSchema,
|
|
1787
|
+
securityLogger,
|
|
1788
|
+
setLogger,
|
|
1789
|
+
validateApiKey,
|
|
1790
|
+
validateHost,
|
|
1791
|
+
validateOrigin
|
|
1792
|
+
};
|