@j0hanz/superfetch 2.4.3 → 2.4.5

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.
@@ -0,0 +1,44 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { config } from './config.js';
3
+ import { stableStringify } from './json.js';
4
+ /* -------------------------------------------------------------------------------------------------
5
+ * Configuration Resource
6
+ * ------------------------------------------------------------------------------------------------- */
7
+ function scrubAuth(auth) {
8
+ return {
9
+ ...auth,
10
+ clientSecret: auth.clientSecret ? '<REDACTED>' : undefined,
11
+ staticTokens: auth.staticTokens.map(() => '<REDACTED>'),
12
+ };
13
+ }
14
+ function scrubSecurity(security) {
15
+ return {
16
+ ...security,
17
+ apiKey: security.apiKey ? '<REDACTED>' : undefined,
18
+ };
19
+ }
20
+ function scrubConfig(source) {
21
+ return {
22
+ ...source,
23
+ auth: scrubAuth(source.auth),
24
+ security: scrubSecurity(source.security),
25
+ };
26
+ }
27
+ export function registerConfigResource(server) {
28
+ server.registerResource('config', new ResourceTemplate('internal://config', { list: undefined }), {
29
+ title: 'Server Configuration',
30
+ description: 'Current runtime configuration (secrets redacted)',
31
+ mimeType: 'application/json',
32
+ }, (uri) => {
33
+ const scrubbed = scrubConfig(config);
34
+ return {
35
+ contents: [
36
+ {
37
+ uri: uri.href,
38
+ mimeType: 'application/json',
39
+ text: stableStringify(scrubbed),
40
+ },
41
+ ],
42
+ };
43
+ });
44
+ }
package/dist/session.js CHANGED
@@ -14,128 +14,163 @@ export function composeCloseHandlers(first, second) {
14
14
  }
15
15
  };
16
16
  }
17
- // --- Session Store ---
17
+ /* -------------------------------------------------------------------------------------------------
18
+ * Cleanup loop
19
+ * ------------------------------------------------------------------------------------------------- */
18
20
  function getCleanupIntervalMs(sessionTtlMs) {
19
- return Math.min(Math.max(Math.floor(sessionTtlMs / 2), 10000), 60000);
21
+ return Math.min(Math.max(Math.floor(sessionTtlMs / 2), 10_000), 60_000);
20
22
  }
21
23
  function isAbortError(error) {
22
24
  return error instanceof Error && error.name === 'AbortError';
23
25
  }
24
26
  function handleSessionCleanupError(error) {
25
- if (isAbortError(error)) {
27
+ if (isAbortError(error))
26
28
  return;
27
- }
28
29
  logWarn('Session cleanup loop failed', {
29
30
  error: error instanceof Error ? error.message : 'Unknown error',
30
31
  });
31
32
  }
32
- function moveSessionToEnd(sessions, sessionId, session) {
33
- sessions.delete(sessionId);
34
- sessions.set(sessionId, session);
35
- }
36
33
  function isSessionExpired(session, now, sessionTtlMs) {
37
34
  return now - session.lastSeen > sessionTtlMs;
38
35
  }
39
- async function runSessionCleanupLoop(store, sessionTtlMs, signal) {
40
- const intervalMs = getCleanupIntervalMs(sessionTtlMs);
41
- for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
42
- signal,
43
- ref: false,
44
- })) {
45
- const evicted = store.evictExpired();
46
- for (const session of evicted) {
47
- void session.transport.close().catch((err) => {
48
- logWarn('Failed to close expired session', {
49
- error: err instanceof Error ? err : new Error(String(err)),
36
+ class SessionCleanupLoop {
37
+ store;
38
+ sessionTtlMs;
39
+ constructor(store, sessionTtlMs) {
40
+ this.store = store;
41
+ this.sessionTtlMs = sessionTtlMs;
42
+ }
43
+ start() {
44
+ const controller = new AbortController();
45
+ void this.run(controller.signal).catch(handleSessionCleanupError);
46
+ return controller;
47
+ }
48
+ async run(signal) {
49
+ const intervalMs = getCleanupIntervalMs(this.sessionTtlMs);
50
+ for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
51
+ signal,
52
+ ref: false,
53
+ })) {
54
+ const now = getNow();
55
+ const evicted = this.store.evictExpired();
56
+ for (const session of evicted) {
57
+ void session.transport.close().catch((err) => {
58
+ logWarn('Failed to close expired session', {
59
+ error: err instanceof Error ? err : new Error(String(err)),
60
+ });
50
61
  });
51
- });
52
- }
53
- if (evicted.length > 0) {
54
- logInfo('Expired sessions evicted', {
55
- evicted: evicted.length,
56
- timestamp: new Date(getNow()).toISOString(),
57
- });
62
+ }
63
+ if (evicted.length > 0) {
64
+ logInfo('Expired sessions evicted', {
65
+ evicted: evicted.length,
66
+ timestamp: new Date(now).toISOString(),
67
+ });
68
+ }
58
69
  }
59
70
  }
60
71
  }
61
72
  export function startSessionCleanupLoop(store, sessionTtlMs) {
62
- const controller = new AbortController();
63
- void runSessionCleanupLoop(store, sessionTtlMs, controller.signal).catch(handleSessionCleanupError);
64
- return controller;
73
+ return new SessionCleanupLoop(store, sessionTtlMs).start();
65
74
  }
66
- export function createSessionStore(sessionTtlMs) {
67
- const sessions = new Map();
68
- let inflight = 0;
69
- return {
70
- get: (sessionId) => sessions.get(sessionId),
71
- touch: (sessionId) => {
72
- const session = sessions.get(sessionId);
73
- if (session) {
74
- session.lastSeen = Date.now();
75
- // Move to end (LRU behavior if needed, but Map insertion order)
76
- moveSessionToEnd(sessions, sessionId, session);
77
- }
78
- },
79
- set: (sessionId, entry) => {
80
- sessions.set(sessionId, entry);
81
- },
82
- remove: (sessionId) => {
83
- const session = sessions.get(sessionId);
84
- sessions.delete(sessionId);
85
- return session;
86
- },
87
- size: () => sessions.size,
88
- inFlight: () => inflight,
89
- incrementInFlight: () => {
90
- inflight += 1;
91
- },
92
- decrementInFlight: () => {
93
- if (inflight > 0)
94
- inflight -= 1;
95
- },
96
- clear: () => {
97
- const entries = [...sessions.values()];
98
- sessions.clear();
99
- return entries;
100
- },
101
- evictExpired: () => {
102
- const now = Date.now();
103
- const evicted = [];
104
- for (const [id, session] of sessions.entries()) {
105
- if (isSessionExpired(session, now, sessionTtlMs)) {
106
- sessions.delete(id);
107
- evicted.push(session);
108
- }
75
+ /* -------------------------------------------------------------------------------------------------
76
+ * Session store (in-memory, Map order used for LRU)
77
+ * ------------------------------------------------------------------------------------------------- */
78
+ function moveSessionToEnd(sessions, sessionId, session) {
79
+ sessions.delete(sessionId);
80
+ sessions.set(sessionId, session);
81
+ }
82
+ class InMemorySessionStore {
83
+ sessionTtlMs;
84
+ sessions = new Map();
85
+ inflight = 0;
86
+ constructor(sessionTtlMs) {
87
+ this.sessionTtlMs = sessionTtlMs;
88
+ }
89
+ get(sessionId) {
90
+ return this.sessions.get(sessionId);
91
+ }
92
+ touch(sessionId) {
93
+ const session = this.sessions.get(sessionId);
94
+ if (!session)
95
+ return;
96
+ session.lastSeen = Date.now();
97
+ moveSessionToEnd(this.sessions, sessionId, session);
98
+ }
99
+ set(sessionId, entry) {
100
+ this.sessions.set(sessionId, entry);
101
+ }
102
+ remove(sessionId) {
103
+ const session = this.sessions.get(sessionId);
104
+ this.sessions.delete(sessionId);
105
+ return session;
106
+ }
107
+ size() {
108
+ return this.sessions.size;
109
+ }
110
+ inFlight() {
111
+ return this.inflight;
112
+ }
113
+ incrementInFlight() {
114
+ this.inflight += 1;
115
+ }
116
+ decrementInFlight() {
117
+ if (this.inflight > 0)
118
+ this.inflight -= 1;
119
+ }
120
+ clear() {
121
+ const entries = [...this.sessions.values()];
122
+ this.sessions.clear();
123
+ return entries;
124
+ }
125
+ evictExpired() {
126
+ const now = Date.now();
127
+ const evicted = [];
128
+ for (const [id, session] of this.sessions.entries()) {
129
+ if (isSessionExpired(session, now, this.sessionTtlMs)) {
130
+ this.sessions.delete(id);
131
+ evicted.push(session);
109
132
  }
110
- return evicted;
111
- },
112
- evictOldest: () => {
113
- const oldestEntry = sessions.keys().next();
114
- if (oldestEntry.done)
115
- return undefined;
116
- const oldestId = oldestEntry.value;
117
- const session = sessions.get(oldestId);
118
- sessions.delete(oldestId);
119
- return session;
120
- },
121
- };
133
+ }
134
+ return evicted;
135
+ }
136
+ evictOldest() {
137
+ const oldest = this.sessions.keys().next();
138
+ if (oldest.done)
139
+ return undefined;
140
+ const oldestId = oldest.value;
141
+ const session = this.sessions.get(oldestId);
142
+ this.sessions.delete(oldestId);
143
+ return session;
144
+ }
145
+ }
146
+ export function createSessionStore(sessionTtlMs) {
147
+ return new InMemorySessionStore(sessionTtlMs);
148
+ }
149
+ /* -------------------------------------------------------------------------------------------------
150
+ * Slot tracker
151
+ * ------------------------------------------------------------------------------------------------- */
152
+ class SessionSlotTracker {
153
+ store;
154
+ slotReleased = false;
155
+ initialized = false;
156
+ constructor(store) {
157
+ this.store = store;
158
+ }
159
+ releaseSlot() {
160
+ if (this.slotReleased)
161
+ return;
162
+ this.slotReleased = true;
163
+ this.store.decrementInFlight();
164
+ }
165
+ markInitialized() {
166
+ this.initialized = true;
167
+ }
168
+ isInitialized() {
169
+ return this.initialized;
170
+ }
122
171
  }
123
- // --- Slot Tracker ---
124
172
  export function createSlotTracker(store) {
125
- let slotReleased = false;
126
- let initialized = false;
127
- return {
128
- releaseSlot: () => {
129
- if (slotReleased)
130
- return;
131
- slotReleased = true;
132
- store.decrementInFlight();
133
- },
134
- markInitialized: () => {
135
- initialized = true;
136
- },
137
- isInitialized: () => initialized,
138
- };
173
+ return new SessionSlotTracker(store);
139
174
  }
140
175
  export function reserveSessionSlot(store, maxSessions) {
141
176
  if (store.size() + store.inFlight() >= maxSessions) {
@@ -144,16 +179,20 @@ export function reserveSessionSlot(store, maxSessions) {
144
179
  store.incrementInFlight();
145
180
  return true;
146
181
  }
182
+ /* -------------------------------------------------------------------------------------------------
183
+ * Capacity policy
184
+ * ------------------------------------------------------------------------------------------------- */
185
+ function isAtCapacity(store, maxSessions) {
186
+ return store.size() + store.inFlight() >= maxSessions;
187
+ }
147
188
  export function ensureSessionCapacity({ store, maxSessions, evictOldest, }) {
148
189
  const currentSize = store.size();
149
- const isAtCapacity = currentSize + store.inFlight() >= maxSessions;
150
- if (!isAtCapacity)
190
+ const inflight = store.inFlight();
191
+ if (currentSize + inflight < maxSessions)
151
192
  return true;
152
- // Try to free a slot
153
- const canFreeSlot = currentSize >= maxSessions &&
154
- currentSize - 1 + store.inFlight() < maxSessions;
193
+ const canFreeSlot = currentSize >= maxSessions && currentSize - 1 + inflight < maxSessions;
155
194
  if (canFreeSlot && evictOldest(store)) {
156
- return store.size() + store.inFlight() < maxSessions;
195
+ return !isAtCapacity(store, maxSessions);
157
196
  }
158
197
  return false;
159
198
  }
@@ -0,0 +1,37 @@
1
+ export type TaskStatus = 'working' | 'input_required' | 'completed' | 'failed' | 'cancelled';
2
+ export interface TaskState {
3
+ taskId: string;
4
+ status: TaskStatus;
5
+ statusMessage?: string;
6
+ createdAt: string;
7
+ lastUpdatedAt: string;
8
+ ttl: number;
9
+ pollInterval: number;
10
+ result?: unknown;
11
+ error?: unknown;
12
+ }
13
+ export interface CreateTaskOptions {
14
+ ttl?: number;
15
+ }
16
+ export interface CreateTaskResult {
17
+ task: {
18
+ taskId: string;
19
+ status: TaskStatus;
20
+ statusMessage?: string;
21
+ createdAt: string;
22
+ lastUpdatedAt: string;
23
+ ttl: number;
24
+ pollInterval: number;
25
+ };
26
+ _meta?: Record<string, unknown>;
27
+ }
28
+ export declare class TaskManager {
29
+ private tasks;
30
+ createTask(options?: CreateTaskOptions, statusMessage?: string): TaskState;
31
+ getTask(taskId: string): TaskState | undefined;
32
+ updateTask(taskId: string, updates: Partial<Omit<TaskState, 'taskId' | 'createdAt'>>): void;
33
+ cancelTask(taskId: string): TaskState | undefined;
34
+ listTasks(): TaskState[];
35
+ cleanupExpiredTasks(): number;
36
+ }
37
+ export declare const taskManager: TaskManager;
package/dist/tasks.js ADDED
@@ -0,0 +1,66 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const DEFAULT_TTL_MS = 60000;
3
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
4
+ export class TaskManager {
5
+ tasks = new Map();
6
+ createTask(options, statusMessage = 'Task started') {
7
+ const taskId = randomUUID();
8
+ const now = new Date().toISOString();
9
+ const task = {
10
+ taskId,
11
+ status: 'working',
12
+ statusMessage,
13
+ createdAt: now,
14
+ lastUpdatedAt: now,
15
+ ttl: options?.ttl ?? DEFAULT_TTL_MS,
16
+ pollInterval: DEFAULT_POLL_INTERVAL_MS,
17
+ };
18
+ this.tasks.set(taskId, task);
19
+ return task;
20
+ }
21
+ getTask(taskId) {
22
+ return this.tasks.get(taskId);
23
+ }
24
+ updateTask(taskId, updates) {
25
+ const task = this.tasks.get(taskId);
26
+ if (!task)
27
+ return;
28
+ Object.assign(task, {
29
+ ...updates,
30
+ lastUpdatedAt: new Date().toISOString(),
31
+ });
32
+ }
33
+ cancelTask(taskId) {
34
+ const task = this.tasks.get(taskId);
35
+ if (!task)
36
+ return undefined;
37
+ if (task.status === 'completed' ||
38
+ task.status === 'failed' ||
39
+ task.status === 'cancelled') {
40
+ throw new Error(`Cannot cancel task: already in terminal status '${task.status}'`);
41
+ }
42
+ this.updateTask(taskId, {
43
+ status: 'cancelled',
44
+ statusMessage: 'The task was cancelled by request.',
45
+ });
46
+ return this.tasks.get(taskId);
47
+ }
48
+ listTasks() {
49
+ return Array.from(this.tasks.values());
50
+ }
51
+ // Helper to check if task is expired and could be cleaned up
52
+ // In a real implementation, this would be called by a periodic job
53
+ cleanupExpiredTasks() {
54
+ const now = Date.now();
55
+ let count = 0;
56
+ for (const [id, task] of this.tasks.entries()) {
57
+ const created = new Date(task.createdAt).getTime();
58
+ if (now - created > task.ttl) {
59
+ this.tasks.delete(id);
60
+ count++;
61
+ }
62
+ }
63
+ return count;
64
+ }
65
+ }
66
+ export const taskManager = new TaskManager();
package/dist/tools.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { CallToolResult, ContentBlock } from '@modelcontextprotocol/sdk/types.js';
3
- import * as cache from './cache.js';
4
3
  import type { MarkdownTransformResult } from './transform-types.js';
5
4
  export interface FetchUrlInput {
6
5
  url: string;
@@ -35,19 +34,12 @@ export type ToolErrorResponse = CallToolResult & {
35
34
  };
36
35
  export type ToolResponseBase = CallToolResult;
37
36
  export interface FetchPipelineOptions<T> {
38
- /** URL to fetch */
39
37
  url: string;
40
- /** Cache namespace (e.g., 'markdown') */
41
38
  cacheNamespace: string;
42
- /** Optional: AbortSignal for request cancellation */
43
39
  signal?: AbortSignal;
44
- /** Optional: cache variation input for headers/flags */
45
40
  cacheVary?: Record<string, unknown> | string;
46
- /** Transform function to process HTML into desired format */
47
41
  transform: (html: string, url: string) => T | Promise<T>;
48
- /** Optional: serialize result for caching (defaults to JSON.stringify) */
49
42
  serialize?: (result: T) => string;
50
- /** Optional: deserialize cached content */
51
43
  deserialize?: (cached: string) => T | undefined;
52
44
  }
53
45
  export interface PipelineResult<T> {
@@ -80,7 +72,7 @@ export interface ToolHandlerExtra {
80
72
  sendNotification?: (notification: ProgressNotification) => Promise<void>;
81
73
  }
82
74
  export declare const FETCH_URL_TOOL_NAME = "fetch-url";
83
- export declare const FETCH_URL_TOOL_DESCRIPTION = "Fetches a webpage and converts it to clean Markdown format";
75
+ export declare const FETCH_URL_TOOL_DESCRIPTION: string;
84
76
  interface ProgressReporter {
85
77
  report: (progress: number, message: string) => Promise<void>;
86
78
  }
@@ -93,8 +85,12 @@ interface InlineContentResult {
93
85
  error?: string;
94
86
  truncated?: boolean;
95
87
  }
96
- declare function applyInlineContentLimit(content: string, cacheKey: string | null): InlineContentResult;
97
- export type InlineResult = ReturnType<typeof applyInlineContentLimit>;
88
+ export type InlineResult = ReturnType<InlineContentLimiter['apply']>;
89
+ declare class InlineContentLimiter {
90
+ apply(content: string, cacheKey: string | null): InlineContentResult;
91
+ private resolveResourceUri;
92
+ private buildTruncatedFallback;
93
+ }
98
94
  export declare function executeFetchPipeline<T>(options: FetchPipelineOptions<T>): Promise<PipelineResult<T>>;
99
95
  interface SharedFetchOptions<T extends {
100
96
  content: string;
@@ -122,5 +118,5 @@ type MarkdownPipelineResult = MarkdownTransformResult & {
122
118
  export declare function parseCachedMarkdownResult(cached: string): MarkdownPipelineResult | undefined;
123
119
  export declare function fetchUrlToolHandler(input: FetchUrlInput, extra?: ToolHandlerExtra): Promise<ToolResponseBase>;
124
120
  export declare function withRequestContextIfMissing<TParams, TResult, TExtra = unknown>(handler: (params: TParams, extra?: TExtra) => Promise<TResult>): (params: TParams, extra?: TExtra) => Promise<TResult>;
125
- export declare function registerTools(server: McpServer, serverIcons?: cache.McpIcon[]): void;
121
+ export declare function registerTools(server: McpServer): void;
126
122
  export {};