@j0hanz/superfetch 2.4.2 → 2.4.4
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/dist/cache.d.ts +9 -4
- package/dist/cache.js +279 -276
- package/dist/crypto.js +4 -3
- package/dist/dom-noise-removal.js +355 -297
- package/dist/fetch.d.ts +13 -7
- package/dist/fetch.js +636 -690
- package/dist/http-native.js +535 -474
- package/dist/instructions.md +38 -27
- package/dist/language-detection.js +190 -153
- package/dist/markdown-cleanup.js +171 -158
- package/dist/mcp.js +174 -14
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +44 -0
- package/dist/session.js +144 -105
- package/dist/tasks.d.ts +37 -0
- package/dist/tasks.js +66 -0
- package/dist/tools.d.ts +8 -10
- package/dist/tools.js +168 -148
- package/dist/transform.d.ts +3 -1
- package/dist/transform.js +680 -778
- package/package.json +6 -6
package/dist/cache.js
CHANGED
|
@@ -6,29 +6,12 @@ import { getErrorMessage } from './errors.js';
|
|
|
6
6
|
import { stableStringify as stableJsonStringify } from './json.js';
|
|
7
7
|
import { logDebug, logWarn } from './observability.js';
|
|
8
8
|
import { isObject } from './type-guards.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
}
|
|
9
|
+
/* -------------------------------------------------------------------------------------------------
|
|
10
|
+
* Cached payload codec
|
|
11
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
27
12
|
function hasOptionalStringProperty(value, key) {
|
|
28
13
|
const prop = value[key];
|
|
29
|
-
|
|
30
|
-
return true;
|
|
31
|
-
return typeof prop === 'string';
|
|
14
|
+
return prop === undefined ? true : typeof prop === 'string';
|
|
32
15
|
}
|
|
33
16
|
function isCachedPayload(value) {
|
|
34
17
|
if (!isObject(value))
|
|
@@ -41,6 +24,34 @@ function isCachedPayload(value) {
|
|
|
41
24
|
return false;
|
|
42
25
|
return true;
|
|
43
26
|
}
|
|
27
|
+
class CachedPayloadCodec {
|
|
28
|
+
parse(raw) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
return isCachedPayload(parsed) ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
resolveContent(payload) {
|
|
38
|
+
if (typeof payload.markdown === 'string')
|
|
39
|
+
return payload.markdown;
|
|
40
|
+
if (typeof payload.content === 'string')
|
|
41
|
+
return payload.content;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const payloadCodec = new CachedPayloadCodec();
|
|
46
|
+
export function parseCachedPayload(raw) {
|
|
47
|
+
return payloadCodec.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
export function resolveCachedPayloadContent(payload) {
|
|
50
|
+
return payloadCodec.resolveContent(payload);
|
|
51
|
+
}
|
|
52
|
+
/* -------------------------------------------------------------------------------------------------
|
|
53
|
+
* Cache key codec (hashing + parsing + resource URI)
|
|
54
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
44
55
|
const CACHE_HASH = {
|
|
45
56
|
URL_HASH_LENGTH: 32,
|
|
46
57
|
VARY_HASH_LENGTH: 16,
|
|
@@ -64,50 +75,59 @@ function buildCacheKey(namespace, urlHash, varyHash) {
|
|
|
64
75
|
function getVaryHash(vary) {
|
|
65
76
|
if (!vary)
|
|
66
77
|
return undefined;
|
|
67
|
-
|
|
68
|
-
if (typeof vary === 'string') {
|
|
69
|
-
varyString = vary;
|
|
70
|
-
}
|
|
71
|
-
else {
|
|
72
|
-
varyString = stableStringify(vary);
|
|
73
|
-
}
|
|
78
|
+
const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
|
|
74
79
|
if (varyString === null)
|
|
75
80
|
return null;
|
|
76
81
|
if (!varyString)
|
|
77
82
|
return undefined;
|
|
78
83
|
return createHashFragment(varyString, CACHE_HASH.VARY_HASH_LENGTH);
|
|
79
84
|
}
|
|
85
|
+
function buildCacheResourceUri(namespace, urlHash) {
|
|
86
|
+
return `superfetch://cache/${namespace}/${urlHash}`;
|
|
87
|
+
}
|
|
88
|
+
class CacheKeyCodec {
|
|
89
|
+
create(namespace, url, vary) {
|
|
90
|
+
if (!namespace || !url)
|
|
91
|
+
return null;
|
|
92
|
+
const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
|
|
93
|
+
const varyHash = getVaryHash(vary);
|
|
94
|
+
if (varyHash === null)
|
|
95
|
+
return null;
|
|
96
|
+
return buildCacheKey(namespace, urlHash, varyHash);
|
|
97
|
+
}
|
|
98
|
+
parse(cacheKey) {
|
|
99
|
+
if (!cacheKey)
|
|
100
|
+
return null;
|
|
101
|
+
const [namespace, ...rest] = cacheKey.split(':');
|
|
102
|
+
const urlHash = rest.join(':');
|
|
103
|
+
if (!namespace || !urlHash)
|
|
104
|
+
return null;
|
|
105
|
+
return { namespace, urlHash };
|
|
106
|
+
}
|
|
107
|
+
toResourceUri(cacheKey) {
|
|
108
|
+
const parts = this.parse(cacheKey);
|
|
109
|
+
if (!parts)
|
|
110
|
+
return null;
|
|
111
|
+
return buildCacheResourceUri(parts.namespace, parts.urlHash);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cacheKeyCodec = new CacheKeyCodec();
|
|
80
115
|
export function createCacheKey(namespace, url, vary) {
|
|
81
|
-
|
|
82
|
-
return null;
|
|
83
|
-
const urlHash = createHashFragment(url, CACHE_HASH.URL_HASH_LENGTH);
|
|
84
|
-
const varyHash = getVaryHash(vary);
|
|
85
|
-
if (varyHash === null)
|
|
86
|
-
return null;
|
|
87
|
-
return buildCacheKey(namespace, urlHash, varyHash);
|
|
116
|
+
return cacheKeyCodec.create(namespace, url, vary);
|
|
88
117
|
}
|
|
89
118
|
export function parseCacheKey(cacheKey) {
|
|
90
|
-
|
|
91
|
-
return null;
|
|
92
|
-
const [namespace, ...rest] = cacheKey.split(':');
|
|
93
|
-
const urlHash = rest.join(':');
|
|
94
|
-
if (!namespace || !urlHash)
|
|
95
|
-
return null;
|
|
96
|
-
return { namespace, urlHash };
|
|
97
|
-
}
|
|
98
|
-
function buildCacheResourceUri(namespace, urlHash) {
|
|
99
|
-
return `superfetch://cache/${namespace}/${urlHash}`;
|
|
119
|
+
return cacheKeyCodec.parse(cacheKey);
|
|
100
120
|
}
|
|
101
121
|
export function toResourceUri(cacheKey) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
return cacheKeyCodec.toResourceUri(cacheKey);
|
|
123
|
+
}
|
|
124
|
+
/* -------------------------------------------------------------------------------------------------
|
|
125
|
+
* In-memory LRU cache (native)
|
|
126
|
+
* Contract:
|
|
127
|
+
* - Max entries: config.cache.maxKeys
|
|
128
|
+
* - TTL in ms: config.cache.ttl * 1000
|
|
129
|
+
* - Access does NOT extend TTL
|
|
130
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
111
131
|
class NativeLruCache {
|
|
112
132
|
max;
|
|
113
133
|
ttlMs;
|
|
@@ -135,10 +155,7 @@ class NativeLruCache {
|
|
|
135
155
|
return;
|
|
136
156
|
const now = Date.now();
|
|
137
157
|
this.entries.delete(key);
|
|
138
|
-
this.entries.set(key, {
|
|
139
|
-
value,
|
|
140
|
-
expiresAtMs: now + this.ttlMs,
|
|
141
|
-
});
|
|
158
|
+
this.entries.set(key, { value, expiresAtMs: now + this.ttlMs });
|
|
142
159
|
this.maybePurge(now);
|
|
143
160
|
while (this.entries.size > this.max) {
|
|
144
161
|
const oldestKey = this.entries.keys().next().value;
|
|
@@ -159,131 +176,141 @@ class NativeLruCache {
|
|
|
159
176
|
}
|
|
160
177
|
purgeExpired(now) {
|
|
161
178
|
for (const [key, entry] of this.entries) {
|
|
162
|
-
if (this.isExpired(entry, now))
|
|
179
|
+
if (this.isExpired(entry, now))
|
|
163
180
|
this.entries.delete(key);
|
|
164
|
-
}
|
|
165
181
|
}
|
|
166
182
|
}
|
|
167
183
|
isExpired(entry, now) {
|
|
168
184
|
return entry.expiresAtMs <= now;
|
|
169
185
|
}
|
|
170
186
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
updateListeners.delete(listener);
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
function notifyCacheUpdate(cacheKey) {
|
|
183
|
-
if (updateListeners.size === 0)
|
|
184
|
-
return;
|
|
185
|
-
const parts = parseCacheKey(cacheKey);
|
|
186
|
-
if (!parts)
|
|
187
|
-
return;
|
|
188
|
-
const event = { cacheKey, ...parts };
|
|
189
|
-
for (const listener of updateListeners) {
|
|
190
|
-
listener(event);
|
|
187
|
+
class InMemoryCacheStore {
|
|
188
|
+
cache = new NativeLruCache({
|
|
189
|
+
max: config.cache.maxKeys,
|
|
190
|
+
ttlMs: config.cache.ttl * 1000,
|
|
191
|
+
});
|
|
192
|
+
listeners = new Set();
|
|
193
|
+
isEnabled() {
|
|
194
|
+
return config.cache.enabled;
|
|
191
195
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (!isCacheReadable(cacheKey))
|
|
195
|
-
return undefined;
|
|
196
|
-
return runCacheOperation(cacheKey, 'Cache get error', () => contentCache.get(cacheKey));
|
|
197
|
-
}
|
|
198
|
-
function isCacheReadable(cacheKey) {
|
|
199
|
-
return config.cache.enabled && Boolean(cacheKey);
|
|
200
|
-
}
|
|
201
|
-
function isCacheWritable(cacheKey, content) {
|
|
202
|
-
return config.cache.enabled && Boolean(cacheKey) && Boolean(content);
|
|
203
|
-
}
|
|
204
|
-
function runCacheOperation(cacheKey, message, operation) {
|
|
205
|
-
try {
|
|
206
|
-
return operation();
|
|
196
|
+
keys() {
|
|
197
|
+
return [...this.cache.keys()];
|
|
207
198
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
return
|
|
199
|
+
onUpdate(listener) {
|
|
200
|
+
this.listeners.add(listener);
|
|
201
|
+
return () => this.listeners.delete(listener);
|
|
211
202
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
203
|
+
get(cacheKey) {
|
|
204
|
+
if (!this.isReadable(cacheKey))
|
|
205
|
+
return undefined;
|
|
206
|
+
return this.run(cacheKey, 'Cache get error', () => this.cache.get(cacheKey));
|
|
207
|
+
}
|
|
208
|
+
set(cacheKey, content, metadata) {
|
|
209
|
+
if (!this.isWritable(cacheKey, content))
|
|
210
|
+
return;
|
|
211
|
+
this.run(cacheKey, 'Cache set error', () => {
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
const expiresAtMs = now + config.cache.ttl * 1000; // preserve existing behavior
|
|
214
|
+
const entry = this.buildEntry(content, metadata, now, expiresAtMs);
|
|
215
|
+
this.cache.set(cacheKey, entry);
|
|
216
|
+
this.notify(cacheKey);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
isReadable(cacheKey) {
|
|
220
|
+
return config.cache.enabled && Boolean(cacheKey);
|
|
221
|
+
}
|
|
222
|
+
isWritable(cacheKey, content) {
|
|
223
|
+
return config.cache.enabled && Boolean(cacheKey) && Boolean(content);
|
|
224
|
+
}
|
|
225
|
+
run(cacheKey, message, operation) {
|
|
226
|
+
try {
|
|
227
|
+
return operation();
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.logError(message, cacheKey, error);
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
buildEntry(content, metadata, fetchedAtMs, expiresAtMs) {
|
|
235
|
+
return {
|
|
236
|
+
url: metadata.url,
|
|
220
237
|
content,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
fetchedAt: new Date(fetchedAtMs).toISOString(),
|
|
239
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
240
|
+
...(metadata.title === undefined ? {} : { title: metadata.title }),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
notify(cacheKey) {
|
|
244
|
+
if (this.listeners.size === 0)
|
|
245
|
+
return;
|
|
246
|
+
const parts = cacheKeyCodec.parse(cacheKey);
|
|
247
|
+
if (!parts)
|
|
248
|
+
return;
|
|
249
|
+
const event = { cacheKey, ...parts };
|
|
250
|
+
for (const listener of this.listeners)
|
|
251
|
+
listener(event);
|
|
252
|
+
}
|
|
253
|
+
logError(message, cacheKey, error) {
|
|
254
|
+
logWarn(message, {
|
|
255
|
+
key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
|
|
256
|
+
error: getErrorMessage(error),
|
|
224
257
|
});
|
|
225
|
-
|
|
226
|
-
});
|
|
258
|
+
}
|
|
227
259
|
}
|
|
228
|
-
|
|
229
|
-
|
|
260
|
+
const store = new InMemoryCacheStore();
|
|
261
|
+
export function onCacheUpdate(listener) {
|
|
262
|
+
return store.onUpdate(listener);
|
|
230
263
|
}
|
|
231
|
-
export function
|
|
232
|
-
return
|
|
264
|
+
export function get(cacheKey) {
|
|
265
|
+
return store.get(cacheKey);
|
|
233
266
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
url: metadata.url,
|
|
237
|
-
content,
|
|
238
|
-
fetchedAt: new Date(fetchedAtMs).toISOString(),
|
|
239
|
-
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
240
|
-
...(metadata.title === undefined ? {} : { title: metadata.title }),
|
|
241
|
-
};
|
|
267
|
+
export function set(cacheKey, content, metadata) {
|
|
268
|
+
store.set(cacheKey, content, metadata);
|
|
242
269
|
}
|
|
243
|
-
function
|
|
244
|
-
|
|
245
|
-
notifyCacheUpdate(cacheKey);
|
|
270
|
+
export function keys() {
|
|
271
|
+
return store.keys();
|
|
246
272
|
}
|
|
247
|
-
function
|
|
248
|
-
|
|
249
|
-
key: cacheKey.length > 100 ? cacheKey.slice(0, 100) : cacheKey,
|
|
250
|
-
error: getErrorMessage(error),
|
|
251
|
-
});
|
|
273
|
+
export function isEnabled() {
|
|
274
|
+
return store.isEnabled();
|
|
252
275
|
}
|
|
276
|
+
/* -------------------------------------------------------------------------------------------------
|
|
277
|
+
* MCP cached content resource (superfetch://cache/markdown/{urlHash})
|
|
278
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
253
279
|
const CACHE_NAMESPACE = 'markdown';
|
|
254
280
|
const HASH_PATTERN = /^[a-f0-9.]+$/i;
|
|
255
281
|
const INVALID_CACHE_PARAMS_MESSAGE = 'Invalid cache resource parameters';
|
|
256
282
|
function throwInvalidCacheParams() {
|
|
257
283
|
throw new McpError(ErrorCode.InvalidParams, INVALID_CACHE_PARAMS_MESSAGE);
|
|
258
284
|
}
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return
|
|
285
|
+
function resolveStringParam(value) {
|
|
286
|
+
return typeof value === 'string' ? value : null;
|
|
287
|
+
}
|
|
288
|
+
function isValidNamespace(namespace) {
|
|
289
|
+
return namespace === CACHE_NAMESPACE;
|
|
290
|
+
}
|
|
291
|
+
function isValidHash(hash) {
|
|
292
|
+
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
267
293
|
}
|
|
268
294
|
function requireRecordParams(value) {
|
|
269
|
-
if (!isObject(value))
|
|
295
|
+
if (!isObject(value))
|
|
270
296
|
throwInvalidCacheParams();
|
|
271
|
-
}
|
|
272
297
|
return value;
|
|
273
298
|
}
|
|
274
299
|
function requireParamString(params, key) {
|
|
275
|
-
const
|
|
276
|
-
const resolved = resolveStringParam(raw);
|
|
300
|
+
const resolved = resolveStringParam(params[key]);
|
|
277
301
|
if (!resolved) {
|
|
278
302
|
throw new McpError(ErrorCode.InvalidParams, 'Both namespace and urlHash parameters are required');
|
|
279
303
|
}
|
|
280
304
|
return resolved;
|
|
281
305
|
}
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
306
|
+
function resolveCacheParams(params) {
|
|
307
|
+
const parsed = requireRecordParams(params);
|
|
308
|
+
const namespace = requireParamString(parsed, 'namespace');
|
|
309
|
+
const urlHash = requireParamString(parsed, 'urlHash');
|
|
310
|
+
if (!isValidNamespace(namespace) || !isValidHash(urlHash)) {
|
|
311
|
+
throwInvalidCacheParams();
|
|
312
|
+
}
|
|
313
|
+
return { namespace, urlHash };
|
|
287
314
|
}
|
|
288
315
|
function isValidCacheResourceUri(uri) {
|
|
289
316
|
if (!uri)
|
|
@@ -307,9 +334,6 @@ function isValidCacheResourceUri(uri) {
|
|
|
307
334
|
return false;
|
|
308
335
|
return isValidNamespace(namespace) && isValidHash(urlHash);
|
|
309
336
|
}
|
|
310
|
-
function resolveStringParam(value) {
|
|
311
|
-
return typeof value === 'string' ? value : null;
|
|
312
|
-
}
|
|
313
337
|
function buildResourceEntry(namespace, urlHash) {
|
|
314
338
|
return {
|
|
315
339
|
name: `${namespace}:${urlHash}`,
|
|
@@ -321,7 +345,7 @@ function buildResourceEntry(namespace, urlHash) {
|
|
|
321
345
|
function listCachedResources() {
|
|
322
346
|
const resources = keys()
|
|
323
347
|
.map((key) => {
|
|
324
|
-
const parts =
|
|
348
|
+
const parts = cacheKeyCodec.parse(key);
|
|
325
349
|
if (parts?.namespace !== CACHE_NAMESPACE)
|
|
326
350
|
return null;
|
|
327
351
|
return buildResourceEntry(parts.namespace, parts.urlHash);
|
|
@@ -347,18 +371,13 @@ function attachInitializedGate(server) {
|
|
|
347
371
|
}
|
|
348
372
|
function getClientResourceCapabilities(server) {
|
|
349
373
|
const caps = server.server.getClientCapabilities();
|
|
350
|
-
if (!caps || !isObject(caps))
|
|
374
|
+
if (!caps || !isObject(caps))
|
|
351
375
|
return { listChanged: false, subscribe: false };
|
|
352
|
-
}
|
|
353
376
|
const { resources } = caps;
|
|
354
|
-
if (!isObject(resources))
|
|
377
|
+
if (!isObject(resources))
|
|
355
378
|
return { listChanged: false, subscribe: false };
|
|
356
|
-
}
|
|
357
379
|
const { listChanged, subscribe } = resources;
|
|
358
|
-
return {
|
|
359
|
-
listChanged: listChanged === true,
|
|
360
|
-
subscribe: subscribe === true,
|
|
361
|
-
};
|
|
380
|
+
return { listChanged: listChanged === true, subscribe: subscribe === true };
|
|
362
381
|
}
|
|
363
382
|
function registerResourceSubscriptionHandlers(server) {
|
|
364
383
|
const subscriptions = new Set();
|
|
@@ -395,34 +414,37 @@ function notifyResourceUpdate(server, uri, subscriptions) {
|
|
|
395
414
|
});
|
|
396
415
|
});
|
|
397
416
|
}
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
417
|
+
function requireCacheEntry(cacheKey) {
|
|
418
|
+
const cached = get(cacheKey);
|
|
419
|
+
if (!cached) {
|
|
420
|
+
throw new McpError(-32002, `Content not found in cache for key: ${cacheKey}`);
|
|
421
|
+
}
|
|
422
|
+
return cached;
|
|
423
|
+
}
|
|
424
|
+
function buildMarkdownContentResponse(uri, content) {
|
|
425
|
+
const payload = payloadCodec.parse(content);
|
|
426
|
+
const resolvedContent = payload ? payloadCodec.resolveContent(payload) : null;
|
|
427
|
+
if (!resolvedContent) {
|
|
428
|
+
throw new McpError(ErrorCode.InternalError, 'Cached markdown content is missing');
|
|
429
|
+
}
|
|
430
|
+
return {
|
|
431
|
+
contents: [
|
|
432
|
+
{ uri: uri.href, mimeType: 'text/markdown', text: resolvedContent },
|
|
433
|
+
],
|
|
434
|
+
};
|
|
403
435
|
}
|
|
404
436
|
function buildCachedContentResponse(uri, cacheKey) {
|
|
405
437
|
const cached = requireCacheEntry(cacheKey);
|
|
406
438
|
return buildMarkdownContentResponse(uri, cached.content);
|
|
407
439
|
}
|
|
408
|
-
function registerCacheContentResource(server,
|
|
440
|
+
function registerCacheContentResource(server, serverIcons) {
|
|
409
441
|
server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
|
|
410
442
|
list: listCachedResources,
|
|
411
443
|
}), {
|
|
412
444
|
title: 'Cached Content',
|
|
413
445
|
description: 'Access previously fetched web content from cache. Namespace: markdown. UrlHash: SHA-256 hash of the URL.',
|
|
414
446
|
mimeType: 'text/markdown',
|
|
415
|
-
...(
|
|
416
|
-
? {
|
|
417
|
-
icons: [
|
|
418
|
-
{
|
|
419
|
-
src: serverIcon,
|
|
420
|
-
mimeType: 'image/svg+xml',
|
|
421
|
-
sizes: ['any'],
|
|
422
|
-
},
|
|
423
|
-
],
|
|
424
|
-
}
|
|
425
|
-
: {}),
|
|
447
|
+
...(serverIcons ? { icons: serverIcons } : {}),
|
|
426
448
|
}, (uri, params) => {
|
|
427
449
|
const { namespace, urlHash } = resolveCacheParams(params);
|
|
428
450
|
const cacheKey = `${namespace}:${urlHash}`;
|
|
@@ -435,10 +457,9 @@ function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
|
|
|
435
457
|
return;
|
|
436
458
|
const { listChanged, subscribe } = getClientResourceCapabilities(server);
|
|
437
459
|
if (subscribe) {
|
|
438
|
-
const resourceUri = toResourceUri(cacheKey);
|
|
439
|
-
if (resourceUri)
|
|
460
|
+
const resourceUri = cacheKeyCodec.toResourceUri(cacheKey);
|
|
461
|
+
if (resourceUri)
|
|
440
462
|
notifyResourceUpdate(server, resourceUri, subscriptions);
|
|
441
|
-
}
|
|
442
463
|
}
|
|
443
464
|
if (listChanged) {
|
|
444
465
|
server.sendResourceListChanged();
|
|
@@ -446,76 +467,26 @@ function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
|
|
|
446
467
|
});
|
|
447
468
|
appendServerOnClose(server, unsubscribe);
|
|
448
469
|
}
|
|
449
|
-
function
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
return cached;
|
|
455
|
-
}
|
|
456
|
-
function buildMarkdownContentResponse(uri, content) {
|
|
457
|
-
const payload = parseCachedPayload(content);
|
|
458
|
-
const resolvedContent = payload ? resolveCachedPayloadContent(payload) : null;
|
|
459
|
-
if (!resolvedContent) {
|
|
460
|
-
throw new McpError(ErrorCode.InternalError, 'Cached markdown content is missing');
|
|
461
|
-
}
|
|
462
|
-
return {
|
|
463
|
-
contents: [
|
|
464
|
-
{
|
|
465
|
-
uri: uri.href,
|
|
466
|
-
mimeType: 'text/markdown',
|
|
467
|
-
text: resolvedContent,
|
|
468
|
-
},
|
|
469
|
-
],
|
|
470
|
-
};
|
|
471
|
-
}
|
|
472
|
-
function parseDownloadParams(namespace, hash) {
|
|
473
|
-
const resolvedNamespace = resolveStringParam(namespace);
|
|
474
|
-
const resolvedHash = resolveStringParam(hash);
|
|
475
|
-
if (!resolvedNamespace || !resolvedHash)
|
|
476
|
-
return null;
|
|
477
|
-
if (!isValidNamespace(resolvedNamespace))
|
|
478
|
-
return null;
|
|
479
|
-
if (!isValidHash(resolvedHash))
|
|
480
|
-
return null;
|
|
481
|
-
return { namespace: resolvedNamespace, hash: resolvedHash };
|
|
482
|
-
}
|
|
483
|
-
function buildCacheKeyFromParams(params) {
|
|
484
|
-
return `${params.namespace}:${params.hash}`;
|
|
485
|
-
}
|
|
486
|
-
function sendJsonError(res, status, error, code) {
|
|
487
|
-
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
488
|
-
res.end(JSON.stringify({ error, code }));
|
|
489
|
-
}
|
|
490
|
-
function respondBadRequest(res, message) {
|
|
491
|
-
sendJsonError(res, 400, message, 'BAD_REQUEST');
|
|
492
|
-
}
|
|
493
|
-
function respondNotFound(res) {
|
|
494
|
-
sendJsonError(res, 404, 'Content not found or expired', 'NOT_FOUND');
|
|
495
|
-
}
|
|
496
|
-
function respondServiceUnavailable(res) {
|
|
497
|
-
sendJsonError(res, 503, 'Download service is disabled', 'SERVICE_UNAVAILABLE');
|
|
498
|
-
}
|
|
499
|
-
export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
|
|
500
|
-
const fromUrl = extractFilenameFromUrl(url);
|
|
501
|
-
if (fromUrl)
|
|
502
|
-
return sanitizeFilename(fromUrl, extension);
|
|
503
|
-
if (title) {
|
|
504
|
-
const fromTitle = slugifyTitle(title);
|
|
505
|
-
if (fromTitle)
|
|
506
|
-
return sanitizeFilename(fromTitle, extension);
|
|
507
|
-
}
|
|
508
|
-
if (hashFallback) {
|
|
509
|
-
return `${hashFallback.substring(0, 16)}${extension}`;
|
|
510
|
-
}
|
|
511
|
-
return `download-${Date.now()}${extension}`;
|
|
470
|
+
export function registerCachedContentResource(server, serverIcons) {
|
|
471
|
+
const isInitialized = attachInitializedGate(server);
|
|
472
|
+
const subscriptions = registerResourceSubscriptionHandlers(server);
|
|
473
|
+
registerCacheContentResource(server, serverIcons);
|
|
474
|
+
registerCacheUpdateSubscription(server, subscriptions, isInitialized);
|
|
512
475
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
476
|
+
/* -------------------------------------------------------------------------------------------------
|
|
477
|
+
* Filename generation
|
|
478
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
479
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
480
|
+
const UNSAFE_CHARS_REGEX = /[<>:"/\\|?*]|\p{C}/gu;
|
|
481
|
+
const WHITESPACE_REGEX = /\s+/g;
|
|
482
|
+
function trimHyphens(value) {
|
|
483
|
+
let start = 0;
|
|
484
|
+
let end = value.length;
|
|
485
|
+
while (start < end && value[start] === '-')
|
|
486
|
+
start += 1;
|
|
487
|
+
while (end > start && value[end - 1] === '-')
|
|
488
|
+
end -= 1;
|
|
489
|
+
return value.slice(start, end);
|
|
519
490
|
}
|
|
520
491
|
function stripCommonPageExtension(segment) {
|
|
521
492
|
return segment.replace(/\.(html?|php|aspx?|jsp)$/i, '');
|
|
@@ -528,6 +499,12 @@ function normalizeUrlFilenameSegment(segment) {
|
|
|
528
499
|
return null;
|
|
529
500
|
return cleaned;
|
|
530
501
|
}
|
|
502
|
+
function getLastPathSegment(url) {
|
|
503
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
504
|
+
if (segments.length === 0)
|
|
505
|
+
return null;
|
|
506
|
+
return segments[segments.length - 1] ?? null;
|
|
507
|
+
}
|
|
531
508
|
function extractFilenameFromUrl(url) {
|
|
532
509
|
try {
|
|
533
510
|
const urlObj = new URL(url);
|
|
@@ -540,20 +517,6 @@ function extractFilenameFromUrl(url) {
|
|
|
540
517
|
return null;
|
|
541
518
|
}
|
|
542
519
|
}
|
|
543
|
-
const MAX_FILENAME_LENGTH = 200;
|
|
544
|
-
const UNSAFE_CHARS_REGEX = /[<>:"/\\|?*]|\p{C}/gu;
|
|
545
|
-
const WHITESPACE_REGEX = /\s+/g;
|
|
546
|
-
function trimHyphens(value) {
|
|
547
|
-
let start = 0;
|
|
548
|
-
let end = value.length;
|
|
549
|
-
while (start < end && value[start] === '-') {
|
|
550
|
-
start += 1;
|
|
551
|
-
}
|
|
552
|
-
while (end > start && value[end - 1] === '-') {
|
|
553
|
-
end -= 1;
|
|
554
|
-
}
|
|
555
|
-
return value.slice(start, end);
|
|
556
|
-
}
|
|
557
520
|
function slugifyTitle(title) {
|
|
558
521
|
const slug = title
|
|
559
522
|
.toLowerCase()
|
|
@@ -569,27 +532,52 @@ function sanitizeFilename(name, extension) {
|
|
|
569
532
|
.replace(UNSAFE_CHARS_REGEX, '')
|
|
570
533
|
.replace(WHITESPACE_REGEX, '-')
|
|
571
534
|
.trim();
|
|
572
|
-
// Truncate if too long
|
|
573
535
|
const maxBase = MAX_FILENAME_LENGTH - extension.length;
|
|
574
536
|
if (sanitized.length > maxBase) {
|
|
575
537
|
sanitized = sanitized.substring(0, maxBase);
|
|
576
538
|
}
|
|
577
539
|
return `${sanitized}${extension}`;
|
|
578
540
|
}
|
|
579
|
-
function
|
|
580
|
-
const
|
|
581
|
-
if (
|
|
541
|
+
export function generateSafeFilename(url, title, hashFallback, extension = '.md') {
|
|
542
|
+
const fromUrl = extractFilenameFromUrl(url);
|
|
543
|
+
if (fromUrl)
|
|
544
|
+
return sanitizeFilename(fromUrl, extension);
|
|
545
|
+
if (title) {
|
|
546
|
+
const fromTitle = slugifyTitle(title);
|
|
547
|
+
if (fromTitle)
|
|
548
|
+
return sanitizeFilename(fromTitle, extension);
|
|
549
|
+
}
|
|
550
|
+
if (hashFallback) {
|
|
551
|
+
return `${hashFallback.substring(0, 16)}${extension}`;
|
|
552
|
+
}
|
|
553
|
+
return `download-${Date.now()}${extension}`;
|
|
554
|
+
}
|
|
555
|
+
function parseDownloadParams(namespace, hash) {
|
|
556
|
+
const resolvedNamespace = resolveStringParam(namespace);
|
|
557
|
+
const resolvedHash = resolveStringParam(hash);
|
|
558
|
+
if (!resolvedNamespace || !resolvedHash)
|
|
582
559
|
return null;
|
|
583
|
-
|
|
584
|
-
if (!content)
|
|
560
|
+
if (!isValidNamespace(resolvedNamespace))
|
|
585
561
|
return null;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
return {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
562
|
+
if (!isValidHash(resolvedHash))
|
|
563
|
+
return null;
|
|
564
|
+
return { namespace: resolvedNamespace, hash: resolvedHash };
|
|
565
|
+
}
|
|
566
|
+
function buildCacheKeyFromParams(params) {
|
|
567
|
+
return `${params.namespace}:${params.hash}`;
|
|
568
|
+
}
|
|
569
|
+
function sendJsonError(res, status, error, code) {
|
|
570
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
571
|
+
res.end(JSON.stringify({ error, code }));
|
|
572
|
+
}
|
|
573
|
+
function respondBadRequest(res, message) {
|
|
574
|
+
sendJsonError(res, 400, message, 'BAD_REQUEST');
|
|
575
|
+
}
|
|
576
|
+
function respondNotFound(res) {
|
|
577
|
+
sendJsonError(res, 404, 'Content not found or expired', 'NOT_FOUND');
|
|
578
|
+
}
|
|
579
|
+
function respondServiceUnavailable(res) {
|
|
580
|
+
sendJsonError(res, 503, 'Download service is disabled', 'SERVICE_UNAVAILABLE');
|
|
593
581
|
}
|
|
594
582
|
function buildContentDisposition(fileName) {
|
|
595
583
|
const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
@@ -603,6 +591,21 @@ function sendDownloadPayload(res, payload) {
|
|
|
603
591
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
604
592
|
res.end(payload.content);
|
|
605
593
|
}
|
|
594
|
+
function resolveDownloadPayload(params, cacheEntry) {
|
|
595
|
+
const payload = payloadCodec.parse(cacheEntry.content);
|
|
596
|
+
if (!payload)
|
|
597
|
+
return null;
|
|
598
|
+
const content = payloadCodec.resolveContent(payload);
|
|
599
|
+
if (!content)
|
|
600
|
+
return null;
|
|
601
|
+
const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
|
|
602
|
+
const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
|
|
603
|
+
return {
|
|
604
|
+
content,
|
|
605
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
606
|
+
fileName,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
606
609
|
export function handleDownload(res, namespace, hash) {
|
|
607
610
|
if (!config.cache.enabled) {
|
|
608
611
|
respondServiceUnavailable(res);
|