@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/README.md +195 -138
- package/dist/http-native.js +2 -2
- package/dist/instructions.md +30 -38
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +270 -98
- package/dist/observability.js +2 -1
- package/dist/tasks.d.ts +24 -5
- package/dist/tasks.js +125 -8
- package/dist/tools.js +46 -4
- package/dist/transform.js +40 -14
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
880
|
-
const
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
1046
|
-
task
|
|
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.
|
|
1085
|
-
if (queuedIndex !==
|
|
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.
|
|
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.
|
|
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.
|
|
1219
|
+
if (this.getQueueDepth() === 0)
|
|
1213
1220
|
return;
|
|
1214
1221
|
}
|
|
1215
1222
|
}
|
|
1216
|
-
if (this.workers.length < this.capacity && this.
|
|
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.
|
|
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.
|
|
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