@j0hanz/fetch-url-mcp 1.0.0 → 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/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)
package/dist/index.js CHANGED
File without changes
@@ -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) {