@j0hanz/filesystem-mcp 1.13.2 → 1.14.1

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 (74) hide show
  1. package/README.md +162 -145
  2. package/dist/cli.js +2 -2
  3. package/dist/completions.js +54 -51
  4. package/dist/config.d.ts +13 -14
  5. package/dist/config.js +12 -12
  6. package/dist/index.js +1 -1
  7. package/dist/lib/abort.d.ts +7 -0
  8. package/dist/lib/abort.js +81 -0
  9. package/dist/lib/constants.d.ts +3 -1
  10. package/dist/lib/constants.js +8 -2
  11. package/dist/lib/errors.d.ts +7 -3
  12. package/dist/lib/errors.js +64 -41
  13. package/dist/lib/file-operations/core.d.ts +3 -3
  14. package/dist/lib/file-operations/core.js +23 -20
  15. package/dist/lib/file-operations/metadata.d.ts +2 -2
  16. package/dist/lib/file-operations/metadata.js +69 -22
  17. package/dist/lib/file-operations/search.d.ts +0 -1
  18. package/dist/lib/file-operations/search.js +87 -95
  19. package/dist/lib/file-operations/traversal.js +13 -15
  20. package/dist/lib/fs-helpers.d.ts +3 -10
  21. package/dist/lib/fs-helpers.js +29 -108
  22. package/dist/lib/globs.d.ts +2 -0
  23. package/dist/lib/globs.js +19 -0
  24. package/dist/lib/logger.d.ts +28 -0
  25. package/dist/lib/logger.js +91 -0
  26. package/dist/lib/observability.d.ts +7 -0
  27. package/dist/lib/observability.js +19 -9
  28. package/dist/lib/paths.js +55 -55
  29. package/dist/lib/resource-store.js +4 -4
  30. package/dist/lib/utils.d.ts +0 -12
  31. package/dist/lib/utils.js +0 -13
  32. package/dist/lib/zod-codecs.d.ts +2 -0
  33. package/dist/lib/zod-codecs.js +18 -0
  34. package/dist/pkg-info.d.ts +1 -0
  35. package/dist/pkg-info.js +2 -2
  36. package/dist/prompts.js +3 -3
  37. package/dist/resources/generated-instructions.js +41 -41
  38. package/dist/resources/tool-catalog.js +33 -58
  39. package/dist/resources/tool-info.d.ts +0 -1
  40. package/dist/resources/tool-info.js +44 -67
  41. package/dist/resources/workflows.js +47 -19
  42. package/dist/resources.d.ts +1 -1
  43. package/dist/resources.js +4 -4
  44. package/dist/schemas.d.ts +185 -465
  45. package/dist/schemas.js +174 -206
  46. package/dist/server/bootstrap.d.ts +12 -11
  47. package/dist/server/bootstrap.js +95 -86
  48. package/dist/server/roots-manager.d.ts +5 -2
  49. package/dist/server/roots-manager.js +9 -7
  50. package/dist/server/task-store.d.ts +10 -0
  51. package/dist/server/task-store.js +73 -0
  52. package/dist/tools/apply-patch.js +39 -20
  53. package/dist/tools/calculate-hash.js +14 -27
  54. package/dist/tools/create-directory.js +11 -9
  55. package/dist/tools/delete-file.js +19 -19
  56. package/dist/tools/diff-files.js +16 -18
  57. package/dist/tools/edit-file.js +11 -5
  58. package/dist/tools/list-directory.js +16 -21
  59. package/dist/tools/move-file.js +105 -100
  60. package/dist/tools/read-multiple.js +15 -10
  61. package/dist/tools/read.js +6 -7
  62. package/dist/tools/replace-in-files.js +76 -115
  63. package/dist/tools/roots.js +3 -7
  64. package/dist/tools/search-content.js +158 -203
  65. package/dist/tools/search-files.js +59 -50
  66. package/dist/tools/shared.d.ts +10 -0
  67. package/dist/tools/shared.js +105 -36
  68. package/dist/tools/stat-many.js +15 -9
  69. package/dist/tools/stat.js +6 -6
  70. package/dist/tools/task-support.d.ts +10 -9
  71. package/dist/tools/task-support.js +94 -23
  72. package/dist/tools/tree.js +4 -4
  73. package/dist/tools/write-file.js +11 -12
  74. package/package.json +10 -9
@@ -1,16 +1,32 @@
1
- import * as path from 'node:path';
2
1
  import { channel } from 'node:diagnostics_channel';
2
+ import { basename } from 'node:path';
3
3
  import { z } from 'zod';
4
+ import { createTimedAbortSignal } from '../lib/abort.js';
4
5
  import { parseTrueEnvFlag } from '../lib/constants.js';
5
6
  import { createDetailedError, ErrorCode, formatDetailedError, getSuggestion, McpError, } from '../lib/errors.js';
6
- import { createTimedAbortSignal } from '../lib/fs-helpers.js';
7
- import { withToolDiagnostics } from '../lib/observability.js';
7
+ import { Logger } from '../lib/logger.js';
8
+ import { withToolDiagnostics, } from '../lib/observability.js';
8
9
  import { getAllowedDirectories } from '../lib/paths.js';
10
+ import { createBase64JsonCodec } from '../lib/zod-codecs.js';
9
11
  export {} from './contract.js';
10
12
  const MAX_INLINE_CONTENT_CHARS = parseInt(process.env['FS_CONTEXT_MAX_INLINE_CHARS'] ?? '', 10) || 20_000;
11
13
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
12
14
  const PROGRESS_RATE_LIMIT_MS = 50;
13
15
  const CONTEXT_DIAGNOSTICS_CHANNEL = channel('filesystem-mcp:context');
16
+ // W3C Trace Context: version-traceid-parentid-traceflags
17
+ const TRACEPARENT_RE = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/i;
18
+ function extractTraceContext(meta) {
19
+ const tp = meta?.traceparent;
20
+ if (typeof tp !== 'string' || !TRACEPARENT_RE.test(tp))
21
+ return undefined;
22
+ return {
23
+ traceparent: tp,
24
+ ...(typeof meta?.tracestate === 'string'
25
+ ? { tracestate: meta.tracestate }
26
+ : {}),
27
+ ...(typeof meta?.baggage === 'string' ? { baggage: meta.baggage } : {}),
28
+ };
29
+ }
14
30
  function publishContextDiagnostics(event) {
15
31
  if (!CONTEXT_DIAGNOSTICS_CHANNEL.hasSubscribers)
16
32
  return;
@@ -44,11 +60,13 @@ function normalizeToolExecution(tool) {
44
60
  ? candidate['execution']
45
61
  : undefined;
46
62
  const executionTaskSupport = existingExecution?.['taskSupport'];
47
- const resolvedTaskSupport = isTaskSupportLevel(topLevelTaskSupport)
48
- ? topLevelTaskSupport
49
- : isTaskSupportLevel(executionTaskSupport)
50
- ? executionTaskSupport
51
- : undefined;
63
+ let resolvedTaskSupport;
64
+ if (isTaskSupportLevel(topLevelTaskSupport)) {
65
+ resolvedTaskSupport = topLevelTaskSupport;
66
+ }
67
+ else if (isTaskSupportLevel(executionTaskSupport)) {
68
+ resolvedTaskSupport = executionTaskSupport;
69
+ }
52
70
  if (resolvedTaskSupport === undefined && topLevelTaskSupport === undefined) {
53
71
  return tool;
54
72
  }
@@ -105,6 +123,7 @@ export function maybeExternalizeTextContent(resourceStore, content, params) {
105
123
  chars: content.length,
106
124
  uri: entry.uri,
107
125
  });
126
+ Logger.debug(`Content externalized: ${params.name} (${content.length} chars) → ${entry.uri}`);
108
127
  return {
109
128
  entry,
110
129
  preview: buildTextPreview(content),
@@ -127,30 +146,70 @@ export function buildResourceLink(params) {
127
146
  }
128
147
  function resolveDetailedError(error, defaultCode, path) {
129
148
  const detailed = createDetailedError(error, path);
130
- if (detailed.code === ErrorCode.E_UNKNOWN) {
149
+ if (detailed.code === ErrorCode.UNKNOWN) {
131
150
  detailed.code = defaultCode;
132
- detailed.suggestion = getSuggestion(defaultCode);
151
+ const suggestion = getSuggestion(defaultCode);
152
+ if (suggestion) {
153
+ detailed.suggestion = suggestion;
154
+ }
133
155
  }
134
156
  return detailed;
135
157
  }
158
+ export function buildStructuredError(error, defaultCode, path) {
159
+ const detailed = resolveDetailedError(error, defaultCode, path);
160
+ return {
161
+ code: detailed.code,
162
+ message: detailed.message,
163
+ ...(detailed.path !== undefined ? { path: detailed.path } : {}),
164
+ ...(detailed.suggestion !== undefined
165
+ ? { suggestion: detailed.suggestion }
166
+ : {}),
167
+ };
168
+ }
136
169
  export function buildToolResponse(text, structuredContent, extraContent = []) {
137
170
  return {
138
171
  content: [{ type: 'text', text }, ...extraContent],
139
172
  structuredContent,
140
173
  };
141
174
  }
175
+ function validateStructuredContent(toolName, outputSchema, structuredContent) {
176
+ const parsed = outputSchema.safeParse(structuredContent);
177
+ if (parsed.success) {
178
+ return parsed.data;
179
+ }
180
+ throw new McpError(ErrorCode.UNKNOWN, `Tool "${toolName}" returned invalid structuredContent.`, undefined, { errors: z.treeifyError(parsed.error) });
181
+ }
182
+ function validateToolResponse(toolName, result, outputSchema) {
183
+ if (!outputSchema)
184
+ return result;
185
+ if (!Object.hasOwn(result, 'structuredContent')) {
186
+ throw new McpError(ErrorCode.UNKNOWN, `Tool "${toolName}" returned success without structuredContent.`);
187
+ }
188
+ return {
189
+ ...result,
190
+ structuredContent: validateStructuredContent(toolName, outputSchema, result.structuredContent),
191
+ };
192
+ }
142
193
  function parseToolArgs(schema, args) {
143
194
  const candidate = args === undefined ? {} : args;
144
195
  const parsed = schema.safeParse(candidate);
145
196
  if (parsed.success) {
146
197
  return parsed.data;
147
198
  }
148
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid tool arguments: ${parsed.error.message}`, undefined, { errors: z.treeifyError(parsed.error) });
199
+ throw new McpError(ErrorCode.INVALID_INPUT, `Invalid tool arguments:\n${z.prettifyError(parsed.error)}`);
149
200
  }
150
201
  export function withValidatedArgs(schema, handler) {
151
202
  return async (args, extra) => {
152
- const normalizedArgs = parseToolArgs(schema, args);
153
- return handler(normalizedArgs, extra);
203
+ try {
204
+ const normalizedArgs = parseToolArgs(schema, args);
205
+ return await handler(normalizedArgs, extra);
206
+ }
207
+ catch (error) {
208
+ if (error instanceof McpError && error.code === ErrorCode.INVALID_INPUT) {
209
+ return buildToolErrorResponse(error, ErrorCode.INVALID_INPUT);
210
+ }
211
+ throw error;
212
+ }
154
213
  };
155
214
  }
156
215
  function canSendProgress(extra) {
@@ -202,7 +261,7 @@ export function buildFileInfoPayload(info) {
202
261
  : {}),
203
262
  };
204
263
  }
205
- const NOT_INITIALIZED_ERROR = new McpError(ErrorCode.E_INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
264
+ const NOT_INITIALIZED_ERROR = new McpError(ErrorCode.INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
206
265
  async function withToolErrorHandling(run, onError) {
207
266
  try {
208
267
  return await run();
@@ -219,15 +278,19 @@ function getToolSignal(extraSignal, timedSignal) {
219
278
  return { signal, cleanup };
220
279
  }
221
280
  export async function executeToolWithDiagnostics(options) {
281
+ const traceContext = extractTraceContext(options.extra._meta);
222
282
  return withToolDiagnostics(options.toolName, () => withToolErrorHandling(async () => {
223
283
  const { signal, cleanup } = getToolSignal(options.extra.signal, options.timedSignal);
224
284
  try {
225
- return await options.run(signal);
285
+ return validateToolResponse(options.toolName, await options.run(signal), options.outputSchema);
226
286
  }
227
287
  finally {
228
288
  cleanup();
229
289
  }
230
- }, options.onError), options.context);
290
+ }, options.onError), {
291
+ ...options.context,
292
+ ...(traceContext ? { traceContext } : {}),
293
+ });
231
294
  }
232
295
  export function buildToolErrorResponse(error, defaultCode, path) {
233
296
  const detailed = resolveDetailedError(error, defaultCode, path);
@@ -239,7 +302,7 @@ export function buildToolErrorResponse(error, defaultCode, path) {
239
302
  };
240
303
  }
241
304
  function buildNotInitializedResult() {
242
- return buildToolErrorResponse(NOT_INITIALIZED_ERROR, ErrorCode.E_INVALID_INPUT);
305
+ return buildToolErrorResponse(NOT_INITIALIZED_ERROR, ErrorCode.INVALID_INPUT);
243
306
  }
244
307
  async function reportProgress(extra, progress) {
245
308
  await updateTaskStoreProgress(extra, progress);
@@ -253,6 +316,10 @@ function formatTaskStatusMessage(progress) {
253
316
  }
254
317
  return progress.message ?? `${progress.current}`;
255
318
  }
319
+ function isBenignTaskStatusUpdateError(error) {
320
+ return (error instanceof Error &&
321
+ /Task .*not found|terminal status/iu.test(error.message));
322
+ }
256
323
  async function updateTaskStoreProgress(extra, progress) {
257
324
  const taskExtra = extra;
258
325
  if (typeof taskExtra.taskId === 'string' &&
@@ -264,7 +331,9 @@ async function updateTaskStoreProgress(extra, progress) {
264
331
  await store.updateTaskStatus(taskExtra.taskId, 'working', formatTaskStatusMessage(progress));
265
332
  }
266
333
  catch (error) {
267
- console.error('Failed to update task status message:', error);
334
+ if (isBenignTaskStatusUpdateError(error))
335
+ return;
336
+ Logger.error('Failed to update task status message:', error);
268
337
  }
269
338
  }
270
339
  }
@@ -285,7 +354,7 @@ async function sendMcpProgressNotification(extra, progress) {
285
354
  });
286
355
  }
287
356
  catch (error) {
288
- console.error('Failed to send progress notification:', error);
357
+ Logger.error('Failed to send progress notification:', error);
289
358
  }
290
359
  }
291
360
  }
@@ -450,42 +519,42 @@ export function resolvePathOrRoot(pathValue) {
450
519
  return pathValue;
451
520
  const roots = getAllowedDirectories();
452
521
  if (roots.length === 0) {
453
- throw new McpError(ErrorCode.E_ACCESS_DENIED, 'No workspace roots configured. Use the roots tool to check, or configure roots via the MCP Roots protocol (or start with --allow-cwd / CLI directories).');
522
+ throw new McpError(ErrorCode.ACCESS_DENIED, 'No roots configured. Use roots tool, --allow-cwd, or MCP Roots protocol.');
454
523
  }
455
524
  if (roots.length > 1) {
456
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Multiple workspace roots configured. Provide an explicit path to disambiguate.');
525
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Multiple roots configured. Provide an explicit path.');
457
526
  }
458
527
  const root = roots[0];
459
528
  if (!root) {
460
- throw new McpError(ErrorCode.E_ACCESS_DENIED, 'Workspace root is unexpectedly undefined');
529
+ throw new McpError(ErrorCode.ACCESS_DENIED, 'Workspace root is unexpectedly undefined');
461
530
  }
462
531
  return root;
463
532
  }
533
+ const OffsetCursorSchema = z.strictObject({
534
+ offset: z.int().min(0),
535
+ });
536
+ const OffsetCursorCodec = createBase64JsonCodec(OffsetCursorSchema);
464
537
  export function encodeOffsetCursor(offset) {
465
- return Buffer.from(JSON.stringify({ offset })).toString('base64url');
538
+ return z.encode(OffsetCursorCodec, { offset });
466
539
  }
467
540
  export function decodeOffsetCursor(cursor) {
468
541
  try {
469
- const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
470
- if (typeof parsed === 'object' &&
471
- parsed !== null &&
472
- typeof parsed.offset === 'number') {
473
- const { offset } = parsed;
474
- if (Number.isInteger(offset) && offset >= 0)
475
- return offset;
476
- }
542
+ return OffsetCursorCodec.parse(cursor).offset;
477
543
  }
478
544
  catch {
479
545
  // fall through to throw
480
546
  }
481
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Invalid cursor: the cursor value is malformed or corrupted. Request the first page without a cursor.`);
547
+ throw new McpError(ErrorCode.INVALID_INPUT, `Invalid cursor. Request the first page without a cursor.`);
482
548
  }
483
549
  export function buildBatchPathContext(paths, unitLabel = 'paths') {
484
550
  const normalizedLabel = paths.length === 1 ? unitLabel.replace(/s$/i, '') : unitLabel;
485
- const first = path.basename(paths[0] ?? '');
486
- const extraPaths = paths.length > 1
487
- ? `, ${path.basename(paths[1] ?? '')}${paths.length > 2 ? '…' : ''}`
488
- : '';
551
+ const first = basename(paths[0] ?? '');
552
+ let extraPaths = '';
553
+ if (paths.length > 1) {
554
+ const secondPath = basename(paths[1] ?? '');
555
+ const ellipsis = paths.length > 2 ? '…' : '';
556
+ extraPaths = `, ${secondPath}${ellipsis}`;
557
+ }
489
558
  return `${paths.length} ${normalizedLabel} [${first}${extraPaths}]`;
490
559
  }
491
560
  export function truncateProgressPattern(pattern, maxLength = 40) {
@@ -3,7 +3,7 @@ import { ErrorCode } from '../lib/errors.js';
3
3
  import { getMultipleFileInfo } from '../lib/file-operations/metadata.js';
4
4
  import { formatBytes, joinLines } from '../config.js';
5
5
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
6
- import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
6
+ import { buildBatchCompletionSuffix, buildBatchPathContext, buildFileInfoPayload, buildStructuredError, buildToolErrorResponse, buildToolResponse, createBatchProgressCallbacks, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, resolveFinalProgressCurrent, withDefaultIcons, withValidatedArgs, wrapToolHandler, } from './shared.js';
7
7
  import { registerToolTaskIfAvailable } from './task-support.js';
8
8
  export const GET_MULTIPLE_FILE_INFO_TOOL = {
9
9
  name: 'stat_many',
@@ -14,7 +14,6 @@ export const GET_MULTIPLE_FILE_INFO_TOOL = {
14
14
  outputSchema: GetMultipleFileInfoOutputSchema,
15
15
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
16
16
  taskSupport: 'optional',
17
- nuances: ['Use before read/search when file size/type uncertainty exists.'],
18
17
  };
19
18
  function formatFileInfoDetail(info) {
20
19
  const lines = [
@@ -38,14 +37,20 @@ async function handleGetMultipleFileInfo(args, signal, onProgress) {
38
37
  const structuredResults = result.results.map((entry) => ({
39
38
  path: entry.path,
40
39
  info: entry.info ? buildFileInfoPayload(entry.info) : undefined,
41
- error: entry.error,
40
+ error: entry.error
41
+ ? buildStructuredError(entry.error, ErrorCode.NOT_FOUND, entry.path)
42
+ : undefined,
42
43
  }));
43
44
  const text = result.results
44
- .map((entry) => entry.error
45
- ? `${entry.path}: ${entry.error}`
46
- : entry.info
47
- ? formatFileInfoDetail(entry.info)
48
- : entry.path)
45
+ .map((entry) => {
46
+ if (entry.error) {
47
+ return `${entry.path}: ${buildStructuredError(entry.error, ErrorCode.NOT_FOUND, entry.path).message}`;
48
+ }
49
+ if (entry.info) {
50
+ return formatFileInfoDetail(entry.info);
51
+ }
52
+ return entry.path;
53
+ })
49
54
  .join('\n\n');
50
55
  const structured = {
51
56
  ok: true,
@@ -64,6 +69,7 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
64
69
  return executeToolWithDiagnostics({
65
70
  toolName: 'stat_many',
66
71
  extra,
72
+ outputSchema: GetMultipleFileInfoOutputSchema,
67
73
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
68
74
  context: { path: primaryPath },
69
75
  run: async (signal) => {
@@ -88,7 +94,7 @@ export function registerGetMultipleFileInfoTool(server, options = {}) {
88
94
  throw error;
89
95
  }
90
96
  },
91
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, primaryPath),
97
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_FOUND, primaryPath),
92
98
  });
93
99
  };
94
100
  const wrappedHandler = wrapToolHandler(handler, {
@@ -1,4 +1,4 @@
1
- import * as path from 'node:path';
1
+ import { basename } from 'node:path';
2
2
  import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { getFileInfo } from '../lib/file-operations/metadata.js';
@@ -15,7 +15,6 @@ export const GET_FILE_INFO_TOOL = {
15
15
  outputSchema: GetFileInfoOutputSchema,
16
16
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
17
17
  taskSupport: 'forbidden',
18
- nuances: ['Use before read/search when file size/type uncertainty exists.'],
19
18
  };
20
19
  function formatFileInfoDetails(info) {
21
20
  const lines = [
@@ -45,20 +44,21 @@ export function registerGetFileInfoTool(server, options = {}) {
45
44
  const handler = (args, extra) => executeToolWithDiagnostics({
46
45
  toolName: 'stat',
47
46
  extra,
47
+ outputSchema: GetFileInfoOutputSchema,
48
48
  timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
49
49
  context: { path: args.path },
50
50
  run: (signal) => handleGetFileInfo(args, signal),
51
- onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, args.path),
51
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.NOT_FOUND, args.path),
52
52
  });
53
53
  const wrappedHandler = wrapToolHandler(handler, {
54
54
  guard: options.isInitialized,
55
- progressMessage: (args) => `🕮 stat: ${path.basename(args.path)}`,
55
+ progressMessage: (args) => `🕮 stat: ${basename(args.path)}`,
56
56
  completionMessage: (args, result) => {
57
- const name = path.basename(args.path);
57
+ const name = basename(args.path);
58
58
  if (result.isError)
59
59
  return `🕮 stat: ${name} • failed`;
60
60
  const sc = result.structuredContent;
61
- if (!sc.ok || !sc.info)
61
+ if (!sc.info)
62
62
  return `🕮 stat: ${name} • failed`;
63
63
  return `🕮 stat: ${sc.info.name} • ${sc.info.type}, ${formatBytes(sc.info.size)}`;
64
64
  },
@@ -2,20 +2,21 @@ import type { ToolTaskHandler } from '@modelcontextprotocol/sdk/experimental/tas
2
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import type { AnySchema, SchemaOutput, ShapeOutput, ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
4
4
  import type { RequestTaskStore } from '@modelcontextprotocol/sdk/shared/protocol.js';
5
- import type { IconInfo, ToolExtra, ToolResult } from './shared.js';
5
+ import { type IconInfo, type ToolExtra, type ToolResult } from './shared.js';
6
6
  type TaskToolExtra = ToolExtra & {
7
7
  taskId?: string;
8
8
  taskStore?: RequestTaskStore;
9
9
  taskRequestedTtl?: number | null;
10
10
  };
11
- type ToolArgs<Args extends ZodRawShapeCompat | AnySchema | undefined> = Args extends ZodRawShapeCompat ? ShapeOutput<Args> : Args extends AnySchema ? SchemaOutput<Args> : undefined;
12
- export declare function registerToolTaskIfAvailable<Args extends ZodRawShapeCompat | AnySchema | undefined, Result>(server: McpServer, toolName: string, toolDef: object, run: (args: ToolArgs<Args>, extra: TaskToolExtra) => Promise<ToolResult<Result>>, iconInfo: IconInfo | undefined, guard?: () => boolean): boolean;
13
- export declare function createToolTaskHandler<Result>(run: (args: undefined, extra: TaskToolExtra) => Promise<ToolResult<Result>>, options?: {
11
+ export type ToolSchema = ZodRawShapeCompat | AnySchema | undefined;
12
+ type ToolArgs<Args extends ToolSchema> = Args extends ZodRawShapeCompat ? ShapeOutput<Args> : Args extends AnySchema ? SchemaOutput<Args> : undefined;
13
+ export declare function registerToolTaskIfAvailable<Args extends ToolSchema, Result>(server: McpServer, toolName: string, toolDef: object, run: (args: ToolArgs<Args>, extra: TaskToolExtra) => Promise<ToolResult<Result>>, iconInfo: IconInfo | undefined, guard?: () => boolean): boolean;
14
+ interface TaskHandlerOptions {
14
15
  guard?: () => boolean;
15
16
  toolName?: string;
16
- }): ToolTaskHandler;
17
- export declare function createToolTaskHandler<Args extends ZodRawShapeCompat | AnySchema, Result>(run: (args: ToolArgs<Args>, extra: TaskToolExtra) => Promise<ToolResult<Result>>, options?: {
18
- guard?: () => boolean;
19
- toolName?: string;
20
- }): ToolTaskHandler<Args>;
17
+ cancelPollMs?: number;
18
+ pollIntervalMs?: number;
19
+ }
20
+ export declare function createToolTaskHandler<Result>(run: (args: undefined, extra: TaskToolExtra) => Promise<ToolResult<Result>>, options?: TaskHandlerOptions): ToolTaskHandler;
21
+ export declare function createToolTaskHandler<Args extends ZodRawShapeCompat | AnySchema, Result>(run: (args: ToolArgs<Args>, extra: TaskToolExtra) => Promise<ToolResult<Result>>, options?: TaskHandlerOptions): ToolTaskHandler<Args>;
21
22
  export {};
@@ -1,10 +1,11 @@
1
+ import { CallToolResultSchema, } from '@modelcontextprotocol/sdk/types.js';
1
2
  import { AsyncLocalStorage } from 'node:async_hooks';
2
3
  import { channel } from 'node:diagnostics_channel';
3
4
  import { performance } from 'node:perf_hooks';
4
5
  import { format } from 'node:util';
5
- import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
6
- import { DEFAULT_TASK_TTL_MS, MAX_CONCURRENT_TASKS, MAX_TASK_TTL_MS, } from '../lib/constants.js';
6
+ import { DEFAULT_TASK_TTL_MS, MAX_CONCURRENT_TASKS, MAX_TASK_TTL_MS, TASK_CANCEL_POLL_MS, TASK_POLL_INTERVAL_MS, } from '../lib/constants.js';
7
7
  import { ErrorCode, McpError } from '../lib/errors.js';
8
+ import { Logger } from '../lib/logger.js';
8
9
  import { isRecord } from '../lib/utils.js';
9
10
  import { buildToolErrorResponse, maybeStripStructuredContentFromResult, withDefaultIcons, } from './shared.js';
10
11
  const taskContext = new AsyncLocalStorage();
@@ -50,6 +51,7 @@ function hasTaskToolCapability(server) {
50
51
  isRecord(tasks.requests.tools.call));
51
52
  }
52
53
  const TASK_STATUS_NOTIFICATION_METHOD = 'notifications/tasks/status';
54
+ const TASK_CREATED_NOTIFICATION_METHOD = 'notifications/tasks/created';
53
55
  function isRequestTaskStore(value) {
54
56
  return (isRecord(value) &&
55
57
  typeof value.createTask === 'function' &&
@@ -67,13 +69,13 @@ function isTaskExtra(value) {
67
69
  }
68
70
  function asCreateTaskExtra(value) {
69
71
  if (!isCreateTaskExtra(value)) {
70
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task store not configured for task-capable tool.');
72
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task store not configured.');
71
73
  }
72
74
  return value;
73
75
  }
74
76
  function asTaskRequestExtra(value) {
75
77
  if (!isTaskExtra(value)) {
76
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task id or task store missing for task operation.');
78
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task id or store missing.');
77
79
  }
78
80
  return value;
79
81
  }
@@ -90,11 +92,11 @@ function isTaskStatus(value) {
90
92
  }
91
93
  function normalizeGetTaskResult(value) {
92
94
  if (!isRecord(value) || typeof value.taskId !== 'string') {
93
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid task object.');
95
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid task object.');
94
96
  }
95
97
  const status = isTaskStatus(value.status) ? value.status : undefined;
96
98
  if (!status) {
97
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Invalid task status.');
99
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid task status.');
98
100
  }
99
101
  const createdAt = typeof value.createdAt === 'string'
100
102
  ? value.createdAt
@@ -123,7 +125,7 @@ function normalizeCallToolResult(value) {
123
125
  const parsed = CallToolResultSchema.safeParse(value);
124
126
  if (parsed.success)
125
127
  return parsed.data;
126
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Stored task result is not a valid tool result.');
128
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Invalid stored task result.');
127
129
  }
128
130
  function getToolResultErrorCode(result) {
129
131
  if (!isRecord(result) || result.isError !== true)
@@ -143,7 +145,7 @@ function getToolResultErrorCode(result) {
143
145
  return match ? match[1] : undefined;
144
146
  }
145
147
  function isCancelledToolResult(result) {
146
- return getToolResultErrorCode(result) === ErrorCode.E_CANCELLED;
148
+ return getToolResultErrorCode(result) === ErrorCode.CANCELLED;
147
149
  }
148
150
  function resolveTaskResultStatuses(result) {
149
151
  if (isCancelledToolResult(result)) {
@@ -192,6 +194,31 @@ function buildTaskStatusNotificationParams(task) {
192
194
  params.statusMessage = task.statusMessage;
193
195
  return params;
194
196
  }
197
+ async function notifyTaskCreatedIfPossible(extra, taskId, toolName) {
198
+ const { sendNotification } = extra;
199
+ if (typeof sendNotification !== 'function')
200
+ return;
201
+ const notify = sendNotification;
202
+ try {
203
+ await notify({
204
+ method: TASK_CREATED_NOTIFICATION_METHOD,
205
+ params: {
206
+ _meta: {
207
+ 'modelcontextprotocol.io/related-task': {
208
+ taskId,
209
+ },
210
+ },
211
+ },
212
+ });
213
+ }
214
+ catch {
215
+ publishTaskDiagnostics({
216
+ phase: 'task_status_notify_failed',
217
+ taskId,
218
+ ...(toolName ? { toolName } : {}),
219
+ });
220
+ }
221
+ }
195
222
  async function notifyTaskStatusIfPossible(extra, taskStore, taskId, toolName) {
196
223
  const { sendNotification } = extra;
197
224
  if (typeof sendNotification !== 'function')
@@ -222,13 +249,13 @@ async function notifyTaskStatusIfPossible(extra, taskStore, taskId, toolName) {
222
249
  }
223
250
  function getTaskStore(extra) {
224
251
  if (!extra.taskStore) {
225
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task store not configured for task-capable tool.');
252
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task store not configured.');
226
253
  }
227
254
  return extra.taskStore;
228
255
  }
229
256
  function getTaskId(extra) {
230
257
  if (!extra.taskId) {
231
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task id missing for task operation.');
258
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task id missing.');
232
259
  }
233
260
  return extra.taskId;
234
261
  }
@@ -260,6 +287,10 @@ async function isTaskAlreadyTerminal(taskStore, taskId) {
260
287
  return false;
261
288
  }
262
289
  }
290
+ function isMissingTaskStoreError(error) {
291
+ return (error instanceof Error &&
292
+ /Task .*not found|not found after cancellation/iu.test(error.message));
293
+ }
263
294
  async function countActiveTasks(taskStore) {
264
295
  if (typeof taskStore.listTasks !== 'function')
265
296
  return 0;
@@ -281,11 +312,11 @@ function resolveRequestedTaskTtl(requestedTtl) {
281
312
  if (requestedTtl == null)
282
313
  return DEFAULT_TASK_TTL_MS;
283
314
  if (!Number.isFinite(requestedTtl)) {
284
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task ttl must be a finite number of milliseconds.');
315
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task TTL must be finite.');
285
316
  }
286
317
  const normalized = Math.trunc(requestedTtl);
287
318
  if (normalized <= 0) {
288
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Task ttl must be greater than zero.');
319
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Task TTL must be > 0.');
289
320
  }
290
321
  return Math.min(normalized, MAX_TASK_TTL_MS);
291
322
  }
@@ -294,18 +325,40 @@ async function safelyStoreTaskResult(taskStore, taskId, storedStatus, result) {
294
325
  await taskStore.storeTaskResult(taskId, storedStatus, result);
295
326
  }
296
327
  catch (error) {
328
+ if (isMissingTaskStoreError(error))
329
+ return;
297
330
  // If task was already cancelled/failed by another process, ignore the write error
298
331
  if (await isTaskAlreadyTerminal(taskStore, taskId))
299
332
  return;
300
333
  throw error;
301
334
  }
302
335
  }
303
- async function runTaskInBackground(run, args, extra, taskStore, taskId, toolName) {
336
+ async function isTaskCancelled(taskStore, taskId) {
337
+ try {
338
+ const task = await taskStore.getTask(taskId);
339
+ return isRecord(task) && task.status === 'cancelled';
340
+ }
341
+ catch {
342
+ return false;
343
+ }
344
+ }
345
+ async function runTaskInBackground(run, args, extra, taskStore, taskId, toolName, cancelPollMs) {
346
+ // Create a dedicated AbortController for background execution.
347
+ // The original request signal is stale once createTask returns.
348
+ const taskAbort = new AbortController();
349
+ const taskExtra = { ...extra, signal: taskAbort.signal };
350
+ // Poll the task store for client-initiated cancellation.
351
+ const cancelPoller = setInterval(() => {
352
+ void isTaskCancelled(taskStore, taskId).then((cancelled) => {
353
+ if (cancelled)
354
+ taskAbort.abort(new Error('Task cancelled by client'));
355
+ });
356
+ }, cancelPollMs ?? TASK_CANCEL_POLL_MS);
304
357
  const start = performance.now();
305
358
  let taskStatuses;
306
359
  let result;
307
360
  try {
308
- const rawResult = await taskContext.run({ taskId, toolName, startTime: start }, () => run(args, extra));
361
+ const rawResult = await taskContext.run({ taskId, toolName, startTime: start }, () => run(args, taskExtra));
309
362
  taskStatuses = resolveTaskResultStatuses(rawResult);
310
363
  result = isErrorResult(rawResult)
311
364
  ? withoutStructuredContent(rawResult)
@@ -313,7 +366,10 @@ async function runTaskInBackground(run, args, extra, taskStore, taskId, toolName
313
366
  }
314
367
  catch (error) {
315
368
  taskStatuses = { storedStatus: 'failed', reportedStatus: 'failed' };
316
- result = maybeStripStructuredContentFromResult(buildToolErrorResponse(error, ErrorCode.E_UNKNOWN));
369
+ result = maybeStripStructuredContentFromResult(buildToolErrorResponse(error, ErrorCode.UNKNOWN));
370
+ }
371
+ finally {
372
+ clearInterval(cancelPoller);
317
373
  }
318
374
  const durationMs = performance.now() - start;
319
375
  try {
@@ -328,7 +384,7 @@ async function runTaskInBackground(run, args, extra, taskStore, taskId, toolName
328
384
  await notifyTaskStatusIfPossible(extra, taskStore, taskId, toolName);
329
385
  }
330
386
  catch (innerError) {
331
- console.error(format('Failed to store task result for task %s:', taskId), innerError);
387
+ Logger.error(format('Failed to store task result for task %s:', taskId), innerError);
332
388
  const syntheticTask = {
333
389
  taskId,
334
390
  status: taskStatuses.reportedStatus === 'cancelled' ? 'cancelled' : 'failed',
@@ -339,10 +395,16 @@ async function runTaskInBackground(run, args, extra, taskStore, taskId, toolName
339
395
  };
340
396
  const { sendNotification } = extra;
341
397
  if (typeof sendNotification === 'function') {
342
- void sendNotification({
343
- method: TASK_STATUS_NOTIFICATION_METHOD,
344
- params: buildTaskStatusNotificationParams(syntheticTask),
345
- });
398
+ try {
399
+ await sendNotification({
400
+ method: TASK_STATUS_NOTIFICATION_METHOD,
401
+ params: buildTaskStatusNotificationParams(syntheticTask),
402
+ });
403
+ }
404
+ catch {
405
+ // Best effort only: transport may already be closed while a cancelled
406
+ // task is still unwinding in the background.
407
+ }
346
408
  }
347
409
  }
348
410
  }
@@ -379,15 +441,23 @@ export function createToolTaskHandler(run, options) {
379
441
  const extra = asCreateTaskExtra(maybeExtra ?? argsOrExtra);
380
442
  const args = (maybeExtra ? argsOrExtra : undefined);
381
443
  if (options?.guard && !options.guard()) {
382
- throw new McpError(ErrorCode.E_INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
444
+ throw new McpError(ErrorCode.INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
383
445
  }
384
446
  const taskStore = getTaskStore(extra);
385
447
  if ((await countActiveTasks(taskStore)) >= MAX_CONCURRENT_TASKS) {
386
- throw new McpError(ErrorCode.E_INVALID_INPUT, `Too many active tasks. Limit: ${String(MAX_CONCURRENT_TASKS)}.`);
448
+ throw new McpError(ErrorCode.INVALID_INPUT, `Too many active tasks (limit: ${String(MAX_CONCURRENT_TASKS)}).`);
387
449
  }
388
450
  const task = await taskStore.createTask({
389
451
  ttl: resolveRequestedTaskTtl(extra.taskRequestedTtl),
452
+ pollInterval: options?.pollIntervalMs ?? TASK_POLL_INTERVAL_MS,
390
453
  });
454
+ const toolLabel = options?.toolName ?? 'tool';
455
+ try {
456
+ await taskStore.updateTaskStatus(task.taskId, 'working', `${toolLabel}: starting`);
457
+ }
458
+ catch {
459
+ // Best effort — status message is informational.
460
+ }
391
461
  publishTaskDiagnostics({
392
462
  phase: 'task_created',
393
463
  taskId: task.taskId,
@@ -399,8 +469,9 @@ export function createToolTaskHandler(run, options) {
399
469
  taskStore,
400
470
  taskId: task.taskId,
401
471
  };
472
+ void notifyTaskCreatedIfPossible(taskExtra, task.taskId, options?.toolName);
402
473
  void notifyTaskStatusIfPossible(taskExtra, taskStore, task.taskId, options?.toolName);
403
- void runTaskInBackground(run, args, taskExtra, taskStore, task.taskId, options?.toolName);
474
+ void runTaskInBackground(run, args, taskExtra, taskStore, task.taskId, options?.toolName, options?.cancelPollMs);
404
475
  return { task };
405
476
  });
406
477
  const getTask = (async (argsOrExtra, maybeExtra) => {