@j0hanz/superfetch 2.0.1 → 2.1.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/README.md +121 -38
- package/dist/cache.d.ts +42 -0
- package/dist/cache.js +674 -0
- package/dist/config/env-parsers.d.ts +1 -0
- package/dist/config/env-parsers.js +12 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +10 -3
- package/dist/config/types/content.d.ts +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.js +274 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +32 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +28 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +930 -0
- package/dist/http/base-middleware.d.ts +7 -0
- package/dist/http/base-middleware.js +143 -0
- package/dist/http/cors.d.ts +0 -5
- package/dist/http/cors.js +0 -6
- package/dist/http/download-routes.js +6 -2
- package/dist/http/error-handler.d.ts +2 -0
- package/dist/http/error-handler.js +55 -0
- package/dist/http/mcp-routes.js +2 -2
- package/dist/http/mcp-sessions.d.ts +3 -5
- package/dist/http/mcp-sessions.js +8 -8
- package/dist/http/server-tuning.d.ts +9 -0
- package/dist/http/server-tuning.js +45 -0
- package/dist/http/server.d.ts +0 -10
- package/dist/http/server.js +33 -333
- package/dist/http.d.ts +86 -0
- package/dist/http.js +1507 -0
- package/dist/index.js +3 -3
- package/dist/instructions.md +96 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +104 -0
- package/dist/observability.d.ts +16 -0
- package/dist/observability.js +78 -0
- package/dist/server.js +20 -5
- package/dist/services/cache.d.ts +1 -1
- package/dist/services/context.d.ts +2 -0
- package/dist/services/context.js +3 -0
- package/dist/services/extractor.d.ts +1 -0
- package/dist/services/extractor.js +28 -2
- package/dist/services/fetcher.d.ts +2 -0
- package/dist/services/fetcher.js +35 -14
- package/dist/services/logger.js +4 -1
- package/dist/services/telemetry.d.ts +19 -0
- package/dist/services/telemetry.js +43 -0
- package/dist/services/transform-worker-pool.d.ts +10 -3
- package/dist/services/transform-worker-pool.js +213 -184
- package/dist/tools/handlers/fetch-url.tool.js +8 -6
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/schemas.d.ts +2 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/utils/content-transform-core.d.ts +5 -0
- package/dist/tools/utils/content-transform-core.js +180 -0
- package/dist/tools/utils/content-transform-workers.d.ts +1 -0
- package/dist/tools/utils/content-transform-workers.js +1 -0
- package/dist/tools/utils/content-transform.d.ts +3 -5
- package/dist/tools/utils/content-transform.js +35 -148
- package/dist/tools/utils/raw-markdown.js +15 -1
- package/dist/tools.d.ts +109 -0
- package/dist/tools.js +434 -0
- package/dist/transform.d.ts +69 -0
- package/dist/transform.js +1814 -0
- package/dist/transformers/markdown.d.ts +4 -1
- package/dist/transformers/markdown.js +182 -53
- package/dist/utils/cancellation.d.ts +1 -0
- package/dist/utils/cancellation.js +18 -0
- package/dist/utils/code-language.d.ts +0 -9
- package/dist/utils/code-language.js +5 -5
- package/dist/utils/host-normalizer.d.ts +1 -0
- package/dist/utils/host-normalizer.js +37 -0
- package/dist/utils/url-redactor.d.ts +1 -0
- package/dist/utils/url-redactor.js +13 -0
- package/dist/utils/url-validator.js +8 -5
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +3 -0
- package/dist/workers/transform-worker.js +80 -38
- package/package.json +10 -9
package/dist/cache.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
2
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { sha256Hex } from './crypto.js';
|
|
6
|
+
import { getErrorMessage } from './errors.js';
|
|
7
|
+
import { logDebug, logWarn } from './observability.js';
|
|
8
|
+
import { isRecord } from './utils.js';
|
|
9
|
+
export function parseCachedPayload(raw) {
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
return isCachedPayload(parsed) ? parsed : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function resolveCachedPayloadContent(payload) {
|
|
19
|
+
if (typeof payload.markdown === 'string') {
|
|
20
|
+
return payload.markdown;
|
|
21
|
+
}
|
|
22
|
+
if (typeof payload.content === 'string') {
|
|
23
|
+
return payload.content;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
function hasOptionalStringProperty(value, key) {
|
|
28
|
+
const prop = value[key];
|
|
29
|
+
if (prop === undefined)
|
|
30
|
+
return true;
|
|
31
|
+
return typeof prop === 'string';
|
|
32
|
+
}
|
|
33
|
+
function isCachedPayload(value) {
|
|
34
|
+
if (!isRecord(value))
|
|
35
|
+
return false;
|
|
36
|
+
if (!hasOptionalStringProperty(value, 'content'))
|
|
37
|
+
return false;
|
|
38
|
+
if (!hasOptionalStringProperty(value, 'markdown'))
|
|
39
|
+
return false;
|
|
40
|
+
if (!hasOptionalStringProperty(value, 'title'))
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const CACHE_HASH = {
|
|
45
|
+
URL_HASH_LENGTH: 16,
|
|
46
|
+
VARY_HASH_LENGTH: 12,
|
|
47
|
+
};
|
|
48
|
+
const CACHE_VARY_LIMITS = {
|
|
49
|
+
MAX_STRING_LENGTH: 4096,
|
|
50
|
+
MAX_KEYS: 64,
|
|
51
|
+
MAX_ARRAY_LENGTH: 64,
|
|
52
|
+
MAX_DEPTH: 6,
|
|
53
|
+
MAX_NODES: 512,
|
|
54
|
+
};
|
|
55
|
+
function bumpStableStringifyNodeCount(state) {
|
|
56
|
+
state.nodes += 1;
|
|
57
|
+
return state.nodes <= CACHE_VARY_LIMITS.MAX_NODES;
|
|
58
|
+
}
|
|
59
|
+
function stableStringifyPrimitive(value) {
|
|
60
|
+
if (value === null || value === undefined) {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
const json = JSON.stringify(value);
|
|
64
|
+
return typeof json === 'string' ? json : '';
|
|
65
|
+
}
|
|
66
|
+
function stableStringifyArray(value, state) {
|
|
67
|
+
if (value.length > CACHE_VARY_LIMITS.MAX_ARRAY_LENGTH) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const parts = ['['];
|
|
71
|
+
let length = 1;
|
|
72
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
73
|
+
if (index > 0) {
|
|
74
|
+
parts.push(',');
|
|
75
|
+
length += 1;
|
|
76
|
+
if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const entry = stableStringifyInner(value[index], state);
|
|
80
|
+
if (entry === null)
|
|
81
|
+
return null;
|
|
82
|
+
parts.push(entry);
|
|
83
|
+
length += entry.length;
|
|
84
|
+
if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
parts.push(']');
|
|
88
|
+
length += 1;
|
|
89
|
+
return length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : parts.join('');
|
|
90
|
+
}
|
|
91
|
+
function stableStringifyRecord(value, state) {
|
|
92
|
+
const keys = Object.keys(value);
|
|
93
|
+
if (keys.length > CACHE_VARY_LIMITS.MAX_KEYS) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
keys.sort((a, b) => a.localeCompare(b));
|
|
97
|
+
const parts = ['{'];
|
|
98
|
+
let length = 1;
|
|
99
|
+
let isFirst = true;
|
|
100
|
+
for (const key of keys) {
|
|
101
|
+
const entryValue = value[key];
|
|
102
|
+
if (entryValue === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const encodedValue = stableStringifyInner(entryValue, state);
|
|
105
|
+
if (encodedValue === null)
|
|
106
|
+
return null;
|
|
107
|
+
const entry = `${JSON.stringify(key)}:${encodedValue}`;
|
|
108
|
+
if (!isFirst) {
|
|
109
|
+
parts.push(',');
|
|
110
|
+
length += 1;
|
|
111
|
+
if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
parts.push(entry);
|
|
115
|
+
length += entry.length;
|
|
116
|
+
if (length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH)
|
|
117
|
+
return null;
|
|
118
|
+
isFirst = false;
|
|
119
|
+
}
|
|
120
|
+
parts.push('}');
|
|
121
|
+
length += 1;
|
|
122
|
+
return length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : parts.join('');
|
|
123
|
+
}
|
|
124
|
+
function stableStringifyObject(value, state) {
|
|
125
|
+
if (state.stack.has(value)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (state.depth >= CACHE_VARY_LIMITS.MAX_DEPTH) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
state.stack.add(value);
|
|
132
|
+
state.depth += 1;
|
|
133
|
+
try {
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
return stableStringifyArray(value, state);
|
|
136
|
+
}
|
|
137
|
+
return isRecord(value) ? stableStringifyRecord(value, state) : null;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
state.depth -= 1;
|
|
141
|
+
state.stack.delete(value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function stableStringifyInner(value, state) {
|
|
145
|
+
if (!bumpStableStringifyNodeCount(state)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (value === null || value === undefined) {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
if (typeof value !== 'object') {
|
|
152
|
+
return stableStringifyPrimitive(value);
|
|
153
|
+
}
|
|
154
|
+
return stableStringifyObject(value, state);
|
|
155
|
+
}
|
|
156
|
+
function stableStringify(value) {
|
|
157
|
+
const state = {
|
|
158
|
+
depth: 0,
|
|
159
|
+
nodes: 0,
|
|
160
|
+
stack: new WeakSet(),
|
|
161
|
+
};
|
|
162
|
+
return stableStringifyInner(value, state);
|
|
163
|
+
}
|
|
164
|
+
function createHashFragment(input, length) {
|
|
165
|
+
return sha256Hex(input).substring(0, length);
|
|
166
|
+
}
|
|
167
|
+
function buildCacheKey(namespace, urlHash, varyHash) {
|
|
168
|
+
return varyHash
|
|
169
|
+
? `${namespace}:${urlHash}.${varyHash}`
|
|
170
|
+
: `${namespace}:${urlHash}`;
|
|
171
|
+
}
|
|
172
|
+
function getVaryHash(vary) {
|
|
173
|
+
if (!vary)
|
|
174
|
+
return undefined;
|
|
175
|
+
let varyString;
|
|
176
|
+
if (typeof vary === 'string') {
|
|
177
|
+
varyString =
|
|
178
|
+
vary.length > CACHE_VARY_LIMITS.MAX_STRING_LENGTH ? null : vary;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
varyString = stableStringify(vary);
|
|
182
|
+
}
|
|
183
|
+
if (varyString === null)
|
|
184
|
+
return null;
|
|
185
|
+
if (!varyString)
|
|
186
|
+
return undefined;
|
|
187
|
+
return createHashFragment(varyString, CACHE_HASH.VARY_HASH_LENGTH);
|
|
188
|
+
}
|
|
189
|
+
export function createCacheKey(namespace, url, vary) {
|
|
190
|
+
if (!namespace || !url)
|
|
191
|
+
return null;
|
|
192
|
+
const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
|
|
193
|
+
const varyHash = getVaryHash(vary);
|
|
194
|
+
if (varyHash === null)
|
|
195
|
+
return null;
|
|
196
|
+
return buildCacheKey(namespace, urlHash, varyHash);
|
|
197
|
+
}
|
|
198
|
+
export function parseCacheKey(cacheKey) {
|
|
199
|
+
if (!cacheKey)
|
|
200
|
+
return null;
|
|
201
|
+
const [namespace, ...rest] = cacheKey.split(':');
|
|
202
|
+
const urlHash = rest.join(':');
|
|
203
|
+
if (!namespace || !urlHash)
|
|
204
|
+
return null;
|
|
205
|
+
return { namespace, urlHash };
|
|
206
|
+
}
|
|
207
|
+
export function toResourceUri(cacheKey) {
|
|
208
|
+
const parts = parseCacheKey(cacheKey);
|
|
209
|
+
if (!parts)
|
|
210
|
+
return null;
|
|
211
|
+
return `superfetch://cache/${parts.namespace}/${parts.urlHash}`;
|
|
212
|
+
}
|
|
213
|
+
const contentCache = new Map();
|
|
214
|
+
let cleanupController = null;
|
|
215
|
+
const updateListeners = new Set();
|
|
216
|
+
export function onCacheUpdate(listener) {
|
|
217
|
+
updateListeners.add(listener);
|
|
218
|
+
return () => {
|
|
219
|
+
updateListeners.delete(listener);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function notifyCacheUpdate(cacheKey) {
|
|
223
|
+
if (updateListeners.size === 0)
|
|
224
|
+
return;
|
|
225
|
+
const parts = parseCacheKey(cacheKey);
|
|
226
|
+
if (!parts)
|
|
227
|
+
return;
|
|
228
|
+
const event = { cacheKey, ...parts };
|
|
229
|
+
for (const listener of updateListeners) {
|
|
230
|
+
listener(event);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function startCleanupLoop() {
|
|
234
|
+
if (cleanupController)
|
|
235
|
+
return;
|
|
236
|
+
cleanupController = new AbortController();
|
|
237
|
+
void runCleanupLoop(cleanupController.signal).catch((error) => {
|
|
238
|
+
if (error instanceof Error && error.name !== 'AbortError') {
|
|
239
|
+
logWarn('Cache cleanup loop failed', { error: getErrorMessage(error) });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
async function runCleanupLoop(signal) {
|
|
244
|
+
const intervalMs = Math.floor(config.cache.ttl * 1000);
|
|
245
|
+
for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
|
|
246
|
+
signal,
|
|
247
|
+
ref: false,
|
|
248
|
+
})) {
|
|
249
|
+
enforceCacheLimits(getNow());
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function enforceCacheLimits(now) {
|
|
253
|
+
for (const [key, item] of contentCache.entries()) {
|
|
254
|
+
if (now > item.expiresAt) {
|
|
255
|
+
contentCache.delete(key);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
trimCacheToMaxKeys();
|
|
259
|
+
}
|
|
260
|
+
export function get(cacheKey) {
|
|
261
|
+
if (!isCacheReadable(cacheKey))
|
|
262
|
+
return undefined;
|
|
263
|
+
return runCacheOperation(cacheKey, 'Cache get error', () => readCacheEntry(cacheKey));
|
|
264
|
+
}
|
|
265
|
+
function isCacheReadable(cacheKey) {
|
|
266
|
+
return config.cache.enabled && Boolean(cacheKey);
|
|
267
|
+
}
|
|
268
|
+
function isCacheWritable(cacheKey, content) {
|
|
269
|
+
return config.cache.enabled && Boolean(cacheKey) && Boolean(content);
|
|
270
|
+
}
|
|
271
|
+
function runCacheOperation(cacheKey, message, operation) {
|
|
272
|
+
try {
|
|
273
|
+
return operation();
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
logCacheError(message, cacheKey, error);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function readCacheEntry(cacheKey) {
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
return readCacheItem(cacheKey, now)?.entry;
|
|
283
|
+
}
|
|
284
|
+
function isExpired(item, now) {
|
|
285
|
+
return now > item.expiresAt;
|
|
286
|
+
}
|
|
287
|
+
function readCacheItem(cacheKey, now) {
|
|
288
|
+
const item = contentCache.get(cacheKey);
|
|
289
|
+
if (!item)
|
|
290
|
+
return undefined;
|
|
291
|
+
if (isExpired(item, now)) {
|
|
292
|
+
contentCache.delete(cacheKey);
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return item;
|
|
296
|
+
}
|
|
297
|
+
export function set(cacheKey, content, metadata) {
|
|
298
|
+
if (!isCacheWritable(cacheKey, content))
|
|
299
|
+
return;
|
|
300
|
+
runCacheOperation(cacheKey, 'Cache set error', () => {
|
|
301
|
+
startCleanupLoop();
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const expiresAtMs = now + config.cache.ttl * 1000;
|
|
304
|
+
const entry = buildCacheEntry({
|
|
305
|
+
content,
|
|
306
|
+
metadata,
|
|
307
|
+
fetchedAtMs: now,
|
|
308
|
+
expiresAtMs,
|
|
309
|
+
});
|
|
310
|
+
persistCacheEntry(cacheKey, entry, expiresAtMs);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
export function keys() {
|
|
314
|
+
return Array.from(contentCache.keys());
|
|
315
|
+
}
|
|
316
|
+
export function isEnabled() {
|
|
317
|
+
return config.cache.enabled;
|
|
318
|
+
}
|
|
319
|
+
function buildCacheEntry({ content, metadata, fetchedAtMs, expiresAtMs, }) {
|
|
320
|
+
return {
|
|
321
|
+
url: metadata.url,
|
|
322
|
+
content,
|
|
323
|
+
fetchedAt: new Date(fetchedAtMs).toISOString(),
|
|
324
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
325
|
+
...(metadata.title === undefined ? {} : { title: metadata.title }),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function persistCacheEntry(cacheKey, entry, expiresAtMs) {
|
|
329
|
+
contentCache.set(cacheKey, { entry, expiresAt: expiresAtMs });
|
|
330
|
+
trimCacheToMaxKeys();
|
|
331
|
+
notifyCacheUpdate(cacheKey);
|
|
332
|
+
}
|
|
333
|
+
function trimCacheToMaxKeys() {
|
|
334
|
+
if (contentCache.size <= config.cache.maxKeys)
|
|
335
|
+
return;
|
|
336
|
+
removeOldestEntries(contentCache.size - config.cache.maxKeys);
|
|
337
|
+
}
|
|
338
|
+
function removeOldestEntries(count) {
|
|
339
|
+
const iterator = contentCache.keys();
|
|
340
|
+
for (let removed = 0; removed < count; removed += 1) {
|
|
341
|
+
const next = iterator.next();
|
|
342
|
+
if (next.done)
|
|
343
|
+
break;
|
|
344
|
+
contentCache.delete(next.value);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function logCacheError(message, cacheKey, error) {
|
|
348
|
+
logWarn(message, {
|
|
349
|
+
key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
|
|
350
|
+
error: getErrorMessage(error),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const CACHE_NAMESPACE = 'markdown';
|
|
354
|
+
const HASH_PATTERN = /^[a-f0-9.]+$/i;
|
|
355
|
+
function resolveCacheParams(params) {
|
|
356
|
+
const parsed = requireRecordParams(params);
|
|
357
|
+
const namespace = requireParamString(parsed, 'namespace');
|
|
358
|
+
const urlHash = requireParamString(parsed, 'urlHash');
|
|
359
|
+
if (!isValidNamespace(namespace) || !isValidHash(urlHash)) {
|
|
360
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid cache resource parameters');
|
|
361
|
+
}
|
|
362
|
+
return { namespace, urlHash };
|
|
363
|
+
}
|
|
364
|
+
function requireRecordParams(value) {
|
|
365
|
+
if (!isRecord(value)) {
|
|
366
|
+
throw new McpError(ErrorCode.InvalidParams, 'Invalid cache resource parameters');
|
|
367
|
+
}
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
function requireParamString(params, key) {
|
|
371
|
+
const raw = params[key];
|
|
372
|
+
const resolved = resolveStringParam(raw);
|
|
373
|
+
if (!resolved) {
|
|
374
|
+
throw new McpError(ErrorCode.InvalidParams, 'Both namespace and urlHash parameters are required');
|
|
375
|
+
}
|
|
376
|
+
return resolved;
|
|
377
|
+
}
|
|
378
|
+
function isValidNamespace(namespace) {
|
|
379
|
+
return namespace === CACHE_NAMESPACE;
|
|
380
|
+
}
|
|
381
|
+
function isValidHash(hash) {
|
|
382
|
+
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
383
|
+
}
|
|
384
|
+
function resolveStringParam(value) {
|
|
385
|
+
return typeof value === 'string' ? value : null;
|
|
386
|
+
}
|
|
387
|
+
function buildResourceEntry(namespace, urlHash) {
|
|
388
|
+
return {
|
|
389
|
+
name: `${namespace}:${urlHash}`,
|
|
390
|
+
uri: `superfetch://cache/${namespace}/${urlHash}`,
|
|
391
|
+
description: `Cached content entry for ${namespace}`,
|
|
392
|
+
mimeType: 'text/markdown',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function listCachedResources() {
|
|
396
|
+
const resources = keys()
|
|
397
|
+
.map((key) => {
|
|
398
|
+
const parts = parseCacheKey(key);
|
|
399
|
+
if (parts?.namespace !== CACHE_NAMESPACE)
|
|
400
|
+
return null;
|
|
401
|
+
return buildResourceEntry(parts.namespace, parts.urlHash);
|
|
402
|
+
})
|
|
403
|
+
.filter((entry) => entry !== null);
|
|
404
|
+
return { resources };
|
|
405
|
+
}
|
|
406
|
+
function appendServerOnClose(server, handler) {
|
|
407
|
+
const previousOnClose = server.server.onclose;
|
|
408
|
+
server.server.onclose = () => {
|
|
409
|
+
previousOnClose?.();
|
|
410
|
+
handler();
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function registerResourceSubscriptionHandlers(server) {
|
|
414
|
+
const subscriptions = new Set();
|
|
415
|
+
server.server.setRequestHandler(SubscribeRequestSchema, (request) => {
|
|
416
|
+
subscriptions.add(request.params.uri);
|
|
417
|
+
return {};
|
|
418
|
+
});
|
|
419
|
+
server.server.setRequestHandler(UnsubscribeRequestSchema, (request) => {
|
|
420
|
+
subscriptions.delete(request.params.uri);
|
|
421
|
+
return {};
|
|
422
|
+
});
|
|
423
|
+
appendServerOnClose(server, () => {
|
|
424
|
+
subscriptions.clear();
|
|
425
|
+
});
|
|
426
|
+
return subscriptions;
|
|
427
|
+
}
|
|
428
|
+
function notifyResourceUpdate(server, uri, subscriptions) {
|
|
429
|
+
if (!server.isConnected())
|
|
430
|
+
return;
|
|
431
|
+
if (!subscriptions.has(uri))
|
|
432
|
+
return;
|
|
433
|
+
void server.server.sendResourceUpdated({ uri }).catch((error) => {
|
|
434
|
+
logWarn('Failed to send resource update notification', {
|
|
435
|
+
uri,
|
|
436
|
+
error: getErrorMessage(error),
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
export function registerCachedContentResource(server) {
|
|
441
|
+
const subscriptions = registerResourceSubscriptionHandlers(server);
|
|
442
|
+
registerCacheContentResource(server);
|
|
443
|
+
registerCacheUpdateSubscription(server, subscriptions);
|
|
444
|
+
}
|
|
445
|
+
function buildCachedContentResponse(uri, cacheKey) {
|
|
446
|
+
const cached = requireCacheEntry(cacheKey);
|
|
447
|
+
return buildMarkdownContentResponse(uri, cached.content);
|
|
448
|
+
}
|
|
449
|
+
function registerCacheContentResource(server) {
|
|
450
|
+
server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
|
|
451
|
+
list: listCachedResources,
|
|
452
|
+
}), {
|
|
453
|
+
title: 'Cached Content',
|
|
454
|
+
description: 'Access previously fetched web content from cache. Namespace: markdown. UrlHash: SHA-256 hash of the URL.',
|
|
455
|
+
mimeType: 'text/plain',
|
|
456
|
+
}, (uri, params) => {
|
|
457
|
+
const { namespace, urlHash } = resolveCacheParams(params);
|
|
458
|
+
const cacheKey = `${namespace}:${urlHash}`;
|
|
459
|
+
return buildCachedContentResponse(uri, cacheKey);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function registerCacheUpdateSubscription(server, subscriptions) {
|
|
463
|
+
const unsubscribe = onCacheUpdate(({ cacheKey }) => {
|
|
464
|
+
const resourceUri = toResourceUri(cacheKey);
|
|
465
|
+
if (!resourceUri)
|
|
466
|
+
return;
|
|
467
|
+
notifyResourceUpdate(server, resourceUri, subscriptions);
|
|
468
|
+
if (server.isConnected()) {
|
|
469
|
+
server.sendResourceListChanged();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
appendServerOnClose(server, unsubscribe);
|
|
473
|
+
}
|
|
474
|
+
function requireCacheEntry(cacheKey) {
|
|
475
|
+
const cached = get(cacheKey);
|
|
476
|
+
if (!cached) {
|
|
477
|
+
throw new McpError(-32002, `Content not found in cache for key: ${cacheKey}`);
|
|
478
|
+
}
|
|
479
|
+
return cached;
|
|
480
|
+
}
|
|
481
|
+
function buildMarkdownContentResponse(uri, content) {
|
|
482
|
+
const payload = parseCachedPayload(content);
|
|
483
|
+
const resolvedContent = payload ? resolveCachedPayloadContent(payload) : null;
|
|
484
|
+
if (!resolvedContent) {
|
|
485
|
+
throw new McpError(ErrorCode.InternalError, 'Cached markdown content is missing');
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
contents: [
|
|
489
|
+
{
|
|
490
|
+
uri: uri.href,
|
|
491
|
+
mimeType: 'text/markdown',
|
|
492
|
+
text: resolvedContent,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function validateNamespace(namespace) {
|
|
498
|
+
return namespace === 'markdown';
|
|
499
|
+
}
|
|
500
|
+
function validateHash(hash) {
|
|
501
|
+
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
502
|
+
}
|
|
503
|
+
function isSingleParam(value) {
|
|
504
|
+
return typeof value === 'string';
|
|
505
|
+
}
|
|
506
|
+
function parseDownloadParams(req) {
|
|
507
|
+
const { namespace, hash } = req.params;
|
|
508
|
+
if (!isSingleParam(namespace) || !isSingleParam(hash))
|
|
509
|
+
return null;
|
|
510
|
+
if (!namespace || !hash)
|
|
511
|
+
return null;
|
|
512
|
+
if (!validateNamespace(namespace))
|
|
513
|
+
return null;
|
|
514
|
+
if (!validateHash(hash))
|
|
515
|
+
return null;
|
|
516
|
+
return { namespace, hash };
|
|
517
|
+
}
|
|
518
|
+
function buildCacheKeyFromParams(params) {
|
|
519
|
+
return `${params.namespace}:${params.hash}`;
|
|
520
|
+
}
|
|
521
|
+
function respondBadRequest(res, message) {
|
|
522
|
+
res.status(400).json({
|
|
523
|
+
error: message,
|
|
524
|
+
code: 'BAD_REQUEST',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
function respondNotFound(res) {
|
|
528
|
+
res.status(404).json({
|
|
529
|
+
error: 'Content not found or expired',
|
|
530
|
+
code: 'NOT_FOUND',
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
function respondServiceUnavailable(res) {
|
|
534
|
+
res.status(503).json({
|
|
535
|
+
error: 'Download service is disabled',
|
|
536
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
|
|
540
|
+
const fromUrl = extractFilenameFromUrl(url);
|
|
541
|
+
if (fromUrl)
|
|
542
|
+
return sanitizeFilename(fromUrl, extension);
|
|
543
|
+
if (title) {
|
|
544
|
+
const fromTitle = slugifyTitle(title);
|
|
545
|
+
if (fromTitle)
|
|
546
|
+
return sanitizeFilename(fromTitle, extension);
|
|
547
|
+
}
|
|
548
|
+
if (hashFallback) {
|
|
549
|
+
return `${hashFallback.substring(0, 16)}${extension}`;
|
|
550
|
+
}
|
|
551
|
+
return `download-${Date.now()}${extension}`;
|
|
552
|
+
}
|
|
553
|
+
function getLastPathSegment(url) {
|
|
554
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
555
|
+
if (segments.length === 0)
|
|
556
|
+
return null;
|
|
557
|
+
const lastSegment = segments[segments.length - 1];
|
|
558
|
+
return lastSegment ?? null;
|
|
559
|
+
}
|
|
560
|
+
function stripCommonPageExtension(segment) {
|
|
561
|
+
return segment.replace(/\.(html?|php|aspx?|jsp)$/i, '');
|
|
562
|
+
}
|
|
563
|
+
function normalizeUrlFilenameSegment(segment) {
|
|
564
|
+
const cleaned = stripCommonPageExtension(segment);
|
|
565
|
+
if (!cleaned)
|
|
566
|
+
return null;
|
|
567
|
+
if (cleaned === 'index')
|
|
568
|
+
return null;
|
|
569
|
+
return cleaned;
|
|
570
|
+
}
|
|
571
|
+
function extractFilenameFromUrl(url) {
|
|
572
|
+
try {
|
|
573
|
+
const urlObj = new URL(url);
|
|
574
|
+
const lastSegment = getLastPathSegment(urlObj);
|
|
575
|
+
if (!lastSegment)
|
|
576
|
+
return null;
|
|
577
|
+
return normalizeUrlFilenameSegment(lastSegment);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
584
|
+
const UNSAFE_CHARS_REGEX = /[<>:"/\\|?*]|\p{C}/gu;
|
|
585
|
+
const WHITESPACE_REGEX = /\s+/g;
|
|
586
|
+
function trimHyphens(value) {
|
|
587
|
+
let start = 0;
|
|
588
|
+
let end = value.length;
|
|
589
|
+
while (start < end && value[start] === '-') {
|
|
590
|
+
start += 1;
|
|
591
|
+
}
|
|
592
|
+
while (end > start && value[end - 1] === '-') {
|
|
593
|
+
end -= 1;
|
|
594
|
+
}
|
|
595
|
+
return value.slice(start, end);
|
|
596
|
+
}
|
|
597
|
+
function slugifyTitle(title) {
|
|
598
|
+
const slug = title
|
|
599
|
+
.toLowerCase()
|
|
600
|
+
.trim()
|
|
601
|
+
.replace(UNSAFE_CHARS_REGEX, '')
|
|
602
|
+
.replace(WHITESPACE_REGEX, '-')
|
|
603
|
+
.replace(/-+/g, '-');
|
|
604
|
+
const trimmed = trimHyphens(slug);
|
|
605
|
+
return trimmed || null;
|
|
606
|
+
}
|
|
607
|
+
function sanitizeFilename(name, extension) {
|
|
608
|
+
let sanitized = name
|
|
609
|
+
.replace(UNSAFE_CHARS_REGEX, '')
|
|
610
|
+
.replace(WHITESPACE_REGEX, '-')
|
|
611
|
+
.trim();
|
|
612
|
+
// Truncate if too long
|
|
613
|
+
const maxBase = MAX_FILENAME_LENGTH - extension.length;
|
|
614
|
+
if (sanitized.length > maxBase) {
|
|
615
|
+
sanitized = sanitized.substring(0, maxBase);
|
|
616
|
+
}
|
|
617
|
+
return `${sanitized}${extension}`;
|
|
618
|
+
}
|
|
619
|
+
function resolveDownloadPayload(params, cacheEntry) {
|
|
620
|
+
const payload = parseCachedPayload(cacheEntry.content);
|
|
621
|
+
if (!payload)
|
|
622
|
+
return null;
|
|
623
|
+
const content = resolveCachedPayloadContent(payload);
|
|
624
|
+
if (!content)
|
|
625
|
+
return null;
|
|
626
|
+
const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
|
|
627
|
+
const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
|
|
628
|
+
return {
|
|
629
|
+
content,
|
|
630
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
631
|
+
fileName,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
function buildContentDisposition(fileName) {
|
|
635
|
+
const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
636
|
+
return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
|
|
637
|
+
}
|
|
638
|
+
function sendDownloadPayload(res, payload) {
|
|
639
|
+
const disposition = buildContentDisposition(payload.fileName);
|
|
640
|
+
res.setHeader('Content-Type', payload.contentType);
|
|
641
|
+
res.setHeader('Content-Disposition', disposition);
|
|
642
|
+
res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
|
|
643
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
644
|
+
res.send(payload.content);
|
|
645
|
+
}
|
|
646
|
+
function handleDownload(req, res) {
|
|
647
|
+
if (!config.cache.enabled) {
|
|
648
|
+
respondServiceUnavailable(res);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const params = parseDownloadParams(req);
|
|
652
|
+
if (!params) {
|
|
653
|
+
respondBadRequest(res, 'Invalid namespace or hash format');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const cacheKey = buildCacheKeyFromParams(params);
|
|
657
|
+
const cacheEntry = get(cacheKey);
|
|
658
|
+
if (!cacheEntry) {
|
|
659
|
+
logDebug('Download request for missing cache key', { cacheKey });
|
|
660
|
+
respondNotFound(res);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const payload = resolveDownloadPayload(params, cacheEntry);
|
|
664
|
+
if (!payload) {
|
|
665
|
+
logDebug('Download payload unavailable', { cacheKey });
|
|
666
|
+
respondNotFound(res);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
logDebug('Serving download', { cacheKey, fileName: payload.fileName });
|
|
670
|
+
sendDownloadPayload(res, payload);
|
|
671
|
+
}
|
|
672
|
+
export function registerDownloadRoutes(app) {
|
|
673
|
+
app.get('/mcp/downloads/:namespace/:hash', handleDownload);
|
|
674
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LogLevel } from './types/runtime.js';
|
|
2
2
|
export declare function parseInteger(envValue: string | undefined, defaultValue: number, min?: number, max?: number): number;
|
|
3
|
+
export declare function parseOptionalInteger(envValue: string | undefined, min?: number, max?: number): number | undefined;
|
|
3
4
|
export declare function parseBoolean(envValue: string | undefined, defaultValue: boolean): boolean;
|
|
4
5
|
export declare function parseList(envValue: string | undefined): string[];
|
|
5
6
|
export declare function parseUrlEnv(value: string | undefined, name: string): URL | undefined;
|
|
@@ -45,6 +45,18 @@ export function parseInteger(envValue, defaultValue, min, max) {
|
|
|
45
45
|
return defaultValue;
|
|
46
46
|
return parsed;
|
|
47
47
|
}
|
|
48
|
+
export function parseOptionalInteger(envValue, min, max) {
|
|
49
|
+
if (!envValue)
|
|
50
|
+
return undefined;
|
|
51
|
+
const parsed = parseInt(envValue, 10);
|
|
52
|
+
if (Number.isNaN(parsed))
|
|
53
|
+
return undefined;
|
|
54
|
+
if (isBelowMin(parsed, min))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (isAboveMax(parsed, max))
|
|
57
|
+
return undefined;
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
48
60
|
export function parseBoolean(envValue, defaultValue) {
|
|
49
61
|
if (!envValue)
|
|
50
62
|
return defaultValue;
|
package/dist/config/index.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export declare const config: {
|
|
|
10
10
|
sessionTtlMs: number;
|
|
11
11
|
sessionInitTimeoutMs: number;
|
|
12
12
|
maxSessions: number;
|
|
13
|
+
http: {
|
|
14
|
+
headersTimeoutMs: number | undefined;
|
|
15
|
+
requestTimeoutMs: number | undefined;
|
|
16
|
+
keepAliveTimeoutMs: number | undefined;
|
|
17
|
+
shutdownCloseIdleConnections: boolean;
|
|
18
|
+
shutdownCloseAllConnections: boolean;
|
|
19
|
+
};
|
|
13
20
|
};
|
|
14
21
|
fetcher: {
|
|
15
22
|
timeout: number;
|