@j0hanz/superfetch 2.5.2 → 2.6.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.
Files changed (46) hide show
  1. package/README.md +356 -223
  2. package/dist/assets/logo.svg +24837 -24835
  3. package/dist/cache.d.ts +28 -20
  4. package/dist/cache.js +292 -514
  5. package/dist/config.d.ts +41 -7
  6. package/dist/config.js +298 -148
  7. package/dist/crypto.js +25 -12
  8. package/dist/dom-noise-removal.js +379 -421
  9. package/dist/errors.d.ts +2 -2
  10. package/dist/errors.js +25 -8
  11. package/dist/fetch.d.ts +18 -16
  12. package/dist/fetch.js +1132 -526
  13. package/dist/host-normalization.js +40 -10
  14. package/dist/http-native.js +628 -287
  15. package/dist/index.js +67 -7
  16. package/dist/instructions.md +44 -30
  17. package/dist/ip-blocklist.d.ts +8 -0
  18. package/dist/ip-blocklist.js +65 -0
  19. package/dist/json.js +14 -9
  20. package/dist/language-detection.d.ts +2 -11
  21. package/dist/language-detection.js +289 -280
  22. package/dist/markdown-cleanup.d.ts +0 -1
  23. package/dist/markdown-cleanup.js +391 -429
  24. package/dist/mcp-validator.js +4 -2
  25. package/dist/mcp.js +184 -135
  26. package/dist/observability.js +89 -21
  27. package/dist/resources.js +16 -6
  28. package/dist/server-tuning.d.ts +2 -0
  29. package/dist/server-tuning.js +25 -23
  30. package/dist/session.d.ts +1 -0
  31. package/dist/session.js +41 -33
  32. package/dist/tasks.d.ts +2 -0
  33. package/dist/tasks.js +91 -9
  34. package/dist/timer-utils.d.ts +5 -0
  35. package/dist/timer-utils.js +20 -0
  36. package/dist/tools.d.ts +28 -5
  37. package/dist/tools.js +317 -183
  38. package/dist/transform-types.d.ts +5 -1
  39. package/dist/transform.d.ts +3 -2
  40. package/dist/transform.js +1138 -421
  41. package/dist/type-guards.d.ts +1 -0
  42. package/dist/type-guards.js +7 -0
  43. package/dist/workers/transform-child.d.ts +1 -0
  44. package/dist/workers/transform-child.js +118 -0
  45. package/dist/workers/transform-worker.js +87 -78
  46. package/package.json +21 -13
@@ -1,12 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  // --- Validation ---
3
3
  const paramsSchema = z.looseObject({});
4
- const mcpRequestSchema = z.looseObject({
4
+ const mcpRequestSchema = z
5
+ .object({
5
6
  jsonrpc: z.literal('2.0'),
6
7
  method: z.string().min(1),
7
8
  id: z.union([z.string(), z.number(), z.null()]).optional(),
8
9
  params: paramsSchema.optional(),
9
- });
10
+ })
11
+ .strict();
10
12
  export function isJsonRpcBatchRequest(body) {
11
13
  return Array.isArray(body);
12
14
  }
package/dist/mcp.js CHANGED
@@ -1,23 +1,31 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { readFile } from 'node:fs/promises';
3
+ import process from 'node:process';
2
4
  import { z } from 'zod';
3
5
  import { McpServer, ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
4
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
7
  import { CallToolRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
6
8
  import { registerCachedContentResource } from './cache.js';
7
9
  import { config } from './config.js';
8
- import { logError, logInfo, setMcpServer } from './observability.js';
10
+ import { logError, logInfo, runWithRequestContext, setMcpServer, } from './observability.js';
9
11
  import { registerConfigResource } from './resources.js';
10
12
  import { taskManager } from './tasks.js';
11
- import { FETCH_URL_TOOL_NAME, fetchUrlToolHandler, registerTools, } from './tools.js';
13
+ import { FETCH_URL_TOOL_NAME, fetchUrlInputSchema, fetchUrlToolHandler, registerTools, } from './tools.js';
12
14
  import { shutdownTransformWorkerPool } from './transform.js';
13
15
  import { isObject } from './type-guards.js';
14
- async function getLocalIcons() {
16
+ /* -------------------------------------------------------------------------------------------------
17
+ * Icons + server info
18
+ * ------------------------------------------------------------------------------------------------- */
19
+ async function getLocalIcons(signal) {
15
20
  try {
16
21
  const iconPath = new URL('../assets/logo.svg', import.meta.url);
17
- const buffer = await readFile(iconPath);
22
+ const base64 = await readFile(iconPath, {
23
+ encoding: 'base64',
24
+ ...(signal ? { signal } : {}),
25
+ });
18
26
  return [
19
27
  {
20
- src: `data:image/svg+xml;base64,${buffer.toString('base64')}`,
28
+ src: `data:image/svg+xml;base64,${base64}`,
21
29
  mimeType: 'image/svg+xml',
22
30
  sizes: ['any'],
23
31
  },
@@ -27,18 +35,10 @@ async function getLocalIcons() {
27
35
  return undefined;
28
36
  }
29
37
  }
30
- async function createServerInfo() {
31
- const localIcons = await getLocalIcons();
32
- return {
33
- name: config.server.name,
34
- version: config.server.version,
35
- ...(localIcons ? { icons: localIcons } : {}),
36
- };
37
- }
38
38
  function createServerCapabilities() {
39
39
  return {
40
40
  tools: { listChanged: false },
41
- resources: { listChanged: false, subscribe: false },
41
+ resources: { listChanged: true, subscribe: true },
42
42
  logging: {},
43
43
  tasks: {
44
44
  list: {},
@@ -51,15 +51,25 @@ function createServerCapabilities() {
51
51
  },
52
52
  };
53
53
  }
54
- async function createServerInstructions(serverVersion) {
54
+ async function createServerInstructions(serverVersion, signal) {
55
55
  try {
56
- const raw = await readFile(new URL('./instructions.md', import.meta.url), 'utf8');
56
+ const raw = await readFile(new URL('./instructions.md', import.meta.url), {
57
+ encoding: 'utf8',
58
+ ...(signal ? { signal } : {}),
59
+ });
57
60
  return raw.replaceAll('{{SERVER_VERSION}}', serverVersion).trim();
58
61
  }
59
62
  catch {
60
63
  return `Instructions unavailable | ${serverVersion}`;
61
64
  }
62
65
  }
66
+ function createServerInfo(icons) {
67
+ return {
68
+ name: config.server.name,
69
+ version: config.server.version,
70
+ ...(icons ? { icons } : {}),
71
+ };
72
+ }
63
73
  function registerInstructionsResource(server, instructions) {
64
74
  server.registerResource('instructions', new ResourceTemplate('internal://instructions', { list: undefined }), {
65
75
  title: `SuperFetch MCP | ${config.server.version}`,
@@ -75,7 +85,9 @@ function registerInstructionsResource(server, instructions) {
75
85
  ],
76
86
  }));
77
87
  }
78
- // Schemas based on methods strings
88
+ /* -------------------------------------------------------------------------------------------------
89
+ * Tasks API schemas
90
+ * ------------------------------------------------------------------------------------------------- */
79
91
  const TaskGetSchema = z.object({
80
92
  method: z.literal('tasks/get'),
81
93
  params: z.object({ taskId: z.string() }),
@@ -96,53 +108,37 @@ const TaskResultSchema = z.object({
96
108
  method: z.literal('tasks/result'),
97
109
  params: z.object({ taskId: z.string() }),
98
110
  });
99
- function isNonEmptyString(value) {
100
- return typeof value === 'string' && value.length > 0;
111
+ const ExtendedCallToolRequestSchema = z.looseObject({
112
+ method: z.literal('tools/call'),
113
+ params: z.looseObject({
114
+ name: z.string().min(1),
115
+ arguments: z.record(z.string(), z.unknown()).optional(),
116
+ task: z
117
+ .object({
118
+ ttl: z.number().optional(),
119
+ })
120
+ .optional(),
121
+ _meta: z
122
+ .looseObject({
123
+ progressToken: z.union([z.string(), z.number()]).optional(),
124
+ 'io.modelcontextprotocol/related-task': z
125
+ .object({
126
+ taskId: z.string(),
127
+ })
128
+ .optional(),
129
+ })
130
+ .optional(),
131
+ }),
132
+ });
133
+ function parseExtendedCallToolRequest(request) {
134
+ const parsed = ExtendedCallToolRequestSchema.safeParse(request);
135
+ if (parsed.success)
136
+ return parsed.data;
137
+ throw new McpError(ErrorCode.InvalidParams, 'Invalid tool request');
101
138
  }
102
139
  function isRecord(value) {
103
140
  return isObject(value);
104
141
  }
105
- function isValidTask(task) {
106
- if (task === undefined)
107
- return true;
108
- if (!isRecord(task))
109
- return false;
110
- const { ttl } = task;
111
- return ttl === undefined || typeof ttl === 'number';
112
- }
113
- function isValidMeta(meta) {
114
- if (meta === undefined)
115
- return true;
116
- if (!isRecord(meta))
117
- return false;
118
- const { progressToken } = meta;
119
- if (progressToken !== undefined &&
120
- typeof progressToken !== 'string' &&
121
- typeof progressToken !== 'number') {
122
- return false;
123
- }
124
- const related = meta['io.modelcontextprotocol/related-task'];
125
- if (related === undefined)
126
- return true;
127
- if (!isRecord(related))
128
- return false;
129
- const { taskId } = related;
130
- return typeof taskId === 'string';
131
- }
132
- function isExtendedCallToolRequest(request) {
133
- if (!isRecord(request))
134
- return false;
135
- const { method, params } = request;
136
- if (method !== 'tools/call')
137
- return false;
138
- if (!isRecord(params))
139
- return false;
140
- const { name, arguments: args, task, _meta, } = params;
141
- return (isNonEmptyString(name) &&
142
- (args === undefined || isRecord(args)) &&
143
- isValidTask(task) &&
144
- isValidMeta(_meta));
145
- }
146
142
  function resolveTaskOwnerKey(extra) {
147
143
  if (extra?.sessionId)
148
144
  return `session:${extra.sessionId}`;
@@ -165,10 +161,11 @@ function resolveToolCallContext(extra) {
165
161
  return context;
166
162
  }
167
163
  function requireFetchUrlArgs(args) {
168
- if (!isObject(args) || typeof args.url !== 'string') {
164
+ const parsed = fetchUrlInputSchema.safeParse(args);
165
+ if (!parsed.success) {
169
166
  throw new McpError(ErrorCode.InvalidParams, 'Invalid arguments for fetch-url');
170
167
  }
171
- return { url: args.url };
168
+ return parsed.data;
172
169
  }
173
170
  function throwTaskNotFound() {
174
171
  throw new McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
@@ -200,63 +197,92 @@ function buildCreateTaskResult(task) {
200
197
  },
201
198
  };
202
199
  }
200
+ /**
201
+ * Track in-flight task executions so `tasks/cancel` can actually abort work.
202
+ * This is intentionally local to this module (no new files, no global singletons elsewhere).
203
+ */
204
+ const taskAbortControllers = new Map();
205
+ function attachAbortController(taskId) {
206
+ const existing = taskAbortControllers.get(taskId);
207
+ if (existing) {
208
+ // Defensive: should not happen, but avoid leaking the old controller.
209
+ taskAbortControllers.delete(taskId);
210
+ }
211
+ const controller = new AbortController();
212
+ taskAbortControllers.set(taskId, controller);
213
+ return controller;
214
+ }
215
+ function abortTaskExecution(taskId) {
216
+ const controller = taskAbortControllers.get(taskId);
217
+ if (!controller)
218
+ return;
219
+ controller.abort();
220
+ taskAbortControllers.delete(taskId);
221
+ }
222
+ function clearTaskExecution(taskId) {
223
+ taskAbortControllers.delete(taskId);
224
+ }
203
225
  async function runFetchTaskExecution(params) {
204
226
  const { taskId, args, meta, sendNotification } = params;
205
- try {
206
- const controller = new AbortController();
207
- const relatedMeta = buildRelatedTaskMeta(taskId, meta);
208
- const result = await fetchUrlToolHandler(args, {
209
- signal: controller.signal,
210
- requestId: taskId, // Correlation
211
- _meta: relatedMeta,
212
- ...(sendNotification ? { sendNotification } : {}),
213
- });
214
- const isToolError = typeof result.isError === 'boolean'
215
- ? result.isError
216
- : false;
217
- taskManager.updateTask(taskId, {
218
- status: isToolError ? 'failed' : 'completed',
219
- ...(isToolError
227
+ return runWithRequestContext({ requestId: taskId, operationId: taskId }, async () => {
228
+ const controller = attachAbortController(taskId);
229
+ try {
230
+ const relatedMeta = buildRelatedTaskMeta(taskId, meta);
231
+ const result = await fetchUrlToolHandler(args, {
232
+ signal: controller.signal,
233
+ requestId: taskId, // Correlation
234
+ _meta: relatedMeta,
235
+ ...(sendNotification ? { sendNotification } : {}),
236
+ });
237
+ const isToolError = isRecord(result) &&
238
+ typeof result.isError === 'boolean' &&
239
+ result.isError;
240
+ taskManager.updateTask(taskId, {
241
+ status: isToolError ? 'failed' : 'completed',
242
+ ...(isToolError
243
+ ? {
244
+ statusMessage: result
245
+ .structuredContent?.error ?? 'Tool execution failed',
246
+ }
247
+ : {}),
248
+ result,
249
+ });
250
+ }
251
+ catch (error) {
252
+ const errorMessage = error instanceof Error ? error.message : String(error);
253
+ const errorPayload = error instanceof McpError
220
254
  ? {
221
- statusMessage: result
222
- .structuredContent?.error ?? 'Tool execution failed',
255
+ code: error.code,
256
+ message: errorMessage,
257
+ data: error.data,
223
258
  }
224
- : {}),
225
- result,
226
- });
227
- }
228
- catch (error) {
229
- const errorMessage = error instanceof Error ? error.message : String(error);
230
- const errorPayload = error instanceof McpError
231
- ? {
232
- code: error.code,
233
- message: errorMessage,
234
- data: error.data,
235
- }
236
- : {
237
- code: ErrorCode.InternalError,
238
- message: errorMessage,
239
- };
240
- taskManager.updateTask(taskId, {
241
- status: 'failed',
242
- statusMessage: errorMessage,
243
- error: errorPayload,
244
- });
245
- }
259
+ : {
260
+ code: ErrorCode.InternalError,
261
+ message: errorMessage,
262
+ };
263
+ taskManager.updateTask(taskId, {
264
+ status: 'failed',
265
+ statusMessage: errorMessage,
266
+ error: errorPayload,
267
+ });
268
+ }
269
+ finally {
270
+ clearTaskExecution(taskId);
271
+ }
272
+ });
246
273
  }
247
274
  function handleTaskToolCall(params, context) {
248
275
  requireFetchUrlToolName(params.name);
249
276
  const validArgs = requireFetchUrlArgs(params.arguments);
250
277
  const task = taskManager.createTask(params.task?.ttl !== undefined ? { ttl: params.task.ttl } : undefined, 'Task started', context.ownerKey);
251
- const executionParams = {
278
+ void runFetchTaskExecution({
252
279
  taskId: task.taskId,
253
280
  args: validArgs,
254
281
  ...(params._meta ? { meta: params._meta } : {}),
255
282
  ...(context.sendNotification
256
283
  ? { sendNotification: context.sendNotification }
257
284
  : {}),
258
- };
259
- void runFetchTaskExecution(executionParams);
285
+ });
260
286
  return buildCreateTaskResult({
261
287
  taskId: task.taskId,
262
288
  status: task.status,
@@ -269,9 +295,11 @@ function handleTaskToolCall(params, context) {
269
295
  }
270
296
  async function handleDirectToolCall(params, context) {
271
297
  const args = requireFetchUrlArgs(params.arguments);
272
- return fetchUrlToolHandler({ url: args.url }, {
298
+ return fetchUrlToolHandler(args, {
273
299
  ...(context.signal ? { signal: context.signal } : {}),
274
- ...(context.requestId ? { requestId: context.requestId } : {}),
300
+ ...(context.requestId !== undefined
301
+ ? { requestId: context.requestId }
302
+ : {}),
275
303
  ...(context.sendNotification
276
304
  ? { sendNotification: context.sendNotification }
277
305
  : {}),
@@ -288,22 +316,31 @@ async function handleToolCallRequest(request, context) {
288
316
  }
289
317
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${params.name}`);
290
318
  }
319
+ /* -------------------------------------------------------------------------------------------------
320
+ * Register handlers
321
+ * ------------------------------------------------------------------------------------------------- */
291
322
  function registerTaskHandlers(server) {
292
323
  server.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
293
324
  const context = resolveToolCallContext(extra);
294
- if (!isExtendedCallToolRequest(request)) {
295
- throw new McpError(ErrorCode.InvalidParams, 'Invalid tool request');
296
- }
297
- const result = await handleToolCallRequest(request, context);
298
- return result;
325
+ const requestId = context.requestId !== undefined
326
+ ? String(context.requestId)
327
+ : randomUUID();
328
+ const sessionId = extra?.sessionId;
329
+ return runWithRequestContext({
330
+ requestId,
331
+ operationId: requestId,
332
+ ...(sessionId ? { sessionId } : {}),
333
+ }, () => {
334
+ const parsed = parseExtendedCallToolRequest(request);
335
+ return handleToolCallRequest(parsed, context);
336
+ });
299
337
  });
300
338
  server.server.setRequestHandler(TaskGetSchema, async (request, extra) => {
301
339
  const { taskId } = request.params;
302
340
  const ownerKey = resolveTaskOwnerKey(extra);
303
341
  const task = taskManager.getTask(taskId, ownerKey);
304
- if (!task) {
342
+ if (!task)
305
343
  throwTaskNotFound();
306
- }
307
344
  return Promise.resolve({
308
345
  taskId: task.taskId,
309
346
  status: task.status,
@@ -318,9 +355,8 @@ function registerTaskHandlers(server) {
318
355
  const { taskId } = request.params;
319
356
  const ownerKey = resolveTaskOwnerKey(extra);
320
357
  const task = await taskManager.waitForTerminalTask(taskId, ownerKey, extra?.signal);
321
- if (!task) {
358
+ if (!task)
322
359
  throwTaskNotFound();
323
- }
324
360
  if (task.status === 'failed') {
325
361
  if (task.error) {
326
362
  throw new McpError(task.error.code, task.error.message, task.error.data);
@@ -375,9 +411,10 @@ function registerTaskHandlers(server) {
375
411
  const { taskId } = request.params;
376
412
  const ownerKey = resolveTaskOwnerKey(extra);
377
413
  const task = taskManager.cancelTask(taskId, ownerKey);
378
- if (!task) {
414
+ if (!task)
379
415
  throwTaskNotFound();
380
- }
416
+ // Make cancellation actionable: abort any in-flight execution.
417
+ abortTaskExecution(taskId);
381
418
  return Promise.resolve({
382
419
  taskId: task.taskId,
383
420
  status: task.status,
@@ -389,15 +426,21 @@ function registerTaskHandlers(server) {
389
426
  });
390
427
  });
391
428
  }
429
+ /* -------------------------------------------------------------------------------------------------
430
+ * Server lifecycle
431
+ * ------------------------------------------------------------------------------------------------- */
392
432
  export async function createMcpServer() {
393
- const instructions = await createServerInstructions(config.server.version);
394
- const serverInfo = await createServerInfo();
433
+ const startupSignal = AbortSignal.timeout(5000);
434
+ const [instructions, localIcons] = await Promise.all([
435
+ createServerInstructions(config.server.version, startupSignal),
436
+ getLocalIcons(startupSignal),
437
+ ]);
438
+ const serverInfo = createServerInfo(localIcons);
395
439
  const server = new McpServer(serverInfo, {
396
440
  capabilities: createServerCapabilities(),
397
441
  instructions,
398
442
  });
399
443
  setMcpServer(server);
400
- const localIcons = await getLocalIcons();
401
444
  registerTools(server);
402
445
  registerCachedContentResource(server, localIcons);
403
446
  registerInstructionsResource(server, instructions);
@@ -410,19 +453,13 @@ function attachServerErrorHandler(server) {
410
453
  logError('[MCP Error]', error instanceof Error ? error : { error });
411
454
  };
412
455
  }
413
- function handleShutdownSignal(server, signal) {
456
+ async function shutdownServer(server, signal) {
414
457
  process.stderr.write(`\n${signal} received, shutting down superFetch MCP server...\n`);
415
- Promise.resolve()
416
- .then(async () => {
417
- await shutdownTransformWorkerPool();
418
- await server.close();
419
- })
420
- .catch((err) => {
421
- logError('Error during shutdown', err instanceof Error ? err : undefined);
422
- })
423
- .finally(() => {
424
- process.exit(0);
425
- });
458
+ // Ensure any in-flight tool executions are aborted promptly.
459
+ for (const taskId of taskAbortControllers.keys())
460
+ abortTaskExecution(taskId);
461
+ await shutdownTransformWorkerPool();
462
+ await server.close();
426
463
  }
427
464
  function createShutdownHandler(server) {
428
465
  let shuttingDown = false;
@@ -437,7 +474,17 @@ function createShutdownHandler(server) {
437
474
  }
438
475
  shuttingDown = true;
439
476
  initialSignal = signal;
440
- handleShutdownSignal(server, signal);
477
+ Promise.resolve()
478
+ .then(() => shutdownServer(server, signal))
479
+ .catch((err) => {
480
+ const error = err instanceof Error ? err : new Error(String(err));
481
+ logError('Error during shutdown', error);
482
+ process.exitCode = 1;
483
+ })
484
+ .finally(() => {
485
+ if (process.exitCode === undefined)
486
+ process.exitCode = 0;
487
+ });
441
488
  };
442
489
  }
443
490
  function registerSignalHandlers(handler) {
@@ -454,8 +501,10 @@ async function connectStdioServer(server, transport) {
454
501
  logInfo('superFetch MCP server running on stdio');
455
502
  }
456
503
  catch (error) {
457
- logError('Failed to start stdio server', error instanceof Error ? error : undefined);
458
- process.exit(1);
504
+ const err = error instanceof Error ? error : new Error(String(error));
505
+ throw new Error(`Failed to start stdio server: ${err.message}`, {
506
+ cause: err,
507
+ });
459
508
  }
460
509
  }
461
510
  export async function startStdioServer() {
@@ -1,38 +1,69 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import process from 'node:process';
3
+ import { inspect, stripVTControlCharacters } from 'node:util';
2
4
  import { config } from './config.js';
3
- const requestContext = new AsyncLocalStorage();
5
+ const requestContext = new AsyncLocalStorage({
6
+ name: 'requestContext',
7
+ });
4
8
  let mcpServer;
9
+ let stderrAvailable = true;
10
+ process.stderr.on('error', () => {
11
+ stderrAvailable = false;
12
+ });
5
13
  export function setMcpServer(server) {
6
14
  mcpServer = server;
7
15
  }
8
16
  export function runWithRequestContext(context, fn) {
9
17
  return requestContext.run(context, fn);
10
18
  }
19
+ function getRequestContext() {
20
+ return requestContext.getStore();
21
+ }
11
22
  export function getRequestId() {
12
- return requestContext.getStore()?.requestId;
23
+ return getRequestContext()?.requestId;
13
24
  }
14
25
  function getSessionId() {
15
- return requestContext.getStore()?.sessionId;
26
+ return getRequestContext()?.sessionId;
16
27
  }
17
28
  export function getOperationId() {
18
- return requestContext.getStore()?.operationId;
29
+ return getRequestContext()?.operationId;
30
+ }
31
+ function isDebugEnabled() {
32
+ return config.logging.level === 'debug';
19
33
  }
20
34
  function buildContextMetadata() {
21
- const requestId = getRequestId();
22
- const sessionId = getSessionId();
23
- const operationId = getOperationId();
24
- const contextMeta = {};
35
+ const ctx = requestContext.getStore();
36
+ if (!ctx)
37
+ return undefined;
38
+ const { requestId, operationId, sessionId } = ctx;
39
+ const includeSession = sessionId && isDebugEnabled();
40
+ if (!requestId && !operationId && !includeSession)
41
+ return undefined;
42
+ const meta = {};
25
43
  if (requestId)
26
- contextMeta.requestId = requestId;
27
- if (sessionId && config.logging.level === 'debug')
28
- contextMeta.sessionId = sessionId;
44
+ meta.requestId = requestId;
29
45
  if (operationId)
30
- contextMeta.operationId = operationId;
31
- return contextMeta;
46
+ meta.operationId = operationId;
47
+ if (includeSession)
48
+ meta.sessionId = sessionId;
49
+ return meta;
50
+ }
51
+ function mergeMetadata(meta) {
52
+ const contextMeta = buildContextMetadata();
53
+ const hasMeta = meta && Object.keys(meta).length > 0;
54
+ if (!contextMeta && !hasMeta)
55
+ return undefined;
56
+ if (!contextMeta)
57
+ return meta;
58
+ if (!hasMeta)
59
+ return contextMeta;
60
+ return { ...contextMeta, ...meta };
32
61
  }
33
62
  function formatMetadata(meta) {
34
- const merged = { ...buildContextMetadata(), ...meta };
35
- return Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : '';
63
+ const merged = mergeMetadata(meta);
64
+ if (!merged)
65
+ return '';
66
+ return ` ${inspect(merged, { breakLength: Infinity, colors: false, compact: true, sorted: true })}`;
36
67
  }
37
68
  function createTimestamp() {
38
69
  return new Date().toISOString();
@@ -43,7 +74,7 @@ function formatLogEntry(level, message, meta) {
43
74
  function shouldLog(level) {
44
75
  // Debug logs only when LOG_LEVEL=debug
45
76
  if (level === 'debug')
46
- return config.logging.level === 'debug';
77
+ return isDebugEnabled();
47
78
  // All other levels always log
48
79
  return true;
49
80
  }
@@ -60,18 +91,55 @@ function mapToMcpLevel(level) {
60
91
  return 'info';
61
92
  }
62
93
  }
94
+ function safeWriteStderr(line) {
95
+ if (!stderrAvailable)
96
+ return;
97
+ if (process.stderr.destroyed || process.stderr.writableEnded) {
98
+ stderrAvailable = false;
99
+ return;
100
+ }
101
+ try {
102
+ process.stderr.write(line);
103
+ }
104
+ catch {
105
+ // Logging must never take down the process (e.g. EPIPE).
106
+ stderrAvailable = false;
107
+ }
108
+ }
63
109
  function writeLog(level, message, meta) {
64
110
  if (!shouldLog(level))
65
111
  return;
66
- process.stderr.write(`${formatLogEntry(level, message, meta)}\n`);
67
- if (mcpServer) {
68
- const sessionId = getSessionId();
69
- mcpServer.server
112
+ const line = formatLogEntry(level, message, meta);
113
+ safeWriteStderr(`${stripVTControlCharacters(line)}\n`);
114
+ const server = mcpServer;
115
+ if (!server)
116
+ return;
117
+ const sessionId = getSessionId();
118
+ try {
119
+ server.server
70
120
  .sendLoggingMessage({
71
121
  level: mapToMcpLevel(level),
122
+ // Preserve existing behavior: MCP payload includes only message + provided meta (not ALS context meta).
72
123
  data: meta ? { message, ...meta } : message,
73
124
  }, sessionId)
74
- .catch(() => { });
125
+ .catch((err) => {
126
+ if (!isDebugEnabled())
127
+ return;
128
+ let errorText = 'unknown error';
129
+ if (err instanceof Error) {
130
+ errorText = err.message;
131
+ }
132
+ else if (typeof err === 'string') {
133
+ errorText = err;
134
+ }
135
+ safeWriteStderr(`[${createTimestamp()}] WARN: Failed to forward log to MCP${sessionId ? ` (sessionId=${sessionId})` : ''}: ${errorText}\n`);
136
+ });
137
+ }
138
+ catch (err) {
139
+ if (!isDebugEnabled())
140
+ return;
141
+ const errorText = err instanceof Error ? err.message : 'unknown error';
142
+ safeWriteStderr(`[${createTimestamp()}] WARN: Failed to forward log to MCP (sync error): ${errorText}\n`);
75
143
  }
76
144
  }
77
145
  export function logInfo(message, meta) {