@j0hanz/superfetch 2.4.5 → 2.4.6

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/tasks.js CHANGED
@@ -1,13 +1,26 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
3
  const DEFAULT_TTL_MS = 60000;
3
4
  const DEFAULT_POLL_INTERVAL_MS = 1000;
5
+ const DEFAULT_OWNER_KEY = 'default';
6
+ const DEFAULT_PAGE_SIZE = 50;
7
+ const TERMINAL_STATUSES = new Set([
8
+ 'completed',
9
+ 'failed',
10
+ 'cancelled',
11
+ ]);
12
+ function isTerminalStatus(status) {
13
+ return TERMINAL_STATUSES.has(status);
14
+ }
4
15
  export class TaskManager {
5
16
  tasks = new Map();
6
- createTask(options, statusMessage = 'Task started') {
17
+ waiters = new Map();
18
+ createTask(options, statusMessage = 'Task started', ownerKey = DEFAULT_OWNER_KEY) {
7
19
  const taskId = randomUUID();
8
20
  const now = new Date().toISOString();
9
21
  const task = {
10
22
  taskId,
23
+ ownerKey,
11
24
  status: 'working',
12
25
  statusMessage,
13
26
  createdAt: now,
@@ -18,26 +31,40 @@ export class TaskManager {
18
31
  this.tasks.set(taskId, task);
19
32
  return task;
20
33
  }
21
- getTask(taskId) {
22
- return this.tasks.get(taskId);
34
+ getTask(taskId, ownerKey) {
35
+ const task = this.tasks.get(taskId);
36
+ if (!task)
37
+ return undefined;
38
+ if (ownerKey && task.ownerKey !== ownerKey)
39
+ return undefined;
40
+ if (this.isExpired(task)) {
41
+ this.tasks.delete(taskId);
42
+ return undefined;
43
+ }
44
+ return task;
23
45
  }
24
46
  updateTask(taskId, updates) {
25
47
  const task = this.tasks.get(taskId);
26
48
  if (!task)
27
49
  return;
50
+ if (updates.status && task.status !== updates.status) {
51
+ if (isTerminalStatus(task.status))
52
+ return;
53
+ }
28
54
  Object.assign(task, {
29
55
  ...updates,
30
56
  lastUpdatedAt: new Date().toISOString(),
31
57
  });
58
+ this.notifyWaiters(task);
32
59
  }
33
- cancelTask(taskId) {
34
- const task = this.tasks.get(taskId);
60
+ cancelTask(taskId, ownerKey) {
61
+ const task = this.getTask(taskId, ownerKey);
35
62
  if (!task)
36
63
  return undefined;
37
64
  if (task.status === 'completed' ||
38
65
  task.status === 'failed' ||
39
66
  task.status === 'cancelled') {
40
- throw new Error(`Cannot cancel task: already in terminal status '${task.status}'`);
67
+ throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task: already in terminal status '${task.status}'`);
41
68
  }
42
69
  this.updateTask(taskId, {
43
70
  status: 'cancelled',
@@ -45,8 +72,26 @@ export class TaskManager {
45
72
  });
46
73
  return this.tasks.get(taskId);
47
74
  }
48
- listTasks() {
49
- return Array.from(this.tasks.values());
75
+ listTasks(options) {
76
+ const { ownerKey, cursor, limit } = options;
77
+ const pageSize = limit && limit > 0 ? limit : DEFAULT_PAGE_SIZE;
78
+ const startIndex = cursor ? this.decodeCursor(cursor) : 0;
79
+ if (startIndex === null) {
80
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid cursor');
81
+ }
82
+ const allTasks = Array.from(this.tasks.values()).filter((task) => {
83
+ if (task.ownerKey !== ownerKey)
84
+ return false;
85
+ if (this.isExpired(task)) {
86
+ this.tasks.delete(task.taskId);
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ const page = allTasks.slice(startIndex, startIndex + pageSize);
92
+ const nextIndex = startIndex + page.length;
93
+ const nextCursor = nextIndex < allTasks.length ? this.encodeCursor(nextIndex) : undefined;
94
+ return nextCursor ? { tasks: page, nextCursor } : { tasks: page };
50
95
  }
51
96
  // Helper to check if task is expired and could be cleaned up
52
97
  // In a real implementation, this would be called by a periodic job
@@ -62,5 +107,77 @@ export class TaskManager {
62
107
  }
63
108
  return count;
64
109
  }
110
+ async waitForTerminalTask(taskId, ownerKey, signal) {
111
+ const task = this.getTask(taskId, ownerKey);
112
+ if (!task)
113
+ return undefined;
114
+ if (isTerminalStatus(task.status))
115
+ return task;
116
+ return new Promise((resolve, reject) => {
117
+ const onAbort = () => {
118
+ cleanup();
119
+ removeWaiter();
120
+ reject(new McpError(ErrorCode.ConnectionClosed, 'Request was cancelled'));
121
+ };
122
+ const cleanup = () => {
123
+ if (signal) {
124
+ signal.removeEventListener('abort', onAbort);
125
+ }
126
+ };
127
+ const removeWaiter = () => {
128
+ const waiters = this.waiters.get(taskId);
129
+ if (!waiters)
130
+ return;
131
+ waiters.delete(waiter);
132
+ if (waiters.size === 0)
133
+ this.waiters.delete(taskId);
134
+ };
135
+ const waiter = (updated) => {
136
+ cleanup();
137
+ resolve(updated);
138
+ };
139
+ if (signal?.aborted) {
140
+ onAbort();
141
+ return;
142
+ }
143
+ const waiters = this.waiters.get(taskId) ?? new Set();
144
+ waiters.add(waiter);
145
+ this.waiters.set(taskId, waiters);
146
+ if (signal) {
147
+ signal.addEventListener('abort', onAbort, { once: true });
148
+ }
149
+ });
150
+ }
151
+ notifyWaiters(task) {
152
+ if (!isTerminalStatus(task.status))
153
+ return;
154
+ const waiters = this.waiters.get(task.taskId);
155
+ if (!waiters)
156
+ return;
157
+ this.waiters.delete(task.taskId);
158
+ for (const waiter of waiters)
159
+ waiter(task);
160
+ }
161
+ isExpired(task) {
162
+ const createdAt = Date.parse(task.createdAt);
163
+ if (!Number.isFinite(createdAt))
164
+ return false;
165
+ return Date.now() - createdAt > task.ttl;
166
+ }
167
+ encodeCursor(index) {
168
+ return Buffer.from(String(index)).toString('base64');
169
+ }
170
+ decodeCursor(cursor) {
171
+ try {
172
+ const decoded = Buffer.from(cursor, 'base64').toString('utf8');
173
+ const value = Number.parseInt(decoded, 10);
174
+ if (!Number.isFinite(value) || value < 0)
175
+ return null;
176
+ return value;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
65
182
  }
66
183
  export const taskManager = new TaskManager();
package/dist/tools.js CHANGED
@@ -66,20 +66,32 @@ const TOOL_ICON = {
66
66
  /* -------------------------------------------------------------------------------------------------
67
67
  * Progress reporting
68
68
  * ------------------------------------------------------------------------------------------------- */
69
+ function resolveRelatedTaskMeta(meta) {
70
+ if (!meta)
71
+ return undefined;
72
+ const related = meta['io.modelcontextprotocol/related-task'];
73
+ if (!isObject(related))
74
+ return undefined;
75
+ const { taskId } = related;
76
+ return typeof taskId === 'string' ? { taskId } : undefined;
77
+ }
69
78
  class ToolProgressReporter {
70
79
  token;
71
80
  sendNotification;
72
- constructor(token, sendNotification) {
81
+ relatedTaskMeta;
82
+ constructor(token, sendNotification, relatedTaskMeta) {
73
83
  this.token = token;
74
84
  this.sendNotification = sendNotification;
85
+ this.relatedTaskMeta = relatedTaskMeta;
75
86
  }
76
87
  static create(extra) {
77
88
  const token = extra?._meta?.progressToken ?? null;
78
89
  const sendNotification = extra?.sendNotification;
90
+ const relatedTaskMeta = resolveRelatedTaskMeta(extra?._meta);
79
91
  if (token === null || !sendNotification) {
80
92
  return { report: async () => { } };
81
93
  }
82
- return new ToolProgressReporter(token, sendNotification);
94
+ return new ToolProgressReporter(token, sendNotification, relatedTaskMeta);
83
95
  }
84
96
  async report(progress, message) {
85
97
  try {
@@ -91,6 +103,13 @@ class ToolProgressReporter {
91
103
  progress,
92
104
  total: FETCH_PROGRESS_TOTAL,
93
105
  message,
106
+ ...(this.relatedTaskMeta
107
+ ? {
108
+ _meta: {
109
+ 'io.modelcontextprotocol/related-task': this.relatedTaskMeta,
110
+ },
111
+ }
112
+ : {}),
94
113
  },
95
114
  }),
96
115
  new Promise((_, reject) => {
@@ -477,7 +496,6 @@ async function executeFetch(input, extra) {
477
496
  await progress.report(1, 'Validating URL');
478
497
  logDebug('Fetching URL', { url });
479
498
  await progress.report(2, 'Fetching content');
480
- await progress.report(2, 'Fetching content'); // preserve existing behavior
481
499
  const { pipeline, inlineResult } = await fetchPipeline(url, signal, progress);
482
500
  if (pipeline.fromCache) {
483
501
  await progress.report(3, 'Using cached content');
@@ -501,6 +519,9 @@ const TOOL_DEFINITION = {
501
519
  inputSchema: fetchUrlInputSchema,
502
520
  outputSchema: fetchUrlOutputSchema,
503
521
  handler: fetchUrlToolHandler,
522
+ execution: {
523
+ taskSupport: 'optional',
524
+ },
504
525
  annotations: {
505
526
  readOnlyHint: true,
506
527
  destructiveHint: false,
@@ -515,7 +536,12 @@ export function withRequestContextIfMissing(handler) {
515
536
  return handler(params, extra);
516
537
  }
517
538
  const derivedRequestId = resolveRequestIdFromExtra(extra) ?? randomUUID();
518
- return runWithRequestContext({ requestId: derivedRequestId, operationId: derivedRequestId }, () => handler(params, extra));
539
+ const derivedSessionId = resolveSessionIdFromExtra(extra);
540
+ return runWithRequestContext({
541
+ requestId: derivedRequestId,
542
+ operationId: derivedRequestId,
543
+ ...(derivedSessionId ? { sessionId: derivedSessionId } : {}),
544
+ }, () => handler(params, extra));
519
545
  };
520
546
  }
521
547
  function resolveRequestIdFromExtra(extra) {
@@ -528,6 +554,21 @@ function resolveRequestIdFromExtra(extra) {
528
554
  return String(requestId);
529
555
  return undefined;
530
556
  }
557
+ function resolveSessionIdFromExtra(extra) {
558
+ if (!isObject(extra))
559
+ return undefined;
560
+ const { sessionId } = extra;
561
+ if (typeof sessionId === 'string')
562
+ return sessionId;
563
+ const { requestInfo } = extra;
564
+ if (!isObject(requestInfo))
565
+ return undefined;
566
+ const { headers } = requestInfo;
567
+ if (!isObject(headers))
568
+ return undefined;
569
+ const headerValue = headers['mcp-session-id'];
570
+ return typeof headerValue === 'string' ? headerValue : undefined;
571
+ }
531
572
  export function registerTools(server) {
532
573
  if (config.tools.enabled.includes(FETCH_URL_TOOL_NAME)) {
533
574
  server.registerTool(TOOL_DEFINITION.name, {
@@ -536,6 +577,7 @@ export function registerTools(server) {
536
577
  inputSchema: TOOL_DEFINITION.inputSchema,
537
578
  outputSchema: TOOL_DEFINITION.outputSchema,
538
579
  annotations: TOOL_DEFINITION.annotations,
580
+ execution: TOOL_DEFINITION.execution,
539
581
  // Use specific tool icon here
540
582
  icons: [TOOL_ICON],
541
583
  }, withRequestContextIfMissing(TOOL_DEFINITION.handler));
package/dist/transform.js CHANGED
@@ -876,8 +876,8 @@ function buildContentSource(params) {
876
876
  return { sourceHtml: article.content, title: article.title, metadata };
877
877
  }
878
878
  if (document) {
879
- const cleanedHtml = removeNoiseFromHtml(html, undefined, url);
880
- const { document: cleanedDoc } = parseHTML(cleanedHtml);
879
+ removeNoiseFromHtml(html, document, url);
880
+ const cleanedDoc = document;
881
881
  const contentRoot = findContentRoot(cleanedDoc);
882
882
  if (contentRoot) {
883
883
  logDebug('Using content root fallback instead of full HTML', {
@@ -993,6 +993,7 @@ class WorkerPool {
993
993
  minCapacity = POOL_MIN_WORKERS;
994
994
  maxCapacity = POOL_MAX_WORKERS;
995
995
  queue = [];
996
+ queueHead = 0;
996
997
  inflight = new Map();
997
998
  timeoutMs;
998
999
  queueMax;
@@ -1006,7 +1007,7 @@ class WorkerPool {
1006
1007
  this.ensureOpen();
1007
1008
  if (options.signal?.aborted)
1008
1009
  throw abortPolicy.createAbortError(url, 'transform:enqueue');
1009
- if (this.queue.length >= this.queueMax) {
1010
+ if (this.getQueueDepth() >= this.queueMax) {
1010
1011
  throw new FetchError('Transform worker queue is full', url, 503, {
1011
1012
  reason: 'queue_full',
1012
1013
  stage: 'transform:enqueue',
@@ -1019,7 +1020,8 @@ class WorkerPool {
1019
1020
  });
1020
1021
  }
1021
1022
  getQueueDepth() {
1022
- return this.queue.length;
1023
+ const depth = this.queue.length - this.queueHead;
1024
+ return depth > 0 ? depth : 0;
1023
1025
  }
1024
1026
  getActiveWorkers() {
1025
1027
  return this.workers.filter((s) => s?.busy).length;
@@ -1042,9 +1044,13 @@ class WorkerPool {
1042
1044
  inflight.reject(new Error('Transform worker pool closed'));
1043
1045
  this.inflight.delete(id);
1044
1046
  }
1045
- for (const task of this.queue)
1046
- task.reject(new Error('Transform worker pool closed'));
1047
+ for (let i = this.queueHead; i < this.queue.length; i += 1) {
1048
+ const task = this.queue[i];
1049
+ if (task)
1050
+ task.reject(new Error('Transform worker pool closed'));
1051
+ }
1047
1052
  this.queue.length = 0;
1053
+ this.queueHead = 0;
1048
1054
  await Promise.allSettled(terminations);
1049
1055
  }
1050
1056
  ensureOpen() {
@@ -1081,10 +1087,11 @@ class WorkerPool {
1081
1087
  this.abortInflight(id, url, inflight.workerIndex);
1082
1088
  return;
1083
1089
  }
1084
- const queuedIndex = this.queue.findIndex((t) => t.id === id);
1085
- if (queuedIndex !== -1) {
1090
+ const queuedIndex = this.findQueuedIndex(id);
1091
+ if (queuedIndex !== null) {
1086
1092
  this.queue.splice(queuedIndex, 1);
1087
1093
  reject(abortPolicy.createAbortError(url, 'transform:queued-abort'));
1094
+ this.maybeCompactQueue();
1088
1095
  }
1089
1096
  }
1090
1097
  abortInflight(id, url, workerIndex) {
@@ -1196,29 +1203,29 @@ class WorkerPool {
1196
1203
  this.markIdle(inflight.workerIndex);
1197
1204
  }
1198
1205
  maybeScaleUp() {
1199
- if (this.queue.length > this.capacity * POOL_SCALE_THRESHOLD &&
1206
+ if (this.getQueueDepth() > this.capacity * POOL_SCALE_THRESHOLD &&
1200
1207
  this.capacity < this.maxCapacity) {
1201
1208
  this.capacity += 1;
1202
1209
  }
1203
1210
  }
1204
1211
  drainQueue() {
1205
- if (this.closed || this.queue.length === 0)
1212
+ if (this.closed || this.getQueueDepth() === 0)
1206
1213
  return;
1207
1214
  this.maybeScaleUp();
1208
1215
  for (let i = 0; i < this.workers.length; i += 1) {
1209
1216
  const slot = this.workers[i];
1210
1217
  if (slot && !slot.busy) {
1211
1218
  this.dispatchFromQueue(i, slot);
1212
- if (this.queue.length === 0)
1219
+ if (this.getQueueDepth() === 0)
1213
1220
  return;
1214
1221
  }
1215
1222
  }
1216
- if (this.workers.length < this.capacity && this.queue.length > 0) {
1223
+ if (this.workers.length < this.capacity && this.getQueueDepth() > 0) {
1217
1224
  const workerIndex = this.workers.length;
1218
1225
  const slot = this.spawnWorker(workerIndex);
1219
1226
  this.workers.push(slot);
1220
1227
  this.dispatchFromQueue(workerIndex, slot);
1221
- if (this.workers.length < this.capacity && this.queue.length > 0) {
1228
+ if (this.workers.length < this.capacity && this.getQueueDepth() > 0) {
1222
1229
  setImmediate(() => {
1223
1230
  this.drainQueue();
1224
1231
  });
@@ -1226,9 +1233,11 @@ class WorkerPool {
1226
1233
  }
1227
1234
  }
1228
1235
  dispatchFromQueue(workerIndex, slot) {
1229
- const task = this.queue.shift();
1236
+ const task = this.queue[this.queueHead];
1230
1237
  if (!task)
1231
1238
  return;
1239
+ this.queueHead += 1;
1240
+ this.maybeCompactQueue();
1232
1241
  if (this.closed) {
1233
1242
  task.reject(new Error('Transform worker pool closed'));
1234
1243
  return;
@@ -1285,6 +1294,23 @@ class WorkerPool {
1285
1294
  this.restartWorker(workerIndex, slot);
1286
1295
  }
1287
1296
  }
1297
+ findQueuedIndex(id) {
1298
+ for (let i = this.queueHead; i < this.queue.length; i += 1) {
1299
+ const task = this.queue[i];
1300
+ if (task?.id === id)
1301
+ return i;
1302
+ }
1303
+ return null;
1304
+ }
1305
+ maybeCompactQueue() {
1306
+ if (this.queueHead === 0)
1307
+ return;
1308
+ if (this.queueHead >= this.queue.length ||
1309
+ (this.queueHead > 1024 && this.queueHead > this.queue.length / 2)) {
1310
+ this.queue.splice(0, this.queueHead);
1311
+ this.queueHead = 0;
1312
+ }
1313
+ }
1288
1314
  }
1289
1315
  class TransformWorkerPoolManager {
1290
1316
  pool = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/superfetch",
3
- "version": "2.4.5",
3
+ "version": "2.4.6",
4
4
  "mcpName": "io.github.j0hanz/superfetch",
5
5
  "description": "Intelligent web content fetcher MCP server that converts HTML to clean, AI-readable Markdown",
6
6
  "type": "module",