@j0hanz/fetch-url-mcp 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/AGENTS.md CHANGED
@@ -81,6 +81,7 @@ All commands verified from `.github/workflows/release.yml` (CI) and `package.jso
81
81
 
82
82
  - `strict: true`
83
83
  - `noUncheckedIndexedAccess: true`
84
+ - `noPropertyAccessFromIndexSignature: true`
84
85
  - `exactOptionalPropertyTypes: true`
85
86
  - `verbatimModuleSyntax: true`
86
87
  - `isolatedModules: true`
package/dist/cache.d.ts CHANGED
@@ -31,6 +31,7 @@ interface CacheUpdateEvent {
31
31
  cacheKey: string;
32
32
  namespace: string;
33
33
  urlHash: string;
34
+ listChanged: boolean;
34
35
  }
35
36
  type CacheUpdateListener = (event: CacheUpdateEvent) => unknown;
36
37
  export declare function parseCachedPayload(raw: string): CachedPayload | null;
package/dist/cache.js CHANGED
@@ -132,6 +132,7 @@ class InMemoryCacheStore {
132
132
  const now = Date.now();
133
133
  if (entry.expiresAtMs <= now) {
134
134
  this.delete(cacheKey);
135
+ this.notify(cacheKey, true);
135
136
  return undefined;
136
137
  }
137
138
  // Refresh LRU position
@@ -144,7 +145,9 @@ class InMemoryCacheStore {
144
145
  if (entry) {
145
146
  this.currentBytes -= entry.content.length;
146
147
  this.entries.delete(cacheKey);
148
+ return true;
147
149
  }
150
+ return false;
148
151
  }
149
152
  set(cacheKey, content, metadata, options) {
150
153
  if (!cacheKey || !content)
@@ -163,12 +166,15 @@ class InMemoryCacheStore {
163
166
  });
164
167
  return;
165
168
  }
169
+ let listChanged = !this.entries.has(cacheKey);
166
170
  // Evict if needed (size-based)
167
171
  while (this.currentBytes + entrySize > this.maxBytes) {
168
172
  const firstKey = this.entries.keys().next();
169
173
  if (firstKey.done)
170
174
  break;
171
- this.delete(firstKey.value);
175
+ if (this.delete(firstKey.value)) {
176
+ listChanged = true;
177
+ }
172
178
  }
173
179
  const entry = {
174
180
  url: metadata.url,
@@ -186,18 +192,19 @@ class InMemoryCacheStore {
186
192
  // Eviction (LRU: first insertion-order key) - Count based
187
193
  if (this.entries.size > this.max) {
188
194
  const firstKey = this.entries.keys().next();
189
- if (!firstKey.done)
190
- this.delete(firstKey.value);
195
+ if (!firstKey.done && this.delete(firstKey.value)) {
196
+ listChanged = true;
197
+ }
191
198
  }
192
- this.notify(cacheKey);
199
+ this.notify(cacheKey, listChanged);
193
200
  }
194
- notify(cacheKey) {
201
+ notify(cacheKey, listChanged) {
195
202
  if (this.updateEmitter.listenerCount('update') === 0)
196
203
  return;
197
204
  const parts = parseCacheKey(cacheKey);
198
205
  if (!parts)
199
206
  return;
200
- this.updateEmitter.emit('update', { cacheKey, ...parts });
207
+ this.updateEmitter.emit('update', { cacheKey, ...parts, listChanged });
201
208
  }
202
209
  logError(message, cacheKey, error) {
203
210
  logWarn(message, {
package/dist/config.js CHANGED
@@ -216,27 +216,27 @@ function readOptionalFilePath(value) {
216
216
  return trimmed.length > 0 ? trimmed : undefined;
217
217
  }
218
218
  const MAX_HTML_BYTES = 10 * 1024 * 1024; // 10 MB
219
- const MAX_INLINE_CONTENT_CHARS = parseInteger(env.MAX_INLINE_CONTENT_CHARS, 0, 0, MAX_HTML_BYTES);
219
+ const MAX_INLINE_CONTENT_CHARS = parseInteger(env['MAX_INLINE_CONTENT_CHARS'], 0, 0, MAX_HTML_BYTES);
220
220
  const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
221
221
  const DEFAULT_SESSION_INIT_TIMEOUT_MS = 10000;
222
222
  const DEFAULT_MAX_SESSIONS = 200;
223
223
  const DEFAULT_USER_AGENT = `fetch-url-mcp/${serverVersion}`;
224
224
  const DEFAULT_TOOL_TIMEOUT_PADDING_MS = 5000;
225
225
  const DEFAULT_TRANSFORM_TIMEOUT_MS = 30000;
226
- const DEFAULT_FETCH_TIMEOUT_MS = parseInteger(env.FETCH_TIMEOUT_MS, 15000, 1000, 60000);
226
+ const DEFAULT_FETCH_TIMEOUT_MS = parseInteger(env['FETCH_TIMEOUT_MS'], 15000, 1000, 60000);
227
227
  const DEFAULT_TOOL_TIMEOUT_MS = DEFAULT_FETCH_TIMEOUT_MS +
228
228
  DEFAULT_TRANSFORM_TIMEOUT_MS +
229
229
  DEFAULT_TOOL_TIMEOUT_PADDING_MS;
230
- const DEFAULT_TASKS_MAX_TOTAL = parseInteger(env.TASKS_MAX_TOTAL, 5000, 1);
231
- const DEFAULT_TASKS_MAX_PER_OWNER = parseInteger(env.TASKS_MAX_PER_OWNER, 1000, 1);
230
+ const DEFAULT_TASKS_MAX_TOTAL = parseInteger(env['TASKS_MAX_TOTAL'], 5000, 1);
231
+ const DEFAULT_TASKS_MAX_PER_OWNER = parseInteger(env['TASKS_MAX_PER_OWNER'], 1000, 1);
232
232
  const RESOLVED_TASKS_MAX_PER_OWNER = Math.min(DEFAULT_TASKS_MAX_PER_OWNER, DEFAULT_TASKS_MAX_TOTAL);
233
233
  function resolveWorkerResourceLimits() {
234
234
  const limits = {};
235
235
  let hasAny = false;
236
- const maxOldGenerationSizeMb = parseOptionalInteger(env.TRANSFORM_WORKER_MAX_OLD_GENERATION_MB, 1);
237
- const maxYoungGenerationSizeMb = parseOptionalInteger(env.TRANSFORM_WORKER_MAX_YOUNG_GENERATION_MB, 1);
238
- const codeRangeSizeMb = parseOptionalInteger(env.TRANSFORM_WORKER_CODE_RANGE_MB, 1);
239
- const stackSizeMb = parseOptionalInteger(env.TRANSFORM_WORKER_STACK_MB, 1);
236
+ const maxOldGenerationSizeMb = parseOptionalInteger(env['TRANSFORM_WORKER_MAX_OLD_GENERATION_MB'], 1);
237
+ const maxYoungGenerationSizeMb = parseOptionalInteger(env['TRANSFORM_WORKER_MAX_YOUNG_GENERATION_MB'], 1);
238
+ const codeRangeSizeMb = parseOptionalInteger(env['TRANSFORM_WORKER_CODE_RANGE_MB'], 1);
239
+ const stackSizeMb = parseOptionalInteger(env['TRANSFORM_WORKER_STACK_MB'], 1);
240
240
  if (maxOldGenerationSizeMb !== undefined) {
241
241
  limits.maxOldGenerationSizeMb = maxOldGenerationSizeMb;
242
242
  hasAny = true;
@@ -283,9 +283,9 @@ function resolveAuthMode(urls) {
283
283
  return oauthConfigured ? 'oauth' : 'static';
284
284
  }
285
285
  function collectStaticTokens() {
286
- const staticTokens = new Set(parseList(env.ACCESS_TOKENS));
287
- if (env.API_KEY)
288
- staticTokens.add(env.API_KEY);
286
+ const staticTokens = new Set(parseList(env['ACCESS_TOKENS']));
287
+ if (env['API_KEY'])
288
+ staticTokens.add(env['API_KEY']);
289
289
  return [...staticTokens];
290
290
  }
291
291
  function buildAuthConfig(baseUrl) {
@@ -294,17 +294,17 @@ function buildAuthConfig(baseUrl) {
294
294
  return {
295
295
  mode,
296
296
  ...urls,
297
- requiredScopes: parseList(env.OAUTH_REQUIRED_SCOPES),
298
- clientId: env.OAUTH_CLIENT_ID,
299
- clientSecret: env.OAUTH_CLIENT_SECRET,
297
+ requiredScopes: parseList(env['OAUTH_REQUIRED_SCOPES']),
298
+ clientId: env['OAUTH_CLIENT_ID'],
299
+ clientSecret: env['OAUTH_CLIENT_SECRET'],
300
300
  introspectionTimeoutMs: 5000,
301
301
  staticTokens: collectStaticTokens(),
302
302
  };
303
303
  }
304
304
  function buildHttpsConfig() {
305
- const keyFile = readOptionalFilePath(env.SERVER_TLS_KEY_FILE);
306
- const certFile = readOptionalFilePath(env.SERVER_TLS_CERT_FILE);
307
- const caFile = readOptionalFilePath(env.SERVER_TLS_CA_FILE);
305
+ const keyFile = readOptionalFilePath(env['SERVER_TLS_KEY_FILE']);
306
+ const certFile = readOptionalFilePath(env['SERVER_TLS_CERT_FILE']);
307
+ const caFile = readOptionalFilePath(env['SERVER_TLS_CA_FILE']);
308
308
  if ((keyFile && !certFile) || (!keyFile && certFile)) {
309
309
  throw new ConfigError('Both SERVER_TLS_KEY_FILE and SERVER_TLS_CERT_FILE must be set together');
310
310
  }
@@ -349,17 +349,17 @@ const BLOCKED_IP_PATTERNS = [
349
349
  ];
350
350
  const BLOCKED_IP_PATTERN = /^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.|169\.254\.|100\.64\.|fc00:|fd00:|fe80:)/i;
351
351
  const BLOCKED_IPV4_MAPPED_PATTERN = /^::ffff:(?:127\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
352
- const host = (env.HOST ?? LOOPBACK_V4).trim();
353
- const port = parsePort(env.PORT);
352
+ const host = (env['HOST'] ?? LOOPBACK_V4).trim();
353
+ const port = parsePort(env['PORT']);
354
354
  const httpsConfig = buildHttpsConfig();
355
- const maxConnections = parseInteger(env.SERVER_MAX_CONNECTIONS, 0, 0);
356
- const headersTimeoutMs = parseOptionalInteger(env.SERVER_HEADERS_TIMEOUT_MS, 1);
357
- const requestTimeoutMs = parseOptionalInteger(env.SERVER_REQUEST_TIMEOUT_MS, 0);
358
- const keepAliveTimeoutMs = parseOptionalInteger(env.SERVER_KEEP_ALIVE_TIMEOUT_MS, 1);
359
- const keepAliveTimeoutBufferMs = parseOptionalInteger(env.SERVER_KEEP_ALIVE_TIMEOUT_BUFFER_MS, 0);
360
- const maxHeadersCount = parseOptionalInteger(env.SERVER_MAX_HEADERS_COUNT, 1);
361
- const blockPrivateConnections = parseBoolean(env.SERVER_BLOCK_PRIVATE_CONNECTIONS, false);
362
- const allowRemote = parseBoolean(env.ALLOW_REMOTE, false);
355
+ const maxConnections = parseInteger(env['SERVER_MAX_CONNECTIONS'], 0, 0);
356
+ const headersTimeoutMs = parseOptionalInteger(env['SERVER_HEADERS_TIMEOUT_MS'], 1);
357
+ const requestTimeoutMs = parseOptionalInteger(env['SERVER_REQUEST_TIMEOUT_MS'], 0);
358
+ const keepAliveTimeoutMs = parseOptionalInteger(env['SERVER_KEEP_ALIVE_TIMEOUT_MS'], 1);
359
+ const keepAliveTimeoutBufferMs = parseOptionalInteger(env['SERVER_KEEP_ALIVE_TIMEOUT_BUFFER_MS'], 0);
360
+ const maxHeadersCount = parseOptionalInteger(env['SERVER_MAX_HEADERS_COUNT'], 1);
361
+ const blockPrivateConnections = parseBoolean(env['SERVER_BLOCK_PRIVATE_CONNECTIONS'], false);
362
+ const allowRemote = parseBoolean(env['ALLOW_REMOTE'], false);
363
363
  const baseUrl = new URL(`${httpsConfig.enabled ? 'https' : 'http'}://${formatHostForUrl(host)}:${port}`);
364
364
  const runtimeState = {
365
365
  httpMode: false,
@@ -389,7 +389,7 @@ export const config = {
389
389
  fetcher: {
390
390
  timeout: DEFAULT_FETCH_TIMEOUT_MS,
391
391
  maxRedirects: 5,
392
- userAgent: env.USER_AGENT ?? DEFAULT_USER_AGENT,
392
+ userAgent: env['USER_AGENT'] ?? DEFAULT_USER_AGENT,
393
393
  maxContentLength: MAX_HTML_BYTES,
394
394
  },
395
395
  transform: {
@@ -397,7 +397,7 @@ export const config = {
397
397
  stageWarnRatio: 0.5,
398
398
  metadataFormat: 'markdown',
399
399
  maxWorkerScale: 4,
400
- workerMode: parseTransformWorkerMode(env.TRANSFORM_WORKER_MODE),
400
+ workerMode: parseTransformWorkerMode(env['TRANSFORM_WORKER_MODE']),
401
401
  workerResourceLimits: resolveWorkerResourceLimits(),
402
402
  },
403
403
  tools: {
@@ -409,7 +409,7 @@ export const config = {
409
409
  maxPerOwner: RESOLVED_TASKS_MAX_PER_OWNER,
410
410
  },
411
411
  cache: {
412
- enabled: parseBoolean(env.CACHE_ENABLED, true),
412
+ enabled: parseBoolean(env['CACHE_ENABLED'], true),
413
413
  ttl: 86400,
414
414
  maxKeys: 100,
415
415
  maxSizeBytes: 50 * 1024 * 1024, // 50MB
@@ -419,8 +419,8 @@ export const config = {
419
419
  minParagraphLength: 10,
420
420
  },
421
421
  noiseRemoval: {
422
- extraTokens: parseList(env.FETCH_URL_MCP_EXTRA_NOISE_TOKENS),
423
- extraSelectors: parseList(env.FETCH_URL_MCP_EXTRA_NOISE_SELECTORS),
422
+ extraTokens: parseList(env['FETCH_URL_MCP_EXTRA_NOISE_TOKENS']),
423
+ extraSelectors: parseList(env['FETCH_URL_MCP_EXTRA_NOISE_SELECTORS']),
424
424
  enabledCategories: [
425
425
  'cookie-banners',
426
426
  'newsletters',
@@ -443,14 +443,14 @@ export const config = {
443
443
  removeSkipLinks: true,
444
444
  removeTocBlocks: true,
445
445
  removeTypeDocComments: true,
446
- headingKeywords: parseListOrDefault(env.MARKDOWN_HEADING_KEYWORDS, DEFAULT_HEADING_KEYWORDS),
446
+ headingKeywords: parseListOrDefault(env['MARKDOWN_HEADING_KEYWORDS'], DEFAULT_HEADING_KEYWORDS),
447
447
  },
448
448
  i18n: {
449
- locale: normalizeLocale(env.FETCH_URL_MCP_LOCALE),
449
+ locale: normalizeLocale(env['FETCH_URL_MCP_LOCALE']),
450
450
  },
451
451
  logging: {
452
- level: parseLogLevel(env.LOG_LEVEL),
453
- format: env.LOG_FORMAT?.toLowerCase() === 'json' ? 'json' : 'text',
452
+ level: parseLogLevel(env['LOG_LEVEL']),
453
+ format: env['LOG_FORMAT']?.toLowerCase() === 'json' ? 'json' : 'text',
454
454
  },
455
455
  constants: {
456
456
  maxHtmlSize: MAX_HTML_BYTES,
@@ -462,8 +462,8 @@ export const config = {
462
462
  blockedIpPatterns: BLOCKED_IP_PATTERNS,
463
463
  blockedIpPattern: BLOCKED_IP_PATTERN,
464
464
  blockedIpv4MappedPattern: BLOCKED_IPV4_MAPPED_PATTERN,
465
- allowedHosts: parseAllowedHosts(env.ALLOWED_HOSTS),
466
- apiKey: env.API_KEY,
465
+ allowedHosts: parseAllowedHosts(env['ALLOWED_HOSTS']),
466
+ apiKey: env['API_KEY'],
467
467
  allowRemote,
468
468
  },
469
469
  auth: buildAuthConfig(baseUrl),
package/dist/fetch.js CHANGED
@@ -26,6 +26,23 @@ const defaultRedactor = {
26
26
  redact: redactUrl,
27
27
  };
28
28
  const defaultFetch = (input, init) => globalThis.fetch(input, init);
29
+ function assertReadableStreamLike(stream, url, stage) {
30
+ if (isObject(stream) && typeof stream['getReader'] === 'function')
31
+ return;
32
+ throw new FetchError('Invalid response stream', url, 500, {
33
+ reason: 'invalid_stream',
34
+ stage,
35
+ });
36
+ }
37
+ function toNodeReadableStream(stream, url, stage) {
38
+ assertReadableStreamLike(stream, url, stage);
39
+ return stream;
40
+ }
41
+ function toWebReadableStream(stream, url, stage) {
42
+ const converted = Readable.toWeb(stream);
43
+ assertReadableStreamLike(converted, url, stage);
44
+ return converted;
45
+ }
29
46
  class IpBlocker {
30
47
  security;
31
48
  blockList = createDefaultBlockList();
@@ -653,9 +670,9 @@ class FetchTelemetry {
653
670
  url: ctx.url,
654
671
  };
655
672
  if (ctx.contextRequestId)
656
- logData.contextRequestId = ctx.contextRequestId;
673
+ logData['contextRequestId'] = ctx.contextRequestId;
657
674
  if (ctx.operationId)
658
- logData.operationId = ctx.operationId;
675
+ logData['operationId'] = ctx.operationId;
659
676
  this.logger.debug('HTTP Request', logData);
660
677
  return ctx;
661
678
  }
@@ -685,13 +702,13 @@ class FetchTelemetry {
685
702
  duration: durationLabel,
686
703
  };
687
704
  if (context.contextRequestId)
688
- logData.contextRequestId = context.contextRequestId;
705
+ logData['contextRequestId'] = context.contextRequestId;
689
706
  if (context.operationId)
690
- logData.operationId = context.operationId;
707
+ logData['operationId'] = context.operationId;
691
708
  if (contentType)
692
- logData.contentType = contentType;
709
+ logData['contentType'] = contentType;
693
710
  if (size)
694
- logData.size = size;
711
+ logData['size'] = size;
695
712
  this.logger.debug('HTTP Response', logData);
696
713
  if (duration > SLOW_REQUEST_THRESHOLD_MS) {
697
714
  const warnData = {
@@ -700,9 +717,9 @@ class FetchTelemetry {
700
717
  duration: durationLabel,
701
718
  };
702
719
  if (context.contextRequestId)
703
- warnData.contextRequestId = context.contextRequestId;
720
+ warnData['contextRequestId'] = context.contextRequestId;
704
721
  if (context.operationId)
705
- warnData.operationId = context.operationId;
722
+ warnData['operationId'] = context.operationId;
706
723
  this.logger.warn('Slow HTTP request detected', warnData);
707
724
  }
708
725
  }
@@ -735,9 +752,9 @@ class FetchTelemetry {
735
752
  error: err.message,
736
753
  };
737
754
  if (context.contextRequestId)
738
- logData.contextRequestId = context.contextRequestId;
755
+ logData['contextRequestId'] = context.contextRequestId;
739
756
  if (context.operationId)
740
- logData.operationId = context.operationId;
757
+ logData['operationId'] = context.operationId;
741
758
  if (status === 429) {
742
759
  this.logger.warn('HTTP Request Error', logData);
743
760
  return;
@@ -839,7 +856,7 @@ class RedirectFollower {
839
856
  annotateRedirectError(error, url) {
840
857
  if (!isObject(error))
841
858
  return;
842
- error.requestUrl = url;
859
+ error['requestUrl'] = url;
843
860
  }
844
861
  async withRedirectErrorContext(url, fn) {
845
862
  try {
@@ -1072,7 +1089,7 @@ class ResponseTextReader {
1072
1089
  let encodingResolved = false;
1073
1090
  let total = 0;
1074
1091
  const chunks = [];
1075
- const source = Readable.fromWeb(stream);
1092
+ const source = Readable.fromWeb(toNodeReadableStream(stream, url, 'response:read-stream-buffer'));
1076
1093
  const guard = new Transform({
1077
1094
  transform(chunk, _encoding, callback) {
1078
1095
  try {
@@ -1377,7 +1394,7 @@ async function decodeResponseIfNeeded(response, url, signal) {
1377
1394
  });
1378
1395
  }
1379
1396
  const decompressors = decodeOrder.map((encoding) => createDecompressor(encoding));
1380
- const sourceStream = Readable.fromWeb(createPumpedStream(initialChunk, reader));
1397
+ const sourceStream = Readable.fromWeb(toNodeReadableStream(createPumpedStream(initialChunk, reader), url, 'response:decode-content-encoding'));
1381
1398
  const decodedNodeStream = new PassThrough();
1382
1399
  const pipelinePromise = pipeline([
1383
1400
  sourceStream,
@@ -1397,7 +1414,7 @@ async function decodeResponseIfNeeded(response, url, signal) {
1397
1414
  void pipelinePromise.catch((error) => {
1398
1415
  decodedNodeStream.destroy(error instanceof Error ? error : new Error(String(error)));
1399
1416
  });
1400
- const decodedBody = Readable.toWeb(decodedNodeStream);
1417
+ const decodedBody = toWebReadableStream(decodedNodeStream, url, 'response:decode-content-encoding');
1401
1418
  const headers = new Headers(response.headers);
1402
1419
  headers.delete('content-encoding');
1403
1420
  headers.delete('content-length');
@@ -617,7 +617,7 @@ class AuthService {
617
617
  'content-type': 'application/x-www-form-urlencoded',
618
618
  };
619
619
  if (clientId) {
620
- headers.authorization = this.buildBasicAuthHeader(clientId, clientSecret);
620
+ headers['authorization'] = this.buildBasicAuthHeader(clientId, clientSecret);
621
621
  }
622
622
  return { body, headers };
623
623
  }
@@ -641,12 +641,13 @@ class AuthService {
641
641
  return response.json();
642
642
  }
643
643
  buildIntrospectionAuthInfo(token, payload) {
644
- const expiresAt = typeof payload.exp === 'number' ? payload.exp : undefined;
645
- const clientId = typeof payload.client_id === 'string' ? payload.client_id : 'unknown';
644
+ const { exp, client_id: clientIdRaw, scope: scopeRaw } = payload;
645
+ const expiresAt = typeof exp === 'number' ? exp : undefined;
646
+ const clientId = typeof clientIdRaw === 'string' ? clientIdRaw : 'unknown';
646
647
  const info = {
647
648
  token,
648
649
  clientId,
649
- scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
650
+ scopes: typeof scopeRaw === 'string' ? scopeRaw.split(' ') : [],
650
651
  resource: config.auth.resourceUrl,
651
652
  };
652
653
  if (expiresAt !== undefined)
@@ -659,7 +660,7 @@ class AuthService {
659
660
  }
660
661
  const req = this.buildIntrospectionRequest(token, config.auth.resourceUrl, config.auth.clientId, config.auth.clientSecret);
661
662
  const payload = await this.requestIntrospection(config.auth.introspectionUrl, req, config.auth.introspectionTimeoutMs, signal);
662
- if (!isObject(payload) || payload.active !== true) {
663
+ if (!isObject(payload) || payload['active'] !== true) {
663
664
  throw new InvalidTokenError('Token is inactive');
664
665
  }
665
666
  return this.buildIntrospectionAuthInfo(token, payload);
@@ -1245,6 +1246,7 @@ function resolveListeningPort(server, fallback) {
1245
1246
  return fallback;
1246
1247
  }
1247
1248
  function createShutdownHandler(options) {
1249
+ const closeBatchSize = 10;
1248
1250
  return async (signal) => {
1249
1251
  logInfo(`Stopping HTTP server (${signal})...`);
1250
1252
  options.rateLimiter.stop();
@@ -1252,16 +1254,19 @@ function createShutdownHandler(options) {
1252
1254
  drainConnectionsOnShutdown(options.server);
1253
1255
  eventLoopDelay.disable();
1254
1256
  const sessions = options.sessionStore.clear();
1255
- await Promise.all(sessions.map(async (session) => {
1256
- const sessionId = resolveMcpSessionIdByServer(session.server);
1257
- if (sessionId) {
1258
- cancelTasksForOwner(`session:${sessionId}`, 'The task was cancelled because the HTTP server is shutting down.');
1259
- unregisterMcpSessionServer(sessionId);
1260
- }
1261
- unregisterMcpSessionServerByServer(session.server);
1262
- await closeTransportBestEffort(session.transport, 'shutdown-session-close');
1263
- await closeMcpServerBestEffort(session.server, 'shutdown-session-close');
1264
- }));
1257
+ for (let i = 0; i < sessions.length; i += closeBatchSize) {
1258
+ const batch = sessions.slice(i, i + closeBatchSize);
1259
+ await Promise.all(batch.map(async (session) => {
1260
+ const sessionId = resolveMcpSessionIdByServer(session.server);
1261
+ if (sessionId) {
1262
+ cancelTasksForOwner(`session:${sessionId}`, 'The task was cancelled because the HTTP server is shutting down.');
1263
+ unregisterMcpSessionServer(sessionId);
1264
+ }
1265
+ unregisterMcpSessionServerByServer(session.server);
1266
+ await closeTransportBestEffort(session.transport, 'shutdown-session-close');
1267
+ await closeMcpServerBestEffort(session.server, 'shutdown-session-close');
1268
+ }));
1269
+ }
1265
1270
  await new Promise((resolve, reject) => {
1266
1271
  options.server.close((err) => {
1267
1272
  if (err)
@@ -22,7 +22,9 @@ Available as resource (`internal://instructions`) or prompt (`get-help`). Load w
22
22
 
23
23
  - `internal://instructions`: This document.
24
24
  - `internal://cache/{namespace}/{hash}`: Immutable cached Markdown snapshots from previous `fetch-url` calls. Ephemeral — lost when the server process restarts.
25
+ - `fetch-url` responses include a `resource_link` content block when cache is enabled; use that URI directly with `resources/read`/`resources/subscribe`.
25
26
  - If inline Markdown is truncated (ends with `...[truncated]`), the full content may be available via the cache resource. Use `resources/read` with the cache URI to retrieve it.
27
+ - Clients can subscribe to cache resource URIs via `resources/subscribe` and receive `notifications/resources/updated` when that specific cache entry changes.
26
28
 
27
29
  ---
28
30
 
@@ -44,7 +46,7 @@ Available as resource (`internal://instructions`) or prompt (`get-help`). Load w
44
46
 
45
47
  1. Call `fetch-url` with `{ "url": "https://..." }`.
46
48
  2. Read the `markdown` field from `structuredContent`.
47
- 3. If `truncated` is `true`: use the cache resource URI or paginated access to get full content.
49
+ 3. If `truncated` is `true`: use `cacheResourceUri` from `structuredContent` with `resources/read` to get full content.
48
50
  NOTE: Never guess URIs; always use values returned in responses.
49
51
 
50
52
  ### WORKFLOW B: FRESH CONTENT (BYPASS CACHE)
@@ -77,13 +79,14 @@ Available as resource (`internal://instructions`) or prompt (`get-help`). Load w
77
79
  - `skipNoiseRemoval` (bool): Keeps navigation, footers, and other elements normally filtered.
78
80
  - `forceRefresh` (bool): Bypasses the cache and fetches live.
79
81
  - `maxInlineChars` (int, 0–10485760): Per-call inline limit. `0` means unlimited. If a global limit is configured, the lower value wins.
80
- - Output: `{ url, inputUrl, resolvedUrl, finalUrl, title, metadata, markdown, fromCache, fetchedAt, contentSize, truncated, error, statusCode, details }`
82
+ - Output: `{ url, inputUrl, resolvedUrl, finalUrl, cacheResourceUri, title, metadata, markdown, fromCache, fetchedAt, contentSize, truncated, error, statusCode, details }`
81
83
  - `metadata`: Extracted page metadata — `title`, `description`, `author`, `image`, `favicon`, `publishedAt`, `modifiedAt`.
82
84
  - `markdown`: The extracted content. May be absent on error.
83
85
  - `truncated`: `true` when inline content was cut. Full content stored in cache.
84
86
  - `resolvedUrl`: The normalized/raw-transformed URL actually fetched (GitHub/GitLab/Bitbucket URLs auto-convert to raw content URLs).
85
87
  - `finalUrl`: The URL after following redirects.
86
88
  - Side effects: None (read-only, idempotent). Populates the in-memory cache automatically.
89
+ - `cacheResourceUri`: Present when cache key generation succeeds; use with `resources/read` for full content retrieval.
87
90
  - Gotcha: Inline Markdown may be truncated when `MAX_INLINE_CONTENT_CHARS` is configured. Check the `truncated` field and use the cache resource for full content.
88
91
  - Gotcha: GitHub, GitLab, and Bitbucket URLs are auto-transformed to raw content endpoints. Check `resolvedUrl` to see the actual fetched URL.
89
92
  - Gotcha: Does not execute client-side JavaScript. Content requiring JS rendering may be incomplete.
package/dist/mcp.js CHANGED
@@ -71,6 +71,48 @@ function parseExtendedCallToolRequest(request) {
71
71
  function isRecord(value) {
72
72
  return isObject(value);
73
73
  }
74
+ function parseHandlerExtra(extra) {
75
+ if (!isObject(extra))
76
+ return undefined;
77
+ const parsed = {};
78
+ const { sessionId, authInfo, signal, requestId, sendNotification } = extra;
79
+ if (typeof sessionId === 'string')
80
+ parsed.sessionId = sessionId;
81
+ if (isObject(authInfo)) {
82
+ const { clientId, token } = authInfo;
83
+ const normalized = {};
84
+ if (typeof clientId === 'string')
85
+ normalized.clientId = clientId;
86
+ if (typeof token === 'string')
87
+ normalized.token = token;
88
+ if (normalized.clientId || normalized.token)
89
+ parsed.authInfo = normalized;
90
+ }
91
+ if (signal instanceof AbortSignal)
92
+ parsed.signal = signal;
93
+ if (typeof requestId === 'string' || typeof requestId === 'number') {
94
+ parsed.requestId = requestId;
95
+ }
96
+ if (typeof sendNotification === 'function') {
97
+ const notify = sendNotification;
98
+ parsed.sendNotification = async (notification) => {
99
+ await Promise.resolve(notify(notification));
100
+ };
101
+ }
102
+ return parsed;
103
+ }
104
+ function isServerResult(value) {
105
+ return (isObject(value) && Array.isArray(value.content));
106
+ }
107
+ function tryReadToolStructuredError(value) {
108
+ if (!isObject(value))
109
+ return undefined;
110
+ const record = value;
111
+ if (!isObject(record.structuredContent))
112
+ return undefined;
113
+ const structured = record.structuredContent;
114
+ return typeof structured.error === 'string' ? structured.error : undefined;
115
+ }
74
116
  function resolveTaskOwnerKey(extra) {
75
117
  if (extra?.sessionId)
76
118
  return `session:${extra.sessionId}`;
@@ -221,13 +263,12 @@ async function runFetchTaskExecution(params) {
221
263
  },
222
264
  });
223
265
  const isToolError = isRecord(result) &&
224
- typeof result.isError === 'boolean' &&
225
- result.isError;
266
+ typeof result['isError'] === 'boolean' &&
267
+ result['isError'];
226
268
  taskManager.updateTask(taskId, {
227
269
  status: isToolError ? 'failed' : 'completed',
228
270
  statusMessage: isToolError
229
- ? (result
230
- .structuredContent?.error ?? 'Tool execution failed')
271
+ ? (tryReadToolStructuredError(result) ?? 'Tool execution failed')
231
272
  : 'Task completed successfully.',
232
273
  result,
233
274
  });
@@ -313,11 +354,12 @@ async function handleToolCallRequest(server, request, context) {
313
354
  * ------------------------------------------------------------------------------------------------- */
314
355
  export function registerTaskHandlers(server) {
315
356
  server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
316
- const context = resolveToolCallContext(extra);
357
+ const parsedExtra = parseHandlerExtra(extra);
358
+ const context = resolveToolCallContext(parsedExtra);
317
359
  const requestId = context.requestId !== undefined
318
360
  ? String(context.requestId)
319
361
  : randomUUID();
320
- const sessionId = extra?.sessionId;
362
+ const sessionId = parsedExtra?.sessionId;
321
363
  return runWithRequestContext({
322
364
  requestId,
323
365
  operationId: requestId,
@@ -329,7 +371,8 @@ export function registerTaskHandlers(server) {
329
371
  });
330
372
  server.server.setRequestHandler(TaskGetSchema, async (request, extra) => {
331
373
  const { taskId } = request.params;
332
- const ownerKey = resolveTaskOwnerKey(extra);
374
+ const parsedExtra = parseHandlerExtra(extra);
375
+ const ownerKey = resolveTaskOwnerKey(parsedExtra);
333
376
  const task = taskManager.getTask(taskId, ownerKey);
334
377
  if (!task)
335
378
  throwTaskNotFound();
@@ -345,8 +388,9 @@ export function registerTaskHandlers(server) {
345
388
  });
346
389
  server.server.setRequestHandler(TaskResultSchema, async (request, extra) => {
347
390
  const { taskId } = request.params;
348
- const ownerKey = resolveTaskOwnerKey(extra);
349
- const task = await taskManager.waitForTerminalTask(taskId, ownerKey, extra?.signal);
391
+ const parsedExtra = parseHandlerExtra(extra);
392
+ const ownerKey = resolveTaskOwnerKey(parsedExtra);
393
+ const task = await taskManager.waitForTerminalTask(taskId, ownerKey, parsedExtra?.signal);
350
394
  if (!task)
351
395
  throwTaskNotFound();
352
396
  if (task.status === 'failed') {
@@ -374,7 +418,9 @@ export function registerTaskHandlers(server) {
374
418
  if (task.status === 'cancelled') {
375
419
  throw new McpError(ErrorCode.InvalidRequest, 'Task was cancelled');
376
420
  }
377
- const result = (task.result ?? { content: [] });
421
+ const result = isServerResult(task.result)
422
+ ? task.result
423
+ : { content: [] };
378
424
  return Promise.resolve({
379
425
  ...result,
380
426
  _meta: {
@@ -384,7 +430,8 @@ export function registerTaskHandlers(server) {
384
430
  });
385
431
  });
386
432
  server.server.setRequestHandler(TaskListSchema, async (request, extra) => {
387
- const ownerKey = resolveTaskOwnerKey(extra);
433
+ const parsedExtra = parseHandlerExtra(extra);
434
+ const ownerKey = resolveTaskOwnerKey(parsedExtra);
388
435
  const cursor = request.params?.cursor;
389
436
  const { tasks, nextCursor } = taskManager.listTasks(cursor === undefined ? { ownerKey } : { ownerKey, cursor });
390
437
  return Promise.resolve({
@@ -401,7 +448,8 @@ export function registerTaskHandlers(server) {
401
448
  });
402
449
  server.server.setRequestHandler(TaskCancelSchema, async (request, extra) => {
403
450
  const { taskId } = request.params;
404
- const ownerKey = resolveTaskOwnerKey(extra);
451
+ const parsedExtra = parseHandlerExtra(extra);
452
+ const ownerKey = resolveTaskOwnerKey(parsedExtra);
405
453
  const task = taskManager.cancelTask(taskId, ownerKey);
406
454
  if (!task)
407
455
  throwTaskNotFound();
@@ -66,11 +66,11 @@ function buildContextMetadata() {
66
66
  return undefined;
67
67
  const meta = {};
68
68
  if (requestId)
69
- meta.requestId = requestId;
69
+ meta['requestId'] = requestId;
70
70
  if (operationId)
71
- meta.operationId = operationId;
71
+ meta['operationId'] = operationId;
72
72
  if (includeSession)
73
- meta.sessionId = sessionId;
73
+ meta['sessionId'] = sessionId;
74
74
  return meta;
75
75
  }
76
76
  function mergeMetadata(meta) {
package/dist/resources.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import { ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
3
- import { get as getCacheEntry, keys as listCacheKeys, parseCachedPayload, parseCacheKey, resolveCachedPayloadContent, } from './cache.js';
2
+ import { ErrorCode, McpError, SubscribeRequestSchema, UnsubscribeRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { get as getCacheEntry, keys as listCacheKeys, onCacheUpdate, parseCachedPayload, parseCacheKey, resolveCachedPayloadContent, } from './cache.js';
4
+ import { logWarn } from './observability.js';
5
+ import { isObject } from './type-guards.js';
4
6
  const CACHE_RESOURCE_TEMPLATE_URI = 'internal://cache/{namespace}/{hash}';
5
7
  const CACHE_RESOURCE_PREFIX = 'internal://cache/';
6
8
  const CACHE_NAMESPACE_PATTERN = /^[a-z0-9_-]{1,64}$/i;
@@ -34,8 +36,8 @@ function firstVariableValue(value) {
34
36
  return undefined;
35
37
  }
36
38
  function parseCacheResourceFromVariables(variables) {
37
- const namespace = firstVariableValue(variables.namespace);
38
- const hash = firstVariableValue(variables.hash);
39
+ const namespace = firstVariableValue(variables['namespace']);
40
+ const hash = firstVariableValue(variables['hash']);
39
41
  if (!namespace || !hash)
40
42
  return null;
41
43
  const decoded = {
@@ -86,7 +88,7 @@ function completeCacheNamespaces(value) {
86
88
  }
87
89
  function completeCacheHashes(value, context) {
88
90
  const normalized = value.trim().toLowerCase();
89
- const namespace = context?.arguments?.namespace?.trim();
91
+ const namespace = context?.arguments?.['namespace']?.trim();
90
92
  const hashes = new Set();
91
93
  for (const key of listCacheKeys()) {
92
94
  const parsed = parseCacheKey(key);
@@ -106,7 +108,6 @@ function listCacheResources() {
106
108
  const resources = listCacheKeys()
107
109
  .map((key) => parseCacheKey(key))
108
110
  .filter((parts) => Boolean(parts))
109
- .slice(0, MAX_COMPLETION_VALUES)
110
111
  .map((parts) => {
111
112
  const cacheParts = {
112
113
  namespace: parts.namespace,
@@ -126,6 +127,85 @@ function listCacheResources() {
126
127
  });
127
128
  return { resources };
128
129
  }
130
+ function normalizeSubscriptionUri(uri) {
131
+ if (!URL.canParse(uri)) {
132
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid resource URI');
133
+ }
134
+ const parsedUri = new URL(uri);
135
+ const cacheParts = parseCacheResourceFromUri(parsedUri);
136
+ if (cacheParts)
137
+ return toCacheResourceUri(cacheParts);
138
+ return parsedUri.href;
139
+ }
140
+ function registerCacheResourceNotifications(server) {
141
+ const subscribedResourceUris = new Set();
142
+ server.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
143
+ subscribedResourceUris.add(normalizeSubscriptionUri(request.params.uri));
144
+ return Promise.resolve({});
145
+ });
146
+ server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
147
+ subscribedResourceUris.delete(normalizeSubscriptionUri(request.params.uri));
148
+ return Promise.resolve({});
149
+ });
150
+ const unsubscribe = onCacheUpdate((event) => {
151
+ const changedUri = toCacheResourceUri({
152
+ namespace: event.namespace,
153
+ hash: event.urlHash,
154
+ });
155
+ if (server.isConnected() && subscribedResourceUris.has(changedUri)) {
156
+ void server.server
157
+ .sendResourceUpdated({ uri: changedUri })
158
+ .catch((error) => {
159
+ logWarn('Failed to send resource updated notification', {
160
+ uri: changedUri,
161
+ error,
162
+ });
163
+ });
164
+ }
165
+ if (!event.listChanged)
166
+ return;
167
+ if (!server.isConnected())
168
+ return;
169
+ try {
170
+ server.sendResourceListChanged();
171
+ }
172
+ catch (error) {
173
+ logWarn('Failed to send resources list changed notification', { error });
174
+ }
175
+ });
176
+ let cleanedUp = false;
177
+ const cleanup = () => {
178
+ if (cleanedUp)
179
+ return;
180
+ cleanedUp = true;
181
+ unsubscribe();
182
+ };
183
+ const originalOnClose = server.server.onclose;
184
+ server.server.onclose = () => {
185
+ cleanup();
186
+ originalOnClose?.();
187
+ };
188
+ const originalClose = server.close.bind(server);
189
+ server.close = async () => {
190
+ cleanup();
191
+ await originalClose();
192
+ };
193
+ }
194
+ function normalizeTemplateVariables(variables) {
195
+ if (!isObject(variables))
196
+ return {};
197
+ const normalized = {};
198
+ for (const [key, value] of Object.entries(variables)) {
199
+ if (typeof value === 'string' || value === undefined) {
200
+ normalized[key] = value;
201
+ continue;
202
+ }
203
+ if (Array.isArray(value)) {
204
+ normalized[key] = value.filter((item) => typeof item === 'string');
205
+ }
206
+ }
207
+ return normalized;
208
+ }
129
209
  function resolveCacheResourceParts(uri, variables) {
130
210
  const fromVariables = parseCacheResourceFromVariables(variables);
131
211
  if (fromVariables)
@@ -212,5 +292,6 @@ export function registerCacheResourceTemplate(server, iconInfo) {
212
292
  ],
213
293
  }
214
294
  : {}),
215
- }, (uri, variables) => readCacheResource(uri, variables));
295
+ }, (uri, variables) => readCacheResource(uri, normalizeTemplateVariables(variables)));
296
+ registerCacheResourceNotifications(server);
216
297
  }
package/dist/server.js CHANGED
@@ -42,7 +42,10 @@ catch (error) {
42
42
  function createServerCapabilities() {
43
43
  return {
44
44
  logging: {},
45
- resources: {},
45
+ resources: {
46
+ subscribe: true,
47
+ listChanged: true,
48
+ },
46
49
  tools: {},
47
50
  prompts: {},
48
51
  completions: {},
package/dist/session.js CHANGED
@@ -56,12 +56,15 @@ class SessionCleanupLoop {
56
56
  for await (const getNow of ticks) {
57
57
  const now = getNow();
58
58
  const evicted = this.store.evictExpired();
59
- for (const session of evicted) {
60
- unregisterMcpSessionServerByServer(session.server);
61
- void Promise.allSettled([
62
- session.transport.close(),
63
- session.server.close(),
64
- ]).then((results) => {
59
+ const closeBatchSize = 10;
60
+ for (let i = 0; i < evicted.length; i += closeBatchSize) {
61
+ const batch = evicted.slice(i, i + closeBatchSize);
62
+ await Promise.allSettled(batch.map(async (session) => {
63
+ unregisterMcpSessionServerByServer(session.server);
64
+ const results = await Promise.allSettled([
65
+ session.transport.close(),
66
+ session.server.close(),
67
+ ]);
65
68
  const [transportResult, serverResult] = results;
66
69
  if (transportResult.status === 'rejected') {
67
70
  logWarn('Failed to close expired session transport', {
@@ -73,7 +76,9 @@ class SessionCleanupLoop {
73
76
  error: formatError(serverResult.reason),
74
77
  });
75
78
  }
76
- });
79
+ }));
80
+ if (signal.aborted)
81
+ return;
77
82
  }
78
83
  if (evicted.length > 0) {
79
84
  logInfo('Expired sessions evicted', {
package/dist/tools.js CHANGED
@@ -53,6 +53,11 @@ const fetchUrlOutputSchema = z.strictObject({
53
53
  .max(config.constants.maxUrlLength)
54
54
  .optional()
55
55
  .describe('The final response URL after redirects'),
56
+ cacheResourceUri: z
57
+ .string()
58
+ .max(config.constants.maxUrlLength)
59
+ .optional()
60
+ .describe('Internal cache resource URI for retrieving full markdown via resources/read'),
56
61
  title: z.string().max(512).optional().describe('Page title'),
57
62
  metadata: z
58
63
  .strictObject({
@@ -413,8 +418,27 @@ function buildEmbeddedResource(content, url, title) {
413
418
  resource,
414
419
  };
415
420
  }
416
- function buildToolContentBlocks(structuredContent, embeddedResource) {
421
+ function buildCacheResourceLink(cacheResourceUri, contentSize, fetchedAt) {
422
+ return {
423
+ type: 'resource_link',
424
+ uri: cacheResourceUri,
425
+ name: 'cached-markdown',
426
+ title: 'Cached Fetch Output',
427
+ description: 'Read full markdown via resources/read.',
428
+ mimeType: 'text/markdown',
429
+ ...(contentSize > 0 ? { size: contentSize } : {}),
430
+ annotations: {
431
+ audience: ['assistant'],
432
+ priority: 0.8,
433
+ lastModified: fetchedAt,
434
+ },
435
+ };
436
+ }
437
+ function buildToolContentBlocks(structuredContent, resourceLink, embeddedResource) {
417
438
  const blocks = [buildTextBlock(structuredContent)];
439
+ if (resourceLink) {
440
+ blocks.push(resourceLink);
441
+ }
418
442
  if (embeddedResource) {
419
443
  blocks.push(embeddedResource);
420
444
  }
@@ -434,7 +458,7 @@ function logRawUrlTransformation(resolvedUrl) {
434
458
  }
435
459
  function extractTitle(value) {
436
460
  const record = asRecord(value);
437
- const title = record ? record.title : undefined;
461
+ const title = record ? record['title'] : undefined;
438
462
  return typeof title === 'string' ? title : undefined;
439
463
  }
440
464
  function logCacheMiss(reason, cacheNamespace, normalizedUrl, error) {
@@ -682,6 +706,7 @@ function serializeMarkdownResult(result) {
682
706
  * fetch-url tool implementation
683
707
  * ------------------------------------------------------------------------------------------------- */
684
708
  function buildStructuredContent(pipeline, inlineResult, inputUrl) {
709
+ const cacheResourceUri = resolveCacheResourceUri(pipeline.cacheKey);
685
710
  const truncated = inlineResult.truncated ?? pipeline.data.truncated;
686
711
  let markdown = inlineResult.content;
687
712
  if (pipeline.data.truncated &&
@@ -694,6 +719,7 @@ function buildStructuredContent(pipeline, inlineResult, inputUrl) {
694
719
  url: pipeline.originalUrl ?? pipeline.url,
695
720
  resolvedUrl: pipeline.url,
696
721
  ...(pipeline.finalUrl ? { finalUrl: pipeline.finalUrl } : {}),
722
+ ...(cacheResourceUri ? { cacheResourceUri } : {}),
697
723
  inputUrl,
698
724
  title: pipeline.data.title,
699
725
  ...(metadata ? { metadata } : {}),
@@ -704,14 +730,30 @@ function buildStructuredContent(pipeline, inlineResult, inputUrl) {
704
730
  ...(truncated ? { truncated: true } : {}),
705
731
  };
706
732
  }
733
+ function resolveCacheResourceUri(cacheKey) {
734
+ if (!cacheKey)
735
+ return undefined;
736
+ if (!cache.isEnabled())
737
+ return undefined;
738
+ if (!cache.get(cacheKey))
739
+ return undefined;
740
+ const parsed = cache.parseCacheKey(cacheKey);
741
+ if (!parsed)
742
+ return undefined;
743
+ return `internal://cache/${encodeURIComponent(parsed.namespace)}/${encodeURIComponent(parsed.urlHash)}`;
744
+ }
707
745
  function buildFetchUrlContentBlocks(structuredContent, pipeline, inlineResult) {
746
+ const cacheResourceUri = readString(structuredContent, 'cacheResourceUri');
708
747
  const contentToEmbed = config.runtime.httpMode
709
748
  ? inlineResult.content
710
749
  : pipeline.data.content;
750
+ const resourceLink = cacheResourceUri
751
+ ? buildCacheResourceLink(cacheResourceUri, inlineResult.contentSize, pipeline.fetchedAt)
752
+ : null;
711
753
  const embedded = contentToEmbed && pipeline.url
712
754
  ? buildEmbeddedResource(contentToEmbed, pipeline.url, pipeline.data.title)
713
755
  : null;
714
- return buildToolContentBlocks(structuredContent, embedded);
756
+ return buildToolContentBlocks(structuredContent, resourceLink, embedded);
715
757
  }
716
758
  function buildResponse(pipeline, inlineResult, inputUrl) {
717
759
  const structuredContent = buildStructuredContent(pipeline, inlineResult, inputUrl);
package/dist/transform.js CHANGED
@@ -44,7 +44,7 @@ function getTagName(node) {
44
44
  }
45
45
  function getAbortReason(signal) {
46
46
  const record = isObject(signal) ? signal : null;
47
- return record && 'reason' in record ? record.reason : undefined;
47
+ return record && 'reason' in record ? record['reason'] : undefined;
48
48
  }
49
49
  function isTimeoutAbortReason(reason) {
50
50
  return reason instanceof Error && reason.name === 'TimeoutError';
@@ -424,6 +424,35 @@ function isReadabilityCompatible(doc) {
424
424
  'function' &&
425
425
  typeof record.querySelector === 'function');
426
426
  }
427
+ function resolveCollapsedTextLengthUpTo(text, max) {
428
+ if (max <= 0)
429
+ return 0;
430
+ let length = 0;
431
+ let seenNonWhitespace = false;
432
+ let pendingSpace = false;
433
+ for (let i = 0; i < text.length; i += 1) {
434
+ const code = text.charCodeAt(i);
435
+ const isWhitespace = code <= 0x20;
436
+ if (isWhitespace) {
437
+ if (seenNonWhitespace)
438
+ pendingSpace = true;
439
+ continue;
440
+ }
441
+ if (!seenNonWhitespace) {
442
+ seenNonWhitespace = true;
443
+ }
444
+ else if (pendingSpace) {
445
+ length += 1;
446
+ pendingSpace = false;
447
+ if (length >= max)
448
+ return length;
449
+ }
450
+ length += 1;
451
+ if (length >= max)
452
+ return length;
453
+ }
454
+ return length;
455
+ }
427
456
  function extractArticle(document, url, signal) {
428
457
  if (!isReadabilityCompatible(document)) {
429
458
  logWarn('Document not compatible with Readability');
@@ -436,7 +465,7 @@ function extractArticle(document, url, signal) {
436
465
  const rawText = doc.querySelector('body')?.textContent ??
437
466
  doc.documentElement.textContent ??
438
467
  '';
439
- const textLength = rawText.replace(/\s+/g, ' ').trim().length;
468
+ const textLength = resolveCollapsedTextLengthUpTo(rawText, 401);
440
469
  if (textLength < 100) {
441
470
  logWarn('Very minimal server-rendered content detected (< 100 chars). ' +
442
471
  'This might be a client-side rendered (SPA) application. ' +
@@ -1610,13 +1639,13 @@ function isWorkerErrorPayload(value) {
1610
1639
  function isWorkerResponse(raw) {
1611
1640
  if (!isObject(raw))
1612
1641
  return false;
1613
- if (typeof raw.id !== 'string')
1642
+ if (typeof raw['id'] !== 'string')
1614
1643
  return false;
1615
- if (raw.type === 'result') {
1616
- return isWorkerResultPayload(raw.result);
1644
+ if (raw['type'] === 'result') {
1645
+ return isWorkerResultPayload(raw['result']);
1617
1646
  }
1618
- if (raw.type === 'error') {
1619
- return isWorkerErrorPayload(raw.error);
1647
+ if (raw['type'] === 'error') {
1648
+ return isWorkerErrorPayload(raw['error']);
1620
1649
  }
1621
1650
  return false;
1622
1651
  }
@@ -2287,7 +2316,7 @@ async function transformWithWorkerPool(htmlOrBuffer, url, options) {
2287
2316
  });
2288
2317
  }
2289
2318
  function resolveWorkerFallback(error, htmlOrBuffer, url, options) {
2290
- const isQueueFull = error instanceof FetchError && error.details.reason === 'queue_full';
2319
+ const isQueueFull = error instanceof FetchError && error.details['reason'] === 'queue_full';
2291
2320
  if (isQueueFull) {
2292
2321
  logWarn('Transform worker queue full; falling back to in-process', {
2293
2322
  url: redactUrl(url),
@@ -122,15 +122,16 @@ process.on('message', (raw) => {
122
122
  if (!raw || typeof raw !== 'object')
123
123
  return;
124
124
  const msg = raw;
125
- if (msg.type === 'cancel') {
126
- if (typeof msg.id !== 'string')
125
+ const { type, id } = msg;
126
+ if (type === 'cancel') {
127
+ if (typeof id !== 'string')
127
128
  return;
128
- const controller = controllersById.get(msg.id);
129
+ const controller = controllersById.get(id);
129
130
  if (controller)
130
131
  controller.abort(new Error('Canceled'));
131
132
  return;
132
133
  }
133
- if (msg.type === 'transform') {
134
+ if (type === 'transform') {
134
135
  handleTransform(msg);
135
136
  }
136
137
  });
@@ -114,15 +114,16 @@ port.on('message', (raw) => {
114
114
  if (!raw || typeof raw !== 'object')
115
115
  return;
116
116
  const msg = raw;
117
- if (msg.type === 'cancel') {
118
- if (typeof msg.id !== 'string')
117
+ const { type, id } = msg;
118
+ if (type === 'cancel') {
119
+ if (typeof id !== 'string')
119
120
  return;
120
- const controller = controllersById.get(msg.id);
121
+ const controller = controllersById.get(id);
121
122
  if (controller)
122
123
  controller.abort(new Error('Canceled'));
123
124
  return;
124
125
  }
125
- if (msg.type === 'transform') {
126
+ if (type === 'transform') {
126
127
  handleTransform(msg);
127
128
  }
128
129
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/fetch-url-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.j0hanz/fetch-url-mcp",
5
5
  "description": "Intelligent web content fetcher MCP server that converts HTML to clean, AI-readable Markdown",
6
6
  "type": "module",