@j0hanz/filesystem-mcp 1.1.2 → 1.2.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 (62) hide show
  1. package/README.md +514 -188
  2. package/dist/cli.js +29 -12
  3. package/dist/completions.js +50 -24
  4. package/dist/config.d.ts +4 -2
  5. package/dist/config.js +2 -1
  6. package/dist/index.js +14 -12
  7. package/dist/instructions.md +109 -97
  8. package/dist/lib/constants.js +25 -14
  9. package/dist/lib/errors.js +15 -8
  10. package/dist/lib/file-operations/common.d.ts +4 -0
  11. package/dist/lib/file-operations/common.js +9 -0
  12. package/dist/lib/file-operations/file-info.js +22 -10
  13. package/dist/lib/file-operations/gitignore.js +14 -11
  14. package/dist/lib/file-operations/glob-engine.d.ts +1 -0
  15. package/dist/lib/file-operations/glob-engine.js +46 -33
  16. package/dist/lib/file-operations/list-directory.js +31 -35
  17. package/dist/lib/file-operations/read-multiple-files.js +70 -62
  18. package/dist/lib/file-operations/search-content.js +83 -64
  19. package/dist/lib/file-operations/search-files.js +32 -30
  20. package/dist/lib/file-operations/search-worker.js +22 -12
  21. package/dist/lib/file-operations/tree.js +43 -34
  22. package/dist/lib/fs-helpers.js +61 -124
  23. package/dist/lib/observability.js +29 -28
  24. package/dist/lib/path-format.d.ts +1 -0
  25. package/dist/lib/path-format.js +7 -0
  26. package/dist/lib/path-policy.js +22 -20
  27. package/dist/lib/path-validation.js +13 -7
  28. package/dist/lib/resource-store.d.ts +2 -0
  29. package/dist/lib/resource-store.js +26 -5
  30. package/dist/lib/type-guards.d.ts +1 -0
  31. package/dist/lib/type-guards.js +3 -0
  32. package/dist/prompts.d.ts +1 -5
  33. package/dist/prompts.js +9 -16
  34. package/dist/resources.d.ts +1 -5
  35. package/dist/resources.js +12 -26
  36. package/dist/schemas.d.ts +232 -30
  37. package/dist/schemas.js +52 -90
  38. package/dist/server.js +96 -44
  39. package/dist/tools/apply-patch.js +23 -22
  40. package/dist/tools/calculate-hash.js +41 -43
  41. package/dist/tools/create-directory.js +17 -19
  42. package/dist/tools/delete-file.js +35 -37
  43. package/dist/tools/diff-files.js +15 -19
  44. package/dist/tools/edit-file.js +15 -18
  45. package/dist/tools/list-directory.js +24 -23
  46. package/dist/tools/move-file.js +17 -19
  47. package/dist/tools/read-multiple.js +55 -66
  48. package/dist/tools/read.js +26 -30
  49. package/dist/tools/replace-in-files.js +27 -33
  50. package/dist/tools/roots.js +8 -8
  51. package/dist/tools/search-content.js +73 -72
  52. package/dist/tools/search-files.js +44 -50
  53. package/dist/tools/shared.d.ts +44 -6
  54. package/dist/tools/shared.js +86 -64
  55. package/dist/tools/stat-many.js +44 -66
  56. package/dist/tools/stat.js +10 -37
  57. package/dist/tools/task-support.d.ts +9 -1
  58. package/dist/tools/task-support.js +86 -81
  59. package/dist/tools/tree.js +12 -28
  60. package/dist/tools/write-file.js +17 -19
  61. package/dist/tools.js +23 -18
  62. package/package.json +6 -7
@@ -3,23 +3,18 @@ import { formatOperationSummary, joinLines } from '../config.js';
3
3
  import { DEFAULT_EXCLUDE_PATTERNS, DEFAULT_SEARCH_TIMEOUT_MS, } from '../lib/constants.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import { searchFiles } from '../lib/file-operations/search-files.js';
6
- import { createTimedAbortSignal } from '../lib/fs-helpers.js';
7
- import { withToolDiagnostics } from '../lib/observability.js';
8
6
  import { SearchFilesInputSchema, SearchFilesOutputSchema } from '../schemas.js';
9
- import { buildToolErrorResponse, buildToolResponse, createProgressReporter, getExperimentalTaskRegistration, notifyProgress, resolvePathOrRoot, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
10
- import { createToolTaskHandler } from './task-support.js';
7
+ import { buildToolErrorResponse, buildToolResponse, createProgressReporter, executeToolWithDiagnostics, notifyProgress, READ_ONLY_TOOL_ANNOTATIONS, resolvePathOrRoot, withDefaultIcons, wrapToolHandler, } from './shared.js';
8
+ import { registerToolTaskIfAvailable } from './task-support.js';
11
9
  const SEARCH_FILES_TOOL = {
12
10
  title: 'Find Files',
13
11
  description: 'Find files by glob pattern (e.g., **/*.ts). ' +
14
12
  'Returns a list of matching files with metadata. ' +
15
- 'For text search inside files, use grep.',
13
+ 'For text search inside files, use grep. ' +
14
+ 'To bulk-edit the matched files, pass the same glob pattern to search_and_replace.',
16
15
  inputSchema: SearchFilesInputSchema,
17
16
  outputSchema: SearchFilesOutputSchema,
18
- annotations: {
19
- readOnlyHint: true,
20
- idempotentHint: true,
21
- openWorldHint: false,
22
- },
17
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
23
18
  };
24
19
  async function handleSearchFiles(args, signal, onProgress) {
25
20
  const basePath = resolvePathOrRoot(args.path);
@@ -30,27 +25,30 @@ async function handleSearchFiles(args, signal, onProgress) {
30
25
  sortBy: args.sortBy,
31
26
  respectGitignore: !args.includeIgnored,
32
27
  ...(args.maxDepth !== undefined ? { maxDepth: args.maxDepth } : {}),
33
- ...(args.maxFilesScanned !== undefined
34
- ? { maxFilesScanned: args.maxFilesScanned }
35
- : {}),
36
28
  ...(onProgress ? { onProgress } : {}),
37
29
  ...(signal ? { signal } : {}),
38
30
  };
39
31
  const result = await searchFiles(basePath, args.pattern, excludePatterns, searchOptions);
40
- const relativeResults = result.results.map((entry) => ({
41
- path: path.relative(result.basePath, entry.path),
42
- size: entry.size,
43
- modified: entry.modified?.toISOString(),
44
- }));
32
+ const relativeResults = [];
33
+ for (const entry of result.results) {
34
+ relativeResults.push({
35
+ path: path.relative(result.basePath, entry.path),
36
+ size: entry.size,
37
+ modified: entry.modified?.toISOString(),
38
+ });
39
+ }
45
40
  const structured = {
46
41
  ok: true,
47
42
  root: basePath,
48
43
  pattern: args.pattern,
49
44
  results: relativeResults,
50
45
  totalMatches: result.summary.matched,
51
- truncated: result.summary.truncated,
52
- filesScanned: result.summary.filesScanned,
53
- skippedInaccessible: result.summary.skippedInaccessible,
46
+ ...(result.summary.truncated
47
+ ? { truncated: result.summary.truncated }
48
+ : {}),
49
+ ...(result.summary.skippedInaccessible
50
+ ? { skippedInaccessible: result.summary.skippedInaccessible }
51
+ : {}),
54
52
  ...(result.summary.stoppedReason
55
53
  ? { stoppedReason: result.summary.stoppedReason }
56
54
  : {}),
@@ -71,51 +69,47 @@ async function handleSearchFiles(args, signal, onProgress) {
71
69
  truncated: result.summary.truncated,
72
70
  ...(truncatedReason ? { truncatedReason } : {}),
73
71
  };
74
- const textLines = relativeResults.length === 0
75
- ? ['No matches']
76
- : [
77
- `Found ${relativeResults.length}:`,
78
- ...relativeResults.map((entry) => ` ${entry.path}`),
79
- ];
72
+ const textLines = [];
73
+ if (relativeResults.length === 0) {
74
+ textLines.push('No matches');
75
+ }
76
+ else {
77
+ textLines.push(`Found ${relativeResults.length}:`);
78
+ for (const entry of relativeResults) {
79
+ textLines.push(` ${entry.path}`);
80
+ }
81
+ }
80
82
  const text = joinLines(textLines) + formatOperationSummary(summaryOptions);
81
83
  return buildToolResponse(text, structured);
82
84
  }
83
85
  export function registerSearchFilesTool(server, options = {}) {
84
- const handler = (args, extra) => withToolDiagnostics('find', () => withToolErrorHandling(async () => {
85
- notifyProgress(extra, {
86
- current: 0,
87
- message: `🔎︎ find: ${args.pattern}`,
88
- });
89
- const { signal, cleanup } = createTimedAbortSignal(extra.signal, DEFAULT_SEARCH_TIMEOUT_MS);
90
- try {
86
+ const handler = (args, extra) => executeToolWithDiagnostics({
87
+ toolName: 'find',
88
+ extra,
89
+ timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
90
+ context: { path: args.path ?? '.' },
91
+ run: async (signal) => {
92
+ notifyProgress(extra, {
93
+ current: 0,
94
+ message: `🔎︎ find: ${args.pattern}`,
95
+ });
91
96
  const result = await handleSearchFiles(args, signal, createProgressReporter(extra));
92
97
  const sc = result.structuredContent;
93
- const suffix = sc.ok && sc.totalMatches
94
- ? String(sc.totalMatches)
95
- : 'No matches';
98
+ const suffix = sc.ok && sc.totalMatches ? String(sc.totalMatches) : 'No matches';
96
99
  const finalCurrent = (sc.filesScanned ?? 0) + 1;
97
100
  notifyProgress(extra, {
98
101
  current: finalCurrent,
99
102
  message: `🔎︎ find: ${args.pattern} ➟ ${suffix}`,
100
103
  });
101
104
  return result;
102
- }
103
- finally {
104
- cleanup();
105
- }
106
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_INVALID_PATTERN, args.path)), { path: args.path ?? '.' });
105
+ },
106
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_INVALID_PATTERN, args.path),
107
+ });
107
108
  const { isInitialized } = options;
108
109
  const wrappedHandler = wrapToolHandler(handler, {
109
110
  guard: isInitialized,
110
111
  });
111
- const taskOptions = isInitialized ? { guard: isInitialized } : undefined;
112
- const tasks = getExperimentalTaskRegistration(server);
113
- if (tasks?.registerToolTask) {
114
- tasks.registerToolTask('find', withDefaultIcons({
115
- ...SEARCH_FILES_TOOL,
116
- execution: { taskSupport: 'optional' },
117
- }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
112
+ if (registerToolTaskIfAvailable(server, 'find', SEARCH_FILES_TOOL, wrappedHandler, options.iconInfo, isInitialized))
118
113
  return;
119
- }
120
114
  server.registerTool('find', withDefaultIcons({ ...SEARCH_FILES_TOOL }, options.iconInfo), wrappedHandler);
121
115
  }
@@ -1,9 +1,24 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
1
  import type { ContentBlock, Icon, ProgressNotificationParams } from '@modelcontextprotocol/sdk/types.js';
3
2
  import type { z } from 'zod';
3
+ import type { FileInfo } from '../config.js';
4
4
  import { ErrorCode } from '../lib/errors.js';
5
5
  import type { ResourceStore } from '../lib/resource-store.js';
6
6
  import type { ToolErrorResponseSchema } from '../schemas.js';
7
+ export declare const READ_ONLY_TOOL_ANNOTATIONS: {
8
+ readonly readOnlyHint: true;
9
+ readonly idempotentHint: true;
10
+ readonly openWorldHint: false;
11
+ };
12
+ export declare const DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS: {
13
+ readonly readOnlyHint: false;
14
+ readonly destructiveHint: true;
15
+ readonly openWorldHint: false;
16
+ };
17
+ export declare const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS: {
18
+ readonly readOnlyHint: false;
19
+ readonly idempotentHint: true;
20
+ readonly openWorldHint: false;
21
+ };
7
22
  type ResourceEntry = ReturnType<ResourceStore['putText']>;
8
23
  export declare function maybeExternalizeTextContent(resourceStore: ResourceStore | undefined, content: string, params: {
9
24
  name: string;
@@ -18,7 +33,7 @@ export declare function buildResourceLink(params: {
18
33
  mimeType?: string;
19
34
  description?: string;
20
35
  }): ContentBlock;
21
- export declare function buildToolResponse<T>(text: string, structuredContent: T, extraContent?: ContentBlock[], resourceStore?: ResourceStore): {
36
+ export declare function buildToolResponse<T>(text: string, structuredContent: T, extraContent?: ContentBlock[]): {
22
37
  content: ContentBlock[];
23
38
  structuredContent: T;
24
39
  };
@@ -54,14 +69,37 @@ export interface ToolRegistrationOptions {
54
69
  serverIcon?: string;
55
70
  iconInfo?: IconInfo;
56
71
  }
57
- export declare function getExperimentalTaskRegistration(server: McpServer): {
58
- registerToolTask?: (...args: unknown[]) => unknown;
59
- } | undefined;
60
- export declare function withToolErrorHandling<T>(run: () => Promise<ToolResponse<T>>, onError: (error: unknown) => ToolResult<T>): Promise<ToolResult<T>>;
72
+ interface FileInfoPayload {
73
+ name: string;
74
+ path: string;
75
+ type: FileInfo['type'];
76
+ size: number;
77
+ tokenEstimate?: number;
78
+ created: string;
79
+ modified: string;
80
+ accessed: string;
81
+ permissions: string;
82
+ isHidden: boolean;
83
+ mimeType?: string;
84
+ symlinkTarget?: string;
85
+ }
86
+ export declare function buildFileInfoPayload(info: FileInfo): FileInfoPayload;
87
+ interface ToolExecutionOptions<T> {
88
+ toolName: string;
89
+ extra: ToolExtra;
90
+ run: (signal: AbortSignal | undefined) => ToolResponse<T> | Promise<ToolResponse<T>>;
91
+ onError: (error: unknown) => ToolResult<T>;
92
+ context?: Record<string, unknown>;
93
+ timedSignal?: {
94
+ timeoutMs?: number;
95
+ };
96
+ }
97
+ export declare function executeToolWithDiagnostics<T>(options: ToolExecutionOptions<T>): Promise<ToolResult<T>>;
61
98
  export declare function buildToolErrorResponse(error: unknown, defaultCode: ErrorCode, path?: string): ToolErrorResponse;
62
99
  export declare function createProgressReporter(extra: ToolExtra): (progress: {
63
100
  total?: number;
64
101
  current: number;
102
+ message?: string;
65
103
  }) => void;
66
104
  export declare function notifyProgress(extra: ToolExtra, progress: {
67
105
  current: number;
@@ -1,8 +1,25 @@
1
- import { inspect } from 'node:util';
2
1
  import { createDetailedError, ErrorCode, formatDetailedError, getSuggestion, McpError, } from '../lib/errors.js';
2
+ import { createTimedAbortSignal } from '../lib/fs-helpers.js';
3
+ import { withToolDiagnostics } from '../lib/observability.js';
3
4
  import { getAllowedDirectories } from '../lib/path-validation.js';
4
5
  const MAX_INLINE_CONTENT_CHARS = 20_000;
5
6
  const MAX_INLINE_PREVIEW_CHARS = 4_000;
7
+ const PROGRESS_RATE_LIMIT_MS = 50;
8
+ export const READ_ONLY_TOOL_ANNOTATIONS = {
9
+ readOnlyHint: true,
10
+ idempotentHint: true,
11
+ openWorldHint: false,
12
+ };
13
+ export const DESTRUCTIVE_WRITE_TOOL_ANNOTATIONS = {
14
+ readOnlyHint: false,
15
+ destructiveHint: true,
16
+ openWorldHint: false,
17
+ };
18
+ export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
19
+ readOnlyHint: false,
20
+ idempotentHint: true,
21
+ openWorldHint: false,
22
+ };
6
23
  function buildTextPreview(text) {
7
24
  if (text.length <= MAX_INLINE_PREVIEW_CHARS)
8
25
  return text;
@@ -32,42 +49,9 @@ export function buildResourceLink(params) {
32
49
  ...(params.mimeType ? { mimeType: params.mimeType } : {}),
33
50
  };
34
51
  }
35
- function buildContentBlock(text, structuredContent, extraContent = [], resourceStore) {
36
- let json;
37
- try {
38
- json = JSON.stringify(structuredContent);
39
- }
40
- catch (error) {
41
- const preview = inspect(structuredContent, {
42
- depth: 4,
43
- colors: false,
44
- compact: 3,
45
- breakLength: 80,
46
- });
47
- const errorMessage = error instanceof Error ? error.message : String(error);
48
- json = JSON.stringify({
49
- ok: false,
50
- error: `Failed to serialize structuredContent: ${errorMessage}`,
51
- preview,
52
- });
53
- }
54
- const externalized = maybeExternalizeTextContent(resourceStore, json, {
55
- name: 'tool:structuredContent',
56
- mimeType: 'application/json',
57
- });
58
- const jsonContent = externalized
59
- ? [
60
- { type: 'text', text: externalized.preview },
61
- buildResourceLink({
62
- uri: externalized.entry.uri,
63
- name: externalized.entry.name,
64
- mimeType: externalized.entry.mimeType,
65
- description: 'Full structuredContent JSON',
66
- }),
67
- ]
68
- : [{ type: 'text', text: json }];
52
+ function buildContentBlock(text, structuredContent, extraContent = []) {
69
53
  return {
70
- content: [{ type: 'text', text }, ...extraContent, ...jsonContent],
54
+ content: [{ type: 'text', text }, ...extraContent],
71
55
  structuredContent,
72
56
  };
73
57
  }
@@ -79,8 +63,8 @@ function resolveDetailedError(error, defaultCode, path) {
79
63
  }
80
64
  return detailed;
81
65
  }
82
- export function buildToolResponse(text, structuredContent, extraContent = [], resourceStore) {
83
- return buildContentBlock(text, structuredContent, extraContent, resourceStore);
66
+ export function buildToolResponse(text, structuredContent, extraContent = []) {
67
+ return buildContentBlock(text, structuredContent, extraContent);
84
68
  }
85
69
  function canSendProgress(extra) {
86
70
  return (extra._meta?.progressToken !== undefined &&
@@ -103,25 +87,28 @@ export function withDefaultIcons(tool, iconInfo) {
103
87
  ],
104
88
  };
105
89
  }
106
- function isExperimentalTaskRegistration(value) {
107
- if (!value || typeof value !== 'object')
108
- return false;
109
- const { registerToolTask } = value;
110
- return (registerToolTask === undefined || typeof registerToolTask === 'function');
111
- }
112
- export function getExperimentalTaskRegistration(server) {
113
- const serverWithExperimental = server;
114
- const { experimental } = serverWithExperimental;
115
- if (!experimental || typeof experimental !== 'object')
116
- return undefined;
117
- const experimentalObject = experimental;
118
- const { tasks } = experimentalObject;
119
- if (!isExperimentalTaskRegistration(tasks))
120
- return undefined;
121
- return tasks;
90
+ export function buildFileInfoPayload(info) {
91
+ return {
92
+ name: info.name,
93
+ path: info.path,
94
+ type: info.type,
95
+ size: info.size,
96
+ ...(info.tokenEstimate !== undefined
97
+ ? { tokenEstimate: info.tokenEstimate }
98
+ : {}),
99
+ created: info.created.toISOString(),
100
+ modified: info.modified.toISOString(),
101
+ accessed: info.accessed.toISOString(),
102
+ permissions: info.permissions,
103
+ isHidden: info.isHidden,
104
+ ...(info.mimeType !== undefined ? { mimeType: info.mimeType } : {}),
105
+ ...(info.symlinkTarget !== undefined
106
+ ? { symlinkTarget: info.symlinkTarget }
107
+ : {}),
108
+ };
122
109
  }
123
110
  const NOT_INITIALIZED_ERROR = new McpError(ErrorCode.E_INVALID_INPUT, 'Client not initialized; wait for notifications/initialized');
124
- export async function withToolErrorHandling(run, onError) {
111
+ async function withToolErrorHandling(run, onError) {
125
112
  try {
126
113
  return await run();
127
114
  }
@@ -129,6 +116,24 @@ export async function withToolErrorHandling(run, onError) {
129
116
  return onError(error);
130
117
  }
131
118
  }
119
+ function getToolSignal(extraSignal, timedSignal) {
120
+ if (!timedSignal) {
121
+ return { signal: extraSignal, cleanup: () => { } };
122
+ }
123
+ const { signal, cleanup } = createTimedAbortSignal(extraSignal, timedSignal.timeoutMs);
124
+ return { signal, cleanup };
125
+ }
126
+ export async function executeToolWithDiagnostics(options) {
127
+ return withToolDiagnostics(options.toolName, () => withToolErrorHandling(async () => {
128
+ const { signal, cleanup } = getToolSignal(options.extra.signal, options.timedSignal);
129
+ try {
130
+ return await options.run(signal);
131
+ }
132
+ finally {
133
+ cleanup();
134
+ }
135
+ }, options.onError), options.context);
136
+ }
132
137
  export function buildToolErrorResponse(error, defaultCode, path) {
133
138
  const detailed = resolveDetailedError(error, defaultCode, path);
134
139
  const text = formatDetailedError(detailed);
@@ -163,8 +168,9 @@ async function sendProgressNotification(extra, params) {
163
168
  params,
164
169
  });
165
170
  }
166
- catch {
171
+ catch (error) {
167
172
  // Ignore progress notification failures to avoid breaking tool execution.
173
+ console.error('Failed to send progress notification:', error);
168
174
  }
169
175
  }
170
176
  export function createProgressReporter(extra) {
@@ -172,12 +178,25 @@ export function createProgressReporter(extra) {
172
178
  return () => { };
173
179
  }
174
180
  const token = extra._meta.progressToken;
181
+ // State for monotonic enforcement and rate-limiting.
182
+ let lastProgress = -1;
183
+ let lastSentMs = 0;
175
184
  return (progress) => {
176
- const { current, total } = progress;
185
+ const { current, total, message } = progress;
186
+ // Enforce monotonic progress to prevent client confusion. Client behavior on
187
+ if (current <= lastProgress)
188
+ return;
189
+ // Enforce rate-limiting to prevent client flooding. Progress updates that are
190
+ const now = Date.now();
191
+ if (now - lastSentMs < PROGRESS_RATE_LIMIT_MS)
192
+ return;
193
+ lastProgress = current;
194
+ lastSentMs = now;
177
195
  void sendProgressNotification(extra, {
178
196
  progressToken: token,
179
- total,
180
197
  progress: current,
198
+ ...(total !== undefined ? { total } : {}),
199
+ ...(message !== undefined ? { message } : {}),
181
200
  });
182
201
  };
183
202
  }
@@ -194,7 +213,7 @@ export function notifyProgress(extra, progress) {
194
213
  }
195
214
  async function withProgress(message, extra, run, getCompletionMessage) {
196
215
  if (!canSendProgress(extra)) {
197
- return await run();
216
+ return run();
198
217
  }
199
218
  const token = extra._meta.progressToken;
200
219
  const total = 1;
@@ -216,11 +235,10 @@ async function withProgress(message, extra, run, getCompletionMessage) {
216
235
  return result;
217
236
  }
218
237
  catch (error) {
219
- await sendProgressNotification(extra, {
238
+ void sendProgressNotification(extra, {
220
239
  progressToken: token,
221
240
  progress: total,
222
241
  total,
223
- message,
224
242
  });
225
243
  throw error;
226
244
  }
@@ -237,9 +255,9 @@ export function wrapToolHandler(handler, options) {
237
255
  const completionFn = completionMessage
238
256
  ? (result) => completionMessage(args, result)
239
257
  : undefined;
240
- return await withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
258
+ return withProgress(message, resolvedExtra, () => handler(args, resolvedExtra), completionFn);
241
259
  }
242
- return await handler(args, resolvedExtra);
260
+ return handler(args, resolvedExtra);
243
261
  };
244
262
  }
245
263
  export function resolvePathOrRoot(pathValue) {
@@ -252,5 +270,9 @@ export function resolvePathOrRoot(pathValue) {
252
270
  if (roots.length > 1) {
253
271
  throw new McpError(ErrorCode.E_INVALID_INPUT, 'Multiple workspace roots configured. Provide an explicit path to disambiguate.');
254
272
  }
255
- return roots[0] ?? '';
273
+ const root = roots[0];
274
+ if (!root) {
275
+ throw new McpError(ErrorCode.E_ACCESS_DENIED, 'Workspace root is unexpectedly undefined');
276
+ }
277
+ return root;
256
278
  }
@@ -1,104 +1,82 @@
1
- import { formatBytes } from '../config.js';
1
+ import { formatBytes, joinLines } from '../config.js';
2
2
  import { DEFAULT_SEARCH_TIMEOUT_MS } from '../lib/constants.js';
3
3
  import { ErrorCode } from '../lib/errors.js';
4
4
  import { getMultipleFileInfo } from '../lib/file-operations/file-info.js';
5
- import { createTimedAbortSignal } from '../lib/fs-helpers.js';
6
- import { withToolDiagnostics } from '../lib/observability.js';
7
5
  import { GetMultipleFileInfoInputSchema, GetMultipleFileInfoOutputSchema, } from '../schemas.js';
8
- import { buildToolErrorResponse, buildToolResponse, getExperimentalTaskRegistration, withDefaultIcons, withToolErrorHandling, wrapToolHandler, } from './shared.js';
9
- import { createToolTaskHandler } from './task-support.js';
6
+ import { buildFileInfoPayload, buildToolErrorResponse, buildToolResponse, executeToolWithDiagnostics, READ_ONLY_TOOL_ANNOTATIONS, withDefaultIcons, wrapToolHandler, } from './shared.js';
7
+ import { registerToolTaskIfAvailable } from './task-support.js';
10
8
  const GET_MULTIPLE_FILE_INFO_TOOL = {
11
9
  title: 'Get Multiple File Info',
12
10
  description: 'Get metadata for multiple files or directories in one request.',
13
11
  inputSchema: GetMultipleFileInfoInputSchema,
14
12
  outputSchema: GetMultipleFileInfoOutputSchema,
15
- annotations: {
16
- readOnlyHint: true,
17
- idempotentHint: true,
18
- openWorldHint: false,
19
- },
13
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
20
14
  };
21
- function buildFileInfoPayload(info) {
22
- return {
23
- name: info.name,
24
- path: info.path,
25
- type: info.type,
26
- size: info.size,
27
- ...(info.tokenEstimate !== undefined
28
- ? { tokenEstimate: info.tokenEstimate }
29
- : {}),
30
- created: info.created.toISOString(),
31
- modified: info.modified.toISOString(),
32
- accessed: info.accessed.toISOString(),
33
- permissions: info.permissions,
34
- isHidden: info.isHidden,
35
- ...(info.mimeType !== undefined ? { mimeType: info.mimeType } : {}),
36
- ...(info.symlinkTarget !== undefined
37
- ? { symlinkTarget: info.symlinkTarget }
38
- : {}),
39
- };
40
- }
41
- function formatFileInfoSummary(pathValue, info) {
42
- return `${pathValue} (${info.type}, ${formatBytes(info.size)})`;
15
+ function formatFileInfoDetail(info) {
16
+ const lines = [
17
+ `${info.name} (${info.type})`,
18
+ ` Path: ${info.path}`,
19
+ ` Size: ${formatBytes(info.size)}`,
20
+ ` Modified: ${info.modified.toISOString()}`,
21
+ ];
22
+ if (info.mimeType)
23
+ lines.push(` Type: ${info.mimeType}`);
24
+ if (info.symlinkTarget)
25
+ lines.push(` Target: ${info.symlinkTarget}`);
26
+ return joinLines(lines);
43
27
  }
44
28
  async function handleGetMultipleFileInfo(args, signal) {
45
29
  const result = await getMultipleFileInfo(args.paths, {
46
30
  includeMimeType: true,
47
31
  ...(signal ? { signal } : {}),
48
32
  });
49
- const structured = {
50
- ok: true,
51
- results: result.results.map((entry) => ({
33
+ const structuredResults = [];
34
+ const textBlocks = [];
35
+ for (const entry of result.results) {
36
+ structuredResults.push({
52
37
  path: entry.path,
53
38
  info: entry.info ? buildFileInfoPayload(entry.info) : undefined,
54
39
  error: entry.error,
55
- })),
40
+ });
41
+ if (entry.error) {
42
+ textBlocks.push(`${entry.path}: ${entry.error}`);
43
+ }
44
+ else if (entry.info) {
45
+ textBlocks.push(formatFileInfoDetail(entry.info));
46
+ }
47
+ else {
48
+ textBlocks.push(entry.path);
49
+ }
50
+ }
51
+ const structured = {
52
+ ok: true,
53
+ results: structuredResults,
56
54
  summary: {
57
55
  total: result.summary.total,
58
56
  succeeded: result.summary.succeeded,
59
57
  failed: result.summary.failed,
60
58
  },
61
59
  };
62
- const text = result.results
63
- .map((entry) => {
64
- if (entry.error) {
65
- return `${entry.path}: ${entry.error}`;
66
- }
67
- if (entry.info) {
68
- return formatFileInfoSummary(entry.path, entry.info);
69
- }
70
- return entry.path;
71
- })
72
- .join('\n');
60
+ const text = textBlocks.join('\n\n');
73
61
  return buildToolResponse(text, structured);
74
62
  }
75
63
  export function registerGetMultipleFileInfoTool(server, options = {}) {
76
64
  const handler = (args, extra) => {
77
65
  const primaryPath = args.paths[0] ?? '';
78
- return withToolDiagnostics('stat_many', () => withToolErrorHandling(async () => {
79
- const { signal, cleanup } = createTimedAbortSignal(extra.signal, DEFAULT_SEARCH_TIMEOUT_MS);
80
- try {
81
- return await handleGetMultipleFileInfo(args, signal);
82
- }
83
- finally {
84
- cleanup();
85
- }
86
- }, (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, primaryPath)), { path: primaryPath });
66
+ return executeToolWithDiagnostics({
67
+ toolName: 'stat_many',
68
+ extra,
69
+ timedSignal: { timeoutMs: DEFAULT_SEARCH_TIMEOUT_MS },
70
+ context: { path: primaryPath },
71
+ run: (signal) => handleGetMultipleFileInfo(args, signal),
72
+ onError: (error) => buildToolErrorResponse(error, ErrorCode.E_NOT_FOUND, primaryPath),
73
+ });
87
74
  };
88
75
  const wrappedHandler = wrapToolHandler(handler, {
89
76
  guard: options.isInitialized,
90
77
  progressMessage: (args) => `🕮 stat_many: ${args.paths.length} paths`,
91
78
  });
92
- const taskOptions = options.isInitialized
93
- ? { guard: options.isInitialized }
94
- : undefined;
95
- const tasks = getExperimentalTaskRegistration(server);
96
- if (tasks?.registerToolTask) {
97
- tasks.registerToolTask('stat_many', withDefaultIcons({
98
- ...GET_MULTIPLE_FILE_INFO_TOOL,
99
- execution: { taskSupport: 'optional' },
100
- }, options.iconInfo), createToolTaskHandler(wrappedHandler, taskOptions));
79
+ if (registerToolTaskIfAvailable(server, 'stat_many', GET_MULTIPLE_FILE_INFO_TOOL, wrappedHandler, options.iconInfo, options.isInitialized))
101
80
  return;
102
- }
103
81
  server.registerTool('stat_many', withDefaultIcons({ ...GET_MULTIPLE_FILE_INFO_TOOL }, options.iconInfo), wrappedHandler);
104
82
  }