@j0hanz/superfetch 2.3.0 → 2.4.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/dist/cache.js CHANGED
@@ -1,4 +1,3 @@
1
- import { LRUCache } from 'lru-cache';
2
1
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
2
  import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
4
3
  import { config } from './config.js';
@@ -105,10 +104,65 @@ export function toResourceUri(cacheKey) {
105
104
  return null;
106
105
  return buildCacheResourceUri(parts.namespace, parts.urlHash);
107
106
  }
108
- const contentCache = new LRUCache({
107
+ // Cache behavior contract (native implementation):
108
+ // - Max entries: config.cache.maxKeys
109
+ // - TTL in ms: config.cache.ttl * 1000
110
+ // - Access does NOT extend TTL
111
+ class NativeLruCache {
112
+ max;
113
+ ttlMs;
114
+ entries = new Map();
115
+ constructor({ max, ttlMs }) {
116
+ this.max = max;
117
+ this.ttlMs = ttlMs;
118
+ }
119
+ get(key) {
120
+ const entry = this.entries.get(key);
121
+ if (!entry)
122
+ return undefined;
123
+ if (this.isExpired(entry, Date.now())) {
124
+ this.entries.delete(key);
125
+ return undefined;
126
+ }
127
+ // Refresh LRU order without extending TTL.
128
+ this.entries.delete(key);
129
+ this.entries.set(key, entry);
130
+ return entry.value;
131
+ }
132
+ set(key, value) {
133
+ if (this.max <= 0 || this.ttlMs <= 0)
134
+ return;
135
+ this.entries.delete(key);
136
+ this.entries.set(key, {
137
+ value,
138
+ expiresAtMs: Date.now() + this.ttlMs,
139
+ });
140
+ this.purgeExpired(Date.now());
141
+ while (this.entries.size > this.max) {
142
+ const oldestKey = this.entries.keys().next().value;
143
+ if (oldestKey === undefined)
144
+ break;
145
+ this.entries.delete(oldestKey);
146
+ }
147
+ }
148
+ keys() {
149
+ this.purgeExpired(Date.now());
150
+ return [...this.entries.keys()];
151
+ }
152
+ purgeExpired(now) {
153
+ for (const [key, entry] of this.entries) {
154
+ if (this.isExpired(entry, now)) {
155
+ this.entries.delete(key);
156
+ }
157
+ }
158
+ }
159
+ isExpired(entry, now) {
160
+ return entry.expiresAtMs <= now;
161
+ }
162
+ }
163
+ const contentCache = new NativeLruCache({
109
164
  max: config.cache.maxKeys,
110
- ttl: config.cache.ttl * 1000,
111
- updateAgeOnGet: false,
165
+ ttlMs: config.cache.ttl * 1000,
112
166
  });
113
167
  const updateListeners = new Set();
114
168
  export function onCacheUpdate(listener) {
package/dist/config.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare const serverVersion: string;
1
2
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
3
  export type TransformMetadataFormat = 'markdown' | 'frontmatter';
3
4
  interface AuthConfig {
@@ -43,6 +44,7 @@ export declare const config: {
43
44
  };
44
45
  transform: {
45
46
  timeoutMs: number;
47
+ stageWarnRatio: number;
46
48
  metadataFormat: TransformMetadataFormat;
47
49
  };
48
50
  tools: {
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import packageJson from '../package.json' with { type: 'json' };
2
+ export const serverVersion = packageJson.version;
2
3
  function buildIpv4(parts) {
3
4
  return parts.join('.');
4
5
  }
@@ -199,6 +200,7 @@ export const config = {
199
200
  },
200
201
  transform: {
201
202
  timeoutMs: TIMEOUT.DEFAULT_TRANSFORM_TIMEOUT_MS,
203
+ stageWarnRatio: parseFloat(process.env.TRANSFORM_STAGE_WARN_RATIO ?? '0.5'),
202
204
  metadataFormat: parseTransformMetadataFormat(process.env.TRANSFORM_METADATA_FORMAT),
203
205
  },
204
206
  tools: {
@@ -157,6 +157,16 @@ function getPromoTokens() {
157
157
  promoTokensCache = tokens;
158
158
  return tokens;
159
159
  }
160
+ let promoRegexCache = null;
161
+ function getPromoRegex() {
162
+ if (promoRegexCache)
163
+ return promoRegexCache;
164
+ const tokens = Array.from(getPromoTokens());
165
+ const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
166
+ const pattern = `(?:^|[^a-z0-9])(?:${escaped.join('|')})(?:$|[^a-z0-9])`;
167
+ promoRegexCache = new RegExp(pattern, 'i');
168
+ return promoRegexCache;
169
+ }
160
170
  const HEADER_NOISE_PATTERN = /\b(site-header|masthead|topbar|navbar|nav(?:bar)?|menu|header-nav)\b/i;
161
171
  const FIXED_PATTERN = /\b(fixed|sticky)\b/;
162
172
  const HIGH_Z_PATTERN = /\bz-(?:4\d|50)\b/;
@@ -261,18 +271,9 @@ function isElementHidden(element) {
261
271
  function hasNoiseRole(role) {
262
272
  return role !== null && NAVIGATION_ROLES.has(role);
263
273
  }
264
- function tokenizeIdentifierLikeText(value) {
265
- return value
266
- .toLowerCase()
267
- .replace(/[^a-z0-9]+/g, ' ')
268
- .trim()
269
- .split(' ')
270
- .filter(Boolean);
271
- }
272
274
  function matchesPromoIdOrClass(className, id) {
273
- const tokens = tokenizeIdentifierLikeText(`${className} ${id}`);
274
- const promoTokens = getPromoTokens();
275
- return tokens.some((token) => promoTokens.has(token));
275
+ const regex = getPromoRegex();
276
+ return regex.test(className) || regex.test(id);
276
277
  }
277
278
  function matchesFixedOrHighZIsolate(className) {
278
279
  return (FIXED_PATTERN.test(className) ||
@@ -310,9 +311,10 @@ function isNodeListLike(value) {
310
311
  return isObject(value) && typeof value.length === 'number';
311
312
  }
312
313
  function tryGetNodeListItem(nodes, index) {
313
- if (typeof nodes.item === 'function')
314
+ if ('item' in nodes && typeof nodes.item === 'function') {
314
315
  return nodes.item(index);
315
- return (nodes[index] ?? null);
316
+ }
317
+ return nodes[index] ?? null;
316
318
  }
317
319
  function removeNoiseFromNodeListLike(nodes, shouldCheckNoise) {
318
320
  for (let index = nodes.length - 1; index >= 0; index -= 1) {
package/dist/fetch.js CHANGED
@@ -300,11 +300,11 @@ export function isRawTextContentUrl(url) {
300
300
  return hasKnownRawTextExtension(lowerBase);
301
301
  }
302
302
  function hasKnownRawTextExtension(urlBaseLower) {
303
- for (const ext of RAW_TEXT_EXTENSIONS) {
304
- if (urlBaseLower.endsWith(ext))
305
- return true;
306
- }
307
- return false;
303
+ const lastDot = urlBaseLower.lastIndexOf('.');
304
+ if (lastDot === -1)
305
+ return false;
306
+ const ext = urlBaseLower.slice(lastDot);
307
+ return RAW_TEXT_EXTENSIONS.has(ext);
308
308
  }
309
309
  const DNS_LOOKUP_TIMEOUT_MS = 5000;
310
310
  const SLOW_REQUEST_THRESHOLD_MS = 5000;
@@ -314,25 +314,6 @@ function normalizeLookupResults(addresses, family) {
314
314
  }
315
315
  return [{ address: addresses, family: family ?? 4 }];
316
316
  }
317
- function findBlockedIpError(list, hostname) {
318
- for (const addr of list) {
319
- const ip = typeof addr === 'string' ? addr : addr.address;
320
- if (!isBlockedIp(ip)) {
321
- continue;
322
- }
323
- return createErrorWithCode(`Blocked IP detected for ${hostname}`, 'EBLOCKED');
324
- }
325
- return null;
326
- }
327
- function findInvalidFamilyError(list, hostname) {
328
- for (const addr of list) {
329
- const family = typeof addr === 'string' ? 0 : addr.family;
330
- if (family === 4 || family === 6)
331
- continue;
332
- return createErrorWithCode(`Invalid address family returned for ${hostname}`, 'EINVAL');
333
- }
334
- return null;
335
- }
336
317
  function createNoDnsResultsError(hostname) {
337
318
  return createErrorWithCode(`No DNS results returned for ${hostname}`, 'ENODATA');
338
319
  }
@@ -358,7 +339,17 @@ function selectLookupResult(list, useAll, hostname) {
358
339
  };
359
340
  }
360
341
  function findLookupError(list, hostname) {
361
- return (findInvalidFamilyError(list, hostname) ?? findBlockedIpError(list, hostname));
342
+ for (const addr of list) {
343
+ const family = typeof addr === 'string' ? 0 : addr.family;
344
+ if (family !== 4 && family !== 6) {
345
+ return createErrorWithCode(`Invalid address family returned for ${hostname}`, 'EINVAL');
346
+ }
347
+ const ip = typeof addr === 'string' ? addr : addr.address;
348
+ if (isBlockedIp(ip)) {
349
+ return createErrorWithCode(`Blocked IP detected for ${hostname}`, 'EBLOCKED');
350
+ }
351
+ }
352
+ return null;
362
353
  }
363
354
  function normalizeAndValidateLookupResults(addresses, resolvedFamily, hostname) {
364
355
  const list = normalizeLookupResults(addresses, resolvedFamily);
@@ -5,8 +5,8 @@ import { URL, URLSearchParams } from 'node:url';
5
5
  import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
6
6
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
7
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
8
- import { handleDownload } from './cache.js';
9
- import { config, enableHttpMode } from './config.js';
8
+ import { keys as cacheKeys, handleDownload } from './cache.js';
9
+ import { config, enableHttpMode, serverVersion } from './config.js';
10
10
  import { timingSafeEqualUtf8 } from './crypto.js';
11
11
  import { normalizeHost } from './host-normalization.js';
12
12
  import { acceptsEventStream, isJsonRpcBatchRequest, isMcpRequestBody, } from './mcp-validator.js';
@@ -14,6 +14,7 @@ import { createMcpServer } from './mcp.js';
14
14
  import { logError, logInfo, logWarn } from './observability.js';
15
15
  import { applyHttpServerTuning, drainConnectionsOnShutdown, } from './server-tuning.js';
16
16
  import { composeCloseHandlers, createSessionStore, createSlotTracker, ensureSessionCapacity, reserveSessionSlot, startSessionCleanupLoop, } from './session.js';
17
+ import { getTransformPoolStats } from './transform.js';
17
18
  import { isObject } from './type-guards.js';
18
19
  function createTransportAdapter(transportImpl) {
19
20
  const noopOnClose = () => { };
@@ -580,7 +581,22 @@ async function dispatchRequest(req, res, url, ctx) {
580
581
  const { method } = req;
581
582
  try {
582
583
  if (method === 'GET' && path === '/health') {
583
- res.status(200).json({ status: 'ok' });
584
+ const poolStats = getTransformPoolStats();
585
+ res.status(200).json({
586
+ status: 'ok',
587
+ version: serverVersion,
588
+ uptime: Math.floor(process.uptime()),
589
+ timestamp: new Date().toISOString(),
590
+ stats: {
591
+ activeSessions: ctx.store.size(),
592
+ cacheKeys: cacheKeys().length,
593
+ workerPool: poolStats ?? {
594
+ queueDepth: 0,
595
+ activeWorkers: 0,
596
+ capacity: 0,
597
+ },
598
+ },
599
+ });
584
600
  return;
585
601
  }
586
602
  if (!(await authenticateRequest(req, res))) {
@@ -1,16 +1,10 @@
1
- /**
2
- * Markdown cleanup utilities for post-processing converted content.
3
- *
4
- * Goals:
5
- * - Never mutate fenced code blocks (``` / ~~~) content.
6
- * - Keep rules localized and readable.
7
- * - Avoid multi-pass regexes that accidentally hit code blocks.
8
- */
9
- /**
10
- * Clean up common markdown artifacts and formatting issues.
11
- * IMPORTANT: All rules are applied ONLY outside fenced code blocks.
12
- */
1
+ import type { MetadataBlock } from './transform-types.js';
13
2
  export declare function cleanupMarkdownArtifacts(content: string): string;
3
+ export declare function extractTitleFromRawMarkdown(content: string): string | undefined;
4
+ export declare function addSourceToMarkdown(content: string, url: string): string;
5
+ export declare function isRawTextContent(content: string): boolean;
6
+ export declare function isLikelyHtmlContent(content: string): boolean;
7
+ export declare function buildMetadataFooter(metadata?: MetadataBlock, fallbackUrl?: string): string;
14
8
  /**
15
9
  * Promote standalone lines that look like headings to proper markdown headings.
16
10
  * Fence-aware: never modifies content inside fenced code blocks.
@@ -1,11 +1,4 @@
1
- /**
2
- * Markdown cleanup utilities for post-processing converted content.
3
- *
4
- * Goals:
5
- * - Never mutate fenced code blocks (``` / ~~~) content.
6
- * - Keep rules localized and readable.
7
- * - Avoid multi-pass regexes that accidentally hit code blocks.
8
- */
1
+ import { config } from './config.js';
9
2
  // ─────────────────────────────────────────────────────────────────────────────
10
3
  // Fence state helpers
11
4
  // ─────────────────────────────────────────────────────────────────────────────
@@ -76,15 +69,6 @@ function splitByFences(content) {
76
69
  }
77
70
  return segments;
78
71
  }
79
- /**
80
- * Apply a transformation function only to non-fenced content.
81
- */
82
- function mapOutsideFences(content, transform) {
83
- const segments = splitByFences(content);
84
- return segments
85
- .map((seg) => (seg.inFence ? seg.content : transform(seg.content)))
86
- .join('\n');
87
- }
88
72
  // ─────────────────────────────────────────────────────────────────────────────
89
73
  // Cleanup rules (OUTSIDE fences only)
90
74
  // ─────────────────────────────────────────────────────────────────────────────
@@ -165,6 +149,21 @@ function normalizeListsAndSpacing(text) {
165
149
  // Collapse excessive blank lines
166
150
  return text.replace(/\n{3,}/g, '\n\n');
167
151
  }
152
+ function fixConcatenatedProperties(text) {
153
+ const quotedValuePattern = /([a-z_][a-z0-9_]{0,30}\??:\s+)([\u0022\u201C][^\u0022\u201C\u201D]*[\u0022\u201D])([a-z_][a-z0-9_]{0,30}\??:)/g;
154
+ let result = text;
155
+ let iterations = 0;
156
+ const maxIterations = 3;
157
+ while (iterations < maxIterations) {
158
+ const before = result;
159
+ result = result.replace(quotedValuePattern, '$1$2\n\n$3');
160
+ if (result === before) {
161
+ break;
162
+ }
163
+ iterations++;
164
+ }
165
+ return result;
166
+ }
168
167
  const CLEANUP_STEPS = [
169
168
  fixOrphanHeadings,
170
169
  removeEmptyHeadings,
@@ -173,21 +172,256 @@ const CLEANUP_STEPS = [
173
172
  removeTocBlocks,
174
173
  tidyLinksAndEscapes,
175
174
  normalizeListsAndSpacing,
175
+ fixConcatenatedProperties,
176
176
  ];
177
177
  // ─────────────────────────────────────────────────────────────────────────────
178
178
  // Public API
179
179
  // ─────────────────────────────────────────────────────────────────────────────
180
- /**
181
- * Clean up common markdown artifacts and formatting issues.
182
- * IMPORTANT: All rules are applied ONLY outside fenced code blocks.
183
- */
180
+ function getLastLine(text) {
181
+ const index = text.lastIndexOf('\n');
182
+ return index === -1 ? text : text.slice(index + 1);
183
+ }
184
184
  export function cleanupMarkdownArtifacts(content) {
185
185
  if (!content)
186
186
  return '';
187
- const cleaned = mapOutsideFences(content, (outside) => {
188
- return CLEANUP_STEPS.reduce((text, step) => step(text), outside);
189
- });
190
- return cleaned.trim();
187
+ const segments = splitByFences(content);
188
+ return segments
189
+ .map((seg, index) => {
190
+ if (seg.inFence)
191
+ return seg.content;
192
+ const prevSeg = segments[index - 1];
193
+ const prevLineContext = prevSeg ? getLastLine(prevSeg.content) : '';
194
+ const lines = seg.content.split('\n');
195
+ const promotedLines = [];
196
+ for (let i = 0; i < lines.length; i += 1) {
197
+ const line = lines[i] ?? '';
198
+ const prevLine = i > 0 ? (lines[i - 1] ?? '') : prevLineContext;
199
+ promotedLines.push(processNonFencedLine(line, prevLine));
200
+ }
201
+ const promoted = promotedLines.join('\n');
202
+ return CLEANUP_STEPS.reduce((text, step) => step(text), promoted);
203
+ })
204
+ .join('\n')
205
+ .trim();
206
+ }
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+ // Raw markdown handling + metadata footer
209
+ // ─────────────────────────────────────────────────────────────────────────────
210
+ const HEADING_PATTERN = /^#{1,6}\s/m;
211
+ const LIST_PATTERN = /^(?:[-*+])\s/m;
212
+ const HTML_DOCUMENT_PATTERN = /^(<!doctype|<html)/i;
213
+ function containsMarkdownHeading(content) {
214
+ return HEADING_PATTERN.test(content);
215
+ }
216
+ function containsMarkdownList(content) {
217
+ return LIST_PATTERN.test(content);
218
+ }
219
+ function containsFencedCodeBlock(content) {
220
+ const first = content.indexOf('```');
221
+ if (first === -1)
222
+ return false;
223
+ return content.includes('```', first + 3);
224
+ }
225
+ function looksLikeMarkdown(content) {
226
+ return (containsMarkdownHeading(content) ||
227
+ containsMarkdownList(content) ||
228
+ containsFencedCodeBlock(content));
229
+ }
230
+ function detectLineEnding(content) {
231
+ return content.includes('\r\n') ? '\r\n' : '\n';
232
+ }
233
+ const FRONTMATTER_DELIMITER = '---';
234
+ function findFrontmatterLines(content) {
235
+ const lineEnding = detectLineEnding(content);
236
+ const lines = content.split(lineEnding);
237
+ if (lines[0] !== FRONTMATTER_DELIMITER)
238
+ return null;
239
+ const endIndex = lines.indexOf(FRONTMATTER_DELIMITER, 1);
240
+ if (endIndex === -1)
241
+ return null;
242
+ return { lineEnding, lines, endIndex };
243
+ }
244
+ function stripOptionalQuotes(value) {
245
+ const trimmed = value.trim();
246
+ if (trimmed.length < 2)
247
+ return trimmed;
248
+ const first = trimmed[0];
249
+ const last = trimmed[trimmed.length - 1];
250
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
251
+ return trimmed.slice(1, -1).trim();
252
+ }
253
+ return trimmed;
254
+ }
255
+ function parseFrontmatterEntry(line) {
256
+ const trimmed = line.trim();
257
+ if (!trimmed)
258
+ return null;
259
+ const separatorIndex = trimmed.indexOf(':');
260
+ if (separatorIndex <= 0)
261
+ return null;
262
+ const key = trimmed.slice(0, separatorIndex).trim().toLowerCase();
263
+ const value = trimmed.slice(separatorIndex + 1);
264
+ return { key, value };
265
+ }
266
+ function isTitleKey(key) {
267
+ return key === 'title' || key === 'name';
268
+ }
269
+ function extractTitleFromHeading(content) {
270
+ const lineEnding = detectLineEnding(content);
271
+ const lines = content.split(lineEnding);
272
+ for (const line of lines) {
273
+ const trimmed = line.trim();
274
+ if (!trimmed)
275
+ continue;
276
+ let index = 0;
277
+ while (index < trimmed.length && trimmed[index] === '#') {
278
+ index += 1;
279
+ }
280
+ if (index === 0 || index > 6)
281
+ return undefined;
282
+ const nextChar = trimmed[index];
283
+ if (nextChar !== ' ' && nextChar !== '\t')
284
+ return undefined;
285
+ const heading = trimmed.slice(index).trim();
286
+ return heading.length > 0 ? heading : undefined;
287
+ }
288
+ return undefined;
289
+ }
290
+ export function extractTitleFromRawMarkdown(content) {
291
+ const frontmatter = findFrontmatterLines(content);
292
+ if (!frontmatter) {
293
+ return extractTitleFromHeading(content);
294
+ }
295
+ const { lines, endIndex } = frontmatter;
296
+ const entry = lines
297
+ .slice(1, endIndex)
298
+ .map((line) => parseFrontmatterEntry(line))
299
+ .find((parsed) => parsed !== null && isTitleKey(parsed.key));
300
+ if (!entry)
301
+ return undefined;
302
+ const value = stripOptionalQuotes(entry.value);
303
+ return value || undefined;
304
+ }
305
+ function hasMarkdownSourceLine(content) {
306
+ const lineEnding = detectLineEnding(content);
307
+ const lines = content.split(lineEnding);
308
+ const limit = Math.min(lines.length, 50);
309
+ for (let index = 0; index < limit; index += 1) {
310
+ const line = lines[index];
311
+ if (!line)
312
+ continue;
313
+ if (line.trimStart().toLowerCase().startsWith('source:')) {
314
+ return true;
315
+ }
316
+ }
317
+ return false;
318
+ }
319
+ function addSourceToMarkdownMarkdownFormat(content, url) {
320
+ if (hasMarkdownSourceLine(content))
321
+ return content;
322
+ const lineEnding = detectLineEnding(content);
323
+ const lines = content.split(lineEnding);
324
+ const firstNonEmptyIndex = lines.findIndex((line) => line.trim().length > 0);
325
+ if (firstNonEmptyIndex !== -1) {
326
+ const firstLine = lines[firstNonEmptyIndex];
327
+ if (firstLine && /^#{1,6}\s+/.test(firstLine.trim())) {
328
+ const insertAt = firstNonEmptyIndex + 1;
329
+ const updated = [
330
+ ...lines.slice(0, insertAt),
331
+ '',
332
+ `Source: ${url}`,
333
+ '',
334
+ ...lines.slice(insertAt),
335
+ ];
336
+ return updated.join(lineEnding);
337
+ }
338
+ }
339
+ return [`Source: ${url}`, '', content].join(lineEnding);
340
+ }
341
+ export function addSourceToMarkdown(content, url) {
342
+ const frontmatter = findFrontmatterLines(content);
343
+ if (config.transform.metadataFormat === 'markdown' && !frontmatter) {
344
+ return addSourceToMarkdownMarkdownFormat(content, url);
345
+ }
346
+ if (!frontmatter) {
347
+ return `---\nsource: "${url}"\n---\n\n${content}`;
348
+ }
349
+ const { lineEnding, lines, endIndex } = frontmatter;
350
+ const bodyLines = lines.slice(1, endIndex);
351
+ const hasSource = bodyLines.some((line) => line.trimStart().toLowerCase().startsWith('source:'));
352
+ if (hasSource)
353
+ return content;
354
+ const updatedLines = [
355
+ lines[0],
356
+ ...bodyLines,
357
+ `source: "${url}"`,
358
+ ...lines.slice(endIndex),
359
+ ];
360
+ return updatedLines.join(lineEnding);
361
+ }
362
+ function hasFrontmatter(trimmed) {
363
+ return trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n');
364
+ }
365
+ function looksLikeHtmlDocument(trimmed) {
366
+ return HTML_DOCUMENT_PATTERN.test(trimmed);
367
+ }
368
+ function countCommonHtmlTags(content) {
369
+ const matches = content.match(/<(html|head|body|div|span|script|style|meta|link)\b/gi) ??
370
+ [];
371
+ return matches.length;
372
+ }
373
+ export function isRawTextContent(content) {
374
+ const trimmed = content.trim();
375
+ const isHtmlDocument = looksLikeHtmlDocument(trimmed);
376
+ const hasMarkdownFrontmatter = hasFrontmatter(trimmed);
377
+ const hasTooManyHtmlTags = countCommonHtmlTags(content) > 2;
378
+ const isMarkdown = looksLikeMarkdown(content);
379
+ return (!isHtmlDocument &&
380
+ (hasMarkdownFrontmatter || (!hasTooManyHtmlTags && isMarkdown)));
381
+ }
382
+ export function isLikelyHtmlContent(content) {
383
+ const trimmed = content.trim();
384
+ if (!trimmed)
385
+ return false;
386
+ if (looksLikeHtmlDocument(trimmed))
387
+ return true;
388
+ return countCommonHtmlTags(content) > 2;
389
+ }
390
+ function formatFetchedDate(isoString) {
391
+ try {
392
+ const date = new Date(isoString);
393
+ const day = String(date.getDate()).padStart(2, '0');
394
+ const month = String(date.getMonth() + 1).padStart(2, '0');
395
+ const year = date.getFullYear();
396
+ return `${day}-${month}-${year}`;
397
+ }
398
+ catch {
399
+ return isoString;
400
+ }
401
+ }
402
+ export function buildMetadataFooter(metadata, fallbackUrl) {
403
+ if (!metadata)
404
+ return '';
405
+ const lines = ['---', ''];
406
+ const url = metadata.url || fallbackUrl;
407
+ const parts = [];
408
+ if (metadata.title)
409
+ parts.push(`_${metadata.title}_`);
410
+ if (metadata.author)
411
+ parts.push(`_${metadata.author}_`);
412
+ if (url)
413
+ parts.push(`[_Original Source_](${url})`);
414
+ if (metadata.fetchedAt) {
415
+ const formattedDate = formatFetchedDate(metadata.fetchedAt);
416
+ parts.push(`_${formattedDate}_`);
417
+ }
418
+ if (parts.length > 0) {
419
+ lines.push(` ${parts.join(' | ')}`);
420
+ }
421
+ if (metadata.description) {
422
+ lines.push(` <sub>${metadata.description}</sub>`);
423
+ }
424
+ return lines.join('\n');
191
425
  }
192
426
  // ─────────────────────────────────────────────────────────────────────────────
193
427
  // Heading Promotion (fence-aware)
package/dist/mcp.js CHANGED
@@ -4,37 +4,53 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { registerCachedContentResource } from './cache.js';
5
5
  import { config } from './config.js';
6
6
  import { destroyAgents } from './fetch.js';
7
- import { logError, logInfo } from './observability.js';
7
+ import { logError, logInfo, setMcpServer } from './observability.js';
8
8
  import { registerTools } from './tools.js';
9
9
  import { shutdownTransformWorkerPool } from './transform.js';
10
+ function getLocalIconData() {
11
+ try {
12
+ const iconPath = new URL('../assets/logo.svg', import.meta.url);
13
+ const buffer = readFileSync(iconPath);
14
+ return `data:image/svg+xml;base64,${buffer.toString('base64')}`;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
10
20
  function createServerInfo() {
21
+ const localIcon = getLocalIconData();
11
22
  return {
12
23
  name: config.server.name,
13
24
  version: config.server.version,
25
+ ...(localIcon
26
+ ? {
27
+ icons: [
28
+ { src: localIcon, mimeType: 'image/svg+xml', sizes: ['any'] },
29
+ ],
30
+ }
31
+ : {}),
14
32
  };
15
33
  }
16
34
  function createServerCapabilities() {
17
35
  return {
18
36
  tools: { listChanged: true },
19
37
  resources: { listChanged: true, subscribe: true },
38
+ logging: {},
20
39
  };
21
40
  }
22
41
  function createServerInstructions(serverVersion) {
23
42
  try {
24
- const raw = readFileSync(new URL('./instructions.md', import.meta.url), {
25
- encoding: 'utf8',
26
- });
27
- const resolved = raw.replaceAll('{{SERVER_VERSION}}', serverVersion);
28
- return resolved.trim();
43
+ const raw = readFileSync(new URL('./instructions.md', import.meta.url), 'utf8');
44
+ return raw.replaceAll('{{SERVER_VERSION}}', serverVersion).trim();
29
45
  }
30
46
  catch {
31
- return `superFetch MCP server |${serverVersion}| A high-performance web content fetching and processing server.`;
47
+ return `Instructions unavailable | ${serverVersion}`;
32
48
  }
33
49
  }
34
50
  function registerInstructionsResource(server, instructions) {
35
51
  server.registerResource('instructions', new ResourceTemplate('internal://instructions', { list: undefined }), {
36
- title: 'Server Instructions',
37
- description: 'Usage guidance for the superFetch MCP server.',
52
+ title: `SuperFetch MCP | ${config.server.version}`,
53
+ description: 'Guidance for using the superFetch MCP server.',
38
54
  mimeType: 'text/markdown',
39
55
  }, (uri) => ({
40
56
  contents: [
@@ -52,7 +68,8 @@ export function createMcpServer() {
52
68
  capabilities: createServerCapabilities(),
53
69
  instructions,
54
70
  });
55
- registerTools(server);
71
+ setMcpServer(server);
72
+ registerTools(server, getLocalIconData());
56
73
  registerCachedContentResource(server);
57
74
  registerInstructionsResource(server, instructions);
58
75
  return server;
@@ -1,9 +1,11 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
2
  export type LogMetadata = Record<string, unknown>;
2
3
  interface RequestContext {
3
4
  readonly requestId: string;
4
5
  readonly sessionId?: string;
5
6
  readonly operationId?: string;
6
7
  }
8
+ export declare function setMcpServer(server: McpServer): void;
7
9
  export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
8
10
  export declare function getRequestId(): string | undefined;
9
11
  export declare function getSessionId(): string | undefined;