@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 +1 -0
- package/dist/cache.d.ts +1 -0
- package/dist/cache.js +13 -6
- package/dist/config.js +38 -38
- package/dist/fetch.js +31 -14
- package/dist/http-native.js +20 -15
- package/dist/instructions.md +5 -2
- package/dist/mcp.js +60 -12
- package/dist/observability.js +3 -3
- package/dist/resources.js +88 -7
- package/dist/server.js +4 -1
- package/dist/session.js +12 -7
- package/dist/tools.js +45 -3
- package/dist/transform.js +37 -8
- package/dist/workers/transform-child.js +5 -4
- package/dist/workers/transform-worker.js +5 -4
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
|
231
|
-
const DEFAULT_TASKS_MAX_PER_OWNER = parseInteger(env
|
|
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
|
|
237
|
-
const maxYoungGenerationSizeMb = parseOptionalInteger(env
|
|
238
|
-
const codeRangeSizeMb = parseOptionalInteger(env
|
|
239
|
-
const stackSizeMb = parseOptionalInteger(env
|
|
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
|
|
287
|
-
if (env
|
|
288
|
-
staticTokens.add(env
|
|
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
|
|
298
|
-
clientId: env
|
|
299
|
-
clientSecret: env
|
|
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
|
|
306
|
-
const certFile = readOptionalFilePath(env
|
|
307
|
-
const caFile = readOptionalFilePath(env
|
|
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
|
|
353
|
-
const port = parsePort(env
|
|
352
|
+
const host = (env['HOST'] ?? LOOPBACK_V4).trim();
|
|
353
|
+
const port = parsePort(env['PORT']);
|
|
354
354
|
const httpsConfig = buildHttpsConfig();
|
|
355
|
-
const maxConnections = parseInteger(env
|
|
356
|
-
const headersTimeoutMs = parseOptionalInteger(env
|
|
357
|
-
const requestTimeoutMs = parseOptionalInteger(env
|
|
358
|
-
const keepAliveTimeoutMs = parseOptionalInteger(env
|
|
359
|
-
const keepAliveTimeoutBufferMs = parseOptionalInteger(env
|
|
360
|
-
const maxHeadersCount = parseOptionalInteger(env
|
|
361
|
-
const blockPrivateConnections = parseBoolean(env
|
|
362
|
-
const allowRemote = parseBoolean(env
|
|
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
|
|
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
|
|
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
|
|
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
|
|
423
|
-
extraSelectors: parseList(env
|
|
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
|
|
446
|
+
headingKeywords: parseListOrDefault(env['MARKDOWN_HEADING_KEYWORDS'], DEFAULT_HEADING_KEYWORDS),
|
|
447
447
|
},
|
|
448
448
|
i18n: {
|
|
449
|
-
locale: normalizeLocale(env
|
|
449
|
+
locale: normalizeLocale(env['FETCH_URL_MCP_LOCALE']),
|
|
450
450
|
},
|
|
451
451
|
logging: {
|
|
452
|
-
level: parseLogLevel(env
|
|
453
|
-
format: env
|
|
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
|
|
466
|
-
apiKey: env
|
|
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
|
|
673
|
+
logData['contextRequestId'] = ctx.contextRequestId;
|
|
657
674
|
if (ctx.operationId)
|
|
658
|
-
logData
|
|
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
|
|
705
|
+
logData['contextRequestId'] = context.contextRequestId;
|
|
689
706
|
if (context.operationId)
|
|
690
|
-
logData
|
|
707
|
+
logData['operationId'] = context.operationId;
|
|
691
708
|
if (contentType)
|
|
692
|
-
logData
|
|
709
|
+
logData['contentType'] = contentType;
|
|
693
710
|
if (size)
|
|
694
|
-
logData
|
|
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
|
|
720
|
+
warnData['contextRequestId'] = context.contextRequestId;
|
|
704
721
|
if (context.operationId)
|
|
705
|
-
warnData
|
|
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
|
|
755
|
+
logData['contextRequestId'] = context.contextRequestId;
|
|
739
756
|
if (context.operationId)
|
|
740
|
-
logData
|
|
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
|
|
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 =
|
|
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');
|
package/dist/http-native.js
CHANGED
|
@@ -617,7 +617,7 @@ class AuthService {
|
|
|
617
617
|
'content-type': 'application/x-www-form-urlencoded',
|
|
618
618
|
};
|
|
619
619
|
if (clientId) {
|
|
620
|
-
headers
|
|
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
|
|
645
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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/instructions.md
CHANGED
|
@@ -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
|
|
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
|
|
225
|
-
result
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
349
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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();
|
package/dist/observability.js
CHANGED
|
@@ -66,11 +66,11 @@ function buildContextMetadata() {
|
|
|
66
66
|
return undefined;
|
|
67
67
|
const meta = {};
|
|
68
68
|
if (requestId)
|
|
69
|
-
meta
|
|
69
|
+
meta['requestId'] = requestId;
|
|
70
70
|
if (operationId)
|
|
71
|
-
meta
|
|
71
|
+
meta['operationId'] = operationId;
|
|
72
72
|
if (includeSession)
|
|
73
|
-
meta
|
|
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
|
|
38
|
-
const hash = firstVariableValue(variables
|
|
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
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
session.server
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1642
|
+
if (typeof raw['id'] !== 'string')
|
|
1614
1643
|
return false;
|
|
1615
|
-
if (raw
|
|
1616
|
-
return isWorkerResultPayload(raw
|
|
1644
|
+
if (raw['type'] === 'result') {
|
|
1645
|
+
return isWorkerResultPayload(raw['result']);
|
|
1617
1646
|
}
|
|
1618
|
-
if (raw
|
|
1619
|
-
return isWorkerErrorPayload(raw
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
125
|
+
const { type, id } = msg;
|
|
126
|
+
if (type === 'cancel') {
|
|
127
|
+
if (typeof id !== 'string')
|
|
127
128
|
return;
|
|
128
|
-
const controller = controllersById.get(
|
|
129
|
+
const controller = controllersById.get(id);
|
|
129
130
|
if (controller)
|
|
130
131
|
controller.abort(new Error('Canceled'));
|
|
131
132
|
return;
|
|
132
133
|
}
|
|
133
|
-
if (
|
|
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
|
-
|
|
118
|
-
|
|
117
|
+
const { type, id } = msg;
|
|
118
|
+
if (type === 'cancel') {
|
|
119
|
+
if (typeof id !== 'string')
|
|
119
120
|
return;
|
|
120
|
-
const controller = controllersById.get(
|
|
121
|
+
const controller = controllersById.get(id);
|
|
121
122
|
if (controller)
|
|
122
123
|
controller.abort(new Error('Canceled'));
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
|
-
if (
|
|
126
|
+
if (type === 'transform') {
|
|
126
127
|
handleTransform(msg);
|
|
127
128
|
}
|
|
128
129
|
});
|
package/package.json
CHANGED