@j0hanz/superfetch 2.5.2 → 2.6.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.
Files changed (46) hide show
  1. package/README.md +356 -223
  2. package/dist/assets/logo.svg +24837 -24835
  3. package/dist/cache.d.ts +28 -20
  4. package/dist/cache.js +292 -514
  5. package/dist/config.d.ts +41 -7
  6. package/dist/config.js +298 -148
  7. package/dist/crypto.js +25 -12
  8. package/dist/dom-noise-removal.js +379 -421
  9. package/dist/errors.d.ts +2 -2
  10. package/dist/errors.js +25 -8
  11. package/dist/fetch.d.ts +18 -16
  12. package/dist/fetch.js +1132 -526
  13. package/dist/host-normalization.js +40 -10
  14. package/dist/http-native.js +628 -287
  15. package/dist/index.js +67 -7
  16. package/dist/instructions.md +44 -30
  17. package/dist/ip-blocklist.d.ts +8 -0
  18. package/dist/ip-blocklist.js +65 -0
  19. package/dist/json.js +14 -9
  20. package/dist/language-detection.d.ts +2 -11
  21. package/dist/language-detection.js +289 -280
  22. package/dist/markdown-cleanup.d.ts +0 -1
  23. package/dist/markdown-cleanup.js +391 -429
  24. package/dist/mcp-validator.js +4 -2
  25. package/dist/mcp.js +184 -135
  26. package/dist/observability.js +89 -21
  27. package/dist/resources.js +16 -6
  28. package/dist/server-tuning.d.ts +2 -0
  29. package/dist/server-tuning.js +25 -23
  30. package/dist/session.d.ts +1 -0
  31. package/dist/session.js +41 -33
  32. package/dist/tasks.d.ts +2 -0
  33. package/dist/tasks.js +91 -9
  34. package/dist/timer-utils.d.ts +5 -0
  35. package/dist/timer-utils.js +20 -0
  36. package/dist/tools.d.ts +28 -5
  37. package/dist/tools.js +317 -183
  38. package/dist/transform-types.d.ts +5 -1
  39. package/dist/transform.d.ts +3 -2
  40. package/dist/transform.js +1138 -421
  41. package/dist/type-guards.d.ts +1 -0
  42. package/dist/type-guards.js +7 -0
  43. package/dist/workers/transform-child.d.ts +1 -0
  44. package/dist/workers/transform-child.js +118 -0
  45. package/dist/workers/transform-worker.js +87 -78
  46. package/package.json +21 -13
package/dist/cache.js CHANGED
@@ -1,61 +1,46 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { posix as pathPosix } from 'node:path';
3
+ import { z } from 'zod';
1
4
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
5
  import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
6
  import { config } from './config.js';
4
7
  import { sha256Hex } from './crypto.js';
5
8
  import { getErrorMessage } from './errors.js';
6
9
  import { stableStringify as stableJsonStringify } from './json.js';
7
- import { logDebug, logWarn } from './observability.js';
8
- import { isObject } from './type-guards.js';
10
+ import { logWarn } from './observability.js';
9
11
  /* -------------------------------------------------------------------------------------------------
10
- * Cached payload codec
12
+ * Schemas & Types
11
13
  * ------------------------------------------------------------------------------------------------- */
12
- function hasOptionalStringProperty(value, key) {
13
- const prop = value[key];
14
- return prop === undefined ? true : typeof prop === 'string';
15
- }
16
- function isCachedPayload(value) {
17
- if (!isObject(value))
18
- return false;
19
- if (!hasOptionalStringProperty(value, 'content'))
20
- return false;
21
- if (!hasOptionalStringProperty(value, 'markdown'))
22
- return false;
23
- if (!hasOptionalStringProperty(value, 'title'))
24
- return false;
25
- return true;
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
- }
14
+ const CacheNamespace = z.literal('markdown');
15
+ const HashString = z
16
+ .string()
17
+ .regex(/^[a-f0-9.]+$/i)
18
+ .min(8)
19
+ .max(64);
20
+ const CachedPayloadSchema = z.strictObject({
21
+ content: z.string().optional(),
22
+ markdown: z.string().optional(),
23
+ title: z.string().optional(),
24
+ });
25
+ /* -------------------------------------------------------------------------------------------------
26
+ * Core: Cache Key Logic
27
+ * ------------------------------------------------------------------------------------------------- */
28
+ const CACHE_CONSTANTS = {
29
+ URL_HASH_LENGTH: 32,
30
+ VARY_HASH_LENGTH: 16,
31
+ };
32
+ export function parseCachedPayload(raw) {
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ return CachedPayloadSchema.parse(parsed);
36
36
  }
37
- resolveContent(payload) {
38
- if (typeof payload.markdown === 'string')
39
- return payload.markdown;
40
- if (typeof payload.content === 'string')
41
- return payload.content;
37
+ catch {
42
38
  return null;
43
39
  }
44
40
  }
45
- const payloadCodec = new CachedPayloadCodec();
46
- export function parseCachedPayload(raw) {
47
- return payloadCodec.parse(raw);
48
- }
49
41
  export function resolveCachedPayloadContent(payload) {
50
- return payloadCodec.resolveContent(payload);
42
+ return payload.markdown ?? payload.content ?? null;
51
43
  }
52
- /* -------------------------------------------------------------------------------------------------
53
- * Cache key codec (hashing + parsing + resource URI)
54
- * ------------------------------------------------------------------------------------------------- */
55
- const CACHE_HASH = {
56
- URL_HASH_LENGTH: 32,
57
- VARY_HASH_LENGTH: 16,
58
- };
59
44
  function stableStringify(value) {
60
45
  try {
61
46
  return stableJsonStringify(value);
@@ -72,183 +57,122 @@ function buildCacheKey(namespace, urlHash, varyHash) {
72
57
  ? `${namespace}:${urlHash}.${varyHash}`
73
58
  : `${namespace}:${urlHash}`;
74
59
  }
75
- function getVaryHash(vary) {
76
- if (!vary)
77
- return undefined;
78
- const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
79
- if (varyString === null)
60
+ export function createCacheKey(namespace, url, vary) {
61
+ if (!namespace || !url)
80
62
  return null;
81
- if (!varyString)
82
- return undefined;
83
- return createHashFragment(varyString, CACHE_HASH.VARY_HASH_LENGTH);
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)
63
+ const urlHash = createHashFragment(url, CACHE_CONSTANTS.URL_HASH_LENGTH);
64
+ let varyHash;
65
+ if (vary) {
66
+ const varyString = typeof vary === 'string' ? vary : stableStringify(vary);
67
+ if (varyString === null)
110
68
  return null;
111
- return buildCacheResourceUri(parts.namespace, parts.urlHash);
69
+ if (varyString) {
70
+ varyHash = createHashFragment(varyString, CACHE_CONSTANTS.VARY_HASH_LENGTH);
71
+ }
112
72
  }
113
- }
114
- const cacheKeyCodec = new CacheKeyCodec();
115
- export function createCacheKey(namespace, url, vary) {
116
- return cacheKeyCodec.create(namespace, url, vary);
73
+ return buildCacheKey(namespace, urlHash, varyHash);
117
74
  }
118
75
  export function parseCacheKey(cacheKey) {
119
- return cacheKeyCodec.parse(cacheKey);
76
+ if (!cacheKey)
77
+ return null;
78
+ const [namespace, ...rest] = cacheKey.split(':');
79
+ const urlHash = rest.join(':');
80
+ if (!namespace || !urlHash)
81
+ return null;
82
+ return { namespace, urlHash };
120
83
  }
121
84
  export function toResourceUri(cacheKey) {
122
- return cacheKeyCodec.toResourceUri(cacheKey);
85
+ const parts = parseCacheKey(cacheKey);
86
+ if (!parts)
87
+ return null;
88
+ return `superfetch://cache/${parts.namespace}/${parts.urlHash}`;
123
89
  }
124
90
  /* -------------------------------------------------------------------------------------------------
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
91
+ * Core: In-Memory Store
130
92
  * ------------------------------------------------------------------------------------------------- */
131
- class NativeLruCache {
132
- max;
133
- ttlMs;
134
- entries = new Map();
135
- nextPurgeAtMs = 0;
136
- constructor({ max, ttlMs }) {
137
- this.max = max;
138
- this.ttlMs = ttlMs;
139
- }
140
- get(key) {
141
- const entry = this.entries.get(key);
142
- if (!entry)
143
- return undefined;
144
- if (this.isExpired(entry, Date.now())) {
145
- this.entries.delete(key);
146
- return undefined;
147
- }
148
- // Refresh LRU order without extending TTL.
149
- this.entries.delete(key);
150
- this.entries.set(key, entry);
151
- return entry.value;
152
- }
153
- set(key, value) {
154
- if (this.max <= 0 || this.ttlMs <= 0)
155
- return;
156
- const now = Date.now();
157
- this.entries.delete(key);
158
- this.entries.set(key, { value, expiresAtMs: now + this.ttlMs });
159
- this.maybePurge(now);
160
- while (this.entries.size > this.max) {
161
- const oldestKey = this.entries.keys().next().value;
162
- if (oldestKey === undefined)
163
- break;
164
- this.entries.delete(oldestKey);
165
- }
166
- }
167
- keys() {
168
- this.maybePurge(Date.now());
169
- return [...this.entries.keys()];
170
- }
171
- maybePurge(now) {
172
- if (this.entries.size > this.max || now >= this.nextPurgeAtMs) {
173
- this.purgeExpired(now);
174
- this.nextPurgeAtMs = now + this.ttlMs;
175
- }
176
- }
177
- purgeExpired(now) {
178
- for (const [key, entry] of this.entries) {
179
- if (this.isExpired(entry, now))
180
- this.entries.delete(key);
181
- }
182
- }
183
- isExpired(entry, now) {
184
- return entry.expiresAtMs <= now;
185
- }
186
- }
187
93
  class InMemoryCacheStore {
188
- cache = new NativeLruCache({
189
- max: config.cache.maxKeys,
190
- ttlMs: config.cache.ttl * 1000,
191
- });
192
- listeners = new Set();
94
+ max = config.cache.maxKeys;
95
+ ttlMs = config.cache.ttl * 1000;
96
+ entries = new Map();
97
+ updateEmitter = new EventEmitter();
193
98
  isEnabled() {
194
99
  return config.cache.enabled;
195
100
  }
196
101
  keys() {
197
- return [...this.cache.keys()];
102
+ if (!this.isEnabled())
103
+ return [];
104
+ const now = Date.now();
105
+ return Array.from(this.entries.entries())
106
+ .filter(([, entry]) => entry.expiresAtMs > now)
107
+ .map(([key]) => key);
198
108
  }
199
109
  onUpdate(listener) {
200
- this.listeners.add(listener);
201
- return () => this.listeners.delete(listener);
110
+ const wrapped = (event) => {
111
+ try {
112
+ const result = listener(event);
113
+ if (result instanceof Promise) {
114
+ void result.catch((error) => {
115
+ this.logError('Cache update listener failed (async)', event.cacheKey, error);
116
+ });
117
+ }
118
+ }
119
+ catch (error) {
120
+ this.logError('Cache update listener failed', event.cacheKey, error);
121
+ }
122
+ };
123
+ this.updateEmitter.on('update', wrapped);
124
+ return () => {
125
+ this.updateEmitter.off('update', wrapped);
126
+ };
202
127
  }
203
- get(cacheKey) {
204
- if (!this.isReadable(cacheKey))
128
+ get(cacheKey, options) {
129
+ if (!cacheKey || (!this.isEnabled() && !options?.force))
205
130
  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);
131
+ const entry = this.entries.get(cacheKey);
132
+ if (!entry)
133
+ return undefined;
134
+ if (entry.expiresAtMs <= Date.now()) {
135
+ this.entries.delete(cacheKey);
231
136
  return undefined;
232
137
  }
138
+ // Refresh LRU position
139
+ this.entries.delete(cacheKey);
140
+ this.entries.set(cacheKey, entry);
141
+ return entry;
233
142
  }
234
- buildEntry(content, metadata, fetchedAtMs, expiresAtMs) {
235
- return {
143
+ set(cacheKey, content, metadata, options) {
144
+ if (!cacheKey || !content)
145
+ return;
146
+ if (!this.isEnabled() && !options?.force)
147
+ return;
148
+ const now = Date.now();
149
+ const expiresAtMs = now + this.ttlMs;
150
+ const entry = {
236
151
  url: metadata.url,
237
152
  content,
238
- fetchedAt: new Date(fetchedAtMs).toISOString(),
153
+ fetchedAt: new Date(now).toISOString(),
239
154
  expiresAt: new Date(expiresAtMs).toISOString(),
240
- ...(metadata.title === undefined ? {} : { title: metadata.title }),
155
+ expiresAtMs,
156
+ ...(metadata.title ? { title: metadata.title } : {}),
241
157
  };
158
+ this.entries.delete(cacheKey);
159
+ this.entries.set(cacheKey, entry);
160
+ // Eviction
161
+ if (this.entries.size > this.max) {
162
+ const firstKey = this.entries.keys().next();
163
+ if (!firstKey.done) {
164
+ this.entries.delete(firstKey.value);
165
+ }
166
+ }
167
+ this.notify(cacheKey);
242
168
  }
243
169
  notify(cacheKey) {
244
- if (this.listeners.size === 0)
245
- return;
246
- const parts = cacheKeyCodec.parse(cacheKey);
247
- if (!parts)
170
+ if (this.updateEmitter.listenerCount('update') === 0)
248
171
  return;
249
- const event = { cacheKey, ...parts };
250
- for (const listener of this.listeners)
251
- listener(event);
172
+ const parts = parseCacheKey(cacheKey);
173
+ if (parts) {
174
+ this.updateEmitter.emit('update', { cacheKey, ...parts });
175
+ }
252
176
  }
253
177
  logError(message, cacheKey, error) {
254
178
  logWarn(message, {
@@ -257,15 +181,17 @@ class InMemoryCacheStore {
257
181
  });
258
182
  }
259
183
  }
184
+ // Singleton Instance
260
185
  const store = new InMemoryCacheStore();
186
+ // Public Proxy API
261
187
  export function onCacheUpdate(listener) {
262
188
  return store.onUpdate(listener);
263
189
  }
264
- export function get(cacheKey) {
265
- return store.get(cacheKey);
190
+ export function get(cacheKey, options) {
191
+ return store.get(cacheKey, options);
266
192
  }
267
- export function set(cacheKey, content, metadata) {
268
- store.set(cacheKey, content, metadata);
193
+ export function set(cacheKey, content, metadata, options) {
194
+ store.set(cacheKey, content, metadata, options);
269
195
  }
270
196
  export function keys() {
271
197
  return store.keys();
@@ -274,361 +200,213 @@ export function isEnabled() {
274
200
  return store.isEnabled();
275
201
  }
276
202
  /* -------------------------------------------------------------------------------------------------
277
- * MCP cached content resource (superfetch://cache/markdown/{urlHash})
203
+ * Adapter: MCP Cached Content Resource
278
204
  * ------------------------------------------------------------------------------------------------- */
279
- const CACHE_NAMESPACE = 'markdown';
280
- const HASH_PATTERN = /^[a-f0-9.]+$/i;
281
- const INVALID_CACHE_PARAMS_MESSAGE = 'Invalid cache resource parameters';
282
- function throwInvalidCacheParams() {
283
- throw new McpError(ErrorCode.InvalidParams, INVALID_CACHE_PARAMS_MESSAGE);
284
- }
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;
293
- }
294
- function requireRecordParams(value) {
295
- if (!isObject(value))
296
- throwInvalidCacheParams();
297
- return value;
298
- }
299
- function requireParamString(params, key) {
300
- const resolved = resolveStringParam(params[key]);
301
- if (!resolved) {
302
- throw new McpError(ErrorCode.InvalidParams, 'Both namespace and urlHash parameters are required');
303
- }
304
- return resolved;
305
- }
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 };
314
- }
315
- function isValidCacheResourceUri(uri) {
316
- if (!uri)
317
- return false;
318
- let parsed;
319
- try {
320
- parsed = new URL(uri);
321
- }
322
- catch {
323
- return false;
324
- }
325
- if (parsed.protocol !== 'superfetch:')
326
- return false;
327
- if (parsed.hostname !== 'cache')
328
- return false;
329
- const parts = parsed.pathname.split('/').filter(Boolean);
330
- if (parts.length !== 2)
331
- return false;
332
- const [namespace, urlHash] = parts;
333
- if (!namespace || !urlHash)
334
- return false;
335
- return isValidNamespace(namespace) && isValidHash(urlHash);
336
- }
337
- function buildResourceEntry(namespace, urlHash) {
338
- return {
205
+ const CacheResourceParamsSchema = z.object({
206
+ namespace: CacheNamespace,
207
+ urlHash: HashString,
208
+ });
209
+ function listCachedResources() {
210
+ const resources = store
211
+ .keys()
212
+ .map((key) => parseCacheKey(key))
213
+ .filter((parts) => parts !== null && parts.namespace === 'markdown')
214
+ .map(({ namespace, urlHash }) => ({
339
215
  name: `${namespace}:${urlHash}`,
340
- uri: buildCacheResourceUri(namespace, urlHash),
216
+ uri: `superfetch://cache/${namespace}/${urlHash}`,
341
217
  description: `Cached content entry for ${namespace}`,
342
218
  mimeType: 'text/markdown',
343
- };
344
- }
345
- function listCachedResources() {
346
- const resources = keys()
347
- .map((key) => {
348
- const parts = cacheKeyCodec.parse(key);
349
- if (parts?.namespace !== CACHE_NAMESPACE)
350
- return null;
351
- return buildResourceEntry(parts.namespace, parts.urlHash);
352
- })
353
- .filter((entry) => entry !== null);
219
+ }));
354
220
  return { resources };
355
221
  }
356
- function appendServerOnClose(server, handler) {
357
- const previousOnClose = server.server.onclose;
358
- server.server.onclose = () => {
359
- previousOnClose?.();
360
- handler();
361
- };
362
- }
363
- function attachInitializedGate(server) {
364
- let initialized = false;
365
- const previousInitialized = server.server.oninitialized;
366
- server.server.oninitialized = () => {
367
- initialized = true;
368
- previousInitialized?.();
369
- };
370
- return () => initialized;
371
- }
372
- function getClientResourceCapabilities(server) {
373
- const caps = server.server.getClientCapabilities();
374
- if (!caps || !isObject(caps))
375
- return { listChanged: false, subscribe: false };
376
- const { resources } = caps;
377
- if (!isObject(resources))
378
- return { listChanged: false, subscribe: false };
379
- const { listChanged, subscribe } = resources;
380
- return { listChanged: listChanged === true, subscribe: subscribe === true };
222
+ function resolveCachedMarkdownText(raw) {
223
+ if (!raw)
224
+ return null;
225
+ const payload = parseCachedPayload(raw);
226
+ if (payload)
227
+ return resolveCachedPayloadContent(payload);
228
+ const trimmed = raw.trimStart();
229
+ if (trimmed.startsWith('{') || trimmed.startsWith('['))
230
+ return null;
231
+ return raw;
381
232
  }
382
- function registerResourceSubscriptionHandlers(server) {
233
+ export function registerCachedContentResource(server, serverIcons) {
234
+ // Resource Registration
235
+ server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
236
+ list: listCachedResources,
237
+ }), {
238
+ title: 'Cached Content',
239
+ description: 'Access previously fetched web content from cache.',
240
+ mimeType: 'text/markdown',
241
+ ...(serverIcons ? { icons: serverIcons } : {}),
242
+ }, (uri, params) => {
243
+ const parsed = CacheResourceParamsSchema.safeParse(params);
244
+ if (!parsed.success) {
245
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid resource parameters');
246
+ }
247
+ const { namespace, urlHash } = parsed.data;
248
+ const cacheKey = `${namespace}:${urlHash}`;
249
+ const cached = store.get(cacheKey, { force: true });
250
+ if (!cached) {
251
+ throw new McpError(-32002, `Content not found: ${cacheKey}`);
252
+ }
253
+ const text = resolveCachedMarkdownText(cached.content);
254
+ if (!text) {
255
+ throw new McpError(ErrorCode.InternalError, 'Cached content invalid');
256
+ }
257
+ return {
258
+ contents: [{ uri: uri.href, mimeType: 'text/markdown', text }],
259
+ };
260
+ });
261
+ // Subscriptions
383
262
  const subscriptions = new Set();
384
- server.server.setRequestHandler(SubscribeRequestSchema, (request) => {
385
- const { uri } = request.params;
386
- if (!isValidCacheResourceUri(uri)) {
263
+ server.server.setRequestHandler(SubscribeRequestSchema, (req) => {
264
+ if (isValidCacheUri(req.params.uri)) {
265
+ subscriptions.add(req.params.uri);
266
+ }
267
+ else {
387
268
  throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
388
269
  }
389
- subscriptions.add(uri);
390
270
  return {};
391
271
  });
392
- server.server.setRequestHandler(UnsubscribeRequestSchema, (request) => {
393
- const { uri } = request.params;
394
- if (!isValidCacheResourceUri(uri)) {
272
+ server.server.setRequestHandler(UnsubscribeRequestSchema, (req) => {
273
+ if (isValidCacheUri(req.params.uri)) {
274
+ subscriptions.delete(req.params.uri);
275
+ }
276
+ else {
395
277
  throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
396
278
  }
397
- subscriptions.delete(uri);
398
279
  return {};
399
280
  });
400
- appendServerOnClose(server, () => {
401
- subscriptions.clear();
402
- });
403
- return subscriptions;
404
- }
405
- function notifyResourceUpdate(server, uri, subscriptions) {
406
- if (!server.isConnected())
407
- return;
408
- if (!subscriptions.has(uri))
409
- return;
410
- void server.server.sendResourceUpdated({ uri }).catch((error) => {
411
- logWarn('Failed to send resource update notification', {
412
- uri,
413
- error: getErrorMessage(error),
414
- });
415
- });
416
- }
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
- ],
281
+ // Notifications
282
+ let initialized = false;
283
+ const originalOnInitialized = server.server.oninitialized;
284
+ server.server.oninitialized = () => {
285
+ initialized = true;
286
+ originalOnInitialized?.();
434
287
  };
435
- }
436
- function buildCachedContentResponse(uri, cacheKey) {
437
- const cached = requireCacheEntry(cacheKey);
438
- return buildMarkdownContentResponse(uri, cached.content);
439
- }
440
- function registerCacheContentResource(server, serverIcons) {
441
- server.registerResource('cached-content', new ResourceTemplate('superfetch://cache/{namespace}/{urlHash}', {
442
- list: listCachedResources,
443
- }), {
444
- title: 'Cached Content',
445
- description: 'Access previously fetched web content from cache. Namespace: markdown. UrlHash: SHA-256 hash of the URL.',
446
- mimeType: 'text/markdown',
447
- ...(serverIcons ? { icons: serverIcons } : {}),
448
- }, (uri, params) => {
449
- const { namespace, urlHash } = resolveCacheParams(params);
450
- const cacheKey = `${namespace}:${urlHash}`;
451
- return buildCachedContentResponse(uri, cacheKey);
452
- });
453
- }
454
- function registerCacheUpdateSubscription(server, subscriptions, isInitialized) {
455
- const unsubscribe = onCacheUpdate(({ cacheKey }) => {
456
- if (!server.isConnected() || !isInitialized())
288
+ store.onUpdate(({ cacheKey }) => {
289
+ if (!server.isConnected() || !initialized)
457
290
  return;
458
- const { listChanged, subscribe } = getClientResourceCapabilities(server);
459
- if (subscribe) {
460
- const resourceUri = cacheKeyCodec.toResourceUri(cacheKey);
461
- if (resourceUri)
462
- notifyResourceUpdate(server, resourceUri, subscriptions);
291
+ // Check capabilities via unsafe cast or helper (SDK limitation)
292
+ const capabilities = server.server.getClientCapabilities();
293
+ const uri = toResourceUri(cacheKey);
294
+ if (capabilities?.resources?.subscribe && uri && subscriptions.has(uri)) {
295
+ void server.server
296
+ .sendResourceUpdated({ uri })
297
+ .catch((error) => {
298
+ logWarn('Failed to send update', {
299
+ uri,
300
+ error: getErrorMessage(error),
301
+ });
302
+ });
463
303
  }
464
- if (listChanged) {
465
- server.sendResourceListChanged();
304
+ if (capabilities?.resources?.listChanged) {
305
+ void server.server.sendResourceListChanged().catch(() => { });
466
306
  }
467
307
  });
468
- appendServerOnClose(server, unsubscribe);
469
- }
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);
475
- }
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);
490
308
  }
491
- function stripCommonPageExtension(segment) {
492
- return segment.replace(/\.(html?|php|aspx?|jsp)$/i, '');
493
- }
494
- function normalizeUrlFilenameSegment(segment) {
495
- const cleaned = stripCommonPageExtension(segment);
496
- if (!cleaned)
497
- return null;
498
- if (cleaned === 'index')
499
- return null;
500
- return cleaned;
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
- }
508
- function extractFilenameFromUrl(url) {
309
+ function isValidCacheUri(uri) {
509
310
  try {
510
- const urlObj = new URL(url);
511
- const lastSegment = getLastPathSegment(urlObj);
512
- if (!lastSegment)
513
- return null;
514
- return normalizeUrlFilenameSegment(lastSegment);
311
+ const url = new URL(uri);
312
+ if (url.protocol !== 'superfetch:' || url.hostname !== 'cache')
313
+ return false;
314
+ if (url.search || url.hash)
315
+ return false;
316
+ const parts = url.pathname.split('/').filter(Boolean);
317
+ return parts.length === 2 && parts[0] === 'markdown';
515
318
  }
516
319
  catch {
517
- return null;
320
+ return false;
518
321
  }
519
322
  }
520
- function slugifyTitle(title) {
521
- const slug = title
323
+ /* -------------------------------------------------------------------------------------------------
324
+ * Utils: Filename Logic
325
+ * ------------------------------------------------------------------------------------------------- */
326
+ const FILENAME_RULES = {
327
+ MAX_LEN: 200,
328
+ UNSAFE_CHARS: /[<>:"/\\|?*\p{C}]/gu,
329
+ WHITESPACE: /\s+/g,
330
+ EXTENSIONS: /\.(html?|php|aspx?|jsp)$/i,
331
+ };
332
+ function sanitizeString(input) {
333
+ return input
522
334
  .toLowerCase()
523
- .trim()
524
- .replace(UNSAFE_CHARS_REGEX, '')
525
- .replace(WHITESPACE_REGEX, '-')
526
- .replace(/-+/g, '-');
527
- const trimmed = trimHyphens(slug);
528
- return trimmed || null;
529
- }
530
- function sanitizeFilename(name, extension) {
531
- let sanitized = name
532
- .replace(UNSAFE_CHARS_REGEX, '')
533
- .replace(WHITESPACE_REGEX, '-')
534
- .trim();
535
- const maxBase = MAX_FILENAME_LENGTH - extension.length;
536
- if (sanitized.length > maxBase) {
537
- sanitized = sanitized.substring(0, maxBase);
538
- }
539
- return `${sanitized}${extension}`;
335
+ .replace(FILENAME_RULES.UNSAFE_CHARS, '')
336
+ .replace(FILENAME_RULES.WHITESPACE, '-')
337
+ .replace(/-+/g, '-')
338
+ .replace(/(?:^-|-$)/g, '');
540
339
  }
541
340
  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)
559
- return null;
560
- if (!isValidNamespace(resolvedNamespace))
561
- return null;
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');
581
- }
582
- function buildContentDisposition(fileName) {
583
- const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
584
- return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
585
- }
586
- function sendDownloadPayload(res, payload) {
587
- const disposition = buildContentDisposition(payload.fileName);
588
- res.setHeader('Content-Type', payload.contentType);
589
- res.setHeader('Content-Disposition', disposition);
590
- res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
591
- res.setHeader('X-Content-Type-Options', 'nosniff');
592
- res.end(payload.content);
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,
341
+ const tryUrl = () => {
342
+ try {
343
+ if (!URL.canParse(url))
344
+ return null;
345
+ const parsed = new URL(url);
346
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
347
+ return null;
348
+ const { pathname } = parsed;
349
+ const basename = pathPosix.basename(pathname);
350
+ if (!basename || basename === 'index')
351
+ return null;
352
+ const cleaned = basename.replace(FILENAME_RULES.EXTENSIONS, '');
353
+ const sanitized = sanitizeString(cleaned);
354
+ if (sanitized === 'index')
355
+ return null;
356
+ return sanitized || null;
357
+ }
358
+ catch {
359
+ return null;
360
+ }
607
361
  };
362
+ const tryTitle = () => {
363
+ if (!title)
364
+ return null;
365
+ return sanitizeString(title) || null;
366
+ };
367
+ const name = tryUrl() ??
368
+ tryTitle() ??
369
+ hashFallback?.substring(0, 16) ??
370
+ `download-${Date.now()}`;
371
+ const maxBase = FILENAME_RULES.MAX_LEN - extension.length;
372
+ const truncated = name.length > maxBase ? name.substring(0, maxBase) : name;
373
+ return `${truncated}${extension}`;
608
374
  }
375
+ /* -------------------------------------------------------------------------------------------------
376
+ * Adapter: Download Handler
377
+ * ------------------------------------------------------------------------------------------------- */
378
+ const DownloadParamsSchema = z.object({
379
+ namespace: CacheNamespace,
380
+ hash: HashString,
381
+ });
609
382
  export function handleDownload(res, namespace, hash) {
610
- if (!config.cache.enabled) {
611
- respondServiceUnavailable(res);
612
- return;
613
- }
614
- const params = parseDownloadParams(namespace, hash);
615
- if (!params) {
616
- respondBadRequest(res, 'Invalid namespace or hash format');
383
+ const respond = (status, msg, code) => {
384
+ res.writeHead(status, { 'Content-Type': 'application/json' });
385
+ res.end(JSON.stringify({ error: msg, code }));
386
+ };
387
+ const parsed = DownloadParamsSchema.safeParse({ namespace, hash });
388
+ if (!parsed.success) {
389
+ respond(400, 'Invalid namespace or hash', 'BAD_REQUEST');
617
390
  return;
618
391
  }
619
- const cacheKey = buildCacheKeyFromParams(params);
620
- const cacheEntry = get(cacheKey);
621
- if (!cacheEntry) {
622
- logDebug('Download request for missing cache key', { cacheKey });
623
- respondNotFound(res);
392
+ const cacheKey = `${parsed.data.namespace}:${parsed.data.hash}`;
393
+ const entry = store.get(cacheKey, { force: true });
394
+ if (!entry) {
395
+ respond(404, 'Not found or expired', 'NOT_FOUND');
624
396
  return;
625
397
  }
626
- const payload = resolveDownloadPayload(params, cacheEntry);
627
- if (!payload) {
628
- logDebug('Download payload unavailable', { cacheKey });
629
- respondNotFound(res);
398
+ const payload = parseCachedPayload(entry.content);
399
+ const content = payload ? resolveCachedPayloadContent(payload) : null;
400
+ if (!content) {
401
+ respond(404, 'Content missing', 'NOT_FOUND');
630
402
  return;
631
403
  }
632
- logDebug('Serving download', { cacheKey, fileName: payload.fileName });
633
- sendDownloadPayload(res, payload);
404
+ const fileName = generateSafeFilename(entry.url, payload?.title, parsed.data.hash);
405
+ // Safe header generation
406
+ const encoded = encodeURIComponent(fileName).replace(/'/g, '%27');
407
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
408
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"; filename*=UTF-8''${encoded}`);
409
+ res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
410
+ res.setHeader('X-Content-Type-Options', 'nosniff');
411
+ res.end(content);
634
412
  }