@j0hanz/superfetch 2.1.0 → 2.1.2

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/http.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { once } from 'node:events';
2
3
  import { isIP } from 'node:net';
3
4
  import { setInterval as setIntervalPromise } from 'node:timers/promises';
4
5
  import { z } from 'zod';
@@ -15,6 +16,7 @@ import { destroyAgents } from './fetch.js';
15
16
  import { createMcpServer } from './mcp.js';
16
17
  import { logDebug, logError, logInfo, logWarn, runWithRequestContext, } from './observability.js';
17
18
  import { shutdownTransformWorkerPool } from './transform.js';
19
+ import { isRecord } from './utils.js';
18
20
  function getRateLimitKey(req) {
19
21
  return req.ip ?? req.socket.remoteAddress ?? 'unknown';
20
22
  }
@@ -209,20 +211,40 @@ function registerSignalHandlers(shutdown) {
209
211
  });
210
212
  }
211
213
  function startListening(app) {
212
- return app
213
- .listen(config.server.port, config.server.host, () => {
214
+ const formatHostForUrl = (hostname) => {
215
+ if (hostname.includes(':') && !hostname.startsWith('[')) {
216
+ return `[${hostname}]`;
217
+ }
218
+ return hostname;
219
+ };
220
+ const server = app.listen(config.server.port, config.server.host, () => {
221
+ const address = server.address();
222
+ const resolvedPort = typeof address === 'object' && address
223
+ ? address.port
224
+ : config.server.port;
214
225
  logInfo('superFetch MCP server started', {
215
226
  host: config.server.host,
216
- port: config.server.port,
227
+ port: resolvedPort,
217
228
  });
218
- const baseUrl = `http://${config.server.host}:${config.server.port}`;
229
+ const baseUrl = `http://${formatHostForUrl(config.server.host)}:${resolvedPort}`;
219
230
  logInfo(`superFetch MCP server running at ${baseUrl} (health: ${baseUrl}/health, mcp: ${baseUrl}/mcp)`);
220
231
  logInfo('Run with --stdio flag for direct stdio integration');
221
- })
222
- .on('error', (err) => {
232
+ });
233
+ server.on('error', (err) => {
223
234
  logError('Failed to start server', err);
224
235
  process.exit(1);
225
236
  });
237
+ return server;
238
+ }
239
+ async function stopServerWithoutExit(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
240
+ stopRateLimitCleanup();
241
+ sessionCleanupController.abort();
242
+ await closeSessions(sessionStore);
243
+ await new Promise((resolve) => {
244
+ server.close(() => {
245
+ resolve();
246
+ });
247
+ });
226
248
  }
227
249
  function buildMiddleware() {
228
250
  const { middleware: rateLimitMiddleware, stop: stopRateLimitCleanup } = createRateLimitMiddleware(config.rateLimit);
@@ -280,14 +302,47 @@ function attachSessionRoutes(app, authMiddleware) {
280
302
  registerHttpRoutes(app, sessionStore, authMiddleware);
281
303
  return { sessionStore, sessionCleanupController };
282
304
  }
283
- export async function startHttpServer() {
305
+ async function ensureServerListening(server) {
306
+ if (server.listening)
307
+ return;
308
+ await once(server, 'listening');
309
+ }
310
+ function resolveServerAddress(server) {
311
+ const address = server.address();
312
+ const resolvedPort = typeof address === 'object' && address ? address.port : config.server.port;
313
+ const { host } = config.server;
314
+ const formattedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
315
+ const url = `http://${formattedHost}:${resolvedPort}`;
316
+ return { host, port: resolvedPort, url };
317
+ }
318
+ function createStopHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
319
+ return async () => {
320
+ await stopServerWithoutExit(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
321
+ };
322
+ }
323
+ function buildServerLifecycle(options) {
324
+ const { server, sessionStore, sessionCleanupController, stopRateLimitCleanup, registerSignals, } = options;
325
+ const shutdown = createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
326
+ const stop = createStopHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
327
+ if (registerSignals)
328
+ registerSignalHandlers(shutdown);
329
+ return { shutdown, stop };
330
+ }
331
+ export async function startHttpServer(options) {
284
332
  enableHttpMode();
285
333
  const { app, sessionStore, sessionCleanupController, stopRateLimitCleanup } = await buildServerContext();
286
334
  const server = startListening(app);
287
335
  applyHttpServerTuning(server);
288
- const shutdown = createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
289
- registerSignalHandlers(shutdown);
290
- return { shutdown };
336
+ await ensureServerListening(server);
337
+ const { host, port, url } = resolveServerAddress(server);
338
+ const { shutdown, stop } = buildServerLifecycle({
339
+ server,
340
+ sessionStore,
341
+ sessionCleanupController,
342
+ stopRateLimitCleanup,
343
+ registerSignals: options?.registerSignalHandlers !== false,
344
+ });
345
+ return { shutdown, stop, url, host, port };
291
346
  }
292
347
  async function createExpressApp() {
293
348
  const { default: express } = await import('express');
@@ -533,9 +588,6 @@ export function createCorsMiddleware() {
533
588
  next();
534
589
  };
535
590
  }
536
- function isRecord(value) {
537
- return typeof value === 'object' && value !== null;
538
- }
539
591
  function parseScopes(value) {
540
592
  if (typeof value === 'string') {
541
593
  return value
@@ -963,6 +1015,20 @@ function createAccessorDescriptor(getter, setter) {
963
1015
  configurable: true,
964
1016
  };
965
1017
  }
1018
+ export function composeCloseHandlers(first, second) {
1019
+ if (!first)
1020
+ return second;
1021
+ if (!second)
1022
+ return first;
1023
+ return () => {
1024
+ try {
1025
+ first();
1026
+ }
1027
+ finally {
1028
+ second();
1029
+ }
1030
+ };
1031
+ }
966
1032
  function createOnCloseDescriptor(transport) {
967
1033
  return createAccessorDescriptor(() => transport.onclose, (handler) => {
968
1034
  transport.onclose = handler;
@@ -1025,8 +1091,12 @@ function createSessionTransport({ tracker, timeoutController, }) {
1025
1091
  async function connectTransportOrThrow({ transport, clearInitTimeout, releaseSlot, }) {
1026
1092
  const mcpServer = createMcpServer();
1027
1093
  const transportAdapter = createTransportAdapter(transport);
1094
+ const oncloseBeforeConnect = transport.onclose;
1028
1095
  try {
1029
1096
  await mcpServer.connect(transportAdapter);
1097
+ if (oncloseBeforeConnect && transport.onclose !== oncloseBeforeConnect) {
1098
+ transport.onclose = composeCloseHandlers(transport.onclose, oncloseBeforeConnect);
1099
+ }
1030
1100
  }
1031
1101
  catch (error) {
1032
1102
  clearInitTimeout();
@@ -1077,15 +1147,38 @@ function reserveSessionIfPossible({ options, res, }) {
1077
1147
  }
1078
1148
  return true;
1079
1149
  }
1080
- function resolveSessionId({ transport, res, tracker, clearInitTimeout, }) {
1150
+ function resolveExistingSessionTransport(store, sessionId, res, requestId) {
1151
+ const existingSession = store.get(sessionId);
1152
+ if (existingSession) {
1153
+ store.touch(sessionId);
1154
+ return existingSession.transport;
1155
+ }
1156
+ // Client supplied a session id but it doesn't exist; Streamable HTTP: invalid session IDs => 404.
1157
+ sendJsonRpcError(res, -32600, 'Session not found', 404, requestId);
1158
+ return null;
1159
+ }
1160
+ function createSessionContext() {
1161
+ const tracker = createSlotTracker();
1162
+ const timeoutController = createTimeoutController();
1163
+ const transport = createSessionTransport({ tracker, timeoutController });
1164
+ return { tracker, timeoutController, transport };
1165
+ }
1166
+ function finalizeSessionIfValid({ store, transport, tracker, clearInitTimeout, res, }) {
1081
1167
  const { sessionId } = transport;
1082
1168
  if (typeof sessionId !== 'string') {
1083
1169
  clearInitTimeout();
1084
1170
  tracker.releaseSlot();
1085
1171
  respondBadRequest(res, null);
1086
- return null;
1172
+ return false;
1087
1173
  }
1088
- return sessionId;
1174
+ finalizeSession({
1175
+ store,
1176
+ transport,
1177
+ sessionId,
1178
+ tracker,
1179
+ clearInitTimeout,
1180
+ });
1181
+ return true;
1089
1182
  }
1090
1183
  function finalizeSession({ store, transport, sessionId, tracker, clearInitTimeout, }) {
1091
1184
  clearInitTimeout();
@@ -1097,53 +1190,40 @@ function finalizeSession({ store, transport, sessionId, tracker, clearInitTimeou
1097
1190
  createdAt: now,
1098
1191
  lastSeen: now,
1099
1192
  });
1100
- transport.onclose = () => {
1193
+ const previousOnClose = transport.onclose;
1194
+ transport.onclose = composeCloseHandlers(previousOnClose, () => {
1101
1195
  store.remove(sessionId);
1102
1196
  logInfo('Session closed');
1103
- };
1197
+ });
1104
1198
  logInfo('Session initialized');
1105
1199
  }
1106
1200
  async function createAndConnectTransport({ options, res, }) {
1107
1201
  if (!reserveSessionIfPossible({ options, res }))
1108
1202
  return null;
1109
- const tracker = createSlotTracker();
1110
- const timeoutController = createTimeoutController();
1111
- const transport = createSessionTransport({ tracker, timeoutController });
1203
+ const { tracker, timeoutController, transport } = createSessionContext();
1112
1204
  await connectTransportOrThrow({
1113
1205
  transport,
1114
1206
  clearInitTimeout: timeoutController.clear,
1115
1207
  releaseSlot: tracker.releaseSlot,
1116
1208
  });
1117
- const sessionId = resolveSessionId({
1118
- transport,
1119
- res,
1120
- tracker,
1121
- clearInitTimeout: timeoutController.clear,
1122
- });
1123
- if (!sessionId)
1124
- return null;
1125
- finalizeSession({
1209
+ if (!finalizeSessionIfValid({
1126
1210
  store: options.sessionStore,
1127
1211
  transport,
1128
- sessionId,
1129
1212
  tracker,
1130
1213
  clearInitTimeout: timeoutController.clear,
1131
- });
1214
+ res,
1215
+ })) {
1216
+ return null;
1217
+ }
1132
1218
  return transport;
1133
1219
  }
1134
1220
  export async function resolveTransportForPost({ res, body, sessionId, options, }) {
1221
+ const requestId = body.id ?? null;
1135
1222
  if (sessionId) {
1136
- const existingSession = options.sessionStore.get(sessionId);
1137
- if (existingSession) {
1138
- options.sessionStore.touch(sessionId);
1139
- return existingSession.transport;
1140
- }
1141
- // Client supplied a session id but it doesn't exist; Streamable HTTP: invalid session IDs => 404.
1142
- sendJsonRpcError(res, -32600, 'Session not found', 404, body.id ?? null);
1143
- return null;
1223
+ return resolveExistingSessionTransport(options.sessionStore, sessionId, res, requestId);
1144
1224
  }
1145
1225
  if (!isInitializeRequest(body)) {
1146
- respondBadRequest(res, body.id ?? null);
1226
+ respondBadRequest(res, requestId);
1147
1227
  return null;
1148
1228
  }
1149
1229
  evictExpiredSessionsWithClose(options.sessionStore);
@@ -1238,13 +1318,13 @@ async function handleTransportRequest(transport, req, res, body) {
1238
1318
  }
1239
1319
  catch (error) {
1240
1320
  logError('MCP request handling failed', error instanceof Error ? error : undefined);
1241
- handleTransportError(res);
1321
+ handleTransportError(res, body?.id ?? null);
1242
1322
  }
1243
1323
  }
1244
- function handleTransportError(res) {
1324
+ function handleTransportError(res, id) {
1245
1325
  if (res.headersSent)
1246
1326
  return;
1247
- res.status(500).json({ error: 'Internal Server Error' });
1327
+ sendJsonRpcError(res, -32603, 'Internal error', 500, id);
1248
1328
  }
1249
1329
  function dispatchTransportRequest(transport, req, res, body) {
1250
1330
  return body
@@ -1267,7 +1347,6 @@ function resolveSessionTransport(sessionId, options, res) {
1267
1347
  }
1268
1348
  const MCP_PROTOCOL_VERSION_HEADER = 'mcp-protocol-version';
1269
1349
  const MCP_PROTOCOL_VERSIONS = {
1270
- defaultVersion: '2025-03-26',
1271
1350
  supported: new Set(['2025-11-25']),
1272
1351
  };
1273
1352
  function getHeaderValue(req, headerNameLower) {
@@ -1278,21 +1357,12 @@ function getHeaderValue(req, headerNameLower) {
1278
1357
  return value[0] ?? null;
1279
1358
  return null;
1280
1359
  }
1281
- function setHeaderValue(req, headerNameLower, value) {
1282
- // Express exposes req.headers as a plain object, but the type is readonly-ish.
1283
- req.headers[headerNameLower] = value;
1284
- }
1285
1360
  export function ensureMcpProtocolVersionHeader(req, res) {
1286
1361
  const raw = getHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER);
1287
1362
  const version = raw?.trim();
1288
1363
  if (!version) {
1289
- const assumed = MCP_PROTOCOL_VERSIONS.defaultVersion;
1290
- setHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER, assumed);
1291
- if (!MCP_PROTOCOL_VERSIONS.supported.has(assumed)) {
1292
- sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${assumed}`, 400);
1293
- return false;
1294
- }
1295
- return true;
1364
+ sendJsonRpcError(res, -32600, 'Missing required MCP-Protocol-Version header', 400);
1365
+ return false;
1296
1366
  }
1297
1367
  if (!MCP_PROTOCOL_VERSIONS.supported.has(version)) {
1298
1368
  sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`, 400);
@@ -0,0 +1,66 @@
1
+ # superFetch MCP Server — AI Usage Instructions
2
+
3
+ Use this server to fetch single public http(s) URLs, extract readable content, and return clean Markdown suitable for summarization, RAG ingestion, and citation. Prefer these tools over "remembering" state in chat.
4
+
5
+ ## Operating Rules
6
+
7
+ - Only fetch sources that are necessary and likely authoritative.
8
+ - Cite using `resolvedUrl` (when present) and keep `fetchedAt`/metadata intact.
9
+ - If content is missing/truncated, check for a `resource_link` in the output and read the cache resource.
10
+ - If request is vague, ask clarifying questions.
11
+
12
+ ### Strategies
13
+
14
+ - **Discovery:** Use `fetch-url` to retrieve content. Review the output for `resource_link` if the page is large.
15
+ - **Action:** Read the Markdown content directly from the tool output or the referenced resource.
16
+
17
+ ## Data Model
18
+
19
+ - **Markdown Content:** `markdown` content, `title`, and `url` metadata.
20
+ - **Resources:** Cached content accessible via `superfetch://cache/{namespace}/{hash}`.
21
+
22
+ ## Workflows
23
+
24
+ ### 1) Fetch and Read
25
+
26
+ ```text
27
+ fetch-url(url) → Get markdown content
28
+ If content truncated → read resource(superfetch://cache/...)
29
+ ```
30
+
31
+ ## Tools
32
+
33
+ ### fetch-url
34
+
35
+ Fetches a webpage and converts it to clean Markdown format (HTML → Readability → Markdown).
36
+
37
+ - **Use when:** You need the text content of a specific public URL.
38
+ - **Args:**
39
+ - `url` (string, required): The URL to fetch (must be http/https).
40
+ - **Returns:**
41
+ - `structuredContent` with `markdown`, `title`, `url`.
42
+ - Content block with standard text.
43
+ - Or `resource_link` block if content exceeds inline limits.
44
+
45
+ ## Response Shape
46
+
47
+ Success: `{ "content": [...], "structuredContent": { "markdown": "...", "title": "...", "url": "..." } }`
48
+ Error: `{ "isError": true, "structuredContent": { "error": "...", "url": "..." } }`
49
+
50
+ ### Common Errors
51
+
52
+ | Code | Meaning | Resolution |
53
+ | ------------------ | -------------------- | ------------------------------- |
54
+ | `VALIDATION_ERROR` | Invalid input URL | Ensure URL is valid http/https |
55
+ | `FETCH_ERROR` | Network/HTTP failure | Verify URL is public/accessible |
56
+
57
+ ## Limits
58
+
59
+ - **Max Inline Characters:** 20000
60
+ - **Max Content Size:** 10MB
61
+ - **Fetch Timeout:** 15000ms
62
+
63
+ ## Security
64
+
65
+ - Server blocks private/internal IP ranges (localhost, 127.x, 192.168.x, metadata services).
66
+ - Do not attempt to fetch internal network targets.
package/dist/mcp.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
4
  import { registerCachedContentResource } from './cache.js';
@@ -20,7 +21,16 @@ function createServerCapabilities() {
20
21
  };
21
22
  }
22
23
  function createServerInstructions(serverVersion) {
23
- return `superFetch MCP server |${serverVersion}| A high-performance web content fetching and processing server.`;
24
+ try {
25
+ const raw = readFileSync(new URL('./instructions.md', import.meta.url), {
26
+ encoding: 'utf8',
27
+ });
28
+ const resolved = raw.replaceAll('{{SERVER_VERSION}}', serverVersion);
29
+ return resolved.trim();
30
+ }
31
+ catch {
32
+ return `superFetch MCP server |${serverVersion}| A high-performance web content fetching and processing server.`;
33
+ }
24
34
  }
25
35
  export function createMcpServer() {
26
36
  const server = new McpServer(createServerInfo(), {
@@ -20,7 +20,7 @@ function formatMetadata(meta) {
20
20
  const contextMeta = {};
21
21
  if (requestId)
22
22
  contextMeta.requestId = requestId;
23
- if (sessionId)
23
+ if (sessionId && config.logging.level === 'debug')
24
24
  contextMeta.sessionId = sessionId;
25
25
  if (operationId)
26
26
  contextMeta.operationId = operationId;
package/dist/tools.d.ts CHANGED
@@ -62,6 +62,10 @@ export interface PipelineResult<T> {
62
62
  fetchedAt: string;
63
63
  cacheKey?: string | null;
64
64
  }
65
+ export interface ToolHandlerExtra {
66
+ signal?: AbortSignal;
67
+ requestId?: string | number;
68
+ }
65
69
  export declare const FETCH_URL_TOOL_NAME = "fetch-url";
66
70
  export declare const FETCH_URL_TOOL_DESCRIPTION = "Fetches a webpage and converts it to clean Markdown format";
67
71
  interface InlineContentResult {
@@ -79,6 +83,7 @@ interface SharedFetchOptions<T extends {
79
83
  content: string;
80
84
  }> {
81
85
  readonly url: string;
86
+ readonly signal?: AbortSignal;
82
87
  readonly transform: (html: string, normalizedUrl: string) => T | Promise<T>;
83
88
  readonly serialize?: (result: T) => string;
84
89
  readonly deserialize?: (cached: string) => T | undefined;
@@ -98,7 +103,7 @@ type MarkdownPipelineResult = MarkdownTransformResult & {
98
103
  readonly content: string;
99
104
  };
100
105
  export declare function parseCachedMarkdownResult(cached: string): MarkdownPipelineResult | undefined;
101
- export declare function fetchUrlToolHandler(input: FetchUrlInput): Promise<ToolResponseBase>;
102
- export declare function withRequestContextIfMissing<TParams, TResult>(handler: (params: TParams) => Promise<TResult>): (params: TParams) => Promise<TResult>;
106
+ export declare function fetchUrlToolHandler(input: FetchUrlInput, extra?: ToolHandlerExtra): Promise<ToolResponseBase>;
107
+ export declare function withRequestContextIfMissing<TParams, TResult, TExtra = unknown>(handler: (params: TParams, extra?: TExtra) => Promise<TResult>): (params: TParams, extra?: TExtra) => Promise<TResult>;
103
108
  export declare function registerTools(server: McpServer): void;
104
109
  export {};
package/dist/tools.js CHANGED
@@ -4,8 +4,9 @@ import * as cache from './cache.js';
4
4
  import { config } from './config.js';
5
5
  import { FetchError, isSystemError } from './errors.js';
6
6
  import { fetchNormalizedUrl, normalizeUrl, transformToRawUrl, } from './fetch.js';
7
- import { getRequestId, logDebug, logError, runWithRequestContext, } from './observability.js';
7
+ import { getRequestId, logDebug, logError, logWarn, runWithRequestContext, } from './observability.js';
8
8
  import { transformHtmlToMarkdown, } from './transform.js';
9
+ import { isRecord } from './utils.js';
9
10
  const TRUNCATION_MARKER = '...[truncated]';
10
11
  const fetchUrlInputSchema = z.strictObject({
11
12
  url: z.url({ protocol: /^https?$/i }).describe('The URL to fetch'),
@@ -29,9 +30,6 @@ const fetchUrlOutputSchema = z.strictObject({
29
30
  });
30
31
  export const FETCH_URL_TOOL_NAME = 'fetch-url';
31
32
  export const FETCH_URL_TOOL_DESCRIPTION = 'Fetches a webpage and converts it to clean Markdown format';
32
- function isRecord(value) {
33
- return typeof value === 'object' && value !== null;
34
- }
35
33
  function serializeStructuredContent(structuredContent, fromCache) {
36
34
  return JSON.stringify(structuredContent, fromCache ? undefined : null, fromCache ? undefined : 2);
37
35
  }
@@ -213,7 +211,9 @@ function extractTitle(value) {
213
211
  return typeof title === 'string' ? title : undefined;
214
212
  }
215
213
  function logCacheMiss(reason, cacheNamespace, normalizedUrl) {
216
- logDebug(`Cache miss due to ${reason}`, {
214
+ // Deserialize failures indicate unexpected data; surface at warn level.
215
+ const log = reason === 'deserialize failure' ? logWarn : logDebug;
216
+ log(`Cache miss due to ${reason}`, {
217
217
  namespace: cacheNamespace,
218
218
  url: normalizedUrl,
219
219
  });
@@ -238,6 +238,7 @@ export async function performSharedFetch(options, deps = {}) {
238
238
  const pipelineOptions = {
239
239
  url: options.url,
240
240
  cacheNamespace: 'markdown',
241
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
241
242
  transform: options.transform,
242
243
  };
243
244
  applyOptionalPipelineSerialization(pipelineOptions, options);
@@ -323,9 +324,10 @@ function deserializeMarkdownResult(cached) {
323
324
  return parseCachedMarkdownResult(cached);
324
325
  }
325
326
  function buildMarkdownTransform() {
326
- return async (html, url) => {
327
+ return async (html, url, signal) => {
327
328
  const result = await transformHtmlToMarkdown(html, url, {
328
329
  includeMetadata: true,
330
+ ...(signal === undefined ? {} : { signal }),
329
331
  });
330
332
  return { ...result, content: result.markdown };
331
333
  };
@@ -352,10 +354,11 @@ function buildFetchUrlContentBlocks(structuredContent, pipeline, inlineResult) {
352
354
  function logFetchStart(url) {
353
355
  logDebug('Fetching URL', { url });
354
356
  }
355
- async function fetchPipeline(url) {
357
+ async function fetchPipeline(url, signal) {
356
358
  return performSharedFetch({
357
359
  url,
358
- transform: buildMarkdownTransform(),
360
+ ...(signal === undefined ? {} : { signal }),
361
+ transform: (html, normalizedUrl) => buildMarkdownTransform()(html, normalizedUrl, signal),
359
362
  serialize: serializeMarkdownResult,
360
363
  deserialize: deserializeMarkdownResult,
361
364
  });
@@ -368,20 +371,20 @@ function buildResponse(pipeline, inlineResult, inputUrl) {
368
371
  structuredContent,
369
372
  };
370
373
  }
371
- async function executeFetch(input) {
374
+ async function executeFetch(input, extra) {
372
375
  const { url } = input;
373
376
  if (!url) {
374
377
  return createToolErrorResponse('URL is required', '');
375
378
  }
376
379
  logFetchStart(url);
377
- const { pipeline, inlineResult } = await fetchPipeline(url);
380
+ const { pipeline, inlineResult } = await fetchPipeline(url, extra?.signal);
378
381
  if (inlineResult.error) {
379
382
  return createToolErrorResponse(inlineResult.error, url);
380
383
  }
381
384
  return buildResponse(pipeline, inlineResult, url);
382
385
  }
383
- export async function fetchUrlToolHandler(input) {
384
- return executeFetch(input).catch((error) => {
386
+ export async function fetchUrlToolHandler(input, extra) {
387
+ return executeFetch(input, extra).catch((error) => {
385
388
  logError('fetch-url tool error', error instanceof Error ? error : undefined);
386
389
  return handleToolError(error, input.url, 'Failed to fetch URL');
387
390
  });
@@ -401,15 +404,25 @@ const TOOL_DEFINITION = {
401
404
  },
402
405
  };
403
406
  export function withRequestContextIfMissing(handler) {
404
- return async (params) => {
407
+ return async (params, extra) => {
405
408
  const existingRequestId = getRequestId();
406
409
  if (existingRequestId) {
407
- return handler(params);
410
+ return handler(params, extra);
408
411
  }
409
- const requestId = randomUUID();
410
- return runWithRequestContext({ requestId, operationId: requestId }, () => handler(params));
412
+ const derivedRequestId = resolveRequestIdFromExtra(extra) ?? randomUUID();
413
+ return runWithRequestContext({ requestId: derivedRequestId, operationId: derivedRequestId }, () => handler(params, extra));
411
414
  };
412
415
  }
416
+ function resolveRequestIdFromExtra(extra) {
417
+ if (!isRecord(extra))
418
+ return undefined;
419
+ const { requestId } = extra;
420
+ if (typeof requestId === 'string')
421
+ return requestId;
422
+ if (typeof requestId === 'number')
423
+ return String(requestId);
424
+ return undefined;
425
+ }
413
426
  export function registerTools(server) {
414
427
  server.registerTool(TOOL_DEFINITION.name, {
415
428
  title: TOOL_DEFINITION.title,